using System.IO; using System.Text; using System.Windows; using System.Windows.Controls; using System.Windows.Input; using System.Windows.Media; using System.Windows.Media.Imaging; using System.Windows.Shapes; namespace codelanguage; public class MapEditorControl : Control { public static readonly DependencyProperty MapProperty = DependencyProperty.Register(nameof(Map), typeof(CraftMapFile), typeof(MapEditorControl), new PropertyMetadata(null, OnRenderPropertyChanged)); public static readonly DependencyProperty PlaneIndexProperty = DependencyProperty.Register(nameof(PlaneIndex), typeof(int), typeof(MapEditorControl), new PropertyMetadata(0, OnRenderPropertyChanged)); public static readonly DependencyProperty TileSizeProperty = DependencyProperty.Register(nameof(TileSize), typeof(int), typeof(MapEditorControl), new PropertyMetadata(32, OnRenderPropertyChanged)); public static readonly DependencyProperty ShowGridProperty = DependencyProperty.Register(nameof(ShowGrid), typeof(bool), typeof(MapEditorControl), new PropertyMetadata(true, OnRenderPropertyChanged)); public static readonly DependencyProperty SelectedTileProperty = DependencyProperty.Register(nameof(SelectedTile), typeof(MapTileViewModel), typeof(MapEditorControl)); public static readonly DependencyProperty ZoomProperty = DependencyProperty.Register(nameof(Zoom), typeof(double), typeof(MapEditorControl), new PropertyMetadata(1.0, OnRenderPropertyChanged)); public static readonly DependencyProperty ShowAllPlanesProperty = DependencyProperty.Register(nameof(ShowAllPlanes), typeof(bool), typeof(MapEditorControl), new PropertyMetadata(false, OnRenderPropertyChanged)); public static readonly DependencyProperty PaintModeProperty = DependencyProperty.Register(nameof(PaintMode), typeof(PaintMode), typeof(MapEditorControl), new PropertyMetadata(PaintMode.Select, OnPaintModeChanged)); public static readonly DependencyProperty SelectedTileTypeProperty = DependencyProperty.Register(nameof(SelectedTileType), typeof(string), typeof(MapEditorControl), new PropertyMetadata("/turf")); public static readonly DependencyProperty SelectedDirProperty = DependencyProperty.Register(nameof(SelectedDir), typeof(int), typeof(MapEditorControl), new PropertyMetadata(2)); public static readonly DependencyProperty ShowCoordinatesProperty = DependencyProperty.Register(nameof(ShowCoordinates), typeof(bool), typeof(MapEditorControl), new PropertyMetadata(false, OnRenderPropertyChanged)); public static readonly DependencyProperty ShowMinimapProperty = DependencyProperty.Register(nameof(ShowMinimap), typeof(bool), typeof(MapEditorControl), new PropertyMetadata(true)); public static readonly DependencyProperty ProjectPathProperty = DependencyProperty.Register(nameof(ProjectPath), typeof(string), typeof(MapEditorControl), new PropertyMetadata(null)); public CraftMapFile? Map { get => (CraftMapFile?)GetValue(MapProperty); set => SetValue(MapProperty, value); } public int PlaneIndex { get => (int)GetValue(PlaneIndexProperty); set => SetValue(PlaneIndexProperty, value); } public int TileSize { get => (int)GetValue(TileSizeProperty); set => SetValue(TileSizeProperty, value); } public bool ShowGrid { get => (bool)GetValue(ShowGridProperty); set => SetValue(ShowGridProperty, value); } public MapTileViewModel? SelectedTile { get => (MapTileViewModel?)GetValue(SelectedTileProperty); set => SetValue(SelectedTileProperty, value); } public double Zoom { get => (double)GetValue(ZoomProperty); set => SetValue(ZoomProperty, value); } public bool ShowAllPlanes { get => (bool)GetValue(ShowAllPlanesProperty); set => SetValue(ShowAllPlanesProperty, value); } public PaintMode PaintMode { get => (PaintMode)GetValue(PaintModeProperty); set => SetValue(PaintModeProperty, value); } public string SelectedTileType { get => (string)GetValue(SelectedTileTypeProperty); set => SetValue(SelectedTileTypeProperty, value); } public int SelectedDir { get => (int)GetValue(SelectedDirProperty); set => SetValue(SelectedDirProperty, value); } public bool ShowCoordinates { get => (bool)GetValue(ShowCoordinatesProperty); set => SetValue(ShowCoordinatesProperty, value); } public bool ShowMinimap { get => (bool)GetValue(ShowMinimapProperty); set => SetValue(ShowMinimapProperty, value); } public string? ProjectPath { get => (string?)GetValue(ProjectPathProperty); set => SetValue(ProjectPathProperty, value); } public event MouseButtonEventHandler? TileClick; public event MouseButtonEventHandler? TileRightClick; public event MouseEventHandler? TileMouseMove; public event EventHandler? MapChanged; private Canvas? _canvas; private readonly Dictionary<(int x, int y, int z), Rectangle> _tileVisuals = new(); private bool _isDragging = false; private bool _isSelecting = false; private Point? _selectionStart = null; private Int32Rect? _selectionRect = null; private readonly Dictionary<(int x, int y), string> _clipboardTiles = new(); private int _clipboardW = 0; private int _clipboardH = 0; private int _clipboardAnchorX = 0; private int _clipboardAnchorY = 0; private MapEditCommand? _currentEditCommand = null; private readonly Stack _undoStack = new(); private readonly Stack _redoStack = new(); private const int MaxUndoStack = 64; private readonly Dictionary _iconCache = new(); private readonly Dictionary _tileCache = new(); private bool _listeningForIconChanges; private sealed class MapEditCommand { public Dictionary<(int x, int y, int z), string> OldValues { get; } = new(); public Dictionary<(int x, int y, int z), string> NewValues { get; } = new(); public bool HasChanges => OldValues.Count > 0; } static MapEditorControl() { DefaultStyleKeyProperty.OverrideMetadata(typeof(MapEditorControl), new FrameworkPropertyMetadata(typeof(MapEditorControl))); } public MapEditorControl() { Loaded += MapEditorControl_Loaded; Unloaded += MapEditorControl_Unloaded; } private void MapEditorControl_Loaded(object sender, RoutedEventArgs e) { if (_listeningForIconChanges) return; CraftIconFile.FileChanged += OnDmiFileChanged; _listeningForIconChanges = true; } private void MapEditorControl_Unloaded(object sender, RoutedEventArgs e) { if (!_listeningForIconChanges) return; CraftIconFile.FileChanged -= OnDmiFileChanged; _listeningForIconChanges = false; } private void OnDmiFileChanged(string filePath) { // Clear the cached icon if it was modified var keysToRemove = _iconCache.Keys.Where(k => System.IO.Path.GetFullPath(k).Equals(System.IO.Path.GetFullPath(filePath), StringComparison.OrdinalIgnoreCase) ).ToList(); foreach (var key in keysToRemove) { _iconCache.Remove(key); } // Clear related tile cache entries _tileCache.Clear(); // Re-render the map to show updated icons Dispatcher.InvokeAsync(() => RenderMap()); } private static void OnRenderPropertyChanged(DependencyObject d, DependencyPropertyChangedEventArgs e) { if (d is MapEditorControl ctrl) ctrl.RenderMap(); } private static void OnPaintModeChanged(DependencyObject d, DependencyPropertyChangedEventArgs e) { if (d is MapEditorControl ctrl) ctrl.RenderMap(); } public override void OnApplyTemplate() { base.OnApplyTemplate(); _canvas = GetTemplateChild("PART_Canvas") as Canvas; if (_canvas != null) { _canvas.MouseLeftButtonDown += OnCanvasMouseDown; _canvas.MouseRightButtonDown += OnCanvasMouseRightDown; _canvas.MouseMove += OnCanvasMouseMove; _canvas.MouseLeftButtonUp += OnCanvasMouseUp; _canvas.LostMouseCapture += OnCanvasLostMouseCapture; } RenderMap(); } private void OnCanvasMouseDown(object sender, MouseButtonEventArgs e) { if (_canvas == null || Map == null) return; _isDragging = true; try { _canvas.CaptureMouse(); } catch { // Some test hosts and noninteractive hosts disallow capture; dragging // can still continue within the canvas. } var pos = e.GetPosition(_canvas); int x = PixelToTileCoordinate(pos.X, TileSize, Zoom); int y = PixelToTileCoordinate(pos.Y, TileSize, Zoom); int z = PlaneIndex + 1; SelectedTile = new MapTileViewModel { X = x, Y = y, Z = z }; if (PaintMode == PaintMode.Select) { _isSelecting = true; _selectionStart = new Point(x, y); _selectionRect = new Int32Rect(x, y, 1, 1); RenderMap(); TileClick?.Invoke(this, e); } else if (PaintMode == PaintMode.Paint || PaintMode == PaintMode.Erase) { BeginEditCommand(); PaintTile(x, y, z); } else if (PaintMode == PaintMode.Fill) { BeginEditCommand(); FloodFill(x, y, z); } } private void OnCanvasMouseRightDown(object sender, MouseButtonEventArgs e) { if (_canvas == null || Map == null) { TileRightClick?.Invoke(this, e); return; } var pos = e.GetPosition(_canvas); int x = PixelToTileCoordinate(pos.X, TileSize, Zoom); int y = PixelToTileCoordinate(pos.Y, TileSize, Zoom); int z = PlaneIndex + 1; SelectedTile = new MapTileViewModel { X = x, Y = y, Z = z }; var previous = PaintMode; PaintMode = PaintMode.Erase; BeginEditCommand(); PaintTile(x, y, z); EndEditCommand(); PaintMode = previous; MapChanged?.Invoke(this, EventArgs.Empty); TileRightClick?.Invoke(this, e); e.Handled = true; } private void OnCanvasMouseMove(object sender, MouseEventArgs e) { if (!_isDragging || Map == null) return; var pos = e.GetPosition(_canvas); int x = PixelToTileCoordinate(pos.X, TileSize, Zoom); int y = PixelToTileCoordinate(pos.Y, TileSize, Zoom); int z = PlaneIndex + 1; SelectedTile = new MapTileViewModel { X = x, Y = y, Z = z }; if (PaintMode == PaintMode.Select && _selectionStart.HasValue) { int startX = (int)_selectionStart.Value.X; int startY = (int)_selectionStart.Value.Y; int left = Math.Min(startX, x); int top = Math.Min(startY, y); int width = Math.Max(1, Math.Abs(x - startX) + 1); int height = Math.Max(1, Math.Abs(y - startY) + 1); _selectionRect = new Int32Rect(left, top, width, height); RenderMap(); return; } if (PaintMode == PaintMode.Paint || PaintMode == PaintMode.Erase) { PaintTile(x, y, z); } TileMouseMove?.Invoke(this, e); } public static int PixelToTileCoordinate(double pixel, int tileSize, double zoom) { if (tileSize <= 0 || zoom <= 0) return 1; return Math.Max(1, (int)Math.Floor(pixel / tileSize / zoom) + 1); } private void OnCanvasMouseUp(object sender, MouseButtonEventArgs e) { if (!_isDragging) { if (_canvas is not null && _canvas.IsMouseCaptured) try { _canvas.ReleaseMouseCapture(); } catch { } return; } _isDragging = false; bool hadChanges = PaintMode != PaintMode.Select && (_currentEditCommand?.HasChanges ?? false); if (PaintMode == PaintMode.Select) _isSelecting = false; else EndEditCommand(); if (_canvas is not null && _canvas.IsMouseCaptured) try { _canvas.ReleaseMouseCapture(); } catch { } if (hadChanges) MapChanged?.Invoke(this, EventArgs.Empty); } private void OnCanvasLostMouseCapture(object sender, MouseEventArgs e) { if (!_isDragging) return; _isDragging = false; _isSelecting = false; bool hadChanges = PaintMode != PaintMode.Select && (_currentEditCommand?.HasChanges ?? false); if (PaintMode != PaintMode.Select) EndEditCommand(); if (hadChanges) MapChanged?.Invoke(this, EventArgs.Empty); } private void PaintTile(int x, int y, int z) { if (Map == null || x < 1 || y < 1 || x > Map.MaxX || y > Map.MaxY) return; if (z < 1 || z > Map.MaxZ) return; var key = (x, y, z); string oldValue = Map.Tiles.TryGetValue(key, out var existing) ? existing : "0"; string newValue; if (PaintMode == PaintMode.Paint) { string paintAtomDef = BuildPaintAtomDefinition(SelectedTileType, SelectedDir); newValue = ComposeTileWithAtom(oldValue, paintAtomDef); } else if (PaintMode == PaintMode.Erase) { newValue = "0"; } else { return; } if (oldValue != newValue) { RecordEdit(key, oldValue, newValue); Map.Tiles[key] = newValue; RenderMap(); } } private void FloodFill(int startX, int startY, int z) { if (Map == null) return; var key = (startX, startY, z); var target = Map.Tiles.TryGetValue(key, out var t) ? t : "0"; var replacement = PaintMode == PaintMode.Fill ? ComposeTileWithAtom(target, BuildPaintAtomDefinition(SelectedTileType, SelectedDir)) : "0"; if (target == replacement) return; var queue = new Queue<(int x, int y, int z)>(); queue.Enqueue(key); var visited = new HashSet<(int x, int y, int z)>(); bool changedAny = false; while (queue.Count > 0) { var (cx, cy, cz) = queue.Dequeue(); if (visited.Contains((cx, cy, cz))) continue; if (cx < 1 || cy < 1 || cx > Map.MaxX || cy > Map.MaxY) continue; var currentKey = (cx, cy, cz); var current = Map.Tiles.TryGetValue(currentKey, out var c) ? c : "0"; if (current != target) continue; string newValue = replacement; RecordEdit(currentKey, current, newValue); Map.Tiles[currentKey] = newValue; changedAny = true; visited.Add(currentKey); queue.Enqueue((cx + 1, cy, cz)); queue.Enqueue((cx - 1, cy, cz)); queue.Enqueue((cx, cy + 1, cz)); queue.Enqueue((cx, cy - 1, cz)); } if (changedAny) RenderMap(); // Saved paint operations report changes when the mouse gesture completes. // Keeping this here, in addition to OnCanvasMouseUp, caused duplicate change // notifications during a single fill gesture. } public static string BuildPaintAtomDefinition(string selectedTypePathOrAtomDef, int selectedDir) { if (string.IsNullOrWhiteSpace(selectedTypePathOrAtomDef)) return selectedTypePathOrAtomDef; var parsed = ParseAtomDef(selectedTypePathOrAtomDef); if (string.IsNullOrWhiteSpace(parsed.TypePath) || !parsed.TypePath.StartsWith('/')) return selectedTypePathOrAtomDef; var vars = new Dictionary(parsed.Vars, StringComparer.OrdinalIgnoreCase); if (selectedDir != 2) vars["dir"] = selectedDir.ToString(System.Globalization.CultureInfo.InvariantCulture); else vars.Remove("dir"); if (vars.Count == 0) return parsed.TypePath; string varBody = string.Join(";", vars.OrderBy(kv => kv.Key, StringComparer.OrdinalIgnoreCase).Select(kv => $"{kv.Key}={FormatInlineVarValue(kv.Value)}")); return $"{parsed.TypePath}{{{varBody}}}"; } private static string FormatInlineVarValue(string value) { if (double.TryParse(value, System.Globalization.NumberStyles.Float, System.Globalization.CultureInfo.InvariantCulture, out _)) return value; if (string.Equals(value, "null", StringComparison.OrdinalIgnoreCase) || string.Equals(value, "true", StringComparison.OrdinalIgnoreCase) || string.Equals(value, "false", StringComparison.OrdinalIgnoreCase)) { return value; } return $"\"{value.Replace("\\", "\\\\").Replace("\"", "\\\"")}\""; } /// /// Public helper for consumers (e.g. the map editor's Inspector panel) to break /// a raw DMM tile definition into its individual, parsed atom definitions /// (type path + inline var overrides), ordered area -> turf -> obj -> mob. /// public static List<(string TypePath, Dictionary Vars)> ParseTileAtoms(string tileId) { if (tileId == "0" || string.IsNullOrEmpty(tileId)) return new List<(string, Dictionary)>(); return SplitAtoms(tileId) .Select(ParseAtomDef) .Where(a => !string.IsNullOrWhiteSpace(a.TypePath) && a.TypePath != "0") .OrderBy(a => GetAtomCategory(a.TypePath)) .ToList(); } /// /// Splits a DMM tile definition (e.g. "/turf/space,/area,/obj/machine{icon_state=\"on\"}") /// into its individual atom definitions, respecting braces so inline var overrides /// containing commas aren't split incorrectly. /// public static List SplitAtoms(string tileId) { var atoms = new List(); if (string.IsNullOrEmpty(tileId)) return atoms; int depth = 0; bool inDoubleQuote = false; bool inSingleQuote = false; bool escaped = false; var current = new StringBuilder(); for (int i = 0; i < tileId.Length; i++) { char c = tileId[i]; if (escaped) { escaped = false; current.Append(c); continue; } if (c == '\\' && (inDoubleQuote || inSingleQuote)) { escaped = true; current.Append(c); continue; } if (c == '"' && !inSingleQuote) { inDoubleQuote = !inDoubleQuote; current.Append(c); continue; } if (c == '\'' && !inDoubleQuote) { inSingleQuote = !inSingleQuote; current.Append(c); continue; } if (!inDoubleQuote && !inSingleQuote) { if (c == '{') { depth++; current.Append(c); continue; } if (c == '}') { if (depth > 0) depth--; current.Append(c); continue; } if (c == ',' && depth <= 0) { string atom = current.ToString().Trim(); if (atom.Length > 0) atoms.Add(atom); current.Clear(); continue; } } current.Append(c); } string tail = current.ToString().Trim(); if (tail.Length > 0) atoms.Add(tail); return atoms; } /// /// Parses a single atom definition into its type path and any inline var /// overrides declared in braces, e.g. "/obj/machine{icon_state=\"on\";dir=2}". /// public static (string TypePath, Dictionary Vars) ParseAtomDef(string atomDef) { var vars = new Dictionary(); if (string.IsNullOrEmpty(atomDef)) return ("", vars); int braceIdx = atomDef.IndexOf('{'); string typePath = (braceIdx >= 0 ? atomDef.Substring(0, braceIdx) : atomDef).Trim(); if (braceIdx >= 0) { int closeIdx = FindMatchingBrace(atomDef, braceIdx); if (closeIdx > braceIdx) { string body = atomDef.Substring(braceIdx + 1, closeIdx - braceIdx - 1); foreach (var stmt in SplitInlineStatements(body)) { int eq = stmt.IndexOf('='); if (eq <= 0) continue; string key = stmt.Substring(0, eq).Trim(); string val = UnescapeInlineVarValue(stmt.Substring(eq + 1).Trim()); if (!string.IsNullOrEmpty(key)) vars[key] = val; } } } return (typePath, vars); } private static int FindMatchingBrace(string value, int openBraceIndex) { bool inDoubleQuote = false; bool inSingleQuote = false; bool escaped = false; int depth = 0; for (int i = openBraceIndex; i < value.Length; i++) { char c = value[i]; if (escaped) { escaped = false; continue; } if (c == '\\' && (inDoubleQuote || inSingleQuote)) { escaped = true; continue; } if (c == '"' && !inSingleQuote) { inDoubleQuote = !inDoubleQuote; continue; } if (c == '\'' && !inDoubleQuote) { inSingleQuote = !inSingleQuote; continue; } if (inDoubleQuote || inSingleQuote) continue; if (c == '{') depth++; else if (c == '}') { depth--; if (depth == 0) return i; } } return -1; } private static IEnumerable SplitInlineStatements(string body) { bool inDoubleQuote = false; bool inSingleQuote = false; bool escaped = false; var current = new StringBuilder(); for (int i = 0; i < body.Length; i++) { char c = body[i]; if (escaped) { escaped = false; current.Append(c); continue; } if (c == '\\' && (inDoubleQuote || inSingleQuote)) { escaped = true; current.Append(c); continue; } if (c == '"' && !inSingleQuote) { inDoubleQuote = !inDoubleQuote; current.Append(c); continue; } if (c == '\'' && !inDoubleQuote) { inSingleQuote = !inSingleQuote; current.Append(c); continue; } if (c == ';' && !inDoubleQuote && !inSingleQuote) { string statement = current.ToString().Trim(); if (statement.Length > 0) yield return statement; current.Clear(); continue; } current.Append(c); } string tail = current.ToString().Trim(); if (tail.Length > 0) yield return tail; } private static string UnescapeInlineVarValue(string value) { char dq = (char)34; char sq = (char)39; char bs = (char)92; if (value.Length >= 2 && ((value[0] == dq && value[value.Length - 1] == dq) || (value[0] == sq && value[value.Length - 1] == sq))) { value = value.Substring(1, value.Length - 2); } var result = new StringBuilder(value); result.Replace("" + bs + bs, "" + bs); result.Replace("" + bs + dq, "" + dq); result.Replace("" + bs + sq, "" + sq); result.Replace("" + bs + (char)110, "" + (char)10); result.Replace("" + bs + (char)116, "" + (char)9); return result.ToString(); } /// /// Categorizes an atom type path into DM's standard stacking order: /// area (background) -> turf -> obj -> mob (foreground) -> unknown. /// public static int GetAtomCategory(string typePath) { if (typePath.StartsWith("/area", StringComparison.Ordinal)) return 0; if (typePath.StartsWith("/turf", StringComparison.Ordinal)) return 1; if (typePath.StartsWith("/obj", StringComparison.Ordinal)) return 2; if (typePath.StartsWith("/mob", StringComparison.Ordinal)) return 3; return 4; } /// /// Composes a new tile definition string by adding/replacing an atom within /// the existing stack. Turfs and areas are singular per tile (replace any /// existing atom of the same category); objs and mobs stack additively. /// public static string ComposeTileWithAtom(string existingTileId, string newAtomDef) { var atoms = (existingTileId == "0" || string.IsNullOrEmpty(existingTileId)) ? new List() : SplitAtoms(existingTileId); string newTypePath = ParseAtomDef(newAtomDef).TypePath; if (string.IsNullOrEmpty(newTypePath)) return existingTileId; int newCategory = GetAtomCategory(newTypePath); if (newCategory <= 1) { // Area/turf: only one per tile - replace any existing atom of the same category atoms.RemoveAll(a => GetAtomCategory(ParseAtomDef(a).TypePath) == newCategory); atoms.Add(newAtomDef); } else { // Obj/Mob: multiple can stack on one tile if (!atoms.Contains(newAtomDef)) atoms.Add(newAtomDef); } atoms = atoms.OrderBy(a => GetAtomCategory(ParseAtomDef(a).TypePath)).ToList(); return atoms.Count > 0 ? string.Join(",", atoms) : "0"; } public void RenderMap() { if (_canvas == null || Map == null) return; _canvas.Children.Clear(); _tileVisuals.Clear(); if (ShowAllPlanes) { RenderAllPlanes(); } else { RenderPlane(PlaneIndex + 1, 1.0); } } private void RenderAllPlanes() { if (Map == null) return; int planeCount = Math.Max(1, Map.MaxZ); double baseOpacity = 1.0 / planeCount; for (int z = planeCount; z >= 1; z--) { double opacity = baseOpacity; if (z == PlaneIndex + 1) opacity = Math.Max(opacity, 0.7); RenderPlane(z, opacity); } } private void RenderPlane(int plane, double opacity) { if (Map == null || _canvas == null) return; for (int y = 1; y <= Map.MaxY; y++) { for (int x = 1; x <= Map.MaxX; x++) { var key = (x, y, plane); if (!Map.Tiles.TryGetValue(key, out var tileId)) tileId = "0"; var tileRect = new Rectangle { Width = TileSize * Zoom - 1, Height = TileSize * Zoom - 1, Stroke = ShowGrid ? Brushes.Gray : Brushes.Transparent, StrokeThickness = 0.5, Tag = (x, y, plane) }; List? tooltipLines = null; if (tileId != "0" && !string.IsNullOrEmpty(tileId)) { // Parse and layer every atom on this tile (area -> turf -> obj -> mob), // each resolving its own icon/icon_state via inheritance + inline overrides. var atoms = ParseTileAtoms(tileId); tooltipLines = new List(); int atomIndex = 0; foreach (var atom in atoms) { var tileIcon = GetAtomIcon(atom.TypePath, atom.Vars, out string? resolvedIcon, out string? resolvedIconState, out string? resolvedIconFilePath); tooltipLines.Add(BuildAtomTooltip(atom.TypePath, resolvedIcon, resolvedIconState, atom.Vars)); if (!string.IsNullOrEmpty(resolvedIconFilePath)) tooltipLines.Add($"file: {resolvedIconFilePath}"); if (tileIcon != null) { var img = new Image { Source = tileIcon, Width = TileSize * Zoom - 2, Height = TileSize * Zoom - 2, Stretch = Stretch.Uniform, Opacity = opacity }; RenderOptions.SetBitmapScalingMode(img, BitmapScalingMode.NearestNeighbor); Canvas.SetLeft(img, (x - 1) * TileSize * Zoom + 1); Canvas.SetTop(img, (y - 1) * TileSize * Zoom + 1); ToolTipService.SetToolTip(img, string.Join("\n", tooltipLines)); _canvas.Children.Add(img); } else { // Coded atom (no actual .cpi icon resource) — show a coloured placeholder var placeholder = new Rectangle { Width = Math.Max(4, TileSize * Zoom / 4), Height = Math.Max(4, TileSize * Zoom / 4), Fill = new SolidColorBrush(GetCodedAtomFallbackColor(atom.TypePath)), Opacity = opacity * 0.7, RadiusX = 2, RadiusY = 2, ToolTip = string.Join("\n", tooltipLines) }; Canvas.SetLeft(placeholder, (x - 1) * TileSize * Zoom + TileSize * Zoom / 2 - placeholder.Width / 2); Canvas.SetTop(placeholder, (y - 1) * TileSize * Zoom + TileSize * Zoom / 2 - placeholder.Height / 2); Panel.SetZIndex(placeholder, atomIndex); _canvas.Children.Add(placeholder); } atomIndex++; } tileRect.Fill = Brushes.Transparent; } else { tileRect.Fill = new SolidColorBrush(Color.FromRgb(0x33, 0x33, 0x33)) { Opacity = opacity }; } if (tooltipLines is { Count: > 0 }) ToolTipService.SetToolTip(tileRect, string.Join("\n", tooltipLines)); Canvas.SetLeft(tileRect, (x - 1) * TileSize * Zoom); Canvas.SetTop(tileRect, (y - 1) * TileSize * Zoom); _canvas.Children.Add(tileRect); _tileVisuals[(x, y, plane)] = tileRect; } } } /// /// Builds a human-readable summary of an atom's identity and coded properties /// for display in tile tooltips, e.g. "/obj/machine/door — icon: icons/door.dmi, state: closed". /// private static string BuildAtomTooltip(string typePath, string? icon, string? iconState, Dictionary overrides) { var sb = new StringBuilder(typePath); if (!string.IsNullOrEmpty(icon) || !string.IsNullOrEmpty(iconState)) { sb.Append(" ("); if (!string.IsNullOrEmpty(icon)) sb.Append("icon: ").Append(icon); if (!string.IsNullOrEmpty(iconState)) { if (!string.IsNullOrEmpty(icon)) sb.Append(", "); sb.Append("state: ").Append(iconState); } sb.Append(')'); } foreach (var kv in overrides) { if (kv.Key is "icon" or "icon_state") continue; sb.Append($"\n {kv.Key} = {kv.Value}"); } return sb.ToString(); } private void UpdateTileVisual((int x, int y, int z) key) { if (!_tileVisuals.TryGetValue(key, out var rect) || Map == null) return; string tileId = Map.Tiles.TryGetValue(key, out var tid) ? tid : "0"; Color baseColor = GetTileBaseColor(tileId); double opacity = ShowAllPlanes ? 1.0 / Map.MaxZ : 1.0; if (ShowAllPlanes && key.z == PlaneIndex + 1) opacity = 0.7; rect.Fill = new SolidColorBrush(baseColor) { Opacity = opacity }; if (_selectionRect.HasValue && key.z == PlaneIndex + 1) { var sel = _selectionRect.Value; if (key.x >= sel.X && key.x < sel.X + sel.Width && key.y >= sel.Y && key.y < sel.Y + sel.Height) { rect.Stroke = Brushes.Orange; rect.StrokeThickness = 1.5; } else { rect.Stroke = ShowGrid ? Brushes.Gray : Brushes.Transparent; rect.StrokeThickness = 0.5; } } } private static Color GetTileBaseColor(string tileId) { if (tileId == "0" || string.IsNullOrEmpty(tileId)) return Color.FromRgb(0x33, 0x33, 0x33); if (tileId.Contains("/turf")) return Color.FromRgb(0x66, 0x99, 0xCC); if (tileId.Contains("/area")) return Color.FromRgb(0xCC, 0x99, 0x66); if (tileId.Contains("/obj")) return Color.FromRgb(0x99, 0xCC, 0x66); if (tileId.Contains("/mob")) return Color.FromRgb(0xCC, 0x66, 0x99); return Color.FromRgb(0xAA, 0xAA, 0xAA); } public void BeginEditCommand() { if (_currentEditCommand == null) { _currentEditCommand = new MapEditCommand(); } } public void RecordEdit((int x, int y, int z) key, string oldValue, string newValue) { if (_currentEditCommand == null) BeginEditCommand(); if (_currentEditCommand == null) return; if (!_currentEditCommand.OldValues.ContainsKey(key)) _currentEditCommand.OldValues[key] = oldValue; _currentEditCommand.NewValues[key] = newValue; } public void EndEditCommand() { if (_currentEditCommand == null) return; if (_currentEditCommand.HasChanges) { _undoStack.Push(_currentEditCommand); if (_undoStack.Count > MaxUndoStack) { // _undoStack enumerates newest-first. Retain the newest commands // without reversing their relative order into any unexpected stack. var retained = _undoStack.ToArray().Take(MaxUndoStack).Reverse().ToArray(); _undoStack.Clear(); foreach (var command in retained) _undoStack.Push(command); } _redoStack.Clear(); } _currentEditCommand = null; } /// Starts a clean undo history when a different map document is loaded. public void ResetHistory() { _currentEditCommand = null; _undoStack.Clear(); _redoStack.Clear(); _selectionRect = null; _clipboardTiles.Clear(); _clipboardW = 0; _clipboardH = 0; } /// Notifies the host window after a map-level edit outside tile painting. public void NotifyMapChanged() => MapChanged?.Invoke(this, EventArgs.Empty); private void ApplyEditCommand(MapEditCommand command, bool undo) { if (Map == null) return; var values = undo ? command.OldValues : command.NewValues; foreach (var kvp in values) { SetTileValue(kvp.Key, kvp.Value); } } public void SetTileValue((int x, int y, int z) key, string value) { if (Map == null) return; if (value == "0") Map.Tiles.Remove(key); else Map.Tiles[key] = value; } public void CopySelection() { if (_selectionRect == null || Map == null) return; _clipboardTiles.Clear(); _clipboardW = _selectionRect.Value.Width; _clipboardH = _selectionRect.Value.Height; _clipboardAnchorX = _selectionRect.Value.X; _clipboardAnchorY = _selectionRect.Value.Y; for (int row = 0; row < _clipboardH; row++) { for (int col = 0; col < _clipboardW; col++) { int x = _selectionRect.Value.X + col; int y = _selectionRect.Value.Y + row; var key = (x, y, PlaneIndex + 1); string value = Map.Tiles.TryGetValue(key, out var tile) ? tile : "0"; _clipboardTiles[(col, row)] = value; } } } public void CutSelection() { if (_selectionRect == null || Map == null) return; CopySelection(); BeginEditCommand(); for (int row = 0; row < _selectionRect.Value.Height; row++) { for (int col = 0; col < _selectionRect.Value.Width; col++) { int x = _selectionRect.Value.X + col; int y = _selectionRect.Value.Y + row; var key = (x, y, PlaneIndex + 1); string oldValue = Map.Tiles.TryGetValue(key, out var tile) ? tile : "0"; RecordEdit(key, oldValue, "0"); SetTileValue(key, "0"); } } EndEditCommand(); RenderMap(); MapChanged?.Invoke(this, EventArgs.Empty); } public void PasteClipboard(int destX, int destY) { if (_clipboardW <= 0 || _clipboardH <= 0 || Map == null) return; BeginEditCommand(); for (int row = 0; row < _clipboardH; row++) { for (int col = 0; col < _clipboardW; col++) { int x = destX + col; int y = destY + row; if (x < 1 || y < 1 || x > Map.MaxX || y > Map.MaxY) continue; var key = (x, y, PlaneIndex + 1); string oldValue = Map.Tiles.TryGetValue(key, out var tile) ? tile : "0"; string newValue = _clipboardTiles.TryGetValue((col, row), out var clipboardValue) ? clipboardValue : "0"; if (oldValue == newValue) continue; RecordEdit(key, oldValue, newValue); SetTileValue(key, newValue); } } EndEditCommand(); RenderMap(); MapChanged?.Invoke(this, EventArgs.Empty); } public void PasteClipboardAtCurrentTile() { if (SelectedTile == null) return; PasteClipboard(SelectedTile.X, SelectedTile.Y); } public bool HasClipboardData => _clipboardW > 0 && _clipboardH > 0; public Int32Rect? SelectionRect => _selectionRect; public void Deselect() { _selectionRect = null; RenderMap(); } public void Undo() { if (Map == null || _undoStack.Count == 0) return; var command = _undoStack.Pop(); ApplyEditCommand(command, true); _redoStack.Push(command); RenderMap(); MapChanged?.Invoke(this, EventArgs.Empty); } public void Redo() { if (Map == null || _redoStack.Count == 0) return; var command = _redoStack.Pop(); ApplyEditCommand(command, false); _undoStack.Push(command); RenderMap(); MapChanged?.Invoke(this, EventArgs.Empty); } public bool CanUndo => _undoStack.Count > 0; public bool CanRedo => _redoStack.Count > 0; /// /// Resolves and loads the icon image for a single atom, honoring DM's var /// inheritance (walking up the type hierarchy for "icon"/"icon_state" when /// not directly declared) as well as any inline var overrides painted onto /// the map (e.g. "/obj/door{icon_state=\"open\"}"). Also returns the /// resolved filesystem path used to load the DMI when available. /// public ImageSource? GetAtomIcon(string typePath, Dictionary? overrides, out string? resolvedIcon, out string? resolvedIconState, out string? resolvedIconFilePath) { overrides ??= new Dictionary(); resolvedIcon = overrides.TryGetValue("icon", out var iconOverride) ? iconOverride : TypeRegistry.ResolveVar(typePath, "icon")?.ToText(); resolvedIconState = overrides.TryGetValue("icon_state", out var stateOverride) ? stateOverride : TypeRegistry.ResolveVar(typePath, "icon_state")?.ToText(); // Fallback: if TypeRegistry.DefaultVars didn't have icon/icon_state (e.g. the // type defines them via runtime initializer expressions), instantiate the type // and grab the runtime defaults. if (string.IsNullOrWhiteSpace(resolvedIcon) && string.IsNullOrWhiteSpace(resolvedIconState)) { try { var atom = TypeRegistry.Instantiate(typePath); var runtimeIcon = atom.GetVar("icon")?.ToText(); var runtimeState = atom.GetVar("icon_state")?.ToText(); if (!string.IsNullOrWhiteSpace(runtimeIcon)) { resolvedIcon = runtimeIcon; if (!overrides.ContainsKey("icon")) overrides["icon"] = runtimeIcon; } if (!string.IsNullOrWhiteSpace(runtimeState)) { resolvedIconState = runtimeState; if (!overrides.ContainsKey("icon_state")) overrides["icon_state"] = runtimeState; } } catch { // ignore — proceed with whatever (possibly null) we have } } int resolvedDir = ResolveDirection(typePath, overrides); string cacheKey = $"{typePath}|{resolvedIcon}|{resolvedIconState}|dir={resolvedDir}"; // Compute resolved path even on cache hits so callers can display it. resolvedIconFilePath = ResolveIconFullPath(resolvedIcon); if (_tileCache.TryGetValue(cacheKey, out var cached)) return cached; var image = LoadIconFrame(resolvedIcon, resolvedIconState, resolvedDir, out var fullPath); resolvedIconFilePath = fullPath; _tileCache[cacheKey] = image; return image; } private static int ResolveDirection(string typePath, Dictionary overrides) { if (overrides.TryGetValue("dir", out var dirOverride) && int.TryParse(dirOverride, System.Globalization.NumberStyles.Integer, System.Globalization.CultureInfo.InvariantCulture, out int dirFromOverride)) { return dirFromOverride; } int dirFromType = (int)(TypeRegistry.ResolveVar(typePath, "dir")?.ToNumber() ?? 2); return dirFromType == 0 ? 2 : dirFromType; } /// Backward-compatible overload without inline var overrides. public ImageSource? GetAtomIcon(string typePath, Dictionary? overrides = null) => GetAtomIcon(typePath, overrides, out _, out _, out _); /// /// Resolve an icon path string to the first existing filesystem path, trying /// a Craft++ search order: as-given (relative or absolute), project root, /// and project `icons/` folder, with optional ".cpi" extension appended. /// Returns null when no candidate exists. /// private string? ResolveIconFullPath(string? iconPath) { if (string.IsNullOrEmpty(iconPath)) return null; // Normalize forward slashes to platform separator for File.Exists checks var normalized = iconPath.Replace('/', System.IO.Path.DirectorySeparatorChar).Trim(); bool hasExt = System.IO.Path.HasExtension(normalized); var candidates = new List(); // If absolute path, check it (and with .cpi if needed) if (System.IO.Path.IsPathRooted(normalized)) { candidates.Add(normalized); if (!hasExt) candidates.Add(normalized + ".cpi"); } else { // Try as-given relative to current working directory candidates.Add(normalized); if (!hasExt) candidates.Add(normalized + ".cpi"); // Try project root if available if (!string.IsNullOrEmpty(ProjectPath)) { candidates.Add(System.IO.Path.Combine(ProjectPath, normalized)); if (!hasExt) candidates.Add(System.IO.Path.Combine(ProjectPath, normalized + ".cpi")); // Try icons subfolder inside project candidates.Add(System.IO.Path.Combine(ProjectPath, "icons", normalized)); if (!hasExt) candidates.Add(System.IO.Path.Combine(ProjectPath, "icons", normalized + ".cpi")); } } foreach (var c in candidates.Where(s => !string.IsNullOrEmpty(s)).Select(s => s!)) { try { if (System.IO.File.Exists(c)) return System.IO.Path.GetFullPath(c); } catch { // ignore and continue } } return null; } public static int DirectionToFrameIndex(int dir, int dirCount) { if (dirCount <= 1) return 0; if (dirCount >= 8) { return dir switch { 2 => 0, 1 => 1, 4 => 2, 8 => 3, 6 => 4, 10 => 5, 5 => 6, 9 => 7, _ => 0 }; } if (dirCount >= 4) { return dir switch { 2 => 0, 1 => 1, 4 => 2, 8 => 3, _ => 0 }; } return dir switch { 1 => 1, _ => 0 }; } private ImageSource? LoadIconFrame(string? iconPath, string? iconState, int dir, out string? resolvedFullPath) { resolvedFullPath = null; if (string.IsNullOrEmpty(iconPath)) return null; resolvedFullPath = ResolveIconFullPath(iconPath); if (string.IsNullOrEmpty(resolvedFullPath)) return null; if (!_iconCache.TryGetValue(resolvedFullPath, out var dmi)) { dmi = new CraftIconFile(resolvedFullPath); if (dmi.Load()) _iconCache[resolvedFullPath] = dmi; else dmi = null; } if (dmi == null) return null; var state = string.IsNullOrEmpty(iconState) ? dmi.States.FirstOrDefault() : dmi.GetState(iconState) ?? dmi.States.FirstOrDefault(); if (state == null) return null; int frameDir = Math.Clamp(DirectionToFrameIndex(dir, state.DirCount), 0, Math.Max(0, state.DirCount - 1)); var frame = state.GetFrame(frameDir, 0); if (frame == null || frame.Length <= 8) return null; try { using var ms = new MemoryStream(frame); var decoder = new PngBitmapDecoder(ms, BitmapCreateOptions.None, BitmapCacheOption.OnLoad); return decoder.Frames[0]; } catch { return null; } } public void RenderMinimap(Canvas minimapCanvas, double maxWidth = 150, double maxHeight = 60) { if (minimapCanvas == null || Map == null) return; minimapCanvas.Children.Clear(); if (Map.MaxX <= 0 || Map.MaxY <= 0) return; double scaleX = maxWidth / Map.MaxX; double scaleY = maxHeight / Map.MaxY; double scale = Math.Min(scaleX, scaleY); double width = Map.MaxX * scale; double height = Map.MaxY * scale; minimapCanvas.Width = width; minimapCanvas.Height = height; for (int y = 1; y <= Map.MaxY; y++) { for (int x = 1; x <= Map.MaxX; x++) { var key = (x, y, PlaneIndex + 1); if (!Map.Tiles.TryGetValue(key, out var tileId)) tileId = "0"; Color baseColor = GetTileBaseColor(tileId); var rect = new Rectangle { Width = Math.Max(1, scale - 0.5), Height = Math.Max(1, scale - 0.5), Fill = new SolidColorBrush(baseColor), Stroke = Brushes.Transparent, StrokeThickness = 0 }; Canvas.SetLeft(rect, (x - 1) * scale); Canvas.SetTop(rect, (y - 1) * scale); minimapCanvas.Children.Add(rect); } } } private static Color GetCodedAtomFallbackColor(string typePath) { if (typePath.StartsWith("/area")) return Color.FromRgb(0xAA, 0x55, 0xFF); // purple if (typePath.StartsWith("/turf")) return Color.FromRgb(0x55, 0xCC, 0x55); // green if (typePath.StartsWith("/obj")) return Color.FromRgb(0x55, 0x99, 0xFF); // blue if (typePath.StartsWith("/mob")) return Color.FromRgb(0xFF, 0x66, 0x66); // red return Color.FromRgb(0x88, 0x88, 0x88); // gray } }