using Godot; using System.Collections.Generic; namespace Voider.Tools; /// /// Standalone tool for generating collision shapes from MeshInstance3D nodes in any scene. /// Run the CollisionGenerator.tscn scene, select a target scene, and generate collision. /// public partial class CollisionGenerator : Control { public enum CollisionMode { Trimesh, // Accurate concave collision - best for static geometry Convex, // Fast convex hull - good for simple objects MultiConvex, // Multiple convex shapes - balanced (falls back to trimesh) Merged // Single merged trimesh - best for large maps with many meshes } // UI Elements private LineEdit _scenePathEdit; private Button _browseButton; private Button _generateButton; private Button _clearButton; private Button _mergeButton; private OptionButton _collisionModeOption; private SpinBox _collisionLayerSpin; private SpinBox _collisionMaskSpin; private SpinBox _minSizeThresholdSpin; private CheckBox _skipSmallMeshesCheck; private RichTextLabel _logOutput; private FileDialog _fileDialog; private Panel _previewPanel; private SubViewport _previewViewport; private Node3D _previewRoot; private Camera3D _previewCamera; // State private string _targetScenePath = ""; private PackedScene _loadedScene; private Node _sceneInstance; public override void _Ready() { BuildUI(); Log("[b]Collision Generator Tool[/b]\nSelect a scene file to generate collision shapes."); } private void BuildUI() { // Main container var mainContainer = new VBoxContainer(); mainContainer.SetAnchorsAndOffsetsPreset(LayoutPreset.FullRect); mainContainer.AddThemeConstantOverride("separation", 10); AddChild(mainContainer); // Add some padding var marginContainer = new MarginContainer(); marginContainer.AddThemeConstantOverride("margin_left", 20); marginContainer.AddThemeConstantOverride("margin_right", 20); marginContainer.AddThemeConstantOverride("margin_top", 20); marginContainer.AddThemeConstantOverride("margin_bottom", 20); marginContainer.SizeFlagsHorizontal = SizeFlags.ExpandFill; marginContainer.SizeFlagsVertical = SizeFlags.ExpandFill; mainContainer.AddChild(marginContainer); var innerContainer = new VBoxContainer(); innerContainer.AddThemeConstantOverride("separation", 15); marginContainer.AddChild(innerContainer); // Title var title = new Label(); title.Text = "Collision Generator"; title.AddThemeFontSizeOverride("font_size", 24); innerContainer.AddChild(title); // Scene selection section var sceneSection = CreateSection("Target Scene"); innerContainer.AddChild(sceneSection); var sceneRow = new HBoxContainer(); sceneRow.AddThemeConstantOverride("separation", 10); sceneSection.AddChild(sceneRow); _scenePathEdit = new LineEdit(); _scenePathEdit.PlaceholderText = "Select a .tscn file..."; _scenePathEdit.SizeFlagsHorizontal = SizeFlags.ExpandFill; _scenePathEdit.Editable = false; sceneRow.AddChild(_scenePathEdit); _browseButton = new Button(); _browseButton.Text = "Browse..."; _browseButton.Pressed += OnBrowsePressed; sceneRow.AddChild(_browseButton); // Options section var optionsSection = CreateSection("Options"); innerContainer.AddChild(optionsSection); var optionsGrid = new GridContainer(); optionsGrid.Columns = 2; optionsGrid.AddThemeConstantOverride("h_separation", 20); optionsGrid.AddThemeConstantOverride("v_separation", 10); optionsSection.AddChild(optionsGrid); // Collision Mode optionsGrid.AddChild(CreateLabel("Collision Mode:")); _collisionModeOption = new OptionButton(); _collisionModeOption.AddItem("Trimesh (Accurate)", 0); _collisionModeOption.AddItem("Convex (Fast)", 1); _collisionModeOption.AddItem("Multi-Convex (Balanced)", 2); _collisionModeOption.Selected = 0; _collisionModeOption.SizeFlagsHorizontal = SizeFlags.ExpandFill; optionsGrid.AddChild(_collisionModeOption); // Collision Layer optionsGrid.AddChild(CreateLabel("Collision Layer:")); _collisionLayerSpin = new SpinBox(); _collisionLayerSpin.MinValue = 1; _collisionLayerSpin.MaxValue = 32; _collisionLayerSpin.Value = 1; optionsGrid.AddChild(_collisionLayerSpin); // Collision Mask optionsGrid.AddChild(CreateLabel("Collision Mask:")); _collisionMaskSpin = new SpinBox(); _collisionMaskSpin.MinValue = 1; _collisionMaskSpin.MaxValue = 32; _collisionMaskSpin.Value = 1; optionsGrid.AddChild(_collisionMaskSpin); // Skip Small Meshes optionsGrid.AddChild(CreateLabel("Skip Small Meshes:")); _skipSmallMeshesCheck = new CheckBox(); _skipSmallMeshesCheck.ButtonPressed = true; _skipSmallMeshesCheck.Text = "Filter out tiny objects"; optionsGrid.AddChild(_skipSmallMeshesCheck); // Min Size Threshold optionsGrid.AddChild(CreateLabel("Min Size (meters):")); _minSizeThresholdSpin = new SpinBox(); _minSizeThresholdSpin.MinValue = 0.0; _minSizeThresholdSpin.MaxValue = 10.0; _minSizeThresholdSpin.Step = 0.1; _minSizeThresholdSpin.Value = 0.1; optionsGrid.AddChild(_minSizeThresholdSpin); // Actions section var actionsSection = CreateSection("Actions"); innerContainer.AddChild(actionsSection); var actionsRow = new HBoxContainer(); actionsRow.AddThemeConstantOverride("separation", 10); actionsSection.AddChild(actionsRow); _generateButton = new Button(); _generateButton.Text = "Generate Collision"; _generateButton.Disabled = true; _generateButton.SizeFlagsHorizontal = SizeFlags.ExpandFill; _generateButton.Pressed += OnGeneratePressed; actionsRow.AddChild(_generateButton); _clearButton = new Button(); _clearButton.Text = "Clear Collision"; _clearButton.Disabled = true; _clearButton.SizeFlagsHorizontal = SizeFlags.ExpandFill; _clearButton.Pressed += OnClearPressed; actionsRow.AddChild(_clearButton); _mergeButton = new Button(); _mergeButton.Text = "Merge Existing"; _mergeButton.Disabled = true; _mergeButton.SizeFlagsHorizontal = SizeFlags.ExpandFill; _mergeButton.Pressed += OnMergePressed; actionsRow.AddChild(_mergeButton); // Log output section var logSection = CreateSection("Log"); logSection.SizeFlagsVertical = SizeFlags.ExpandFill; innerContainer.AddChild(logSection); var logScroll = new ScrollContainer(); logScroll.SizeFlagsVertical = SizeFlags.ExpandFill; logScroll.SizeFlagsHorizontal = SizeFlags.ExpandFill; logSection.AddChild(logScroll); _logOutput = new RichTextLabel(); _logOutput.BbcodeEnabled = true; _logOutput.SizeFlagsHorizontal = SizeFlags.ExpandFill; _logOutput.SizeFlagsVertical = SizeFlags.ExpandFill; _logOutput.FitContent = true; _logOutput.ScrollFollowing = true; logScroll.AddChild(_logOutput); // File dialog _fileDialog = new FileDialog(); _fileDialog.FileMode = FileDialog.FileModeEnum.OpenFile; _fileDialog.Access = FileDialog.AccessEnum.Resources; _fileDialog.Filters = new string[] { "*.tscn ; Godot Scene Files" }; _fileDialog.Title = "Select Scene to Process"; _fileDialog.Size = new Vector2I(800, 600); _fileDialog.FileSelected += OnFileSelected; AddChild(_fileDialog); } private VBoxContainer CreateSection(string title) { var container = new VBoxContainer(); container.AddThemeConstantOverride("separation", 8); var label = new Label(); label.Text = title; label.AddThemeFontSizeOverride("font_size", 16); container.AddChild(label); var separator = new HSeparator(); container.AddChild(separator); return container; } private Label CreateLabel(string text) { var label = new Label(); label.Text = text; return label; } private void OnBrowsePressed() { _fileDialog.CurrentDir = "res://Scenes"; _fileDialog.PopupCentered(); } private void OnFileSelected(string path) { _targetScenePath = path; _scenePathEdit.Text = path; _generateButton.Disabled = false; _clearButton.Disabled = false; _mergeButton.Disabled = false; // Load and analyze the scene LoadAndAnalyzeScene(path); } private void LoadAndAnalyzeScene(string path) { Log($"\n[b]Loading:[/b] {path}"); _loadedScene = GD.Load(path); if (_loadedScene == null) { LogError($"Failed to load scene: {path}"); return; } _sceneInstance = _loadedScene.Instantiate(); if (_sceneInstance == null) { LogError("Failed to instantiate scene"); return; } // Count meshes and existing collision var meshInstances = new List(); var existingCollision = new List(); CollectMeshInstances(_sceneInstance, meshInstances); CollectExistingCollision(_sceneInstance, existingCollision); Log($"[color=cyan]Found {meshInstances.Count} mesh instances[/color]"); Log($"[color=yellow]Found {existingCollision.Count} existing collision nodes[/color]"); // Don't add to tree, just keep reference for processing } private void OnGeneratePressed() { if (string.IsNullOrEmpty(_targetScenePath)) { LogError("No scene selected"); return; } GenerateCollisionForScene(); } private void OnClearPressed() { if (string.IsNullOrEmpty(_targetScenePath)) { LogError("No scene selected"); return; } ClearCollisionFromScene(); } private void OnMergePressed() { if (string.IsNullOrEmpty(_targetScenePath)) { LogError("No scene selected"); return; } MergeExistingCollision(); } private void GenerateCollisionForScene() { Log($"\n[b]Generating collision for:[/b] {_targetScenePath}"); // Reload fresh instance _loadedScene = GD.Load(_targetScenePath); _sceneInstance = _loadedScene.Instantiate(); // We need to add scene to tree temporarily to calculate global transforms AddChild(_sceneInstance); var meshInstances = new List(); CollectMeshInstances(_sceneInstance, meshInstances); var collisionMode = (CollisionMode)_collisionModeOption.Selected; uint collisionLayer = (uint)_collisionLayerSpin.Value; uint collisionMask = (uint)_collisionMaskSpin.Value; bool skipSmall = _skipSmallMeshesCheck.ButtonPressed; float minSize = (float)_minSizeThresholdSpin.Value; // Create a container for all collision at the scene root level var collisionContainer = _sceneInstance.GetNodeOrNull("_GeneratedCollision"); if (collisionContainer == null) { collisionContainer = new Node3D(); collisionContainer.Name = "_GeneratedCollision"; _sceneInstance.AddChild(collisionContainer); collisionContainer.Owner = _sceneInstance; } GeneratePerMeshCollision(meshInstances, collisionContainer, collisionMode, collisionLayer, collisionMask, skipSmall, minSize); // Remove from tree before saving RemoveChild(_sceneInstance); // Restore mouse cursor (Player script may have captured it) Input.MouseMode = Input.MouseModeEnum.Visible; // Save the modified scene SaveScene(); } private void MergeExistingCollision() { Log($"\n[b]Merging existing collision in:[/b] {_targetScenePath}"); // Reload fresh instance - bypass cache ResourceLoader.Load(_targetScenePath, cacheMode: ResourceLoader.CacheMode.Ignore); _loadedScene = GD.Load(_targetScenePath); _sceneInstance = _loadedScene.Instantiate(); // Add to tree temporarily AddChild(_sceneInstance); // Find the collision container var collisionContainer = _sceneInstance.GetNodeOrNull("_GeneratedCollision"); if (collisionContainer == null || collisionContainer.GetChildCount() == 0) { RemoveChild(_sceneInstance); _sceneInstance.Free(); Log("[color=yellow]No existing collision found to merge[/color]"); return; } // Check if already merged if (collisionContainer.GetChildCount() == 1 && collisionContainer.GetChild(0).Name == "MergedCollision") { RemoveChild(_sceneInstance); _sceneInstance.Free(); Log("[color=yellow]Collision is already merged[/color]"); return; } uint collisionLayer = (uint)_collisionLayerSpin.Value; uint collisionMask = (uint)_collisionMaskSpin.Value; Log($"[color=cyan]Merging {collisionContainer.GetChildCount()} collision nodes into single shape[/color]"); // Collect all faces from existing collision shapes var allFaces = new List(); var nodesToRemove = new List(); // Handle new structure: single StaticBody3D with multiple CollisionShape3D children var generatedBody = collisionContainer.GetNodeOrNull("GeneratedStaticBody"); if (generatedBody != null) { foreach (var child in generatedBody.GetChildren()) { if (child is CollisionShape3D collisionShape && collisionShape.Shape is ConcavePolygonShape3D concaveShape) { var globalTransform = collisionShape.GlobalTransform; var faces = concaveShape.GetFaces(); foreach (var vertex in faces) { allFaces.Add(globalTransform * vertex); } } } nodesToRemove.Add(generatedBody); } // Also handle legacy structure: multiple StaticBody3D nodes foreach (var child in collisionContainer.GetChildren()) { if (child is StaticBody3D staticBody && child.Name != "GeneratedStaticBody" && child.Name != "MergedCollision") { var globalTransform = staticBody.GlobalTransform; foreach (var bodyChild in staticBody.GetChildren()) { if (bodyChild is CollisionShape3D collisionShape && collisionShape.Shape is ConcavePolygonShape3D concaveShape) { var faces = concaveShape.GetFaces(); foreach (var vertex in faces) { allFaces.Add(globalTransform * vertex); } } } nodesToRemove.Add(child); } } Log($" Collected {allFaces.Count / 3} triangles from existing shapes"); if (allFaces.Count < 9) { RemoveChild(_sceneInstance); _sceneInstance.Free(); LogError("Not enough faces to create merged collision"); return; } // Remove old collision nodes foreach (var node in nodesToRemove) { collisionContainer.RemoveChild(node); node.Free(); } // Create the merged collision shape var shape = new ConcavePolygonShape3D(); shape.SetFaces(allFaces.ToArray()); var mergedBody = new StaticBody3D(); mergedBody.Name = "MergedCollision"; mergedBody.CollisionLayer = collisionLayer; mergedBody.CollisionMask = collisionMask; var mergedShape = new CollisionShape3D(); mergedShape.Name = "CollisionShape3D"; mergedShape.Shape = shape; mergedBody.AddChild(mergedShape); collisionContainer.AddChild(mergedBody); mergedBody.Owner = _sceneInstance; mergedShape.Owner = _sceneInstance; // Remove from tree before saving RemoveChild(_sceneInstance); // Restore mouse cursor Input.MouseMode = Input.MouseModeEnum.Visible; // Save the modified scene SaveScene(); Log($"\n[b]Complete![/b] Merged into single collision with {allFaces.Count / 3} triangles"); } private void GenerateMergedCollision( List meshInstances, Node3D collisionContainer, uint collisionLayer, uint collisionMask, bool skipSmall, float minSize) { Log("[color=cyan]Using MERGED collision mode - combining all meshes into single shape[/color]"); // Collect all vertices from all meshes, transformed to world space var allVertices = new List(); int meshCount = 0; int skipped = 0; foreach (var meshInstance in meshInstances) { if (meshInstance.Mesh == null) continue; // Filter by size if enabled if (skipSmall) { var aabb = meshInstance.Mesh.GetAabb(); float maxExtent = Mathf.Max(aabb.Size.X, Mathf.Max(aabb.Size.Y, aabb.Size.Z)); if (maxExtent < minSize) { skipped++; continue; } } // Get mesh data and transform vertices to world space var globalTransform = meshInstance.GlobalTransform; var mesh = meshInstance.Mesh; for (int surfaceIdx = 0; surfaceIdx < mesh.GetSurfaceCount(); surfaceIdx++) { var arrays = mesh.SurfaceGetArrays(surfaceIdx); if (arrays == null || arrays.Count == 0) continue; var vertices = arrays[(int)Mesh.ArrayType.Vertex].AsVector3Array(); if (vertices == null) continue; foreach (var vertex in vertices) { // Transform vertex to world space allVertices.Add(globalTransform * vertex); } } meshCount++; } Log($" Processed {meshCount} meshes, skipped {skipped} small meshes"); Log($" Total vertices: {allVertices.Count}"); if (allVertices.Count < 3) { LogError("Not enough vertices to create collision shape"); return; } // Create a single ConcavePolygonShape3D from all vertices // We need to reconstruct faces - for merged collision, we'll collect faces instead var allFaces = new List(); foreach (var meshInstance in meshInstances) { if (meshInstance.Mesh == null) continue; if (skipSmall) { var aabb = meshInstance.Mesh.GetAabb(); float maxExtent = Mathf.Max(aabb.Size.X, Mathf.Max(aabb.Size.Y, aabb.Size.Z)); if (maxExtent < minSize) continue; } var globalTransform = meshInstance.GlobalTransform; var mesh = meshInstance.Mesh; for (int surfaceIdx = 0; surfaceIdx < mesh.GetSurfaceCount(); surfaceIdx++) { var arrays = mesh.SurfaceGetArrays(surfaceIdx); if (arrays == null || arrays.Count == 0) continue; var vertices = arrays[(int)Mesh.ArrayType.Vertex].AsVector3Array(); var indices = arrays[(int)Mesh.ArrayType.Index].AsInt32Array(); if (vertices == null) continue; if (indices != null && indices.Length > 0) { // Indexed mesh - use indices to build triangles for (int i = 0; i + 2 < indices.Length; i += 3) { allFaces.Add(globalTransform * vertices[indices[i]]); allFaces.Add(globalTransform * vertices[indices[i + 1]]); allFaces.Add(globalTransform * vertices[indices[i + 2]]); } } else { // Non-indexed mesh - vertices are already in triangle order for (int i = 0; i + 2 < vertices.Length; i += 3) { allFaces.Add(globalTransform * vertices[i]); allFaces.Add(globalTransform * vertices[i + 1]); allFaces.Add(globalTransform * vertices[i + 2]); } } } } Log($" Total triangles: {allFaces.Count / 3}"); if (allFaces.Count < 9) { LogError("Not enough faces to create collision shape"); return; } // Create the merged collision shape var shape = new ConcavePolygonShape3D(); shape.SetFaces(allFaces.ToArray()); // Create single StaticBody3D at origin (vertices are already in world space) var staticBody = new StaticBody3D(); staticBody.Name = "MergedCollision"; staticBody.CollisionLayer = collisionLayer; staticBody.CollisionMask = collisionMask; var collisionShape = new CollisionShape3D(); collisionShape.Name = "CollisionShape3D"; collisionShape.Shape = shape; staticBody.AddChild(collisionShape); collisionContainer.AddChild(staticBody); staticBody.Owner = _sceneInstance; collisionShape.Owner = _sceneInstance; Log($"\n[b]Complete![/b] Created single merged collision with {allFaces.Count / 3} triangles"); } private void GeneratePerMeshCollision( List meshInstances, Node3D collisionContainer, CollisionMode collisionMode, uint collisionLayer, uint collisionMask, bool skipSmall, float minSize) { int generated = 0; int skipped = 0; // Create or get a single StaticBody3D to hold all collision shapes var staticBody = collisionContainer.GetNodeOrNull("GeneratedStaticBody"); if (staticBody == null) { staticBody = new StaticBody3D(); staticBody.Name = "GeneratedStaticBody"; staticBody.CollisionLayer = collisionLayer; staticBody.CollisionMask = collisionMask; collisionContainer.AddChild(staticBody); staticBody.Owner = _sceneInstance; } foreach (var meshInstance in meshInstances) { if (meshInstance.Mesh == null) continue; // Filter by size if enabled if (skipSmall) { var aabb = meshInstance.Mesh.GetAabb(); float maxExtent = Mathf.Max(aabb.Size.X, Mathf.Max(aabb.Size.Y, aabb.Size.Z)); if (maxExtent < minSize) { skipped++; continue; } } // Check if we already generated collision for this mesh var collisionName = GetUniqueMeshName(meshInstance); if (staticBody.HasNode(collisionName)) { Log($" [color=yellow]Skipping[/color] {GetNodePath(meshInstance)} - already has collision"); skipped++; continue; } var shape = CreateCollisionShape(meshInstance.Mesh, collisionMode); if (shape == null) { LogError($" Failed to create shape for {meshInstance.Name}"); continue; } var collisionShape = new CollisionShape3D(); collisionShape.Name = collisionName; collisionShape.Shape = shape; collisionShape.GlobalTransform = meshInstance.GlobalTransform; staticBody.AddChild(collisionShape); collisionShape.Owner = _sceneInstance; generated++; } Log($"\n[b]Complete![/b] Generated: {generated}, Skipped: {skipped}"); } private string GetUniqueMeshName(MeshInstance3D mesh) { // Create a unique name based on the mesh's path in the scene var path = GetNodePath(mesh); return path.Replace("/", "_").Replace(" ", "_"); } private void ClearCollisionFromScene() { Log($"\n[b]Clearing collision from:[/b] {_targetScenePath}"); // Reload fresh instance - bypass cache ResourceLoader.Load(_targetScenePath, cacheMode: ResourceLoader.CacheMode.Ignore); _loadedScene = GD.Load(_targetScenePath); _sceneInstance = _loadedScene.Instantiate(); // Add to tree temporarily (required for proper scene packing) AddChild(_sceneInstance); // Simply remove the _GeneratedCollision container if it exists var collisionContainer = _sceneInstance.GetNodeOrNull("_GeneratedCollision"); if (collisionContainer == null) { RemoveChild(_sceneInstance); _sceneInstance.Free(); Log("[color=yellow]No generated collision found in this scene[/color]"); return; } int cleared = collisionContainer.GetChildCount(); // Must use RemoveChild + Free, not QueueFree (which waits until next frame) _sceneInstance.RemoveChild(collisionContainer); collisionContainer.Free(); // Remove from tree before saving RemoveChild(_sceneInstance); // Restore mouse cursor Input.MouseMode = Input.MouseModeEnum.Visible; // Save the modified scene SaveScene(); Log($"\n[b]Complete![/b] Cleared {cleared} collision nodes"); } private void SaveScene() { var packedScene = new PackedScene(); var error = packedScene.Pack(_sceneInstance); if (error != Error.Ok) { LogError($"Failed to pack scene: {error}"); return; } error = ResourceSaver.Save(packedScene, _targetScenePath); if (error != Error.Ok) { LogError($"Failed to save scene: {error}"); return; } Log($"[color=green]Saved:[/color] {_targetScenePath}"); } private void CollectMeshInstances(Node node, List meshInstances) { if (node is MeshInstance3D meshInstance) { meshInstances.Add(meshInstance); } foreach (var child in node.GetChildren()) { CollectMeshInstances(child, meshInstances); } } private void CollectExistingCollision(Node node, List collisionNodes) { if (node is CollisionShape3D || node is StaticBody3D) { collisionNodes.Add(node); } foreach (var child in node.GetChildren()) { CollectExistingCollision(child, collisionNodes); } } private void CollectGeneratedCollision(Node node, List toRemove, List toUnwrap) { // StaticBody3D nodes we created (named *_collision) if (node is StaticBody3D staticBody && staticBody.Name.ToString().EndsWith("_collision")) { toUnwrap.Add(staticBody); return; } // CollisionShape3D nodes that are direct children of MeshInstance3D if (node is CollisionShape3D && node.GetParent() is MeshInstance3D) { toRemove.Add(node); } foreach (var child in node.GetChildren()) { CollectGeneratedCollision(child, toRemove, toUnwrap); } } private bool HasExistingCollision(MeshInstance3D meshInstance) { // Check for collision shape as child foreach (var child in meshInstance.GetChildren()) { if (child is CollisionShape3D) return true; } // Check for sibling StaticBody3D with matching name var parent = meshInstance.GetParent(); if (parent != null) { var collisionName = $"{meshInstance.Name}_collision"; foreach (var sibling in parent.GetChildren()) { if (sibling is StaticBody3D && sibling.Name == collisionName) return true; } } // Check if parent is a StaticBody3D we created if (parent is StaticBody3D sb && sb.Name.ToString().EndsWith("_collision")) return true; return false; } private Shape3D CreateCollisionShape(Mesh mesh, CollisionMode mode) { return mode switch { CollisionMode.Trimesh => mesh.CreateTrimeshShape(), CollisionMode.Convex => mesh.CreateConvexShape(), CollisionMode.MultiConvex => mesh.CreateTrimeshShape(), // Fallback _ => mesh.CreateTrimeshShape() }; } private string GetNodePath(Node node) { var path = node.Name.ToString(); var parent = node.GetParent(); while (parent != null && parent != _sceneInstance) { path = $"{parent.Name}/{path}"; parent = parent.GetParent(); } return path; } private void Log(string message) { _logOutput.AppendText(message + "\n"); GD.Print(message.Replace("[b]", "").Replace("[/b]", "") .Replace("[color=green]", "").Replace("[color=yellow]", "") .Replace("[color=cyan]", "").Replace("[color=orange]", "") .Replace("[color=red]", "").Replace("[/color]", "")); } private void LogError(string message) { Log($"[color=red]ERROR: {message}[/color]"); } }