From ee97b2fec33ce3a64bead93cb205158154ca8804 Mon Sep 17 00:00:00 2001 From: Sebastian Bularca Date: Sun, 29 Mar 2026 18:31:03 +0200 Subject: [PATCH] Added a bunch of utilities and modfief the character data structue --- Assets/Code/Core/EntryPoint.cs | 2 +- .../Entities/CharacterAttributesFactory.cs | 11 +- .../GameState/Entities/CharacterFactory.cs | 13 +- .../Entities/CharacterStatsFactory.cs | 22 +- .../DefaultCharacterSystemsFactory.cs | 6 +- .../Entities/DefaultPartySettings.cs | 8 +- .../GameState/Entities/EntitiesDefinitions.cs | 84 +- .../GameState/Entities/ModifiersFactory.cs | 9 +- .../GameState/Entities/ModifiersPerksList.cs | 11 - .../Entities/ModifiersPerksList.cs.meta | 3 - .../GameState/Entities/PartyCreatorModel.cs | 80 +- .../Code/GameState/Entities/PartyFactory.cs | 8 +- ...ettings.cs => StarterCharacterSettings.cs} | 2 +- ....meta => StarterCharacterSettings.cs.meta} | 0 .../Entities/CharacterBaseSettings.asset | 98 +- Packages/com.jovian.logger/Editor.meta | 8 + .../com.jovian.logger/Editor/CustomConsole.cs | 2224 +++++++++++++++++ .../Editor/CustomConsole.cs.meta | 2 + .../Editor/Jovian.LoggerEditor.asmdef | 18 + .../Editor/Jovian.LoggerEditor.asmdef.meta | 7 + .../Editor/JovianProjectSettings.cs | 67 + .../Editor/JovianProjectSettings.cs.meta | 2 + .../Editor/LoggerSettingsEditor.cs | 277 ++ .../Editor/LoggerSettingsEditor.cs.meta | 2 + .../Editor/LoggerSettingsProvider.cs | 60 + .../Editor/LoggerSettingsProvider.cs.meta | 2 + .../Editor/RemoteLogReceiver.cs | 245 ++ .../Editor/RemoteLogReceiver.cs.meta | 2 + Packages/com.jovian.logger/Runtime.meta | 8 + .../com.jovian.logger/Runtime/GlobalLogger.cs | 181 ++ .../Runtime/GlobalLogger.cs.meta | 2 + .../Runtime/InternalLogger.cs | 193 ++ .../Runtime/InternalLogger.cs.meta | 2 + .../Runtime/Jovian.Logger.asmdef | 14 + .../Runtime/Jovian.Logger.asmdef.meta | 7 + .../com.jovian.logger/Runtime/LogCategory.cs | 36 + .../Runtime/LogCategory.cs.meta | 2 + Packages/com.jovian.logger/Runtime/Logger.cs | 280 +++ .../com.jovian.logger/Runtime/Logger.cs.meta | 2 + .../Runtime/LoggerSettings.cs | 36 + .../Runtime/LoggerSettings.cs.meta | 2 + .../Runtime/LoggerUtility.cs | 152 ++ .../Runtime/LoggerUtility.cs.meta | 2 + .../Runtime/RemoteLogSender.cs | 270 ++ .../Runtime/RemoteLogSender.cs.meta | 2 + Packages/com.jovian.logger/package.json | 9 + Packages/com.jovian.logger/package.json.meta | 7 + Packages/com.jovian.recentassets/Editor.meta | 8 + .../Editor/Jovian.AssetsHistory.asmdef | 16 + .../Editor/Jovian.AssetsHistory.asmdef.meta | 7 + .../Editor/RecentAssetsMenu.cs | 387 +++ .../Editor/RecentAssetsMenu.cs.meta | 2 + Packages/com.jovian.recentassets/package.json | 6 + .../com.jovian.recentassets/package.json.meta | 7 + Packages/com.jovian.utilties/Editor.meta | 8 + .../Editor/CollisionExtractorEditor.cs | 144 ++ .../Editor/CollisionExtractorEditor.cs.meta | 2 + .../CustomRenderQueueMaterialListEditor.cs | 84 + ...ustomRenderQueueMaterialListEditor.cs.meta | 2 + ...omRenderQueueMaterialListPropertyDrawer.cs | 26 + ...derQueueMaterialListPropertyDrawer.cs.meta | 2 + .../Editor/FloatRangePropertyDrawer.cs | 29 + .../Editor/FloatRangePropertyDrawer.cs.meta | 2 + .../Editor/JovianUtilities.Editor.asmdef | 18 + .../Editor/JovianUtilities.Editor.asmdef.meta | 7 + Packages/com.jovian.utilties/Runtime.meta | 8 + .../Runtime/ArrayUtility.cs | 36 + .../Runtime/ArrayUtility.cs.meta | 2 + .../com.jovian.utilties/Runtime/BowserLog.cs | 42 + .../Runtime/BowserLog.cs.meta | 2 + .../Runtime/CachedMainCamera.cs | 38 + .../Runtime/CachedMainCamera.cs.meta | 2 + .../Runtime/CanvasAutoAssignWorldCamera.cs | 24 + .../CanvasAutoAssignWorldCamera.cs.meta | 2 + .../Runtime/CollectionUtility.cs | 99 + .../Runtime/CollectionUtility.cs.meta | 2 + .../Runtime/ColliderUtilities.cs | 9 + .../Runtime/ColliderUtilities.cs.meta | 2 + .../Runtime/CollisionExtractor.cs | 18 + .../Runtime/CollisionExtractor.cs.meta | 2 + .../Runtime/CustomRenderQueueMaterialList.cs | 64 + .../CustomRenderQueueMaterialList.cs.meta | 2 + .../Runtime/CustomRenderQueueRenderer.cs | 43 + .../Runtime/CustomRenderQueueRenderer.cs.meta | 2 + .../Runtime/EditorRuntimeAccessible.meta | 8 + .../EditorRuntimeAccessible/AssetUtility.cs | 299 +++ .../AssetUtility.cs.meta | 2 + .../EditorServiceLocator.cs | 154 ++ .../EditorServiceLocator.cs.meta | 2 + .../EditorRuntimeAccessible/GizmosUtility.cs | 78 + .../GizmosUtility.cs.meta | 2 + .../HierarchyUtility.cs | 32 + .../HierarchyUtility.cs.meta | 2 + .../InspectorGUIUtility.cs | 68 + .../InspectorGUIUtility.cs.meta | 2 + .../EditorRuntimeAccessible/SceneUtility.cs | 203 ++ .../SceneUtility.cs.meta | 2 + .../SerializedObjectUtility.cs | 211 ++ .../SerializedObjectUtility.cs.meta | 2 + .../com.jovian.utilties/Runtime/FloatRange.cs | 45 + .../Runtime/FloatRange.cs.meta | 2 + .../Runtime/GameObjectUtilities.cs | 59 + .../Runtime/GameObjectUtilities.cs.meta | 2 + .../Runtime/JovianUtilities.asmdef | 14 + .../Runtime/JovianUtilities.asmdef.meta | 7 + .../Runtime/LoadingProcessHandler.cs | 53 + .../Runtime/LoadingProcessHandler.cs.meta | 2 + Packages/com.jovian.utilties/package.json | 7 + .../com.jovian.utilties/package.json.meta | 7 + Packages/packages-lock.json | 20 + 110 files changed, 6752 insertions(+), 169 deletions(-) delete mode 100644 Assets/Code/GameState/Entities/ModifiersPerksList.cs delete mode 100644 Assets/Code/GameState/Entities/ModifiersPerksList.cs.meta rename Assets/Code/GameState/Entities/{CharacterBaseSettings.cs => StarterCharacterSettings.cs} (95%) rename Assets/Code/GameState/Entities/{CharacterBaseSettings.cs.meta => StarterCharacterSettings.cs.meta} (100%) create mode 100644 Packages/com.jovian.logger/Editor.meta create mode 100644 Packages/com.jovian.logger/Editor/CustomConsole.cs create mode 100644 Packages/com.jovian.logger/Editor/CustomConsole.cs.meta create mode 100644 Packages/com.jovian.logger/Editor/Jovian.LoggerEditor.asmdef create mode 100644 Packages/com.jovian.logger/Editor/Jovian.LoggerEditor.asmdef.meta create mode 100644 Packages/com.jovian.logger/Editor/JovianProjectSettings.cs create mode 100644 Packages/com.jovian.logger/Editor/JovianProjectSettings.cs.meta create mode 100644 Packages/com.jovian.logger/Editor/LoggerSettingsEditor.cs create mode 100644 Packages/com.jovian.logger/Editor/LoggerSettingsEditor.cs.meta create mode 100644 Packages/com.jovian.logger/Editor/LoggerSettingsProvider.cs create mode 100644 Packages/com.jovian.logger/Editor/LoggerSettingsProvider.cs.meta create mode 100644 Packages/com.jovian.logger/Editor/RemoteLogReceiver.cs create mode 100644 Packages/com.jovian.logger/Editor/RemoteLogReceiver.cs.meta create mode 100644 Packages/com.jovian.logger/Runtime.meta create mode 100644 Packages/com.jovian.logger/Runtime/GlobalLogger.cs create mode 100644 Packages/com.jovian.logger/Runtime/GlobalLogger.cs.meta create mode 100644 Packages/com.jovian.logger/Runtime/InternalLogger.cs create mode 100644 Packages/com.jovian.logger/Runtime/InternalLogger.cs.meta create mode 100644 Packages/com.jovian.logger/Runtime/Jovian.Logger.asmdef create mode 100644 Packages/com.jovian.logger/Runtime/Jovian.Logger.asmdef.meta create mode 100644 Packages/com.jovian.logger/Runtime/LogCategory.cs create mode 100644 Packages/com.jovian.logger/Runtime/LogCategory.cs.meta create mode 100644 Packages/com.jovian.logger/Runtime/Logger.cs create mode 100644 Packages/com.jovian.logger/Runtime/Logger.cs.meta create mode 100644 Packages/com.jovian.logger/Runtime/LoggerSettings.cs create mode 100644 Packages/com.jovian.logger/Runtime/LoggerSettings.cs.meta create mode 100644 Packages/com.jovian.logger/Runtime/LoggerUtility.cs create mode 100644 Packages/com.jovian.logger/Runtime/LoggerUtility.cs.meta create mode 100644 Packages/com.jovian.logger/Runtime/RemoteLogSender.cs create mode 100644 Packages/com.jovian.logger/Runtime/RemoteLogSender.cs.meta create mode 100644 Packages/com.jovian.logger/package.json create mode 100644 Packages/com.jovian.logger/package.json.meta create mode 100644 Packages/com.jovian.recentassets/Editor.meta create mode 100644 Packages/com.jovian.recentassets/Editor/Jovian.AssetsHistory.asmdef create mode 100644 Packages/com.jovian.recentassets/Editor/Jovian.AssetsHistory.asmdef.meta create mode 100644 Packages/com.jovian.recentassets/Editor/RecentAssetsMenu.cs create mode 100644 Packages/com.jovian.recentassets/Editor/RecentAssetsMenu.cs.meta create mode 100644 Packages/com.jovian.recentassets/package.json create mode 100644 Packages/com.jovian.recentassets/package.json.meta create mode 100644 Packages/com.jovian.utilties/Editor.meta create mode 100644 Packages/com.jovian.utilties/Editor/CollisionExtractorEditor.cs create mode 100644 Packages/com.jovian.utilties/Editor/CollisionExtractorEditor.cs.meta create mode 100644 Packages/com.jovian.utilties/Editor/CustomRenderQueueMaterialListEditor.cs create mode 100644 Packages/com.jovian.utilties/Editor/CustomRenderQueueMaterialListEditor.cs.meta create mode 100644 Packages/com.jovian.utilties/Editor/CustomRenderQueueMaterialListPropertyDrawer.cs create mode 100644 Packages/com.jovian.utilties/Editor/CustomRenderQueueMaterialListPropertyDrawer.cs.meta create mode 100644 Packages/com.jovian.utilties/Editor/FloatRangePropertyDrawer.cs create mode 100644 Packages/com.jovian.utilties/Editor/FloatRangePropertyDrawer.cs.meta create mode 100644 Packages/com.jovian.utilties/Editor/JovianUtilities.Editor.asmdef create mode 100644 Packages/com.jovian.utilties/Editor/JovianUtilities.Editor.asmdef.meta create mode 100644 Packages/com.jovian.utilties/Runtime.meta create mode 100644 Packages/com.jovian.utilties/Runtime/ArrayUtility.cs create mode 100644 Packages/com.jovian.utilties/Runtime/ArrayUtility.cs.meta create mode 100644 Packages/com.jovian.utilties/Runtime/BowserLog.cs create mode 100644 Packages/com.jovian.utilties/Runtime/BowserLog.cs.meta create mode 100644 Packages/com.jovian.utilties/Runtime/CachedMainCamera.cs create mode 100644 Packages/com.jovian.utilties/Runtime/CachedMainCamera.cs.meta create mode 100644 Packages/com.jovian.utilties/Runtime/CanvasAutoAssignWorldCamera.cs create mode 100644 Packages/com.jovian.utilties/Runtime/CanvasAutoAssignWorldCamera.cs.meta create mode 100644 Packages/com.jovian.utilties/Runtime/CollectionUtility.cs create mode 100644 Packages/com.jovian.utilties/Runtime/CollectionUtility.cs.meta create mode 100644 Packages/com.jovian.utilties/Runtime/ColliderUtilities.cs create mode 100644 Packages/com.jovian.utilties/Runtime/ColliderUtilities.cs.meta create mode 100644 Packages/com.jovian.utilties/Runtime/CollisionExtractor.cs create mode 100644 Packages/com.jovian.utilties/Runtime/CollisionExtractor.cs.meta create mode 100644 Packages/com.jovian.utilties/Runtime/CustomRenderQueueMaterialList.cs create mode 100644 Packages/com.jovian.utilties/Runtime/CustomRenderQueueMaterialList.cs.meta create mode 100644 Packages/com.jovian.utilties/Runtime/CustomRenderQueueRenderer.cs create mode 100644 Packages/com.jovian.utilties/Runtime/CustomRenderQueueRenderer.cs.meta create mode 100644 Packages/com.jovian.utilties/Runtime/EditorRuntimeAccessible.meta create mode 100644 Packages/com.jovian.utilties/Runtime/EditorRuntimeAccessible/AssetUtility.cs create mode 100644 Packages/com.jovian.utilties/Runtime/EditorRuntimeAccessible/AssetUtility.cs.meta create mode 100644 Packages/com.jovian.utilties/Runtime/EditorRuntimeAccessible/EditorServiceLocator.cs create mode 100644 Packages/com.jovian.utilties/Runtime/EditorRuntimeAccessible/EditorServiceLocator.cs.meta create mode 100644 Packages/com.jovian.utilties/Runtime/EditorRuntimeAccessible/GizmosUtility.cs create mode 100644 Packages/com.jovian.utilties/Runtime/EditorRuntimeAccessible/GizmosUtility.cs.meta create mode 100644 Packages/com.jovian.utilties/Runtime/EditorRuntimeAccessible/HierarchyUtility.cs create mode 100644 Packages/com.jovian.utilties/Runtime/EditorRuntimeAccessible/HierarchyUtility.cs.meta create mode 100644 Packages/com.jovian.utilties/Runtime/EditorRuntimeAccessible/InspectorGUIUtility.cs create mode 100644 Packages/com.jovian.utilties/Runtime/EditorRuntimeAccessible/InspectorGUIUtility.cs.meta create mode 100644 Packages/com.jovian.utilties/Runtime/EditorRuntimeAccessible/SceneUtility.cs create mode 100644 Packages/com.jovian.utilties/Runtime/EditorRuntimeAccessible/SceneUtility.cs.meta create mode 100644 Packages/com.jovian.utilties/Runtime/EditorRuntimeAccessible/SerializedObjectUtility.cs create mode 100644 Packages/com.jovian.utilties/Runtime/EditorRuntimeAccessible/SerializedObjectUtility.cs.meta create mode 100644 Packages/com.jovian.utilties/Runtime/FloatRange.cs create mode 100644 Packages/com.jovian.utilties/Runtime/FloatRange.cs.meta create mode 100644 Packages/com.jovian.utilties/Runtime/GameObjectUtilities.cs create mode 100644 Packages/com.jovian.utilties/Runtime/GameObjectUtilities.cs.meta create mode 100644 Packages/com.jovian.utilties/Runtime/JovianUtilities.asmdef create mode 100644 Packages/com.jovian.utilties/Runtime/JovianUtilities.asmdef.meta create mode 100644 Packages/com.jovian.utilties/Runtime/LoadingProcessHandler.cs create mode 100644 Packages/com.jovian.utilties/Runtime/LoadingProcessHandler.cs.meta create mode 100644 Packages/com.jovian.utilties/package.json create mode 100644 Packages/com.jovian.utilties/package.json.meta diff --git a/Assets/Code/Core/EntryPoint.cs b/Assets/Code/Core/EntryPoint.cs index 616e913..06ba1a8 100644 --- a/Assets/Code/Core/EntryPoint.cs +++ b/Assets/Code/Core/EntryPoint.cs @@ -100,7 +100,7 @@ namespace Nox.Core { var adventureData = new AdventureData(); - var characterBaseSettings = Addressables.LoadAssetAsync("CharacterBaseSettings").WaitForCompletion(); + var characterBaseSettings = Addressables.LoadAssetAsync("CharacterBaseSettings").WaitForCompletion(); var perKRegistry = Addressables.LoadAssetAsync("PerksRegistry ").WaitForCompletion(); var characterRegistry = Addressables.LoadAssetAsync("CharacterRegistry").WaitForCompletion(); var defaultPartySettings = Addressables.LoadAssetAsync("DefaultPartySettings").WaitForCompletion(); diff --git a/Assets/Code/GameState/Entities/CharacterAttributesFactory.cs b/Assets/Code/GameState/Entities/CharacterAttributesFactory.cs index 16348cc..b1d02d3 100644 --- a/Assets/Code/GameState/Entities/CharacterAttributesFactory.cs +++ b/Assets/Code/GameState/Entities/CharacterAttributesFactory.cs @@ -1,8 +1,9 @@ using System; +using System.Linq; namespace Nox.Game { public interface ICharacterAttributesFactory { - EntityAttributes Create(EntityAttributes attributes); + EntityAttributes Create(EntityAttributes entityAttributes); } public sealed class CharacterAttributesFactory : ICharacterAttributesFactory { @@ -12,12 +13,12 @@ namespace Nox.Game { this.characterRegistry = characterRegistry; } - public EntityAttributes Create(EntityAttributes attributes) { - if(attributes.might <= 0 || attributes.reflex <= 0 || attributes.knowledge <= 0 || attributes.perception <= 0) { + public EntityAttributes Create(EntityAttributes entityAttributes) { + if(entityAttributes.attributes.All(a => a.value != 0)) { throw new ArgumentOutOfRangeException( "attributes cannot be zero or negative.", new ArgumentException() ); } - - return attributes; + //TODO: Handle attributes modifiers and perks + return entityAttributes; } } } diff --git a/Assets/Code/GameState/Entities/CharacterFactory.cs b/Assets/Code/GameState/Entities/CharacterFactory.cs index f27802b..6dd8990 100644 --- a/Assets/Code/GameState/Entities/CharacterFactory.cs +++ b/Assets/Code/GameState/Entities/CharacterFactory.cs @@ -27,6 +27,7 @@ namespace Nox.Game { public EntityStats stats; public EntityAttributes attributes; public PerksData perksData = new(); + public ModifiersData modifiersData = new(); } public sealed class CharacterFactory : ICharacterFactory { @@ -49,6 +50,7 @@ namespace Nox.Game { } var attributes = attributesFactory.Create(request.attributes); + var stats = statsFactory.Create(request.attributes); var character = new CharacterDefinition { ID = string.IsNullOrWhiteSpace(request.id) ? Guid.NewGuid().ToString("N") : request.id, @@ -57,14 +59,9 @@ namespace Nox.Game { @class = request.characterClass, role = CharacterRole.Protagonist, Attributes = attributes, - Stats = { - level = statsFactory.Create(attributes).level, - health = statsFactory.Create(attributes).health, - stamina = statsFactory.Create(attributes).stamina, - experience = statsFactory.Create(attributes).experience - }, - perksData = new PerksData(), - activeModifiers = new ModifiersData() + Stats = stats, + perksData = request.perksData ?? new PerksData(), + activeModifiers = request.modifiersData ?? new ModifiersData() }; AddStartingPerks(character, request.perksData); diff --git a/Assets/Code/GameState/Entities/CharacterStatsFactory.cs b/Assets/Code/GameState/Entities/CharacterStatsFactory.cs index a3bc65c..de11245 100644 --- a/Assets/Code/GameState/Entities/CharacterStatsFactory.cs +++ b/Assets/Code/GameState/Entities/CharacterStatsFactory.cs @@ -1,31 +1,25 @@ using System; +using System.Linq; namespace Nox.Game { public interface ICharacterStatsFactory { EntityStats Create(EntityAttributes attributes); } - public sealed class CharacterStatsFactoryOptions { - public EntityStats entityStats = new EntityStats() { - health = 10, - stamina = 10, - level = 1, - experience = 0 - }; - } - public sealed class CharacterStatsFactory : ICharacterStatsFactory { - private readonly CharacterStatsFactoryOptions options; + private readonly CharacterRegistry characterRegistry; - public CharacterStatsFactory(CharacterStatsFactoryOptions options = null) { - this.options = options ?? new CharacterStatsFactoryOptions(); + public CharacterStatsFactory(CharacterRegistry characterRegistry) { + this.characterRegistry = characterRegistry; } public EntityStats Create(EntityAttributes attributes) { - if(attributes == null) { - throw new ArgumentNullException(nameof(attributes)); + if(attributes.attributes.All(a => a.value != 0)) { + throw new ArgumentOutOfRangeException( "attributes cannot be zero or negative.", new ArgumentException() ); } + //TODO: Create stats based on attributes + // int maxHealth = options.baseHealth + attributes.might * options.mightHealthBonus; // int maxStamina = options.baseStamina + // attributes.might * options.mightStaminaBonus + diff --git a/Assets/Code/GameState/Entities/DefaultCharacterSystemsFactory.cs b/Assets/Code/GameState/Entities/DefaultCharacterSystemsFactory.cs index 05c44ee..3eece8a 100644 --- a/Assets/Code/GameState/Entities/DefaultCharacterSystemsFactory.cs +++ b/Assets/Code/GameState/Entities/DefaultCharacterSystemsFactory.cs @@ -3,16 +3,16 @@ using System; namespace Nox.Game { public static class DefaultCharacterSystemsFactory { public static ICharacterSystems Create( - CharacterBaseSettings characterBaseSettings, + StarterCharacterSettings starterCharacterSettings, PerksRegistry perksRegistry, CharacterRegistry characterRegistry, ModifiersRegistry modifiersRegistry) { IPerkFactory perkFactory = new PerkFactory(perksRegistry); IModfiersFactory modifiersFactory = new ModifiersFactory(modifiersRegistry); ICharacterAttributesFactory attributesFactory = new CharacterAttributesFactory(characterRegistry); - ICharacterStatsFactory statsFactory = new CharacterStatsFactory(); + ICharacterStatsFactory statsFactory = new CharacterStatsFactory(characterRegistry); ICharacterFactory characterFactory = new CharacterFactory(attributesFactory, statsFactory, perkFactory); - IPartyFactory partyFactory = new PartyFactory(characterBaseSettings); + IPartyFactory partyFactory = new PartyFactory(starterCharacterSettings); return new CharacterSystems(perkFactory, modifiersFactory, characterFactory, partyFactory); } diff --git a/Assets/Code/GameState/Entities/DefaultPartySettings.cs b/Assets/Code/GameState/Entities/DefaultPartySettings.cs index 2fd8649..f6fbc89 100644 --- a/Assets/Code/GameState/Entities/DefaultPartySettings.cs +++ b/Assets/Code/GameState/Entities/DefaultPartySettings.cs @@ -13,7 +13,7 @@ namespace Nox.Game { public List partyDefinitionSets; [Header("Testing Party Definition Sets")] - public CharacterBaseSettings characterBaseSettings; + public StarterCharacterSettings starterCharacterSettings; private void OnValidate() { if(String.IsNullOrEmpty(startingSetId)) { @@ -47,9 +47,9 @@ namespace Nox.Game { private void ApplyClassAndRacialBonuses(PartyDefinitionSet testingSet) { var partyDefinition = testingSet.partyDefinition; foreach(var member in partyDefinition.members) { - var baseSettings = characterBaseSettings.defaultEntityAttributes; - var classAttributes = characterBaseSettings.classBonuses.FirstOrDefault(c => c.@class == member.@class)?.bonusAttributes; - var racialAttributes = characterBaseSettings.racialBonuses.FirstOrDefault(rb => rb.race == member.race)?.bonusAttributes; + var baseSettings = starterCharacterSettings.defaultEntityAttributes; + var classAttributes = starterCharacterSettings.classBonuses.FirstOrDefault(c => c.@class == member.@class)?.bonusAttributes; + var racialAttributes = starterCharacterSettings.racialBonuses.FirstOrDefault(rb => rb.race == member.race)?.bonusAttributes; if (classAttributes != null && racialAttributes != null) { member.Attributes += baseSettings + classAttributes + racialAttributes; } diff --git a/Assets/Code/GameState/Entities/EntitiesDefinitions.cs b/Assets/Code/GameState/Entities/EntitiesDefinitions.cs index 7d2543d..5772255 100644 --- a/Assets/Code/GameState/Entities/EntitiesDefinitions.cs +++ b/Assets/Code/GameState/Entities/EntitiesDefinitions.cs @@ -12,6 +12,22 @@ namespace Nox.Game { EntityStats Stats { get; } } + public enum StatType { + None, + Health, + Stamina, + Level, + Experience + } + + public enum AttributeType { + None, + Might, + Reflex, + Knowledge, + Perception + } + public enum CharacterRole { None, Protagonist, @@ -33,29 +49,57 @@ namespace Nox.Game { Tunneler } + [Serializable] + public sealed class Stat { + private readonly StatType health; + public StatType type; + public int value; + public Stat(StatType health, int value) { + this.health = health; + this.value = value; + } + } + + public sealed record Attribute { + private readonly int values; + public AttributeType attribute; + public int value; + public Attribute(AttributeType attributeType, int values) { + this.values = values; + } + } + [Serializable] public sealed class EntityAttributes { - public int might; - public int reflex; - public int knowledge; - public int perception; + public Attribute[] attributes = { + new(AttributeType.Might, 1), + new(AttributeType.Reflex, 1), + new(AttributeType.Knowledge, 1), + new(AttributeType.Perception, 1) + }; public static EntityAttributes operator +(EntityAttributes a, EntityAttributes b) { return new EntityAttributes { - might = a.might + b.might, - reflex = a.reflex + b.reflex, - knowledge = a.knowledge + b.knowledge, - perception = a.perception + b.perception + attributes = a.attributes + .Select(attr => new Attribute(attr.attribute, attr.value + b.attributes + .First(attr2 => attr2.attribute == attr.attribute).value)) + .ToArray() }; } } [Serializable] public sealed class EntityStats { - public int health; - public int stamina; - public int level; - public int experience; + public Stat[] stats = { + new(StatType.Health, 0), + new(StatType.Stamina, 0), + new(StatType.Level, 1), + new(StatType.Experience, 0) + }; + + public int GetValue(StatType statType) { + return stats.First(stat => stat.type == statType).value; + } } [Serializable] @@ -65,8 +109,8 @@ namespace Nox.Game { public CharacterRace race; public CharacterClass @class; public CharacterRole role; - [SerializeField] EntityAttributes attributes; - [SerializeField] EntityStats stats; + [SerializeField] private EntityAttributes attributes; + [SerializeField] private EntityStats stats; public PerksData perksData = new(); public ModifiersData activeModifiers = new(); @@ -77,15 +121,8 @@ namespace Nox.Game { role = role, race = race, @class = @class, - attributes = new EntityAttributes { - might = attributes?.might ?? 1, - reflex = attributes?.reflex ?? 1, - knowledge = attributes?.knowledge ?? 1 - }, - stats = new EntityStats { - health = stats?.health ?? 1, - stamina = stats?.stamina ?? 1 - }, + attributes = new EntityAttributes(), + stats = new EntityStats(), perksData = new PerksData(), activeModifiers = new ModifiersData() }; @@ -100,6 +137,7 @@ namespace Nox.Game { get => displayName; set => displayName = value; } + public EntityAttributes Attributes { get => attributes; set => attributes = value; diff --git a/Assets/Code/GameState/Entities/ModifiersFactory.cs b/Assets/Code/GameState/Entities/ModifiersFactory.cs index 5c5d888..9df7f50 100644 --- a/Assets/Code/GameState/Entities/ModifiersFactory.cs +++ b/Assets/Code/GameState/Entities/ModifiersFactory.cs @@ -10,7 +10,7 @@ namespace Nox.Game { bool TryAddModifier(CharacterDefinition character, string modiferId); } - public enum ModifierType { + public enum ModifierRole { None, Flat, Addition, @@ -20,8 +20,11 @@ namespace Nox.Game { [Serializable] public sealed class ModifierDefinition { - public ModifierIds id; - public ModifierType type; + [ReadOnlyField] + public System.Guid id = Guid.NewGuid(); + public StatType statType; + public AttributeType attributeType; + public ModifierRole role; public float value; } diff --git a/Assets/Code/GameState/Entities/ModifiersPerksList.cs b/Assets/Code/GameState/Entities/ModifiersPerksList.cs deleted file mode 100644 index 4fef5d1..0000000 --- a/Assets/Code/GameState/Entities/ModifiersPerksList.cs +++ /dev/null @@ -1,11 +0,0 @@ -namespace Nox.Game { - public enum ModifierIds { - generic_mgt_health_multiplier, - warrior_mgt_health_multiplier, - generic_mgt_mana_multiplier, - generic_kno_mana_multiplier - } - - public enum PerksIds{ - } -} diff --git a/Assets/Code/GameState/Entities/ModifiersPerksList.cs.meta b/Assets/Code/GameState/Entities/ModifiersPerksList.cs.meta deleted file mode 100644 index 1a61d4a..0000000 --- a/Assets/Code/GameState/Entities/ModifiersPerksList.cs.meta +++ /dev/null @@ -1,3 +0,0 @@ -fileFormatVersion: 2 -guid: fc2631d096794cea9ae7c9cd82a38c53 -timeCreated: 1774366047 \ No newline at end of file diff --git a/Assets/Code/GameState/Entities/PartyCreatorModel.cs b/Assets/Code/GameState/Entities/PartyCreatorModel.cs index 6478ca7..804f364 100644 --- a/Assets/Code/GameState/Entities/PartyCreatorModel.cs +++ b/Assets/Code/GameState/Entities/PartyCreatorModel.cs @@ -19,46 +19,46 @@ namespace Nox.Game { }); CharacterTemplate[] companionTemplates = { - new() { - id = "companion-bruiser", - displayName = "Rook", - attributes = new EntityAttributes { might = 5, reflex = 2, knowledge = 1 }, - perksData = new PerksData(){ - perks = new List() - } - }, - new() { - id = "companion-scout", - displayName = "Sable", - attributes = new EntityAttributes { might = 2, reflex = 5, knowledge = 1 }, - perksData = new PerksData(){ - perks = new List() - } - }, - new() { - id = "companion-scholar", - displayName = "Quill", - attributes = new EntityAttributes { might = 1, reflex = 2, knowledge = 5 }, - perksData = new PerksData(){ - perks = new List() - } - }, - new() { - id = "companion-vanguard", - displayName = "Brant", - attributes = new EntityAttributes { might = 4, reflex = 3, knowledge = 2 }, - perksData = new PerksData(){ - perks = new List() - } - }, - new() { - id = "companion-tracker", - displayName = "Mira", - attributes = new EntityAttributes { might = 2, reflex = 4, knowledge = 3 }, - perksData = new PerksData(){ - perks = new List() - } - } + // new() { + // id = "companion-bruiser", + // displayName = "Rook", + // attributes = new EntityAttributes { might = 5, reflex = 2, knowledge = 1 }, + // perksData = new PerksData(){ + // perks = new List() + // } + // }, + // new() { + // id = "companion-scout", + // displayName = "Sable", + // attributes = new EntityAttributes { might = 2, reflex = 5, knowledge = 1 }, + // perksData = new PerksData(){ + // perks = new List() + // } + // }, + // new() { + // id = "companion-scholar", + // displayName = "Quill", + // attributes = new EntityAttributes { might = 1, reflex = 2, knowledge = 5 }, + // perksData = new PerksData(){ + // perks = new List() + // } + // }, + // new() { + // id = "companion-vanguard", + // displayName = "Brant", + // attributes = new EntityAttributes { might = 4, reflex = 3, knowledge = 2 }, + // perksData = new PerksData(){ + // perks = new List() + // } + // }, + // new() { + // id = "companion-tracker", + // displayName = "Mira", + // attributes = new EntityAttributes { might = 2, reflex = 4, knowledge = 3 }, + // perksData = new PerksData(){ + // perks = new List() + // } + // } }; var companions = new List(); diff --git a/Assets/Code/GameState/Entities/PartyFactory.cs b/Assets/Code/GameState/Entities/PartyFactory.cs index c1af840..f7a0a83 100644 --- a/Assets/Code/GameState/Entities/PartyFactory.cs +++ b/Assets/Code/GameState/Entities/PartyFactory.cs @@ -8,10 +8,10 @@ namespace Nox.Game { } public sealed class PartyFactory : IPartyFactory { - private readonly CharacterBaseSettings characterBaseSettings; + private readonly StarterCharacterSettings starterCharacterSettings; - public PartyFactory(CharacterBaseSettings characterBaseSettings) { - this.characterBaseSettings = characterBaseSettings; + public PartyFactory(StarterCharacterSettings starterCharacterSettings) { + this.starterCharacterSettings = starterCharacterSettings; } public PartyDefinition Create(CharacterDefinition protagonist, IEnumerable companions = null) { @@ -20,7 +20,7 @@ namespace Nox.Game { } var party = new PartyDefinition { - maxPartySize = characterBaseSettings.maxPartySize <= 0 ? int.MaxValue : characterBaseSettings.maxPartySize + maxPartySize = starterCharacterSettings.maxPartySize <= 0 ? int.MaxValue : starterCharacterSettings.maxPartySize }; var protagonistClone = protagonist.Clone(); diff --git a/Assets/Code/GameState/Entities/CharacterBaseSettings.cs b/Assets/Code/GameState/Entities/StarterCharacterSettings.cs similarity index 95% rename from Assets/Code/GameState/Entities/CharacterBaseSettings.cs rename to Assets/Code/GameState/Entities/StarterCharacterSettings.cs index 20e33a6..592e8e8 100644 --- a/Assets/Code/GameState/Entities/CharacterBaseSettings.cs +++ b/Assets/Code/GameState/Entities/StarterCharacterSettings.cs @@ -3,7 +3,7 @@ using UnityEngine; namespace Nox.Game { [CreateAssetMenu(fileName = "CharacterBaseSettings", menuName = "Nox/Database/Entities/CharacterBaseSettings")] - public class CharacterBaseSettings: ScriptableObject { + public class StarterCharacterSettings: ScriptableObject { [Header("Character Creation Defaults")] public DistributionPointsPerClass[] distributionPointsPerClass; public EntityAttributes defaultEntityAttributes; diff --git a/Assets/Code/GameState/Entities/CharacterBaseSettings.cs.meta b/Assets/Code/GameState/Entities/StarterCharacterSettings.cs.meta similarity index 100% rename from Assets/Code/GameState/Entities/CharacterBaseSettings.cs.meta rename to Assets/Code/GameState/Entities/StarterCharacterSettings.cs.meta diff --git a/Assets/Database/Entities/CharacterBaseSettings.asset b/Assets/Database/Entities/CharacterBaseSettings.asset index 309f64e..ff1a270 100644 --- a/Assets/Database/Entities/CharacterBaseSettings.asset +++ b/Assets/Database/Entities/CharacterBaseSettings.asset @@ -21,16 +21,16 @@ MonoBehaviour: points: 10 - class: 4 points: 10 - defaultEntityAttributes: - might: 1 - reflex: 1 - knowledge: 1 - perception: 1 defaultEntityStats: - health: 0 - stamina: 0 - level: 0 - experience: 0 + stats: + - type: 0 + value: 0 + - type: 1 + value: 0 + - type: 2 + value: 1 + - type: 3 + value: 0 defaultPerksData: perks: [] defaultModifiersData: @@ -38,9 +38,6 @@ MonoBehaviour: - id: 0 type: 3 value: 3 - - id: 1 - type: 3 - value: 4 - id: 2 type: 3 value: 1 @@ -49,64 +46,67 @@ MonoBehaviour: value: 2 racialBonuses: - race: 1 - bonusAttributes: - might: 0 - reflex: 0 - knowledge: 1 - perception: 0 bonusStats: - health: 0 - stamina: 0 - level: 0 - experience: 0 + stats: + - type: 0 + value: 10 + - type: 0 + value: 10 + - type: 0 + value: 1 + - type: 0 + value: 0 startingPerks: perks: [] permanentModifiers: modifiers: [] - race: 2 - bonusAttributes: - might: 0 - reflex: 1 - knowledge: 0 - perception: 0 bonusStats: - health: 0 - stamina: 0 - level: 0 - experience: 0 + stats: + - type: 0 + value: 10 + - type: 0 + value: 10 + - type: 0 + value: 1 + - type: 0 + value: 0 startingPerks: perks: [] permanentModifiers: modifiers: [] - race: 2 - bonusAttributes: - might: 1 - reflex: 0 - knowledge: 0 - perception: 0 bonusStats: - health: 0 - stamina: 0 - level: 0 - experience: 0 + stats: + - type: 0 + value: 10 + - type: 0 + value: 10 + - type: 0 + value: 1 + - type: 0 + value: 0 startingPerks: perks: [] permanentModifiers: modifiers: [] classBonuses: - class: 1 - bonusAttributes: - might: 0 - reflex: 0 - knowledge: 0 - perception: 0 bonusStats: - health: 0 - stamina: 0 - level: 0 - experience: 0 + stats: + - type: 0 + value: 10 + - type: 0 + value: 10 + - type: 0 + value: 1 + - type: 0 + value: 0 startingPerks: perks: [] permanentModifiers: - modifiers: [] + modifiers: + - id: 1 + type: 3 + value: 4 maxPartySize: 4 diff --git a/Packages/com.jovian.logger/Editor.meta b/Packages/com.jovian.logger/Editor.meta new file mode 100644 index 0000000..e6cc338 --- /dev/null +++ b/Packages/com.jovian.logger/Editor.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 61591a804f18a0c4684be901a22095d9 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/com.jovian.logger/Editor/CustomConsole.cs b/Packages/com.jovian.logger/Editor/CustomConsole.cs new file mode 100644 index 0000000..832ca9a --- /dev/null +++ b/Packages/com.jovian.logger/Editor/CustomConsole.cs @@ -0,0 +1,2224 @@ +using System; +using System.Collections.Concurrent; +using System.IO; +using System.Linq; +using System.Reflection; +using System.Text; +using System.Text.RegularExpressions; +using UnityEngine; +using UnityEditor; +using System.Collections.Generic; +#if UNITY_6000_3_OR_NEWER +using UnityEditor.Toolbars; +#endif + +namespace Jovian.Logger { + public class CustomConsole : EditorWindow { + private static List logs = new List(); + private static readonly ConcurrentQueue pendingLogs = new ConcurrentQueue(); + private static int lastLogCount; + private Vector2 scrollPosition; + private LogCategory selectedLogCategory = (LogCategory)(1 | 2 | 4 | 8 | 16 | 32 | 64 | 128 | 256 | 512 | 1024 | 2048 | 4096 | 8192); + private JovianLogType selectedJovianLogType = JovianLogType.Spam; + private string searchQuery = ""; + private LogSourceFilter selectedLogSource = LogSourceFilter.Both; + private bool showSpam = true; + private bool showInfo = true; + private bool showWarning = true; + private bool showError = true; + private bool showAssert = true; + private bool showException = true; + private static bool errorPause; + private static bool persistLogs = true; + private bool collapse; + private bool autoScroll = true; + private bool showTimestamps; + private bool showFrameCount; + private bool showColumns; + private bool useRegexSearch; + private Regex searchRegex; + private GUIStyle smallButtonStyle; + private bool remoteLogging; + private string remoteClientName = ""; + private bool newestFirst = true; + private static HashSet ignoredLogKeys = new HashSet(); + private GUIStyle pinnedBoxStyle; + + // Watch mode + private static readonly Dictionary watchEntries = new Dictionary(); + private static readonly List watchKeyOrder = new List(); // insertion order + private bool showWatchPanel = true; + + private enum SortColumn { None, Frame, Timestamp, Type, Source, File, Class, Category, Message } + private SortColumn sortColumn = SortColumn.None; + private bool sortAscending = true; + + private enum LogSourceFilter { Both, Custom, Unity, Remote } + private static LoggerSettings settings; + private static CustomConsole window; + + // ── Performance: cached filtering ── + private bool filterDirty = true; + private int lastFilteredLogCount; + private readonly List<(LogEntry log, int collapseCount)> cachedPinned = new(); + private readonly List<(LogEntry log, int collapseCount)> cachedUnpinned = new(); + + // ── Performance: incremental type counts ── + private int[] typeCounts = new int[6]; + private int lastCountedLogCount; + + // ── Performance: GUIStyle cache ── + private GUIStyle[] logStyleCache; + private bool logStyleCacheDirty = true; + + // ── Performance: virtualized scroll ── + private const float RowHeight = 30f; + private bool hasBlinkingIndicators; + + // Cached during Layout to ensure the same row count is drawn during Repaint, + // preventing "Invalid GUILayout state" errors from mismatched Begin/End calls. + private int _virtPinnedCount; + private int _virtUnpinnedCount; + private int _virtFirst; + private int _virtLast; + + // Subtle tint drawn over the default box background for logs created after the last compile + private static readonly Color newLogTint = new Color(1f, 1f, 1f, 0.06f); + private float viewportHeight; + + // ── Detail pane (stack trace) ── + private LogEntry selectedLog; + private float detailPaneHeight = 150f; + private Vector2 detailPaneScroll; + private bool isDraggingDetailPane; + private GUIStyle stackTraceLinkStyle; + private GUIStyle stackTraceDimStyle; + private GUIStyle stackTraceNormalStyle; + private const float MinDetailPaneHeight = 60f; + private const float MaxDetailPaneHeight = 500f; + private const float DetailPaneDragHandleHeight = 6f; + + // ── Older-log collapse (Persist mode) ── + private bool olderLogsCollapsed = true; + private int _filteredOlderCount; // old logs hidden behind the collapse bar + + private static string PrefsPrefix => "CustomConsole_" + Application.dataPath.GetHashCode().ToString("X") + "_"; + + [InitializeOnLoadMethod] + private static void Register() { + persistLogs = EditorPrefs.GetBool(PrefsPrefix + "persistLogs", true); + LoggerUtility.FormattedLogCallback += HandleCustomLog; + LoggerUtility.WatchCallback += HandleWatch; + LoggerUtility.UnwatchCallback += HandleUnwatch; + Application.logMessageReceived += HandleUnityLog; + AssemblyReloadEvents.beforeAssemblyReload += SaveSessionLogs; + RestoreSessionLogs(); + CaptureExistingConsoleLogs(); + if (!persistLogs) { + // Persist off: keep compile errors/warnings (they're relevant to the + // current compilation) but clear everything else for a clean slate. + // On the 2nd reload of the same recompile (reloadCount > 0), also keep + // logs marked isNew — they came from the 1st reload and are part of the + // same recompile, so they should appear as current logs, not be cached. + logs.RemoveAll(log => !log.isCompileError && !log.isCompileWarning && !log.isNew); + } + LoadIgnoreList(); + EditorApplication.delayCall += () => { + settings = LoggerUtility.LoadCustomLoggerSettings(); + if(window != null) { + window.logStyleCacheDirty = true; + } + + // Re-capture after all InitializeOnLoadMethod and DidReloadScripts + // callbacks have finished, catching any logs that fired before Register(). + CaptureExistingConsoleLogs(); + + // Reset reload counter after the final domain reload completes. + // This ensures the next recompile starts fresh at count 0. + reloadCount = 0; + SessionState.SetInt(SessionStateReloadKey, 0); + }; + } + + private static void HandleCustomLog((JovianLogType logType, LogCategory logCategory, string message) log) { + // Stack trace comes later via HandleUnityLog when Debug.unityLogger.Log fires + var entry = new LogEntry(log.logType, log.logCategory, log.message, "", isCustomLog: true); + if(!LoggerUtility.IsMainThread) { + pendingLogs.Enqueue(entry); + return; + } + + logs.Add(entry); + if (errorPause && EditorApplication.isPlaying && log.logType is JovianLogType.Error or JovianLogType.Exception or JovianLogType.Assert) { + EditorApplication.isPaused = true; + } + window?.Repaint(); + } + + private static void HandleUnityLog(string condition, string stackTrace, UnityEngine.LogType type) { + // Custom-formatted messages: patch the stack trace onto the existing entry + if (IsCustomFormattedMessage(condition)) { + bool patched = false; + if (!string.IsNullOrEmpty(stackTrace)) { + for (int i = logs.Count - 1; i >= 0; i--) { + if (logs[i].isCustomLog && string.IsNullOrEmpty(logs[i].stackTrace)) { + logs[i].stackTrace = stackTrace; + var info = ExtractCallerInfo(stackTrace); + logs[i].sourceFile = info.file; + logs[i].className = info.className; + patched = true; + break; + } + } + } + // If HandleCustomLog didn't capture this (e.g. console opened after the log fired), + // create a new entry from the Unity message so it's not silently dropped. + if (!patched) { + var cleanMsg = StripRichTextTags(condition); + var fType = MapUnityLogType(type); + var entr = new LogEntry(fType, LogCategory.General, cleanMsg, stackTrace ?? "", isCustomLog: true); + var callerInfo = ExtractCallerInfo(stackTrace ?? ""); + entr.sourceFile = callerInfo.file; + entr.className = callerInfo.className; + logs.Add(entr); + window?.Repaint(); + } + return; + } + + var CustomType = MapUnityLogType(type); + var cleanCondition = StripRichTextTags(condition); + var message = cleanCondition; + if (type is UnityEngine.LogType.Error or UnityEngine.LogType.Assert or UnityEngine.LogType.Exception && !string.IsNullOrEmpty(stackTrace)) { + message = $"{cleanCondition}\n{stackTrace}"; + } + + var entry = new LogEntry(CustomType, LogCategory.General, message, stackTrace ?? "", isCustomLog: false); + if (type == UnityEngine.LogType.Error && CompileErrorRegex.IsMatch(condition)) { + entry.isCompileError = true; + } else if (type == UnityEngine.LogType.Warning && CompileWarningRegex.IsMatch(condition)) { + entry.isCompileWarning = true; + } + logs.Add(entry); + if (errorPause && EditorApplication.isPlaying && type is UnityEngine.LogType.Error or UnityEngine.LogType.Assert or UnityEngine.LogType.Exception) { + EditorApplication.isPaused = true; + } + window?.Repaint(); + } + + private static void HandleWatch((string key, string value, LogCategory category) watch) { + if (watchEntries.TryGetValue(watch.key, out var existing)) { + existing.value = watch.value; + existing.timestamp = DateTime.Now.ToString("HH:mm:ss.fff"); + existing.frameCount = Time.frameCount; + existing.updateCount++; + } else { + var entry = new WatchEntry { + key = watch.key, + value = watch.value, + logCategory = watch.category, + timestamp = DateTime.Now.ToString("HH:mm:ss.fff"), + frameCount = Time.frameCount, + updateCount = 1, + }; + watchEntries[watch.key] = entry; + watchKeyOrder.Add(watch.key); + } + window?.Repaint(); + } + + private static void HandleUnwatch(string key) { + if (watchEntries.Remove(key)) { + watchKeyOrder.Remove(key); + window?.Repaint(); + } + } + + // ── Retroactive log capture via internal LogEntries API ── + // Other [InitializeOnLoadMethod] callbacks may have logged messages before + // our handlers were registered. Read Unity's internal console buffer via + // reflection and add any entries we missed. + private static Type s_LogEntriesType; + private static Type s_LogEntryType; + private static MethodInfo s_StartGettingEntries; + private static MethodInfo s_EndGettingEntries; + private static MethodInfo s_GetEntryInternal; + private static FieldInfo f_leMessage; + private static FieldInfo f_leMode; + private static FieldInfo f_leCallstackStart; + private static bool s_reflectionReady; + + private static void InitLogEntriesReflection() { + if (s_reflectionReady) return; + s_reflectionReady = true; + var asm = Assembly.GetAssembly(typeof(EditorApplication)); + s_LogEntriesType = asm.GetType("UnityEditor.LogEntries"); + s_LogEntryType = asm.GetType("UnityEditor.LogEntry"); + if (s_LogEntriesType == null || s_LogEntryType == null) return; + s_StartGettingEntries = s_LogEntriesType.GetMethod("StartGettingEntries", BindingFlags.Static | BindingFlags.Public); + s_EndGettingEntries = s_LogEntriesType.GetMethod("EndGettingEntries", BindingFlags.Static | BindingFlags.Public); + s_GetEntryInternal = s_LogEntriesType.GetMethod("GetEntryInternal", BindingFlags.Static | BindingFlags.Public); + f_leMessage = s_LogEntryType.GetField("message", BindingFlags.Instance | BindingFlags.Public); + f_leMode = s_LogEntryType.GetField("mode", BindingFlags.Instance | BindingFlags.Public); + f_leCallstackStart = s_LogEntryType.GetField("callstackTextStartUTF16", BindingFlags.Instance | BindingFlags.Public); + } + + // LogMessageFlags bitmask categories + private const int LMF_Error = (1 << 0) | (1 << 1) | (1 << 4) | (1 << 6) | (1 << 8) | (1 << 11) | (1 << 17) | (1 << 21); + private const int LMF_Warning = (1 << 7) | (1 << 9) | (1 << 12); + + private static void CaptureExistingConsoleLogs() { + InitLogEntriesReflection(); + if (s_StartGettingEntries == null || s_GetEntryInternal == null || s_EndGettingEntries == null) return; + + // Build a map of message first-lines we already have so we can + // skip true duplicates but mark restored (old) entries as "new" + // when they reappear in Unity's console (i.e. they fired again this session). + var existing = new Dictionary(); + for (int i = 0; i < logs.Count; i++) { + string msg = logs[i].message; + int nl = msg.IndexOf('\n'); + string key = nl >= 0 ? msg.Substring(0, nl) : msg; + existing.TryAdd(key, logs[i]); + } + + int count = (int)s_StartGettingEntries.Invoke(null, null); + try { + for (int i = 0; i < count; i++) { + object entry = Activator.CreateInstance(s_LogEntryType); + bool ok = (bool)s_GetEntryInternal.Invoke(null, new object[] { i, entry }); + if (!ok) continue; + + string fullMessage = (string)f_leMessage.GetValue(entry); + int mode = (int)f_leMode.GetValue(entry); + int stackStart = f_leCallstackStart != null ? (int)f_leCallstackStart.GetValue(entry) : 0; + + string rawLogText = stackStart > 0 ? fullMessage.Substring(0, stackStart).TrimEnd() : fullMessage; + string stackTrace = stackStart > 0 ? fullMessage.Substring(stackStart) : ""; + + // Strip rich text tags (e.g. ...) for clean display + string logText = StripRichTextTags(rawLogText); + + // Skip if we already have this entry, but mark restored entries + // as "new" since they fired again in this session. + string firstLine = logText; + int nl = firstLine.IndexOf('\n'); + if (nl >= 0) firstLine = firstLine.Substring(0, nl); + if (existing.TryGetValue(firstLine, out var existingEntry)) { + if (existingEntry != null && !existingEntry.isNew) { + existingEntry.isNew = true; + existingEntry.timestamp = DateTime.Now.ToString("HH:mm:ss.fff"); + existingEntry.frameCount = LoggerUtility.FrameCount; + } + continue; + } + existing.Add(firstLine, null); // null — no entry to promote later + + // Determine log type from mode flags + JovianLogType fType; + if ((mode & LMF_Error) != 0) fType = JovianLogType.Error; + else if ((mode & LMF_Warning) != 0) fType = JovianLogType.Warning; + else fType = JovianLogType.Info; + + // Check if this is a Custom-formatted message (use raw text — detection looks for tags) + bool isCustom = IsCustomFormattedMessage(rawLogText); + + // For Custom-formatted messages, parse the actual type from the prefix + // (the mode flags only distinguish Error/Warning/Log, losing Spam/Assert/Exception) + if (isCustom) { + if (logText.StartsWith("SPAM -> ")) fType = JovianLogType.Spam; + else if (logText.StartsWith("INFO -> ")) fType = JovianLogType.Info; + else if (logText.StartsWith("WARNING -> ")) fType = JovianLogType.Warning; + else if (logText.StartsWith("ERROR -> ")) fType = JovianLogType.Error; + else if (logText.StartsWith("ASSERT -> ")) fType = JovianLogType.Assert; + else if (logText.StartsWith("EXCEPTION -> ")) fType = JovianLogType.Exception; + } + + var logEntry = new LogEntry(fType, LogCategory.General, logText, stackTrace, isCustomLog: isCustom); + if ((mode & (1 << 11)) != 0) logEntry.isCompileError = true; // kScriptCompileError + if ((mode & (1 << 12)) != 0) logEntry.isCompileWarning = true; // kScriptCompileWarning + + var info = ExtractCallerInfo(stackTrace); + logEntry.sourceFile = info.file; + logEntry.className = info.className; + + logs.Add(logEntry); + } + } + catch (Exception e) { + Debug.LogWarning($"[CustomConsole] Failed to capture existing console logs: {e.Message}"); + } + finally { + s_EndGettingEntries.Invoke(null, null); + } + } + + private static readonly string[] CustomPrefixes = { + "INFO -> ", "ERROR -> ", "WARNING -> ", + "EXCEPTION -> ", "ASSERT -> ", "SPAM -> " + }; + + private static bool IsCustomFormattedMessage(string condition) { + foreach (var prefix in CustomPrefixes) { + if (condition.StartsWith(prefix, System.StringComparison.Ordinal)) { + return true; + } + } + + if (condition.StartsWith(" 22) { + var afterTag = condition.AsSpan(15); + foreach (var prefix in CustomPrefixes) { + if (afterTag.StartsWith(prefix.AsSpan(), System.StringComparison.Ordinal)) { + return true; + } + } + } + + return false; + } + + private static JovianLogType MapUnityLogType(UnityEngine.LogType type) { + return type switch { + UnityEngine.LogType.Error => JovianLogType.Error, + UnityEngine.LogType.Assert => JovianLogType.Assert, + UnityEngine.LogType.Warning => JovianLogType.Warning, + UnityEngine.LogType.Exception => JovianLogType.Exception, + _ => JovianLogType.Info, + }; + } + + private static readonly string[] LogTypeFilterNames = { "Everything", "Exception", "Assert", "Error", "Warning", "Info" }; + private static readonly JovianLogType[] LogTypeFilterValues = { + JovianLogType.Spam, JovianLogType.Exception, JovianLogType.Assert, + JovianLogType.Error, JovianLogType.Warning, JovianLogType.Info + }; + + private static readonly LogCategory AllCategories = (LogCategory)(1 | 2 | 4 | 8 | 16 | 32 | 64 | 128 | 256 | 512 | 1024 | 2048 | 4096 | 8192); + + private void DrawLogCategoryFilter() { + string label = selectedLogCategory == AllCategories ? "Everything" : selectedLogCategory == 0 ? "Nothing" : selectedLogCategory.ToString(); + if (GUILayout.Button(label, EditorStyles.toolbarDropDown, GUILayout.Width(150))) { + var menu = new GenericMenu(); + menu.AddItem(new GUIContent("Everything"), selectedLogCategory == AllCategories, () => { selectedLogCategory = AllCategories; filterDirty = true; Repaint(); }); + menu.AddItem(new GUIContent("Nothing"), selectedLogCategory == 0, () => { selectedLogCategory = 0; filterDirty = true; Repaint(); }); + menu.AddSeparator(""); + foreach (LogCategory cat in Enum.GetValues(typeof(LogCategory))) { + if (cat == LogCategory.None) continue; + LogCategory c = cat; + bool on = selectedLogCategory.HasFlag(c); + menu.AddItem(new GUIContent(c.ToString()), on, () => { + if (on) selectedLogCategory &= ~c; + else selectedLogCategory |= c; + filterDirty = true; + Repaint(); + }); + } + menu.ShowAsContext(); + } + } + + private static JovianLogType DrawLogTypeFilter(JovianLogType current) { + int index = Array.IndexOf(LogTypeFilterValues, current); + if (index < 0) index = 0; + int newIndex = EditorGUILayout.Popup(index, LogTypeFilterNames, EditorStyles.toolbarPopup, GUILayout.Width(100)); + return LogTypeFilterValues[newIndex]; + } + + [MenuItem("Fidelit&y/&Utility/Custom Logger/Custom Console", false, 2)] + public static void ShowWindow() { + window = GetWindow("Custom Console"); + window.Show(); + } + + private void RefreshWindow() { + // Reset all internal state and caches without closing the window + SavePrefs(); + + // Clear cached styles so they're rebuilt + smallButtonStyle = null; + pinnedBoxStyle = null; + logStyleCache = null; + logStyleCacheDirty = true; + stackTraceLinkStyle = null; + stackTraceDimStyle = null; + stackTraceNormalStyle = null; + + // Reset filter/view state + filterDirty = true; + lastFilteredLogCount = 0; + lastCountedLogCount = 0; + Array.Clear(typeCounts, 0, typeCounts.Length); + cachedPinned.Clear(); + cachedUnpinned.Clear(); + selectedLog = null; + scrollPosition = Vector2.zero; + detailPaneScroll = Vector2.zero; + + // Reload settings and prefs + settings = LoggerUtility.LoadCustomLoggerSettings(); + LoadPrefs(); + + Repaint(); + } + + private void OnEnable() { + window = this; + LoadPrefs(); + RemoteLogReceiver.OnRemoteLog += HandleRemoteLog; + RemoteLogReceiver.OnRemoteWatch += HandleRemoteWatch; + RemoteLogReceiver.OnRemoteUnwatch += HandleRemoteUnwatch; + RemoteLogReceiver.OnClientConnected += HandleRemoteClientConnected; + RemoteLogReceiver.OnClientDisconnected += HandleRemoteClientDisconnected; + } + + private void OnDisable() { + SavePrefs(); + RemoteLogReceiver.OnRemoteLog -= HandleRemoteLog; + RemoteLogReceiver.OnRemoteWatch -= HandleRemoteWatch; + RemoteLogReceiver.OnRemoteUnwatch -= HandleRemoteUnwatch; + RemoteLogReceiver.OnClientConnected -= HandleRemoteClientConnected; + RemoteLogReceiver.OnClientDisconnected -= HandleRemoteClientDisconnected; + if (remoteLogging) { + RemoteLogReceiver.Stop(); + remoteLogging = false; + } + if (window == this) { + window = null; + } + } + + private void HandleRemoteLog(RemoteLogReceiver.RemoteLogEntry remote) { + var entry = new LogEntry(remote.type, remote.logCategory, remote.message, remote.stackTrace, remote.isCustomLog); + entry.timestamp = remote.timestamp; + entry.frameCount = remote.frameCount; + entry.isRemote = true; + logs.Add(entry); + if (errorPause && EditorApplication.isPlaying && remote.type is JovianLogType.Error or JovianLogType.Exception or JovianLogType.Assert) { + EditorApplication.isPaused = true; + } + Repaint(); + } + + private void HandleRemoteWatch(RemoteLogReceiver.RemoteWatchEntry remote) { + if (watchEntries.TryGetValue(remote.key, out var existing)) { + existing.value = remote.value; + existing.timestamp = remote.timestamp; + existing.frameCount = remote.frameCount; + existing.updateCount++; + } else { + var entry = new WatchEntry { + key = remote.key, + value = remote.value, + logCategory = remote.logCategory, + timestamp = remote.timestamp, + frameCount = remote.frameCount, + updateCount = 1, + isRemote = true, + }; + watchEntries[remote.key] = entry; + watchKeyOrder.Add(remote.key); + } + Repaint(); + } + + private void HandleRemoteUnwatch(string key) { + if (watchEntries.Remove(key)) { + watchKeyOrder.Remove(key); + Repaint(); + } + } + + private void HandleRemoteClientConnected(string clientName) { + remoteClientName = clientName; + Repaint(); + } + + private void HandleRemoteClientDisconnected() { + remoteClientName = ""; + Repaint(); + } + + private void SavePrefs() { + string p = PrefsPrefix; + EditorPrefs.SetBool(p + "showSpam", showSpam); + EditorPrefs.SetBool(p + "showInfo", showInfo); + EditorPrefs.SetBool(p + "showWarning", showWarning); + EditorPrefs.SetBool(p + "showError", showError); + EditorPrefs.SetBool(p + "showAssert", showAssert); + EditorPrefs.SetBool(p + "showException", showException); + EditorPrefs.SetBool(p + "errorPause", errorPause); + EditorPrefs.SetBool(p + "collapse", collapse); + EditorPrefs.SetBool(p + "autoScroll", autoScroll); + EditorPrefs.SetBool(p + "showTimestamps", showTimestamps); + EditorPrefs.SetBool(p + "showFrameCount", showFrameCount); + EditorPrefs.SetBool(p + "showColumns", showColumns); + EditorPrefs.SetBool(p + "useRegexSearch", useRegexSearch); + EditorPrefs.SetInt(p + "selectedLogSource", (int)selectedLogSource); + EditorPrefs.SetInt(p + "selectedLogType", (int)selectedJovianLogType); + EditorPrefs.SetInt(p + "selectedLogCategory", (int)selectedLogCategory); + EditorPrefs.SetInt(p + "sortColumn", (int)sortColumn); + EditorPrefs.SetBool(p + "sortAscending", sortAscending); + EditorPrefs.SetString(p + "searchQuery", searchQuery); + EditorPrefs.SetBool(p + "newestFirst", newestFirst); + EditorPrefs.SetBool(p + "showWatchPanel", showWatchPanel); + EditorPrefs.SetBool(p + "persistLogs", persistLogs); + EditorPrefs.SetFloat(p + "detailPaneHeight", detailPaneHeight); + EditorPrefs.SetBool(p + "olderLogsCollapsed", olderLogsCollapsed); + } + + private void LoadPrefs() { + string p = PrefsPrefix; + if (!EditorPrefs.HasKey(p + "showSpam")) return; // No saved prefs yet + showSpam = EditorPrefs.GetBool(p + "showSpam", true); + showInfo = EditorPrefs.GetBool(p + "showInfo", true); + showWarning = EditorPrefs.GetBool(p + "showWarning", true); + showError = EditorPrefs.GetBool(p + "showError", true); + showAssert = EditorPrefs.GetBool(p + "showAssert", true); + showException = EditorPrefs.GetBool(p + "showException", true); + errorPause = EditorPrefs.GetBool(p + "errorPause", false); + collapse = EditorPrefs.GetBool(p + "collapse", false); + autoScroll = EditorPrefs.GetBool(p + "autoScroll", true); + showTimestamps = EditorPrefs.GetBool(p + "showTimestamps", false); + showFrameCount = EditorPrefs.GetBool(p + "showFrameCount", false); + showColumns = EditorPrefs.GetBool(p + "showColumns", false); + useRegexSearch = EditorPrefs.GetBool(p + "useRegexSearch", false); + selectedLogSource = (LogSourceFilter)EditorPrefs.GetInt(p + "selectedLogSource", 0); + selectedJovianLogType = (JovianLogType)EditorPrefs.GetInt(p + "selectedLogType", (int)JovianLogType.Spam); + selectedLogCategory = (LogCategory)EditorPrefs.GetInt(p + "selectedLogCategory", (int)AllCategories); + sortColumn = (SortColumn)EditorPrefs.GetInt(p + "sortColumn", 0); + sortAscending = EditorPrefs.GetBool(p + "sortAscending", true); + searchQuery = EditorPrefs.GetString(p + "searchQuery", ""); + newestFirst = EditorPrefs.GetBool(p + "newestFirst", true); + showWatchPanel = EditorPrefs.GetBool(p + "showWatchPanel", true); + persistLogs = EditorPrefs.GetBool(p + "persistLogs", true); + detailPaneHeight = EditorPrefs.GetFloat(p + "detailPaneHeight", 150f); + olderLogsCollapsed = EditorPrefs.GetBool(p + "olderLogsCollapsed", true); + if (useRegexSearch) CompileSearchRegex(); + } + + private void OnGUI() { + while(pendingLogs.TryDequeue(out LogEntry pending)) { + logs.Add(pending); + } + + hasBlinkingIndicators = false; + if (smallButtonStyle == null) { + smallButtonStyle = new GUIStyle(EditorStyles.miniButton) { fontSize = 9 }; + } + if (pinnedBoxStyle == null) { + pinnedBoxStyle = new GUIStyle("box"); + pinnedBoxStyle.border = new RectOffset(2, 2, 2, 2); + pinnedBoxStyle.normal.background = MakePinnedBackground(); + } + // Toolbar row 1: Actions & log type icons + GUILayout.BeginHorizontal(EditorStyles.toolbar); + { bool prevPersist = persistLogs; + string persistLabel = persistLogs ? "Persist \u25CF" : "Persist"; + persistLogs = GUILayout.Toggle(persistLogs, new GUIContent(persistLabel, "Toggle log persistence across assembly reloads"), EditorStyles.toolbarButton); + if(persistLogs != prevPersist) { + bool confirmed = EditorUtility.DisplayDialog( + "Toggle Log Persistence", + persistLogs + ? "Enable log persistence?\n\nLogs will be kept across assembly reloads." + : "Disable log persistence?\n\nLogs will be saved to the log cache and cleared on each assembly reload.", + "Confirm", + "Cancel"); + if(!confirmed) { + persistLogs = prevPersist; + } + } } + if(GUILayout.Button("Clear", EditorStyles.toolbarButton)) { + SaveLogCache(); + logs.Clear(); + Array.Clear(typeCounts, 0, typeCounts.Length); + lastCountedLogCount = 0; + filterDirty = true; + } + errorPause = GUILayout.Toggle(errorPause, "Error Pause", EditorStyles.toolbarButton); + { bool prev = collapse; + collapse = GUILayout.Toggle(collapse, "Collapse", EditorStyles.toolbarButton); + if(collapse != prev) filterDirty = true; } + autoScroll = GUILayout.Toggle(autoScroll, "Auto Scroll", EditorStyles.toolbarButton); + GUILayout.Space(6); + GUILayout.Label("", EditorStyles.toolbarButton, GUILayout.Width(1)); + GUILayout.Space(6); + showFrameCount = GUILayout.Toggle(showFrameCount, "Frame #", EditorStyles.toolbarButton); + showTimestamps = GUILayout.Toggle(showTimestamps, "Timestamps", EditorStyles.toolbarButton); + { bool prev = showColumns; + showColumns = GUILayout.Toggle(showColumns, new GUIContent("Columns", "Show column-based display with sortable headers for Type, Source, File, Class, and Category"), EditorStyles.toolbarButton); + if(showColumns != prev) filterDirty = true; } + GUILayout.Space(6); + GUILayout.Label("", EditorStyles.toolbarButton, GUILayout.Width(1)); + GUILayout.Space(6); + bool prevRemote = remoteLogging; + string remoteLabel = remoteLogging && RemoteLogReceiver.HasClient + ? "Remote ●" + : "Remote"; + string remoteTooltip = remoteLogging + ? (RemoteLogReceiver.HasClient + ? $"Connected: {remoteClientName}\nListening on port {RemoteLogReceiver.Port}" + : $"Listening on port {RemoteLogReceiver.Port}... waiting for device") + : "Enable remote logging to receive logs from device builds.\nAdd CustomRemoteLogSender to your scene."; + remoteLogging = GUILayout.Toggle(remoteLogging, new GUIContent(remoteLabel, remoteTooltip), EditorStyles.toolbarButton); + if(remoteLogging != prevRemote) { + if(remoteLogging) { + RemoteLogReceiver.Start(); + } + else { + RemoteLogReceiver.Stop(); + remoteClientName = ""; + } + } + + GUILayout.FlexibleSpace(); + + // Incremental type counts (O(1) when no new logs) + UpdateTypeCounts(); + int spamCount = typeCounts[(int)JovianLogType.Spam]; + int infoCount = typeCounts[(int)JovianLogType.Info]; + int warnCount = typeCounts[(int)JovianLogType.Warning]; + int errCount = typeCounts[(int)JovianLogType.Error]; + int assertCount = typeCounts[(int)JovianLogType.Assert]; + int exceptionCount = typeCounts[(int)JovianLogType.Exception]; + + { bool prev = showSpam; + showSpam = GUILayout.Toggle(showSpam, new GUIContent($" {spamCount}", EditorGUIUtility.IconContent("TreeEditor.Trash").image, "Spam"), EditorStyles.toolbarButton); + if(showSpam != prev) filterDirty = true; } + { bool prev = showInfo; + showInfo = GUILayout.Toggle(showInfo, new GUIContent($" {infoCount}", EditorGUIUtility.IconContent("console.infoicon.sml").image, "Info"), EditorStyles.toolbarButton); + if(showInfo != prev) filterDirty = true; } + { bool prev = showWarning; + showWarning = GUILayout.Toggle(showWarning, new GUIContent($" {warnCount}", EditorGUIUtility.IconContent("console.warnicon.sml").image, "Warning"), EditorStyles.toolbarButton); + if(showWarning != prev) filterDirty = true; } + { bool prev = showError; + showError = GUILayout.Toggle(showError, new GUIContent($" {errCount}", EditorGUIUtility.IconContent("console.erroricon.sml").image, "Error"), EditorStyles.toolbarButton); + if(showError != prev) filterDirty = true; } + { bool prev = showAssert; + showAssert = GUILayout.Toggle(showAssert, new GUIContent($" {assertCount}", EditorGUIUtility.IconContent("d_DebuggerEnabled").image, "Assert"), EditorStyles.toolbarButton); + if(showAssert != prev) filterDirty = true; } + { bool prev = showException; + showException = GUILayout.Toggle(showException, new GUIContent($" {exceptionCount}", EditorGUIUtility.IconContent("CollabError").image, "Exception"), EditorStyles.toolbarButton); + if(showException != prev) filterDirty = true; } + + GUILayout.EndHorizontal(); + + // Toolbar row 2: Filters & search + GUILayout.BeginHorizontal(EditorStyles.toolbar); + if(GUILayout.Button(new GUIContent(newestFirst ? "Newest First" : "Oldest First", "Toggle log order between newest first and oldest first"), EditorStyles.toolbarButton)) { + newestFirst = !newestFirst; + filterDirty = true; + } + GUILayout.Label("Log Source:", EditorStyles.miniLabel, GUILayout.Width(68)); + { LogSourceFilter prev = selectedLogSource; + selectedLogSource = (LogSourceFilter)EditorGUILayout.EnumPopup(selectedLogSource, EditorStyles.toolbarPopup, GUILayout.Width(80)); + if(selectedLogSource != prev) filterDirty = true; } + GUILayout.Space(10); + GUILayout.Label("Filters:", EditorStyles.miniLabel, GUILayout.Width(42)); + { LogCategory prevCat = selectedLogCategory; + DrawLogCategoryFilter(); + if(selectedLogCategory != prevCat) filterDirty = true; } + { JovianLogType prevType = selectedJovianLogType; + selectedJovianLogType = DrawLogTypeFilter(selectedJovianLogType); + if(selectedJovianLogType != prevType) filterDirty = true; } + GUILayout.Space(10); + string prevQuery = searchQuery; + bool prevRegex = useRegexSearch; + searchQuery = GUILayout.TextField(searchQuery, EditorStyles.toolbarSearchField, GUILayout.Width(200)); + useRegexSearch = GUILayout.Toggle(useRegexSearch, new GUIContent(".*", "Regex search\nExample: error|warning\nExample: Player\\d+\nExample: ^Init"), EditorStyles.toolbarButton, GUILayout.Width(25)); + if(searchQuery != prevQuery || useRegexSearch != prevRegex) { + CompileSearchRegex(); + filterDirty = true; + } + GUILayout.FlexibleSpace(); + if (GUILayout.Button("Refresh", EditorStyles.toolbarButton)) { + RefreshWindow(); + } + if (GUILayout.Button("Export", EditorStyles.toolbarDropDown)) { + var menu = new GenericMenu(); + menu.AddItem(new GUIContent("Export Logs"), false, ExportLogs); + menu.AddItem(new GUIContent("Copy for AI"), false, ExportLogsForAI); + menu.ShowAsContext(); + } + if (GUILayout.Button("Log Cache", EditorStyles.toolbarDropDown)) { + var menu = new GenericMenu(); + var cachedFiles = GetCachedLogFiles(); + if (cachedFiles.Length == 0) { + menu.AddDisabledItem(new GUIContent("No cached logs")); + } else { + foreach (var file in cachedFiles) { + string label = file.CreationTime.ToString("MMM dd, HH:mm:ss"); + string path = file.FullName; + menu.AddItem(new GUIContent(label), false, () => OpenCachedLog(path)); + } + menu.AddSeparator(""); + menu.AddItem(new GUIContent("Clear All Cached Logs"), false, ClearLogCache); + } + menu.ShowAsContext(); + } + GUILayout.EndHorizontal(); + + // Column header row (only in column mode) + if (showColumns) { + GUILayout.BeginHorizontal(EditorStyles.toolbar); + if (collapse) DrawColumnHeader("#", SortColumn.None, 30); + if (showFrameCount) DrawColumnHeader("Frame", SortColumn.Frame, 50); + if (showTimestamps) DrawColumnHeader("Time", SortColumn.Timestamp, 75); + DrawColumnHeader("Type", SortColumn.Type, 65); + DrawColumnHeader("Src", SortColumn.Source, 35); + DrawColumnHeader("File", SortColumn.File, 120); + DrawColumnHeader("Class", SortColumn.Class, 120); + DrawColumnHeader("Category", SortColumn.Category, 90); + DrawColumnHeader("Message", SortColumn.Message, 0); + GUILayout.Label("", EditorStyles.toolbarButton, GUILayout.Width(88)); + GUILayout.EndHorizontal(); + } + + // Watch panel + if (watchEntries.Count > 0) { + DrawWatchPanel(); + } + + // Scrollable Log List + if(autoScroll && logs.Count != lastLogCount) { + scrollPosition = newestFirst ? Vector2.zero : new Vector2(0, float.MaxValue); + lastLogCount = logs.Count; + } + + RebuildFilteredListIfDirty(); + + // Cache visible range during Layout so that Repaint draws the exact same + // number of rows. A mismatch causes "Invalid GUILayout state" errors because + // each DrawLogRow contains BeginHorizontal/EndHorizontal pairs. + const float OlderBarHeight = 24f; + if (Event.current.type == EventType.Layout) { + _virtPinnedCount = cachedPinned.Count; + _virtUnpinnedCount = cachedUnpinned.Count; + + float pinnedHeight = _virtPinnedCount * RowHeight + (_virtPinnedCount > 0 ? 5f : 0f); + float maxContentY = Mathf.Max(0, _virtUnpinnedCount * RowHeight - viewportHeight); + float scrollY = Mathf.Clamp(scrollPosition.y - pinnedHeight, 0, maxContentY); + _virtFirst = Mathf.Max(0, Mathf.FloorToInt(scrollY / RowHeight)); + int visibleCount = Mathf.CeilToInt(viewportHeight / RowHeight) + 2; + _virtLast = Mathf.Min(_virtUnpinnedCount - 1, _virtFirst + visibleCount); + } + + scrollPosition = GUILayout.BeginScrollView(scrollPosition); + + // Render pinned first (top) — always fully rendered (usually very few) + if(_virtPinnedCount > 0) { + for(int i = 0; i < _virtPinnedCount && i < cachedPinned.Count; i++) { + DrawLogRow(cachedPinned[i].log, cachedPinned[i].collapseCount, isPinned: true); + } + // Visual separator between pinned and unpinned + GUILayout.Space(2); + Rect sepRect = GUILayoutUtility.GetRect(0, 1, GUILayout.ExpandWidth(true)); + EditorGUI.DrawRect(sepRect, new Color(1f, 0.8f, 0f, 0.5f)); + GUILayout.Space(2); + } + + // Unpinned: virtualized — only draw visible rows + int firstVisible = _virtFirst; + int lastVisible = _virtLast; + int unpinnedCount = _virtUnpinnedCount; + + if(firstVisible > 0) { + GUILayout.Space(firstVisible * RowHeight); + } + + for(int i = firstVisible; i <= lastVisible && i < cachedUnpinned.Count; i++) { + DrawLogRow(cachedUnpinned[i].log, cachedUnpinned[i].collapseCount, isPinned: false); + } + + int afterCount = unpinnedCount - lastVisible - 1; + if(afterCount > 0) { + GUILayout.Space(afterCount * RowHeight); + } + + GUILayout.EndScrollView(); + + // Capture viewport height on Repaint for virtualization calculations + if(Event.current.type == EventType.Repaint) { + float captured = GUILayoutUtility.GetLastRect().height; + if(captured > 1f) { + viewportHeight = captured; + } + } + + // Sticky older-logs bar — outside scroll view, always visible + // Shows when there are hidden older logs OR when expanded and older logs exist + if (persistLogs && _filteredOlderCount > 0) { + DrawOlderLogsBar(OlderBarHeight); + } else if (persistLogs && !olderLogsCollapsed && HasAnyOlderLogs()) { + DrawOlderLogsBar(OlderBarHeight); + } + + // Detail pane: stack trace for selected log + DrawDetailPane(); + + // Jump to latest button + GUILayout.BeginHorizontal(); + GUILayout.FlexibleSpace(); + string jumpLabel = newestFirst ? "↑ Jump to Latest" : "↓ Jump to Latest"; + if(GUILayout.Button(jumpLabel, EditorStyles.miniButton, GUILayout.Width(110))) { + scrollPosition = newestFirst ? Vector2.zero : new Vector2(0, float.MaxValue); + } + GUILayout.FlexibleSpace(); + GUILayout.EndHorizontal(); + + // Keep repainting while blinking compile-error indicators are visible + if (hasBlinkingIndicators) { + Repaint(); + } + } + + private void DrawLogRow(LogEntry log, int collapseCount, bool isPinned) { + GUIStyle logStyle = GetLogStyle(log); + bool isLong = IsMultiLine(log.message, MaxCollapsedLines); + bool isExpanded = log.expanded && isLong; + GUIStyle rowStyle = isPinned ? pinnedBoxStyle : (GUIStyle)"box"; + Rect rowRect = isExpanded + ? EditorGUILayout.BeginHorizontal(rowStyle) + : EditorGUILayout.BeginHorizontal(rowStyle, GUILayout.Height(RowHeight)); + + // Draw a subtle lighter background for logs created after the last assembly reload + if (log.isNew && Event.current.type == EventType.Repaint) { + EditorGUI.DrawRect(rowRect, newLogTint); + } + + // Highlight selected row + if (selectedLog == log && Event.current.type == EventType.Repaint) { + EditorGUI.DrawRect(rowRect, new Color(0.17f, 0.36f, 0.53f, 0.4f)); + } + + // Tooltip: "Double-click to open source" + if (!string.IsNullOrEmpty(log.stackTrace) || log.isCompileError || log.isCompileWarning) { + GUI.Label(rowRect, new GUIContent("", "Double-click to open source")); + } + + // Double-click: open source file in IDE + if (Event.current.type == EventType.MouseDown && Event.current.button == 0 && Event.current.clickCount == 2 && rowRect.Contains(Event.current.mousePosition)) { + Event.current.Use(); + string sourceRef = (log.isCompileError || log.isCompileWarning) && !HasSourceLocation(log.stackTrace) ? log.message : log.stackTrace; + if (HasSourceLocation(sourceRef)) { + OpenSourceLocation(sourceRef); + } + } + // Single-click: select log to show stack trace in detail pane + else if (Event.current.type == EventType.MouseDown && Event.current.button == 0 && Event.current.clickCount == 1 && rowRect.Contains(Event.current.mousePosition)) { + selectedLog = log; + detailPaneScroll = Vector2.zero; + Repaint(); + } + + // Intercept right-click before controls can steal it (TextArea has its own context menu) + if (Event.current.type == EventType.MouseDown && Event.current.button == 1 && rowRect.Contains(Event.current.mousePosition)) { + Event.current.Use(); + ShowLogContextMenu(log); + } + + if (collapse) { + GUILayout.Label(collapseCount > 1 ? collapseCount.ToString() : "", EditorStyles.miniLabel, GUILayout.Width(30)); + } + if (showFrameCount) { + GUILayout.Label(log.frameCount.ToString(), EditorStyles.miniLabel, GUILayout.Width(50)); + } + if (showTimestamps) { + GUILayout.Label(log.timestamp, EditorStyles.miniLabel, GUILayout.Width(75)); + } + if (showColumns) { + GUILayout.Label(log.type.ToString(), logStyle, GUILayout.Width(65)); + string srcLabel = log.isRemote ? (log.isCustomLog ? "RF" : "RU") : (log.isCustomLog ? "F" : "U"); + GUILayout.Label(srcLabel, EditorStyles.miniLabel, GUILayout.Width(35)); + GUILayout.Label(log.sourceFile ?? "", EditorStyles.miniLabel, GUILayout.Width(120)); + GUILayout.Label(log.className ?? "", EditorStyles.miniLabel, GUILayout.Width(120)); + GUILayout.Label(log.isCustomLog ? log.logCategory.ToString() : "", EditorStyles.miniLabel, GUILayout.Width(90)); + } + string displayText = isLong && !log.expanded + ? TruncateToLines(log.message, MaxCollapsedLines) + " ..." + : log.message; + EditorGUILayout.TextArea(displayText, logStyle); + if (log.isNew && log.isCompileError) { + float t = Mathf.PingPong((float)EditorApplication.timeSinceStartup * 3f, 1f); + Color blinkColor = Color.Lerp(Color.yellow, Color.red, t); + Rect lineRect = GUILayoutUtility.GetRect(5, RowHeight, GUILayout.Width(5)); + EditorGUI.DrawRect(lineRect, blinkColor); + hasBlinkingIndicators = true; + } else if (log.isNew && log.isCompileWarning) { + Rect lineRect = GUILayoutUtility.GetRect(2.5f, RowHeight, GUILayout.Width(2.5f)); + EditorGUI.DrawRect(lineRect, Color.yellow); + } + if (isLong) { + if (GUILayout.Button(log.expanded ? "▲" : "▼", smallButtonStyle, GUILayout.Width(18))) { + log.expanded = !log.expanded; + } + } + if (GUILayout.Button("Copy", smallButtonStyle, GUILayout.Width(32))) { + EditorGUIUtility.systemCopyBuffer = log.message; + } + { string sourceRef = (log.isCompileError || log.isCompileWarning) && !HasSourceLocation(log.stackTrace) ? log.message : log.stackTrace; + using (new EditorGUI.DisabledScope(!HasSourceLocation(sourceRef))) { + if (GUILayout.Button("Show", smallButtonStyle, GUILayout.Width(34))) { + OpenSourceLocation(sourceRef); + } + } } + + EditorGUILayout.EndHorizontal(); + } + + private void EnsureDetailPaneStyles() { + if (stackTraceLinkStyle == null) { + stackTraceLinkStyle = new GUIStyle(EditorStyles.label) { + richText = false, + wordWrap = false, + normal = { textColor = new Color(0.3f, 0.6f, 1f) }, + hover = { textColor = new Color(0.5f, 0.75f, 1f) }, + padding = new RectOffset(4, 4, 1, 1), + }; + } + if (stackTraceDimStyle == null) { + stackTraceDimStyle = new GUIStyle(EditorStyles.label) { + richText = false, + wordWrap = false, + normal = { textColor = new Color(0.5f, 0.5f, 0.5f) }, + padding = new RectOffset(4, 4, 1, 1), + }; + } + if (stackTraceNormalStyle == null) { + stackTraceNormalStyle = new GUIStyle(EditorStyles.label) { + richText = false, + wordWrap = false, + padding = new RectOffset(4, 4, 1, 1), + }; + } + } + + private void DrawOlderLogsBar(float barHeight) { + Rect barRect = GUILayoutUtility.GetRect(0, barHeight, GUILayout.ExpandWidth(true)); + if (Event.current.type == EventType.Repaint) { + EditorGUI.DrawRect(barRect, new Color(0.18f, 0.18f, 0.18f, 1f)); + // Top/bottom border lines + EditorGUI.DrawRect(new Rect(barRect.x, barRect.y, barRect.width, 1), new Color(0.4f, 0.4f, 0.4f, 0.5f)); + EditorGUI.DrawRect(new Rect(barRect.x, barRect.yMax - 1, barRect.width, 1), new Color(0.4f, 0.4f, 0.4f, 0.5f)); + } + string arrow = olderLogsCollapsed ? "▶" : "▼"; + int olderCount = olderLogsCollapsed ? _filteredOlderCount : CountOlderInList(); + string label = olderLogsCollapsed + ? $"{arrow} {olderCount} older log{(olderCount != 1 ? "s" : "")} hidden — click to expand" + : $"{arrow} Collapse {olderCount} older log{(olderCount != 1 ? "s" : "")}"; + GUI.Label(barRect, label, EditorStyles.centeredGreyMiniLabel); + // Handle click + if (Event.current.type == EventType.MouseDown && Event.current.button == 0 && barRect.Contains(Event.current.mousePosition)) { + Event.current.Use(); + olderLogsCollapsed = !olderLogsCollapsed; + // When expanding in oldest-first, scroll to bottom so + // the newly revealed old logs (which are above) push the + // view toward the latest entries the user was looking at. + if (!olderLogsCollapsed && !newestFirst) { + scrollPosition = new Vector2(0, float.MaxValue); + } + SavePrefs(); + filterDirty = true; + Repaint(); + } + EditorGUIUtility.AddCursorRect(barRect, MouseCursor.Link); + } + + private bool HasAnyOlderLogs() { + for (int i = 0; i < cachedUnpinned.Count; i++) { + if (!cachedUnpinned[i].log.isNew) return true; + } + return false; + } + + private int CountOlderInList() { + int count = 0; + for (int i = 0; i < cachedUnpinned.Count; i++) { + if (!cachedUnpinned[i].log.isNew) count++; + } + return count; + } + + private void DrawDetailPane() { + if (selectedLog == null) return; + string trace = selectedLog.stackTrace; + bool hasTrace = !string.IsNullOrEmpty(trace); + + EnsureDetailPaneStyles(); + + // ── Drag handle ── + Rect handleRect = GUILayoutUtility.GetRect(0, DetailPaneDragHandleHeight, GUILayout.ExpandWidth(true)); + EditorGUIUtility.AddCursorRect(handleRect, MouseCursor.ResizeVertical); + if (Event.current.type == EventType.Repaint) { + Color handleColor = isDraggingDetailPane + ? new Color(0.4f, 0.6f, 0.9f, 0.8f) + : new Color(0.5f, 0.5f, 0.5f, 0.5f); + EditorGUI.DrawRect(handleRect, handleColor); + float cx = handleRect.center.x; + float cy = handleRect.center.y; + Color dotColor = new Color(0.7f, 0.7f, 0.7f, 0.8f); + for (int i = -2; i <= 2; i++) { + EditorGUI.DrawRect(new Rect(cx + i * 8 - 1, cy - 1, 3, 3), dotColor); + } + } + + // Handle drag + if (Event.current.type == EventType.MouseDown && Event.current.button == 0 && handleRect.Contains(Event.current.mousePosition)) { + isDraggingDetailPane = true; + Event.current.Use(); + } + if (isDraggingDetailPane) { + if (Event.current.type == EventType.MouseDrag) { + detailPaneHeight -= Event.current.delta.y; + detailPaneHeight = Mathf.Clamp(detailPaneHeight, MinDetailPaneHeight, MaxDetailPaneHeight); + Event.current.Use(); + Repaint(); + } + if (Event.current.type == EventType.MouseUp) { + isDraggingDetailPane = false; + Event.current.Use(); + } + } + + // ── Detail pane content ── + if (!hasTrace) { + EditorGUILayout.BeginVertical("box", GUILayout.Height(detailPaneHeight)); + GUILayout.Label("No stack trace available.", EditorStyles.miniLabel); + GUILayout.FlexibleSpace(); + EditorGUILayout.EndVertical(); + return; + } + + // Header with copy button + GUILayout.BeginHorizontal(EditorStyles.toolbar); + GUILayout.Label("Stack Trace", EditorStyles.miniLabel); + GUILayout.FlexibleSpace(); + if (GUILayout.Button("Copy", EditorStyles.toolbarButton, GUILayout.Width(40))) { + EditorGUIUtility.systemCopyBuffer = trace; + } + GUILayout.EndHorizontal(); + + detailPaneScroll = EditorGUILayout.BeginScrollView(detailPaneScroll, "box", GUILayout.Height(detailPaneHeight)); + + string[] lines = trace.Split('\n'); + foreach (string rawLine in lines) { + string line = rawLine.TrimEnd('\r'); + if (string.IsNullOrWhiteSpace(line)) continue; + + var match = SourceLocationRegex.Match(line); + bool isInternal = false; + foreach (var internalPath in InternalPaths) { + if (line.Contains(internalPath, StringComparison.Ordinal)) { + isInternal = true; + break; + } + } + + if (match.Success) { + string filePath = match.Groups[1].Value; + bool isValidPath = !filePath.StartsWith("<") && filePath.EndsWith(".cs", StringComparison.OrdinalIgnoreCase); + GUIStyle style = isInternal ? stackTraceDimStyle : (isValidPath ? stackTraceLinkStyle : stackTraceNormalStyle); + string tooltip = isValidPath ? $"Click to open {Path.GetFileName(filePath)}:{match.Groups[2].Value}" : null; + Rect lineRect = GUILayoutUtility.GetRect(new GUIContent(line, tooltip), style, GUILayout.ExpandWidth(true)); + + // Right-click: copy this line + if (Event.current.type == EventType.MouseDown && Event.current.button == 1 && lineRect.Contains(Event.current.mousePosition)) { + Event.current.Use(); + var menu = new GenericMenu(); + string capturedLine = line; + menu.AddItem(new GUIContent("Copy Line"), false, () => EditorGUIUtility.systemCopyBuffer = capturedLine); + menu.AddItem(new GUIContent("Copy Full Stack Trace"), false, () => EditorGUIUtility.systemCopyBuffer = trace); + menu.ShowAsContext(); + } + + // Left-click: open source (only for valid paths) + if (isValidPath) { + EditorGUIUtility.AddCursorRect(lineRect, MouseCursor.Link); + if (Event.current.type == EventType.MouseDown && Event.current.button == 0 && lineRect.Contains(Event.current.mousePosition)) { + Event.current.Use(); + int lineNum = int.Parse(match.Groups[2].Value); + UnityEditorInternal.InternalEditorUtility.OpenFileAtLineExternal(filePath, lineNum); + } + } + + if (Event.current.type == EventType.Repaint) { + style.Draw(lineRect, new GUIContent(line, tooltip), lineRect.Contains(Event.current.mousePosition), false, false, false); + } + } else { + GUIStyle style = isInternal ? stackTraceDimStyle : stackTraceNormalStyle; + Rect lineRect = GUILayoutUtility.GetRect(new GUIContent(line), style, GUILayout.ExpandWidth(true)); + + // Right-click: copy + if (Event.current.type == EventType.MouseDown && Event.current.button == 1 && lineRect.Contains(Event.current.mousePosition)) { + Event.current.Use(); + var menu = new GenericMenu(); + string capturedLine = line; + menu.AddItem(new GUIContent("Copy Line"), false, () => EditorGUIUtility.systemCopyBuffer = capturedLine); + menu.AddItem(new GUIContent("Copy Full Stack Trace"), false, () => EditorGUIUtility.systemCopyBuffer = trace); + menu.ShowAsContext(); + } + + if (Event.current.type == EventType.Repaint) { + style.Draw(lineRect, line, false, false, false, false); + } + } + } + + EditorGUILayout.EndScrollView(); + } + + private void DrawWatchPanel() { + // Header bar + GUILayout.BeginHorizontal(EditorStyles.toolbar); + showWatchPanel = EditorGUILayout.Foldout(showWatchPanel, $"Watch ({watchEntries.Count})", true, EditorStyles.foldout); + GUILayout.FlexibleSpace(); + if (GUILayout.Button("Clear", EditorStyles.toolbarButton, GUILayout.Width(40))) { + watchEntries.Clear(); + watchKeyOrder.Clear(); + } + GUILayout.EndHorizontal(); + + if (!showWatchPanel) return; + + // Watch rows + var watchBg = EditorGUIUtility.isProSkin ? new Color(0.18f, 0.22f, 0.28f, 1f) : new Color(0.85f, 0.92f, 1f, 1f); + foreach (var key in watchKeyOrder) { + if (!watchEntries.TryGetValue(key, out var entry)) continue; + var rect = EditorGUILayout.BeginHorizontal("box"); + EditorGUI.DrawRect(rect, watchBg); + GUILayout.Label(entry.key, EditorStyles.boldLabel, GUILayout.Width(150)); + GUILayout.Label(entry.value, EditorStyles.label); + GUILayout.FlexibleSpace(); + if (showTimestamps) { + GUILayout.Label(entry.timestamp, EditorStyles.miniLabel, GUILayout.Width(75)); + } + if (showFrameCount) { + GUILayout.Label(entry.frameCount.ToString(), EditorStyles.miniLabel, GUILayout.Width(50)); + } + GUILayout.Label($"×{entry.updateCount}", EditorStyles.miniLabel, GUILayout.Width(40)); + if (entry.isRemote) { + GUILayout.Label("R", EditorStyles.miniLabel, GUILayout.Width(14)); + } + if (GUILayout.Button("×", smallButtonStyle, GUILayout.Width(18))) { + watchEntries.Remove(key); + watchKeyOrder.Remove(key); + GUILayout.EndHorizontal(); + break; // collection modified + } + GUILayout.EndHorizontal(); + } + + // Separator + GUILayout.Space(1); + var sepRect = GUILayoutUtility.GetRect(0, 1, GUILayout.ExpandWidth(true)); + EditorGUI.DrawRect(sepRect, new Color(0.4f, 0.6f, 0.9f, 0.5f)); + GUILayout.Space(1); + } + + private void DrawColumnHeader(string label, SortColumn column, float width) { + string arrow = sortColumn == column ? (sortAscending ? " ▲" : " ▼") : ""; + bool clicked; + if (width > 0) + clicked = GUILayout.Button(label + arrow, EditorStyles.toolbarButton, GUILayout.Width(width)); + else + clicked = GUILayout.Button(label + arrow, EditorStyles.toolbarButton); + + if(clicked && column != SortColumn.None) { + if(sortColumn == column) { + sortAscending = !sortAscending; + } + else { + sortColumn = column; + sortAscending = true; + } + filterDirty = true; + } + } + + private static Texture2D MakePinnedBackground() { + int w = 8, h = 8; + var tex = new Texture2D(w, h); + var border = new Color(1f, 0.75f, 0f, 1f); // gold + var fill = EditorGUIUtility.isProSkin ? new Color(0.25f, 0.23f, 0.15f, 1f) : new Color(1f, 0.97f, 0.85f, 1f); + for (int y = 0; y < h; y++) { + for (int x = 0; x < w; x++) { + bool isBorder = x == 0 || x == w - 1 || y == 0 || y == h - 1; + tex.SetPixel(x, y, isBorder ? border : fill); + } + } + tex.Apply(); + tex.hideFlags = HideFlags.HideAndDontSave; + return tex; + } + + private static string GetActiveTextSelection() { + var editor = typeof(EditorGUI) + .GetField("activeEditor", System.Reflection.BindingFlags.Static | System.Reflection.BindingFlags.NonPublic) + ?.GetValue(null) as TextEditor; + if (editor != null && editor.hasSelection) + return editor.SelectedText; + return null; + } + + private void CompileSearchRegex() { + if (!useRegexSearch || string.IsNullOrEmpty(searchQuery)) { + searchRegex = null; + return; + } + try { + searchRegex = new Regex(searchQuery, RegexOptions.IgnoreCase | RegexOptions.Compiled, TimeSpan.FromMilliseconds(50)); + } catch (ArgumentException) { + searchRegex = null; + } + } + + private static string GetCollapseKey(LogEntry log) { + // First line of the message + source flag + string firstLine = log.message; + int newlineIdx = firstLine.IndexOf('\n'); + if(newlineIdx >= 0) firstLine = firstLine.Substring(0, newlineIdx); + return $"{(log.isCustomLog ? "F" : "U")}|{log.type}|{firstLine}"; + } + + // ── Performance: cached filtering + type counts ── + + private void RebuildFilteredListIfDirty() { + if(!filterDirty && logs.Count == lastFilteredLogCount) { + return; + } + + cachedPinned.Clear(); + cachedUnpinned.Clear(); + _filteredOlderCount = 0; + + // Build filtered list with collapse counts and per-group metadata + Dictionary collapseCounts = null; + Dictionary collapseHasNew = null; // any member isNew? + Dictionary collapseLatestTime = null; // latest timestamp + Dictionary collapseLatestFrame = null; // latest frame count + HashSet collapseSeenOld = null; + HashSet collapseSeenNew = null; + if(collapse) { + collapseCounts = new Dictionary(); + collapseHasNew = new Dictionary(); + collapseLatestTime = new Dictionary(); + collapseLatestFrame = new Dictionary(); + collapseSeenOld = new HashSet(); + collapseSeenNew = new HashSet(); + for(int i = 0; i < logs.Count; i++) { + LogEntry entry = logs[i]; + if(!MatchesFilter(entry)) continue; + string key = GetCollapseKey(entry); + collapseCounts[key] = collapseCounts.GetValueOrDefault(key, 0) + 1; + if (entry.isNew) collapseHasNew[key] = true; + else collapseHasNew.TryAdd(key, false); + if (!collapseLatestTime.TryGetValue(key, out string prev) || + string.Compare(entry.timestamp, prev, StringComparison.Ordinal) > 0) { + collapseLatestTime[key] = entry.timestamp; + collapseLatestFrame[key] = entry.frameCount; + } + } + } + + // First pass: count old vs new among filtered unpinned logs so we + // know whether hiding older logs makes sense (need BOTH old and new). + bool wantHideOlder = persistLogs && olderLogsCollapsed; + int oldCount = 0, newCount = 0; + if (wantHideOlder) { + for (int i = 0; i < logs.Count; i++) { + LogEntry entry = logs[i]; + if (!MatchesFilter(entry) || entry.pinned) continue; + if (entry.isNew) newCount++; else oldCount++; + } + } + bool hideOlder = wantHideOlder && oldCount > 0 && newCount > 0; + + int start = newestFirst ? logs.Count - 1 : 0; + int end = newestFirst ? -1 : logs.Count; + int step = newestFirst ? -1 : 1; + for(int i = start; i != end; i += step) { + LogEntry entry = logs[i]; + if(!MatchesFilter(entry)) continue; + + // When hiding older logs, skip them BEFORE collapse dedup so + // their keys don't block newer logs with the same message. + if(hideOlder && !entry.isNew && !entry.pinned) { + _filteredOlderCount++; + continue; + } + + int cc = 1; + if(collapse) { + string key = GetCollapseKey(entry); + // When hiding older logs, use separate seen-sets for old vs new + // so that in oldest-first mode an old duplicate doesn't consume + // the key and prevent the new version from appearing. + // When expanded (or not hiding), use a single set — the group + // metadata (isNew, timestamp) already promotes the representative. + if (hideOlder) { + var seen = entry.isNew ? collapseSeenNew : collapseSeenOld; + if(!seen.Add(key)) continue; + } else { + if(!collapseSeenOld.Add(key)) continue; + } + cc = collapseCounts[key]; + + // Promote the representative entry to reflect the entire group: + // show as "new" if any member is new, use the latest timestamp/frame. + if (collapseHasNew.TryGetValue(key, out bool hasNew) && hasNew) + entry.isNew = true; + if (collapseLatestTime.TryGetValue(key, out string latestTs)) { + entry.timestamp = latestTs; + entry.frameCount = collapseLatestFrame[key]; + } + } + + if(entry.pinned) { + cachedPinned.Add((entry, cc)); + } + else { + cachedUnpinned.Add((entry, cc)); + } + } + + // When collapse is on, re-sort by latest timestamp so groups with + // recent activity float to the correct position instead of staying + // at the position of their first occurrence. + if(collapse) { + int dir = newestFirst ? -1 : 1; + cachedUnpinned.Sort((a, b) => dir * string.Compare(a.log.timestamp, b.log.timestamp, StringComparison.Ordinal)); + } + + // Sort if a column is selected (only in column mode) + if(showColumns && sortColumn != SortColumn.None) { + int Compare((LogEntry log, int collapseCount) a, (LogEntry log, int collapseCount) b) { + int cmp = sortColumn switch { + SortColumn.Frame => a.log.frameCount.CompareTo(b.log.frameCount), + SortColumn.Timestamp => string.Compare(a.log.timestamp, b.log.timestamp, StringComparison.Ordinal), + SortColumn.Type => a.log.type.CompareTo(b.log.type), + SortColumn.Source => a.log.isCustomLog.CompareTo(b.log.isCustomLog), + SortColumn.File => string.Compare(a.log.sourceFile, b.log.sourceFile, StringComparison.OrdinalIgnoreCase), + SortColumn.Class => string.Compare(a.log.className, b.log.className, StringComparison.OrdinalIgnoreCase), + SortColumn.Category => a.log.logCategory.CompareTo(b.log.logCategory), + SortColumn.Message => string.Compare(a.log.message, b.log.message, StringComparison.OrdinalIgnoreCase), + _ => 0, + }; + return sortAscending ? cmp : -cmp; + } + cachedPinned.Sort(Compare); + cachedUnpinned.Sort(Compare); + } + + filterDirty = false; + lastFilteredLogCount = logs.Count; + } + + private void UpdateTypeCounts() { + if(logs.Count == 0 && lastCountedLogCount != 0) { + // Logs were cleared + Array.Clear(typeCounts, 0, typeCounts.Length); + lastCountedLogCount = 0; + return; + } + + // Count only newly added entries + for(int i = lastCountedLogCount; i < logs.Count; i++) { + int typeIndex = (int)logs[i].type; + if(typeIndex >= 0 && typeIndex < typeCounts.Length) { + typeCounts[typeIndex]++; + } + } + lastCountedLogCount = logs.Count; + } + + private void MarkFilterDirty() { + filterDirty = true; + } + + private bool MatchesFilter(LogEntry log) { + // Ignore list + if (IsIgnored(log)) return false; + + // Source filter + if (selectedLogSource == LogSourceFilter.Custom && !log.isCustomLog) { + return false; + } + if (selectedLogSource == LogSourceFilter.Unity && log.isCustomLog) { + return false; + } + if (selectedLogSource == LogSourceFilter.Remote && !log.isRemote) { + return false; + } + + // Per-type toggle filters + if (log.isCustomLog) { + // Custom logs: each toggle maps directly + switch (log.type) { + case JovianLogType.Spam when !showSpam: + case JovianLogType.Info when !showInfo: + case JovianLogType.Warning when !showWarning: + case JovianLogType.Error when !showError: + case JovianLogType.Assert when !showAssert: + case JovianLogType.Exception when !showException: + return false; + } + } else { + // Unity logs: Info->showInfo, Warning->showWarning, Error->showError + switch (log.type) { + case JovianLogType.Info or JovianLogType.Spam when !showInfo: + case JovianLogType.Warning when !showWarning: + case JovianLogType.Error when !showError: + return false; + } + } + + // Severity filter + if (selectedJovianLogType < log.type) { + return false; + } + + // Category filter (only applies to Custom logs) + if (log.isCustomLog && !selectedLogCategory.HasFlag(log.logCategory)) { + return false; + } + + // Search filter + if (!string.IsNullOrEmpty(searchQuery)) { + if (useRegexSearch && searchRegex != null) { + if (!searchRegex.IsMatch(log.message)) return false; + } else { + if (log.message.IndexOf(searchQuery, StringComparison.OrdinalIgnoreCase) < 0) return false; + } + } + + return true; + } + + private GUIStyle GetLogStyle(LogEntry log) { + if(logStyleCache == null || logStyleCacheDirty) { + RebuildLogStyleCache(); + } + int index = (int)log.type * 2 + (log.isCustomLog ? 1 : 0); + return logStyleCache[index]; + } + + private void RebuildLogStyleCache() { + logStyleCache = new GUIStyle[12]; // 6 log types × 2 (unity/Custom) + for(int typeIdx = 0; typeIdx < 6; typeIdx++) { + JovianLogType jovianLogType = (JovianLogType)typeIdx; + for(int isCustom = 0; isCustom < 2; isCustom++) { + GUIStyle style = new GUIStyle(EditorStyles.label) { + wordWrap = true, + alignment = TextAnchor.UpperLeft, + padding = new RectOffset(0, 0, 3, 0) + }; + if(isCustom == 0) { + style.normal.textColor = GetUnityLogColor(jovianLogType); + } + else if(settings != null) { + style.normal.textColor = jovianLogType switch { + JovianLogType.Warning => settings.loggerColors.warningColor, + JovianLogType.Error => settings.loggerColors.errorColor, + JovianLogType.Exception => settings.loggerColors.exceptionColor, + JovianLogType.Assert => settings.loggerColors.assertColor, + JovianLogType.Info => settings.loggerColors.infoColor, + _ => settings.loggerColors.spamColor, + }; + } + logStyleCache[typeIdx * 2 + isCustom] = style; + } + } + logStyleCacheDirty = false; + } + + private static Color GetUnityLogColor(JovianLogType type) { + if (settings != null) { + return type switch { + JovianLogType.Warning => settings.loggerColors.warningColor, + JovianLogType.Error => settings.loggerColors.errorColor, + JovianLogType.Exception => settings.loggerColors.exceptionColor, + JovianLogType.Assert => settings.loggerColors.assertColor, + _ => Color.white, + }; + } + + return type switch { + JovianLogType.Warning => Color.yellow, + JovianLogType.Error or JovianLogType.Exception or JovianLogType.Assert => Color.red, + _ => Color.white, + }; + } + + // Matches C# compiler errors: "Assets/Path/File.cs(10,5): error CS0246: ..." + private static readonly Regex CompileErrorRegex = new( + @"\.cs\(\d+,\d+\): error CS\d+:", RegexOptions.Compiled); + + // Matches C# compiler warnings: "Assets/Path/File.cs(10,5): warning CS0168: ..." + private static readonly Regex CompileWarningRegex = new( + @"\.cs\(\d+,\d+\): warning CS\d+:", RegexOptions.Compiled); + + // Extracts file path (group 1) and line number (group 2) from compile error messages + private static readonly Regex CompileErrorLocationRegex = new( + @"(.+\.cs)\((\d+),\d+\)", RegexOptions.Compiled); + + // Matches "(at Assets/Path/File.cs:123)" or "(at /absolute/path/File.cs:123)" + private static readonly Regex SourceLocationRegex = new( + @"\(at\s+(.+?):(\d+)\)", RegexOptions.Compiled); + + // Strips Unity rich text tags: , , , , , , , + private static readonly Regex RichTextTagRegex = new( + @"", RegexOptions.Compiled); + + private static string StripRichTextTags(string text) { + if (string.IsNullOrEmpty(text) || text.IndexOf('<') < 0) return text; + return RichTextTagRegex.Replace(text, ""); + } + + // Stack frames from the logger itself — skip these to find the actual caller + private static readonly string[] InternalPaths = { + "com.resolutiongames.logger/", + "Custom.Logger.", + "UnityEngine.Debug", + "UnityEngine.Logger", + "UnityEngine.DebugLogHandler", + }; + + private static bool HasSourceLocation(string stackTrace) { + return FindCallerMatch(stackTrace).Success; + } + + private static void OpenSourceLocation(string stackTrace) { + var match = FindCallerMatch(stackTrace); + if (!match.Success) return; + + string filePath = match.Groups[1].Value; + int line = int.Parse(match.Groups[2].Value); + UnityEditorInternal.InternalEditorUtility.OpenFileAtLineExternal(filePath, line); + } + + private static Match FindCallerMatch(string stackTrace) { + if (string.IsNullOrEmpty(stackTrace)) return Match.Empty; + + var matches = SourceLocationRegex.Matches(stackTrace); + // Find the lines each match belongs to, skip logger-internal frames + string[] lines = stackTrace.Split('\n'); + foreach (string line in lines) { + var match = SourceLocationRegex.Match(line); + if (!match.Success) continue; + + bool isInternal = false; + foreach (var internalPath in InternalPaths) { + if (line.Contains(internalPath, StringComparison.Ordinal)) { + isInternal = true; + break; + } + } + + if (!isInternal) { + return match; + } + } + + // Fallback: return first match if all frames are internal + if (matches.Count > 0) return matches[0]; + + // Fallback: try compile error format — "Assets/Path/File.cs(10,5): error ..." + var compileMatch = CompileErrorLocationRegex.Match(stackTrace); + return compileMatch.Success ? compileMatch : Match.Empty; + } + + private const int MaxCollapsedLines = 2; + + private static string TruncateToLines(string text, int maxLines) { + int pos = 0; + for (int line = 0; line < maxLines; line++) { + int next = text.IndexOf('\n', pos); + if (next < 0) return text; + pos = next + 1; + } + return text.Substring(0, pos > 0 ? pos - 1 : text.Length); + } + + private static bool IsMultiLine(string text, int maxLines) { + int count = 0; + for (int i = 0; i < text.Length; i++) { + if (text[i] == '\n') { + count++; + if (count >= maxLines) return true; + } + } + return false; + } + + // ── Session persistence across domain reloads ──────────────────── + + private const string SessionStateKey = "CustomConsole_SessionLogs"; + private const string SessionStateWatchKey = "CustomConsole_SessionWatch"; + private const string SessionStateReloadKey = "CustomConsole_ReloadCount"; + + // Tracks how many domain reloads have occurred during the current recompile. + // Unity fires two domain reloads per recompile; we use this to detect the 2nd + // reload so we can (a) keep logs from the 1st reload marked as isNew and + // (b) avoid double-caching logs when persist is off. + private static int reloadCount; + + [Serializable] + private class SerializedWatch { + public string key; + public string value; + public int logCategory; + public string timestamp; + public int frameCount; + public int updateCount; + public bool isRemote; + } + + [Serializable] + private class SerializedWatchList { + public SerializedWatch[] watches; + } + + [Serializable] + private class SerializedLog { + public string message; + public string stackTrace; + public string timestamp; + public int frameCount; + public string sourceFile; + public string className; + public int type; + public int logCategory; + public bool isCustomLog; + public bool isRemote; + public bool pinned; + public bool isCompileError; + public bool isCompileWarning; + public bool isNew; + } + + private static void SaveSessionLogs() { + // Track reload count so we can detect the 2nd reload of the same recompile. + reloadCount++; + SessionState.SetInt(SessionStateReloadKey, reloadCount); + + // Archive to disk cache only on the first reload to avoid double-caching. + if (!persistLogs && reloadCount <= 1) { + SaveLogCache(); + } + + // Always save to SessionState so RestoreSessionLogs can recover logs + // after reload — even when persist is off, compile errors/warnings are + // kept and non-compile entries are cleared in Register(). + if (logs.Count == 0) { + SessionState.SetString(SessionStateKey, ""); + } else { + var serialized = new SerializedLog[logs.Count]; + for (int i = 0; i < logs.Count; i++) { + var log = logs[i]; + serialized[i] = new SerializedLog { + message = log.message, + stackTrace = log.stackTrace, + timestamp = log.timestamp, + frameCount = log.frameCount, + sourceFile = log.sourceFile, + className = log.className, + type = (int)log.type, + logCategory = (int)log.logCategory, + isCustomLog = log.isCustomLog, + isRemote = log.isRemote, + pinned = log.pinned, + isCompileError = log.isCompileError, + isCompileWarning = log.isCompileWarning, + isNew = log.isNew, + }; + } + SessionState.SetString(SessionStateKey, JsonUtility.ToJson(new SerializedLogList { logs = serialized }, false)); + } + + // Save watches (always — watches are real-time monitors, not historical logs) + if (watchEntries.Count == 0) { + SessionState.SetString(SessionStateWatchKey, ""); + } else { + var watches = new SerializedWatch[watchKeyOrder.Count]; + for (int i = 0; i < watchKeyOrder.Count; i++) { + var w = watchEntries[watchKeyOrder[i]]; + watches[i] = new SerializedWatch { + key = w.key, + value = w.value, + logCategory = (int)w.logCategory, + timestamp = w.timestamp, + frameCount = w.frameCount, + updateCount = w.updateCount, + isRemote = w.isRemote, + }; + } + SessionState.SetString(SessionStateWatchKey, JsonUtility.ToJson(new SerializedWatchList { watches = watches }, false)); + } + } + + private static void RestoreSessionLogs() { + // Recover the reload counter from before this domain reload. + reloadCount = SessionState.GetInt(SessionStateReloadKey, 0); + + // On the 2nd+ reload of the same recompile (reloadCount > 1), logs restored + // here came from the 1st reload and should still be treated as "new". + // On the 1st reload (reloadCount == 1), old logs must be reset to isNew = false. + bool keepNewStatus = reloadCount > 1; + + string json = SessionState.GetString(SessionStateKey, ""); + if (!string.IsNullOrEmpty(json)) { + try { + var data = JsonUtility.FromJson(json); + if (data?.logs != null) { + logs.Clear(); + foreach (var s in data.logs) { + var entry = new LogEntry( + (JovianLogType)s.type, + (LogCategory)s.logCategory, + s.message, + s.stackTrace, + s.isCustomLog + ); + entry.timestamp = s.timestamp; + entry.frameCount = s.frameCount; + entry.sourceFile = s.sourceFile; + entry.className = s.className; + entry.isRemote = s.isRemote; + entry.pinned = s.pinned; + entry.isCompileError = s.isCompileError; + entry.isCompileWarning = s.isCompileWarning; + entry.isNew = keepNewStatus ? s.isNew : false; + logs.Add(entry); + } + } + } catch { } + } + + // Restore watches + string watchJson = SessionState.GetString(SessionStateWatchKey, ""); + if (!string.IsNullOrEmpty(watchJson)) { + try { + var data = JsonUtility.FromJson(watchJson); + if (data?.watches != null) { + watchEntries.Clear(); + watchKeyOrder.Clear(); + foreach (var s in data.watches) { + var entry = new WatchEntry { + key = s.key, + value = s.value, + logCategory = (LogCategory)s.logCategory, + timestamp = s.timestamp, + frameCount = s.frameCount, + updateCount = s.updateCount, + isRemote = s.isRemote, + }; + watchEntries[s.key] = entry; + watchKeyOrder.Add(s.key); + } + } + } catch { } + } + } + + [Serializable] + private class SerializedLogList { + public SerializedLog[] logs; + } + + // ── Ignore List ────────────────────────────────────────────────── + + private static string IgnoreListPath { + get { + string projectId = EditorPrefs.GetString(PrefsPrefix + "ignoreListId", ""); + if (string.IsNullOrEmpty(projectId)) { + projectId = Application.dataPath.GetHashCode().ToString("X"); + EditorPrefs.SetString(PrefsPrefix + "ignoreListId", projectId); + } + return Path.Combine(Path.GetTempPath(), "CustomConsole", $"ignore-list-{projectId}.txt"); + } + } + + private static string GetIgnoreKey(LogEntry log) { + // First line of message is the unique key for ignoring + string firstLine = log.message; + int nl = firstLine.IndexOf('\n'); + if (nl >= 0) firstLine = firstLine.Substring(0, nl); + return firstLine.Trim(); + } + + private static void LoadIgnoreList() { + ignoredLogKeys.Clear(); + string path = IgnoreListPath; + if (!File.Exists(path)) return; + try { + foreach (string line in File.ReadAllLines(path, Encoding.UTF8)) { + if (!string.IsNullOrWhiteSpace(line)) ignoredLogKeys.Add(line); + } + } catch { } + } + + private static void SaveIgnoreList() { + string path = IgnoreListPath; + string dir = Path.GetDirectoryName(path); + if (!string.IsNullOrEmpty(dir) && !Directory.Exists(dir)) + Directory.CreateDirectory(dir); + File.WriteAllLines(path, ignoredLogKeys, Encoding.UTF8); + } + + private static void AddToIgnoreList(LogEntry log) { + string key = GetIgnoreKey(log); + if (ignoredLogKeys.Add(key)) { + SaveIgnoreList(); + } + } + + private static void OpenIgnoreList() { + string path = IgnoreListPath; + if (!File.Exists(path)) { + // Create empty file so user can see it + string dir = Path.GetDirectoryName(path); + if (!string.IsNullOrEmpty(dir) && !Directory.Exists(dir)) + Directory.CreateDirectory(dir); + File.WriteAllText(path, "", Encoding.UTF8); + } + System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo { + FileName = path, + UseShellExecute = true, + }); + } + + private bool IsIgnored(LogEntry log) { + return ignoredLogKeys.Contains(GetIgnoreKey(log)); + } + + private void ShowLogContextMenu(LogEntry log) { + // Capture selection now — menu callbacks run later when the TextEditor state may be gone + string selection = GetActiveTextSelection(); + var menu = new GenericMenu(); + menu.AddItem(new GUIContent("Copy"), false, () => { + if (!string.IsNullOrEmpty(selection)) { + EditorGUIUtility.systemCopyBuffer = selection; + } else { + string full = log.message; + if (!string.IsNullOrEmpty(log.stackTrace)) + full += "\n" + log.stackTrace; + EditorGUIUtility.systemCopyBuffer = full; + } + }); + menu.AddSeparator(""); + if(log.pinned) { + menu.AddItem(new GUIContent("Unpin"), false, () => { + log.pinned = false; + filterDirty = true; + Repaint(); + }); + } + else { + menu.AddItem(new GUIContent("Pin"), false, () => { + log.pinned = true; + filterDirty = true; + Repaint(); + }); + } + menu.AddSeparator(""); + menu.AddItem(new GUIContent("Ignore Forever"), false, () => { + AddToIgnoreList(log); + filterDirty = true; + Repaint(); + }); + menu.AddItem(new GUIContent("Show Ignore List"), false, OpenIgnoreList); + menu.AddItem(new GUIContent("Reload Ignore List"), false, () => { + LoadIgnoreList(); + filterDirty = true; + Repaint(); + }); + menu.ShowAsContext(); + } + + // ── Export ──────────────────────────────────────────────────────── + + private static void ExportLogs() { + if (logs.Count == 0) { + EditorUtility.DisplayDialog("Export Logs", "No logs to export.", "OK"); + return; + } + + string path = EditorUtility.SaveFilePanel("Export Logs", "", $"Custom-log-{DateTime.Now:yyyy-MM-dd_HH-mm-ss}", "txt"); + if (string.IsNullOrEmpty(path)) return; + + var sb = new StringBuilder(); + foreach (var log in logs) { + string source = log.isRemote + ? (log.isCustomLog ? "Remote-Custom" : "Remote-Unity") + : (log.isCustomLog ? "Custom" : "Unity"); + sb.AppendLine($"[{log.timestamp}] [F{log.frameCount}] [{log.type}] [{source}] {log.message}"); + if (!string.IsNullOrEmpty(log.stackTrace)) { + sb.AppendLine(log.stackTrace); + } + } + + File.WriteAllText(path, sb.ToString(), Encoding.UTF8); + } + + private static void ExportLogsForAI() { + if (logs.Count == 0) { + EditorUtility.DisplayDialog("Copy for AI", "No logs to copy.", "OK"); + return; + } + + var sb = new StringBuilder(); + sb.AppendLine("# Unity Log Report"); + sb.AppendLine(); + sb.AppendLine("## Environment"); + sb.AppendLine($"- Unity: {Application.unityVersion}"); + sb.AppendLine($"- Platform: {Application.platform}"); + sb.AppendLine($"- Product: {Application.productName}"); +#pragma warning disable CS0618 // Suppress obsolete warning for broad Unity version compatibility + sb.AppendLine($"- Scripting Backend: {PlayerSettings.GetScriptingBackend(EditorUserBuildSettings.selectedBuildTargetGroup)}"); +#pragma warning restore CS0618 + sb.AppendLine($"- Timestamp: {DateTime.Now:yyyy-MM-dd HH:mm:ss}"); + sb.AppendLine(); + + // Summary counts + int errors = 0, warnings = 0, exceptions = 0, asserts = 0, total = logs.Count; + foreach (var log in logs) { + switch (log.type) { + case JovianLogType.Error: errors++; break; + case JovianLogType.Warning: warnings++; break; + case JovianLogType.Exception: exceptions++; break; + case JovianLogType.Assert: asserts++; break; + } + } + sb.AppendLine("## Summary"); + sb.AppendLine($"- Total: {total} | Errors: {errors} | Warnings: {warnings} | Exceptions: {exceptions} | Asserts: {asserts}"); + sb.AppendLine(); + + // Watch variables + if (watchEntries.Count > 0) { + sb.AppendLine("## Watch Variables"); + foreach (var key in watchKeyOrder) { + if (watchEntries.TryGetValue(key, out var w)) { + sb.AppendLine($"- **{w.key}** = `{w.value}` (updated {w.updateCount}x, last: {w.timestamp})"); + } + } + sb.AppendLine(); + } + + // Errors and exceptions first (most relevant for debugging) + var critical = new List(); + var other = new List(); + foreach (var log in logs) { + if (log.type is JovianLogType.Error or JovianLogType.Exception or JovianLogType.Assert) + critical.Add(log); + else + other.Add(log); + } + + if (critical.Count > 0) { + sb.AppendLine("## Errors & Exceptions"); + sb.AppendLine(); + foreach (var log in critical) { + AppendAILogEntry(sb, log); + } + } + + if (other.Count > 0) { + sb.AppendLine("## Log Messages"); + sb.AppendLine(); + // Cap non-critical to last 200 to keep clipboard manageable + int start = other.Count > 200 ? other.Count - 200 : 0; + if (start > 0) + sb.AppendLine($"_(showing last 200 of {other.Count} messages)_\n"); + for (int i = start; i < other.Count; i++) { + AppendAILogEntry(sb, other[i]); + } + } + + EditorGUIUtility.systemCopyBuffer = sb.ToString(); + Debug.Log($"[CustomConsole] Copied {total} log entries for AI ({sb.Length} chars)"); + } + + private static void AppendAILogEntry(StringBuilder sb, LogEntry log) { + string source = log.isRemote + ? (log.isCustomLog ? "Remote-Custom" : "Remote-Unity") + : (log.isCustomLog ? "Custom" : "Unity"); + sb.AppendLine($"### [{log.type}] {log.timestamp} frame:{log.frameCount}"); + sb.AppendLine($"Source: {source} | File: {log.sourceFile ?? "?"} | Class: {log.className ?? "?"}"); + if (log.isCustomLog && log.logCategory != LogCategory.None) + sb.AppendLine($"Category: {log.logCategory}"); + sb.AppendLine("```"); + sb.AppendLine(log.message); + sb.AppendLine("```"); + if (!string.IsNullOrEmpty(log.stackTrace)) { + sb.AppendLine("
Stack Trace"); + sb.AppendLine(); + sb.AppendLine("```"); + sb.AppendLine(log.stackTrace.TrimEnd()); + sb.AppendLine("```"); + sb.AppendLine("
"); + } + sb.AppendLine(); + } + + // ── Log Cache ───────────────────────────────────────────────────── + + private static readonly string LogCacheDir = Path.Combine(Path.GetTempPath(), "CustomConsole", "LogCache"); + private const int MaxCachedFiles = 10; + + private static void SaveLogCache() { + if (logs.Count == 0) return; + + if (!Directory.Exists(LogCacheDir)) { + Directory.CreateDirectory(LogCacheDir); + } + + string fileName = $"log-cache-{DateTime.Now:yyyy-MM-dd_HH-mm-ss}.txt"; + string filePath = Path.Combine(LogCacheDir, fileName); + + var sb = new StringBuilder(); + sb.AppendLine("=== Custom Console Log Cache ==="); + sb.AppendLine($"Saved: {DateTime.Now:yyyy-MM-dd HH:mm:ss}"); + sb.AppendLine($"Entries: {logs.Count}"); + sb.AppendLine(new string('=', 36)); + sb.AppendLine(); + + foreach (var log in logs) { + string source = log.isRemote + ? (log.isCustomLog ? "Remote-Custom" : "Remote-Unity") + : (log.isCustomLog ? "Custom" : "Unity"); + sb.AppendLine($"[{log.timestamp}] [{log.type}] [{source}] {log.message}"); + if (!string.IsNullOrEmpty(log.stackTrace)) { + sb.AppendLine(log.stackTrace); + } + } + + File.WriteAllText(filePath, sb.ToString(), Encoding.UTF8); + + // Prune old files beyond limit + var files = new DirectoryInfo(LogCacheDir) + .GetFiles("log-cache-*.txt") + .OrderByDescending(f => f.CreationTime) + .Skip(MaxCachedFiles) + .ToArray(); + foreach (var file in files) { + file.Delete(); + } + } + + private static FileInfo[] GetCachedLogFiles() { + if (!Directory.Exists(LogCacheDir)) return Array.Empty(); + return new DirectoryInfo(LogCacheDir) + .GetFiles("log-cache-*.txt") + .OrderByDescending(f => f.CreationTime) + .Take(MaxCachedFiles) + .ToArray(); + } + + private static void ClearLogCache() { + if (!Directory.Exists(LogCacheDir)) return; + var files = new DirectoryInfo(LogCacheDir).GetFiles("log-cache-*.txt"); + foreach (var file in files) { + try { file.Delete(); } catch { } + } + Debug.Log($"[CustomConsole] Cleared {files.Length} cached log file(s)."); + } + + private static void OpenCachedLog(string path) { + System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo { + FileName = path, + UseShellExecute = true, + }); + } + + private static (string file, string className) ExtractCallerInfo(string stackTrace) { + var match = FindCallerMatch(stackTrace); + if (!match.Success) return ("", ""); + + string file = string.Empty; + string filePath = string.Empty; + + try { + filePath = match.Groups[1].Value; + file = Path.GetFileName(filePath); + } + catch { + return (string.Empty, string.Empty); + } + + // Parse class name from the stack frame line: "Namespace.Class.Method (args) (at file:line)" + string line = match.Value; + int atIdx = stackTrace.IndexOf(match.Value, StringComparison.Ordinal); + if (atIdx > 0) { + // Get the full line containing the match + int lineStart = stackTrace.LastIndexOf('\n', atIdx - 1) + 1; + string fullLine = stackTrace.Substring(lineStart, atIdx - lineStart).Trim(); + // fullLine looks like "Namespace.Class.Method (args)" + int parenIdx = fullLine.IndexOf('('); + if (parenIdx > 0) fullLine = fullLine.Substring(0, parenIdx).Trim(); + // Now "Namespace.Class.Method" — take second-to-last dot segment + int lastDot = fullLine.LastIndexOf('.'); + if (lastDot > 0) { + int prevDot = fullLine.LastIndexOf('.', lastDot - 1); + string cls = prevDot >= 0 ? fullLine.Substring(prevDot + 1, lastDot - prevDot - 1) : fullLine.Substring(0, lastDot); + return (file, cls); + } + } + + return (file, ""); + } + + private class WatchEntry { + public string key; + public string value; + public LogCategory logCategory; + public string timestamp; + public int frameCount; + public int updateCount; + public bool isRemote; + } + + private class LogEntry { + public string message; + public string stackTrace; + public string timestamp; + public int frameCount; + public string sourceFile; + public string className; + public JovianLogType type; + public LogCategory logCategory; + public bool isCustomLog; + public bool isRemote; + public bool pinned; + public bool expanded; + public bool isNew = true; + public bool isCompileError; + public bool isCompileWarning; + + public LogEntry(JovianLogType type, LogCategory logCategory, string message, string stackTrace, bool isCustomLog) { + this.message = message; + this.stackTrace = stackTrace; + this.timestamp = DateTime.Now.ToString("HH:mm:ss.fff"); + this.frameCount = LoggerUtility.FrameCount; + this.type = type; + this.logCategory = logCategory; + this.isCustomLog = isCustomLog; + var info = ExtractCallerInfo(stackTrace); + this.sourceFile = info.file; + this.className = info.className; + } + } + } + +#if UNITY_6000_3_OR_NEWER + static class CustomConsoleMainToolbar { + const string k_ElementPath = "Custom/Console"; + + [MainToolbarElement(k_ElementPath, defaultDockPosition = MainToolbarDockPosition.Right)] + static MainToolbarButton CreateButton() { + return new MainToolbarButton( + new MainToolbarContent("Custom Console", "Open Custom Console"), + () => CustomConsole.ShowWindow() + ); + } + } +#endif +} diff --git a/Packages/com.jovian.logger/Editor/CustomConsole.cs.meta b/Packages/com.jovian.logger/Editor/CustomConsole.cs.meta new file mode 100644 index 0000000..1e04829 --- /dev/null +++ b/Packages/com.jovian.logger/Editor/CustomConsole.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: fd8ce1f3fd630a147b25c4578865941e \ No newline at end of file diff --git a/Packages/com.jovian.logger/Editor/Jovian.LoggerEditor.asmdef b/Packages/com.jovian.logger/Editor/Jovian.LoggerEditor.asmdef new file mode 100644 index 0000000..89bdb30 --- /dev/null +++ b/Packages/com.jovian.logger/Editor/Jovian.LoggerEditor.asmdef @@ -0,0 +1,18 @@ +{ + "name": "Jovian.LoggerEditor", + "rootNamespace": "", + "references": [ + "GUID:9e11523c9d4d45445a0938098559d830" + ], + "includePlatforms": [ + "Editor" + ], + "excludePlatforms": [], + "allowUnsafeCode": false, + "overrideReferences": false, + "precompiledReferences": [], + "autoReferenced": true, + "defineConstraints": [], + "versionDefines": [], + "noEngineReferences": false +} \ No newline at end of file diff --git a/Packages/com.jovian.logger/Editor/Jovian.LoggerEditor.asmdef.meta b/Packages/com.jovian.logger/Editor/Jovian.LoggerEditor.asmdef.meta new file mode 100644 index 0000000..902e9de --- /dev/null +++ b/Packages/com.jovian.logger/Editor/Jovian.LoggerEditor.asmdef.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 206337e0b2cdd1b448d7d752a7ca77e8 +AssemblyDefinitionImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/com.jovian.logger/Editor/JovianProjectSettings.cs b/Packages/com.jovian.logger/Editor/JovianProjectSettings.cs new file mode 100644 index 0000000..497f0a4 --- /dev/null +++ b/Packages/com.jovian.logger/Editor/JovianProjectSettings.cs @@ -0,0 +1,67 @@ +using System.IO; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; +using UnityEngine; + +namespace Jovian.ProjectSettings { + /// + /// Reads and writes settings to ProjectSettings/CustomLoggerSettings.json. + /// Flat key-value store. Use prefixed keys by convention (e.g. "logger.myKey"). + /// + internal static class JovianProjectSettings { + private const string FileName = "CustomLoggerSettings.json"; + + private static string FilePath { + get { + var fullName = Directory.GetParent(Application.dataPath)?.FullName; + return Path.Combine(fullName ?? Application.dataPath, "ProjectSettings", FileName); + } + } + + internal static T Get(string packagePrefix, string setting, T defaultValue) { + var root = LoadRoot(); + var key = $"{packagePrefix}.{setting}"; + if (root.TryGetValue(key, out var token)) { + try { + return token.ToObject(); + } catch { + return defaultValue; + } + } + + return defaultValue; + } + + internal static void Set(string packagePrefix, string setting, T value) { + var root = LoadRoot(); + var key = $"{packagePrefix}.{setting}"; + root[key] = value != null ? JToken.FromObject(value) : JValue.CreateNull(); + SaveRoot(root); + } + + private static JObject LoadRoot() { + string path = FilePath; + if (!File.Exists(path)) { + return new JObject(); + } + + try { + string json = File.ReadAllText(path); + return JObject.Parse(json); + } catch { + return new JObject(); + } + } + + private static void SaveRoot(JObject root) { + string path = FilePath; + string dir = Path.GetDirectoryName(path); + if (!string.IsNullOrEmpty(dir) && !Directory.Exists(dir)) { + Directory.CreateDirectory(dir); + } + + string json = root.ToString(Formatting.Indented); + File.WriteAllText(path, json); + } + } +} diff --git a/Packages/com.jovian.logger/Editor/JovianProjectSettings.cs.meta b/Packages/com.jovian.logger/Editor/JovianProjectSettings.cs.meta new file mode 100644 index 0000000..c18797f --- /dev/null +++ b/Packages/com.jovian.logger/Editor/JovianProjectSettings.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: eff2fe1736b6efc40b7e52153c3b1010 \ No newline at end of file diff --git a/Packages/com.jovian.logger/Editor/LoggerSettingsEditor.cs b/Packages/com.jovian.logger/Editor/LoggerSettingsEditor.cs new file mode 100644 index 0000000..feb90aa --- /dev/null +++ b/Packages/com.jovian.logger/Editor/LoggerSettingsEditor.cs @@ -0,0 +1,277 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using UnityEditor; +using UnityEngine; +using Newtonsoft.Json; + +namespace Jovian.Logger { + [CustomEditor(typeof(LoggerSettings))] + internal class LoggerSettingsEditor : Editor { + private static SerializedObject LoggerSettingsObj; + + private SerializedProperty enableGlobalLoggingProp; + private SerializedProperty uploadBaseUrlProp; + private SerializedProperty minimumTimeBetweenUploadsProp; + private SerializedProperty loggerColorsProp; + + + // For loggerColors sub-fields + private SerializedProperty infoColorProp; + private SerializedProperty warningColorProp; + private SerializedProperty errorColorProp; + private SerializedProperty assertColorProp; + private SerializedProperty exceptionColorProp; + private SerializedProperty spamColorProp; + + private static Editor editor; + private bool showLocalFilters = true; + private bool showAllFilters = true; + private bool showGlobalFilters = true; + private string globalCallerNames = ""; + private string localCallerNames = ""; + private bool hasGlobalSavedFilter = true; + private bool hasLocalSavedFilter = true; + + private class ListOfFilters { + public List filters; + } + + private void LoadLocalFilters() { + var loggerSettings = (LoggerSettings)target; + var filters = EditorPrefs.GetString("LoggerFilters"); + if(UnityIsHeadless()) { + filters = null; + } + if(!string.IsNullOrEmpty(filters)) { + loggerSettings.LocalFilters = JsonConvert.DeserializeObject(filters).filters; + } + + if(loggerSettings.LocalFilters == null) { + loggerSettings.LocalFilters = new List(0); + } + } + + private bool UnityIsHeadless() { + if(Environment.CommandLine.Contains("-batchmode")) { + return true; + } + + return false; + } + + private void OnEnable() { + enableGlobalLoggingProp = serializedObject.FindProperty("enableGlobalLogging"); + uploadBaseUrlProp = serializedObject.FindProperty("uploadBaseUrl"); + minimumTimeBetweenUploadsProp = serializedObject.FindProperty("minimumTimeBetweenUploads"); + loggerColorsProp = serializedObject.FindProperty("loggerColors"); + + infoColorProp = loggerColorsProp.FindPropertyRelative("infoColor"); + warningColorProp = loggerColorsProp.FindPropertyRelative("warningColor"); + errorColorProp = loggerColorsProp.FindPropertyRelative("errorColor"); + assertColorProp = loggerColorsProp.FindPropertyRelative("assertColor"); + exceptionColorProp = loggerColorsProp.FindPropertyRelative("exceptionColor"); + spamColorProp = loggerColorsProp.FindPropertyRelative("spamColor"); + + LoadLocalFilters(); + } + + public static void ShowSettings() { + var loggerSettings = new AssetSettingsLoader().GetSettings(LoggerSettingsProvider.SETTINGS_FILE); + LoggerSettingsObj = new SerializedObject(loggerSettings); + LoggerSettingsObj.Update(); + editor ??= CreateEditor(loggerSettings); + editor.OnInspectorGUI(); + } + + public override void OnInspectorGUI() { + serializedObject.Update(); + var loggerSettings = (LoggerSettings)target; + + EditorGUILayout.Space(15); + EditorGUILayout.LabelField("Custom Logger Settings", EditorStyles.whiteLargeLabel); + EditorGUILayout.Space(15); + EditorGUILayout.HelpBox("This will enable/disable logging for the entire project. It has a code level method as well.", MessageType.Warning); + EditorGUILayout.Space(3); + EditorGUILayout.PropertyField(enableGlobalLoggingProp, new GUIContent("Enable Global Logging")); + + EditorGUILayout.Space(15); + EditorGUILayout.LabelField("Log Message Colors", EditorStyles.whiteLargeLabel); + EditorGUILayout.Space(3); + EditorGUILayout.PropertyField(infoColorProp, new GUIContent("Info Color")); + EditorGUILayout.PropertyField(warningColorProp, new GUIContent("Warning Color")); + EditorGUILayout.PropertyField(errorColorProp, new GUIContent("Error Color")); + EditorGUILayout.PropertyField(assertColorProp, new GUIContent("Assert Color")); + EditorGUILayout.PropertyField(exceptionColorProp, new GUIContent("Exception Color")); + EditorGUILayout.PropertyField(spamColorProp, new GUIContent("Spam Color")); + + EditorGUILayout.Space(10); + if(GUILayout.Button("Reset To Default Colors")) { + loggerSettings.ResetColorsToDefault(); + EditorUtility.SetDirty(loggerSettings); + } + + EditorGUILayout.Space(20); + EditorGUILayout.LabelField("Log Uploader Settings", EditorStyles.whiteLargeLabel); + EditorGUILayout.Space(3); + EditorGUILayout.PropertyField(uploadBaseUrlProp, new GUIContent("Upload Base URL")); + EditorGUILayout.PropertyField(minimumTimeBetweenUploadsProp, new GUIContent("Min Time Between Uploads (sec)")); + + EditorGUILayout.Space(30); + showAllFilters = EditorGUILayout.Foldout(showAllFilters, "Logger Filters"); + if(showAllFilters) { + EditorGUILayout.Space(20); + EditorGUILayout.LabelField("Global Project Level Filters", EditorStyles.whiteLargeLabel); + EditorGUILayout.Space(3); + EditorGUILayout.HelpBox("Global filters will be saved with the asset and, if commited, applied to everyone using the project", MessageType.Warning); + EditorGUILayout.Space(3); + showGlobalFilters = EditorGUILayout.Foldout(showGlobalFilters, "Active Filters"); + if(showGlobalFilters) { + for(int i = 0; i < loggerSettings.globalFilters.Length; i++) { + EditorGUILayout.BeginVertical("box"); + loggerSettings.globalFilters[i].logCategory = (LogCategory)EditorGUILayout.EnumFlagsField("Log Category", loggerSettings.globalFilters[i].logCategory); + loggerSettings.globalFilters[i].jovianLogType = (JovianLogType)EditorGUILayout.EnumPopup("Log Type", loggerSettings.globalFilters[i].jovianLogType); + EditorGUILayout.BeginHorizontal(); + globalCallerNames = GetCallersAsString(loggerSettings.globalFilters[i].callerNames); + globalCallerNames = EditorGUILayout.TextField("Caller Name", globalCallerNames); + loggerSettings.globalFilters[i].callerListingType = (CallerListingType)EditorGUILayout.EnumPopup("", loggerSettings.globalFilters[i].callerListingType, GUILayout.Width(120)); + EditorGUILayout.EndHorizontal(); + loggerSettings.globalFilters[i].callerNames = globalCallerNames.Split(",").ToList(); + + EditorGUILayout.BeginHorizontal(); + GUILayout.FlexibleSpace(); + if(GUILayout.Button("Remove This Filter")) { + var temp = loggerSettings.globalFilters.ToList(); + temp.RemoveAt(i); + loggerSettings.globalFilters = temp.ToArray(); + EditorUtility.SetDirty(loggerSettings); + AssetDatabase.SaveAssets(); + EditorUtility.RequestScriptReload(); + } + + if(GUILayout.Button("Save Changes")) { + hasGlobalSavedFilter = true; + EditorUtility.SetDirty(loggerSettings); + AssetDatabase.SaveAssets(); + EditorUtility.RequestScriptReload(); + } + + EditorGUILayout.EndHorizontal(); + + if (!hasGlobalSavedFilter) { + GUILayout.Space(10); + EditorGUILayout.BeginHorizontal(); + GUILayout.FlexibleSpace(); + EditorGUILayout.HelpBox("Filter Not Saved...", MessageType.Error); + EditorGUILayout.EndHorizontal(); + GUILayout.Space(5); + } + + EditorGUILayout.EndVertical(); + EditorGUILayout.Space(); + } + + EditorGUILayout.BeginHorizontal(); + GUILayout.FlexibleSpace(); + if(GUILayout.Button("Add Global Filter")) { + var temp = loggerSettings.globalFilters.ToList(); + temp.Add(new Filters()); + loggerSettings.globalFilters = temp.ToArray(); + hasGlobalSavedFilter = false; + } + + GUILayout.FlexibleSpace(); + EditorGUILayout.EndHorizontal(); + } + + EditorGUILayout.Space(20); + EditorGUILayout.LabelField("Local Editor Level Filters (Not Serialized)", EditorStyles.whiteLargeLabel); + EditorGUILayout.Space(3); + EditorGUILayout.HelpBox("Local filters are local and will not be saved with the asset but rather with the editor settings. Use these to set personal filters.", MessageType.Info); + EditorGUILayout.Space(3); + showLocalFilters = EditorGUILayout.Foldout(showLocalFilters, "Active Filters"); + if(showLocalFilters) { + for(int i = 0; i < loggerSettings.LocalFilters.Count; i++) { + EditorGUILayout.BeginVertical("box"); + EditorGUI.BeginChangeCheck(); + loggerSettings.LocalFilters[i].logCategory = (LogCategory)EditorGUILayout.EnumFlagsField("Log Category", loggerSettings.LocalFilters[i].logCategory); + loggerSettings.LocalFilters[i].jovianLogType = (JovianLogType)EditorGUILayout.EnumPopup("Log Type", loggerSettings.LocalFilters[i].jovianLogType); + EditorGUILayout.BeginHorizontal(); + localCallerNames = GetCallersAsString(loggerSettings.LocalFilters[i].callerNames); + localCallerNames = EditorGUILayout.TextField("Caller Name", localCallerNames, GUILayout.MinWidth(400)); + loggerSettings.LocalFilters[i].callerListingType = (CallerListingType)EditorGUILayout.EnumPopup("", loggerSettings.LocalFilters[i].callerListingType, GUILayout.Width(120)); + EditorGUILayout.EndHorizontal(); + loggerSettings.LocalFilters[i].callerNames = localCallerNames.Split(",").ToList(); + + EditorGUILayout.BeginHorizontal(); + GUILayout.FlexibleSpace(); + if(GUILayout.Button("Remove This Filter")) { + var temp = new List(loggerSettings.LocalFilters); + temp.RemoveAt(i); + loggerSettings.LocalFilters = temp; + var filters = JsonConvert.SerializeObject(new ListOfFilters() { filters = loggerSettings.LocalFilters }); + EditorPrefs.SetString("LoggerFilters", filters); + EditorUtility.RequestScriptReload(); + } + + if(GUILayout.Button("Save Changes")) { + var filters = JsonConvert.SerializeObject(new ListOfFilters() { filters = loggerSettings.LocalFilters }); + EditorPrefs.SetString("LoggerFilters", filters); + EditorUtility.RequestScriptReload(); + } + + EditorGUILayout.EndHorizontal(); + + if (!hasLocalSavedFilter) { + GUILayout.Space(10); + EditorGUILayout.BeginHorizontal(); + GUILayout.FlexibleSpace(); + EditorGUILayout.HelpBox("Filter Not Saved...", MessageType.Error); + EditorGUILayout.EndHorizontal(); + GUILayout.Space(5); + } + + EditorGUILayout.EndVertical(); + EditorGUILayout.Space(); + } + + EditorGUILayout.BeginHorizontal(); + GUILayout.FlexibleSpace(); + if(GUILayout.Button("Add Local Filter")) { + loggerSettings.LocalFilters.Add(new Filters()); + hasLocalSavedFilter = false; + } + + GUILayout.FlexibleSpace(); + EditorGUILayout.EndHorizontal(); + } + } + + serializedObject.ApplyModifiedProperties(); + } + + private string GetCallersAsString(List callerNames) { + if(callerNames == null || callerNames.Count == 0) { + return ""; + } + + string result = ""; + for(int i = 0; i < callerNames.Count; i++) { + result += callerNames[i]; + if(i != callerNames.Count - 1) { + result += ","; + } + } + + return result; + } + + private void OnDisable() { + AssetDatabase.SaveAssets(); + } + + private void OnDestroy() { + AssetDatabase.SaveAssets(); + } + } +} diff --git a/Packages/com.jovian.logger/Editor/LoggerSettingsEditor.cs.meta b/Packages/com.jovian.logger/Editor/LoggerSettingsEditor.cs.meta new file mode 100644 index 0000000..45fc6a3 --- /dev/null +++ b/Packages/com.jovian.logger/Editor/LoggerSettingsEditor.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 9e97c6cb58b8e0f4f990c51f841842af \ No newline at end of file diff --git a/Packages/com.jovian.logger/Editor/LoggerSettingsProvider.cs b/Packages/com.jovian.logger/Editor/LoggerSettingsProvider.cs new file mode 100644 index 0000000..591fc52 --- /dev/null +++ b/Packages/com.jovian.logger/Editor/LoggerSettingsProvider.cs @@ -0,0 +1,60 @@ +using System.Collections.Generic; +using System.IO; +using UnityEditor; +using UnityEngine; + +namespace Jovian.Logger { + internal sealed class LoggerSettingsProvider : SettingsProvider { + public LoggerSettingsProvider(string path, SettingsScope scopes, IEnumerable keywords = null) : base(path, scopes, keywords) { } + + public const string SETTINGS_FILE = "Assets/Settings/Resources/logger-settings.asset"; + + [SettingsProvider] + public static SettingsProvider CreateSettingsProvider() { + var provider = new SettingsProvider("Project/Jovian/Logger", SettingsScope.Project) { + guiHandler = (searchContext) => { LoggerSettingsEditor.ShowSettings(); } + }; + return provider; + } + + private class ConfigLoader : AssetPostprocessor { + private static AssetSettingsLoader loader; + + private static void OnPostprocessAllAssets(string[] importedAssets, string[] deletedAssets, string[] movedAssets, string[] movedFromAssetPaths) { + loader ??= new AssetSettingsLoader(); + loader.GetSettings(SETTINGS_FILE); + } + } + } + + public class AssetSettingsLoader where T : ScriptableObject { + private T settings; + + /// + /// Get the settings file if cached/existent. If it doesn't exist, it will create one. + /// + /// + /// + public T GetSettings(string settingsFilePath) { + return settings ?? SetSettings(settingsFilePath); + } + + private T SetSettings(string settingsFilePath) { + if(!Directory.Exists(Path.GetDirectoryName(settingsFilePath))) { + Directory.CreateDirectory(Path.GetDirectoryName(settingsFilePath)); + } + + settings = AssetDatabase.LoadAssetAtPath(settingsFilePath); + if(settings != null) { + return settings; + } + + settings = ScriptableObject.CreateInstance(); + AssetDatabase.CreateAsset(settings, settingsFilePath); + AssetDatabase.SaveAssets(); + + settings = AssetDatabase.LoadAssetAtPath(settingsFilePath); + return settings; + } + } +} diff --git a/Packages/com.jovian.logger/Editor/LoggerSettingsProvider.cs.meta b/Packages/com.jovian.logger/Editor/LoggerSettingsProvider.cs.meta new file mode 100644 index 0000000..5feaf96 --- /dev/null +++ b/Packages/com.jovian.logger/Editor/LoggerSettingsProvider.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 360fda9a575ab7b4eaf523256ac39663 \ No newline at end of file diff --git a/Packages/com.jovian.logger/Editor/RemoteLogReceiver.cs b/Packages/com.jovian.logger/Editor/RemoteLogReceiver.cs new file mode 100644 index 0000000..3ae1cfb --- /dev/null +++ b/Packages/com.jovian.logger/Editor/RemoteLogReceiver.cs @@ -0,0 +1,245 @@ +using System; +using System.Collections.Concurrent; +using System.IO; +using System.Net; +using System.Net.Sockets; +using System.Text; +using System.Threading; +using UnityEditor; +using UnityEngine; + +namespace Jovian.Logger { + /// + /// TCP server that receives log messages from RemoteLogSender on device builds. + /// Managed by Custom Console — not intended for standalone use. + /// + internal static class RemoteLogReceiver { + public const int DefaultPort = 9876; + + public struct RemoteLogEntry { + public string message; + public string stackTrace; + public string timestamp; + public int frameCount; + public JovianLogType type; + public LogCategory logCategory; + public bool isCustomLog; + } + + public struct RemoteWatchEntry { + public string key; + public string value; + public LogCategory logCategory; + public string timestamp; + public int frameCount; + } + + public static event Action OnRemoteLog; + public static event Action OnRemoteWatch; + public static event Action OnRemoteUnwatch; + public static event Action OnClientConnected; + public static event Action OnClientDisconnected; + + private static TcpListener listener; + private static Thread listenThread; + private static volatile bool running; + private static int port = DefaultPort; + + public static bool IsRunning => running; + public static int Port => port; + public static string ConnectedClientName { get; private set; } = ""; + public static bool HasClient { get; private set; } + + private static readonly ConcurrentQueue mainThreadQueue = new ConcurrentQueue(); + + public static void Start(int listenPort = DefaultPort) { + if (running) return; + port = listenPort; + + try { + listener = new TcpListener(IPAddress.Any, port); + listener.Start(); + running = true; + + listenThread = new Thread(ListenLoop) { + IsBackground = true, + Name = "RemoteLogReceiver" + }; + listenThread.Start(); + + EditorApplication.update += ProcessMainThreadQueue; + Debug.Log($"[RemoteLog] Listening on port {port}"); + } catch (Exception e) { + Debug.LogError($"[RemoteLog] Failed to start listener: {e.Message}"); + running = false; + } + } + + public static void Stop() { + running = false; + HasClient = false; + ConnectedClientName = ""; + + try { listener?.Stop(); } catch { } + listener = null; + + EditorApplication.update -= ProcessMainThreadQueue; + } + + private static void ProcessMainThreadQueue() { + while (mainThreadQueue.TryDequeue(out var action)) { + action?.Invoke(); + } + } + + private static void ListenLoop() { + while (running) { + try { + if (!listener.Pending()) { + Thread.Sleep(100); + continue; + } + + var client = listener.AcceptTcpClient(); + client.NoDelay = true; + client.ReceiveTimeout = 0; + HandleClient(client); + } catch (SocketException) { + if (!running) break; + } catch (ObjectDisposedException) { + break; + } + } + } + + private static void HandleClient(TcpClient client) { + var thread = new Thread(() => ClientReadLoop(client)) { + IsBackground = true, + Name = "RemoteLogClient" + }; + thread.Start(); + } + + private static void ClientReadLoop(TcpClient client) { + try { + using var stream = client.GetStream(); + using var reader = new StreamReader(stream, Encoding.UTF8); + + HasClient = true; + + while (running && client.Connected) { + string line = reader.ReadLine(); + if (line == null) break; + if (string.IsNullOrWhiteSpace(line)) continue; + + ParseAndDispatch(line); + } + } catch (IOException) { + // Connection lost + } catch (ObjectDisposedException) { + // Shutting down + } finally { + try { client.Close(); } catch { } + HasClient = false; + ConnectedClientName = ""; + mainThreadQueue.Enqueue(() => OnClientDisconnected?.Invoke()); + } + } + + private static void ParseAndDispatch(string json) { + try { + // Check for handshake + if (json.Contains("\"handshake\"")) { + string appName = ExtractJsonString(json, "app"); + string platform = ExtractJsonString(json, "platform"); + ConnectedClientName = $"{appName} ({platform})"; + mainThreadQueue.Enqueue(() => OnClientConnected?.Invoke(ConnectedClientName)); + return; + } + + // Check for watch + if (json.Contains("\"watch\"")) { + var watchEntry = new RemoteWatchEntry { + key = ExtractJsonString(json, "wk"), + value = ExtractJsonString(json, "wv"), + logCategory = (LogCategory)ExtractJsonInt(json, "c"), + timestamp = ExtractJsonString(json, "ts"), + frameCount = ExtractJsonInt(json, "fc"), + }; + mainThreadQueue.Enqueue(() => OnRemoteWatch?.Invoke(watchEntry)); + return; + } + + // Check for unwatch + if (json.Contains("\"unwatch\"")) { + string key = ExtractJsonString(json, "wk"); + mainThreadQueue.Enqueue(() => OnRemoteUnwatch?.Invoke(key)); + return; + } + + var entry = new RemoteLogEntry { + message = ExtractJsonString(json, "m"), + stackTrace = ExtractJsonString(json, "s"), + timestamp = ExtractJsonString(json, "ts"), + frameCount = ExtractJsonInt(json, "fc"), + type = (JovianLogType)ExtractJsonInt(json, "t"), + logCategory = (LogCategory)ExtractJsonInt(json, "c"), + isCustomLog = ExtractJsonBool(json, "f"), + }; + + mainThreadQueue.Enqueue(() => OnRemoteLog?.Invoke(entry)); + } catch (Exception e) { + Debug.LogWarning($"[RemoteLog] Failed to parse: {e.Message}"); + } + } + + // Lightweight JSON field extractors (avoids dependency on full JSON parser for simple protocol) + private static string ExtractJsonString(string json, string key) { + string pattern = "\"" + key + "\":\""; + int start = json.IndexOf(pattern, StringComparison.Ordinal); + if (start < 0) return ""; + start += pattern.Length; + + var sb = new StringBuilder(); + for (int i = start; i < json.Length; i++) { + char c = json[i]; + if (c == '\\' && i + 1 < json.Length) { + char next = json[i + 1]; + switch (next) { + case '"': sb.Append('"'); i++; break; + case '\\': sb.Append('\\'); i++; break; + case 'n': sb.Append('\n'); i++; break; + case 'r': sb.Append('\r'); i++; break; + case 't': sb.Append('\t'); i++; break; + default: sb.Append(c); break; + } + } else if (c == '"') { + break; + } else { + sb.Append(c); + } + } + return sb.ToString(); + } + + private static int ExtractJsonInt(string json, string key) { + string pattern = "\"" + key + "\":"; + int start = json.IndexOf(pattern, StringComparison.Ordinal); + if (start < 0) return 0; + start += pattern.Length; + + int end = start; + while (end < json.Length && (char.IsDigit(json[end]) || json[end] == '-')) end++; + if (end == start) return 0; + return int.TryParse(json.AsSpan(start, end - start), out int val) ? val : 0; + } + + private static bool ExtractJsonBool(string json, string key) { + string pattern = "\"" + key + "\":"; + int start = json.IndexOf(pattern, StringComparison.Ordinal); + if (start < 0) return false; + start += pattern.Length; + return start < json.Length && json[start] == 't'; + } + } +} diff --git a/Packages/com.jovian.logger/Editor/RemoteLogReceiver.cs.meta b/Packages/com.jovian.logger/Editor/RemoteLogReceiver.cs.meta new file mode 100644 index 0000000..ad9229b --- /dev/null +++ b/Packages/com.jovian.logger/Editor/RemoteLogReceiver.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 13353efce044337428c052b7e37f0445 \ No newline at end of file diff --git a/Packages/com.jovian.logger/Runtime.meta b/Packages/com.jovian.logger/Runtime.meta new file mode 100644 index 0000000..18a43c0 --- /dev/null +++ b/Packages/com.jovian.logger/Runtime.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 68a0fd172189d17458408cca0bc439a9 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/com.jovian.logger/Runtime/GlobalLogger.cs b/Packages/com.jovian.logger/Runtime/GlobalLogger.cs new file mode 100644 index 0000000..0c8c715 --- /dev/null +++ b/Packages/com.jovian.logger/Runtime/GlobalLogger.cs @@ -0,0 +1,181 @@ +using System; +using System.Diagnostics; +using System.Runtime.CompilerServices; + +namespace Jovian.Logger { + /// + /// Default logger for Custom Logger in case you don't want to create a new instance of CustomLogger + /// Recommended is to create a new instance of CustomLogger + /// + public static class GlobalLogger { + + /// + /// Logs messages that are meant to only be seen in the editor + /// + /// Log message + /// Optional Log Category + /// Implicit, do not provide + [Conditional("UNITY_EDITOR")] [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static void LogSpam(string msg, LogCategory logCat, [CallerMemberName] string callerMethod = "") { + InternalLogger.LogSpam($"{GetCaller()?.Name}.{callerMethod}", msg, logCat); + } + + /// + /// Logs messages that are meant to only be seen in the editor with a context object + /// + /// Log message + /// Object to select in scene/project + /// Optional Log Category + /// Implicit, do not provide + [Conditional("UNITY_EDITOR")] [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static void LogSpam(string msg, UnityEngine.Object context, LogCategory logCat, [CallerMemberName] string callerMethod = "") { + InternalLogger.LogSpam($"{GetCaller()?.Name}.{callerMethod}", msg, logCat, context); + } + + /// + /// Logs standard Info/Debug messages + /// + /// + /// Optional Log Category + /// Implicit, do not provide + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static void LogInfo(string msg, LogCategory logCat, [CallerMemberName] string callerMethod = "") { + InternalLogger.LogInfo($"{GetCaller()?.Name}.{callerMethod}", msg, logCat); + } + + /// + /// Logs standard Info/Debug messagesc with a context object + /// + /// + /// Object to select in scene/project + /// + /// Implicit, do not provide + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static void LogInfo(string msg, UnityEngine.Object context, LogCategory logCat, [CallerMemberName] string callerMethod = "") { + InternalLogger.LogInfo($"{GetCaller()?.Name}.{callerMethod}", msg, logCat, context); + } + + /// + /// Logs standard Warning messages + /// + /// + /// Optional Log Category + /// Implicit, do not provide + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static void LogWarning(string msg, LogCategory logCat, [CallerMemberName] string callerMethod = "") { + InternalLogger.LogWarning($"{GetCaller()?.Name}.{callerMethod}", msg, logCat); + } + + /// + /// Logs standard Warning messages with a context object + /// + /// + /// Object to select in scene/project + /// Optional Log Category + /// Implicit, do not provide + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static void LogWarning(string msg, UnityEngine.Object context, LogCategory logCat, [CallerMemberName] string callerMethod = "") { + InternalLogger.LogWarning($"{GetCaller()?.Name}.{callerMethod}", msg, logCat, context); + } + + /// + /// Logs standard Error messages + /// + /// + /// Optional Log Category + /// Implicit, do not provide + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static void LogError(string msg, LogCategory logCat, [CallerMemberName] string callerMethod = "") { + InternalLogger.LogError($"{GetCaller()?.Name}.{callerMethod}", msg, logCat); + } + + /// + /// Logs standard Error messages with a context object + /// + /// + /// Optional Log Category + /// Object to select in scene/project + /// Implicit, do not provide + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static void LogError(string msg, UnityEngine.Object context, LogCategory logCat, [CallerMemberName] string callerMethod = "") { + InternalLogger.LogError($"{GetCaller()?.Name}.{callerMethod}", msg, logCat, context); + } + + /// + /// Logs standard Assert messages + /// + /// + /// Optional Log Category + /// Implicit, do not provide + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static void LogAssert(string msg, LogCategory logCat, [CallerMemberName] string callerMethod = "") { + InternalLogger.LogAssert($"{GetCaller()?.Name}.{callerMethod}", msg, logCat); + } + + /// + /// Logs standard Assert messages with a context object + /// + /// + /// Object to select in scene/project + /// Optional Log Category + /// Implicit, do not provide + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static void LogAssert(string msg, UnityEngine.Object context, LogCategory logCat, [CallerMemberName] string callerMethod = "") { + InternalLogger.LogAssert($"{GetCaller()?.Name}.{callerMethod}", msg, logCat, context); + } + + + /// + /// Logs string Exception messages + /// + /// + /// Optional Log Category + /// Implicit, do not provide + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static void LogException(string msg, LogCategory logCat, [CallerMemberName] string callerMethod = "") { + InternalLogger.LogException($"{GetCaller()?.Name}.{callerMethod}", msg, logCat); + } + + /// + /// Logs string Exception messages with a context object + /// + /// + /// Object to select in scene/project + /// Optional Log Category + /// Implicit, do not provide + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static void LogException(string msg, UnityEngine.Object context, LogCategory logCat, [CallerMemberName] string callerMethod = "") { + InternalLogger.LogException($"{GetCaller()?.Name}.{callerMethod}", msg, logCat, context); + } + + /// + /// Logs standard Exception messages + /// + /// + /// Optional Log Category + /// Implicit, do not provide + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static void LogException(Exception e, LogCategory logCat, [CallerMemberName] string callerMethod = "") { + InternalLogger.LogException($"{GetCaller()?.Name}.{callerMethod}", e.Message, logCat); + } + + /// + /// Logs standard Exception messages with a context object + /// + /// + /// Object to select in scene/project + /// Optional Log Category + /// Implicit, do not provide + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static void LogException(Exception e, UnityEngine.Object context, LogCategory logCat, [CallerMemberName] string callerMethod = "") { + InternalLogger.LogException($"{GetCaller()?.Name}.{callerMethod}", e.Message, logCat, context); + } + + private static Type GetCaller() { + var stackTrace = new StackTrace(); + var stackFrame = stackTrace.GetFrame(2); + var caller = stackFrame?.GetMethod()?.DeclaringType; + return caller; + } + } +} diff --git a/Packages/com.jovian.logger/Runtime/GlobalLogger.cs.meta b/Packages/com.jovian.logger/Runtime/GlobalLogger.cs.meta new file mode 100644 index 0000000..76798dd --- /dev/null +++ b/Packages/com.jovian.logger/Runtime/GlobalLogger.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 351a08a96ffb9654b94e50b31c1058ba \ No newline at end of file diff --git a/Packages/com.jovian.logger/Runtime/InternalLogger.cs b/Packages/com.jovian.logger/Runtime/InternalLogger.cs new file mode 100644 index 0000000..46bdb14 --- /dev/null +++ b/Packages/com.jovian.logger/Runtime/InternalLogger.cs @@ -0,0 +1,193 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Runtime.CompilerServices; +using System.Text; +using System.Threading; +using UnityEngine; +using Debug = UnityEngine.Debug; + +namespace Jovian.Logger { + internal class LoggerSettingsData { + public bool enableGlobalLogging = true; + public Filters[] globalFilters = Array.Empty(); + public LoggerColors loggerColors = new(); + public bool isLoaded = false; +#if UNITY_EDITOR + public List LocalFilters { get; set; } = new(); +#endif + } + + internal static class InternalLogger { + private static readonly LoggerSettingsData loggerSettingsData = new(); + private static bool enableGlobalLogging = true; + internal static bool IsMainThread => LoggerUtility.IsMainThread; + + internal static void LoadSettings() { + var loggerSettings = LoggerUtility.LoadCustomLoggerSettings(); + + if(!loggerSettings) { + return; + } + + var colors = loggerSettings.loggerColors; + loggerSettingsData.globalFilters = loggerSettings.globalFilters; + enableGlobalLogging = loggerSettings.enableGlobalLogging; + loggerSettingsData.loggerColors.infoColor = colors.infoColor; + loggerSettingsData.loggerColors.warningColor = colors.warningColor; + loggerSettingsData.loggerColors.errorColor = colors.errorColor; + loggerSettingsData.loggerColors.assertColor = colors.assertColor; + loggerSettingsData.loggerColors.exceptionColor = colors.exceptionColor; + loggerSettingsData.loggerColors.spamColor = colors.spamColor; + +#if UNITY_EDITOR + if(!Environment.CommandLine.Contains("-batchmode")) { + loggerSettingsData.LocalFilters = loggerSettings.LocalFilters; + } else { + loggerSettings.LocalFilters.Clear(); + } + + Debug.Log("[CustomLogger] Local Filters: " + loggerSettingsData.LocalFilters.Count); +#endif + loggerSettingsData.isLoaded = true; + Debug.Log("[CustomLogger] Global Filters: " + loggerSettingsData.globalFilters.Length); + Debug.Log("[CustomLogger] Settings loaded"); + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static void LogInternal(JovianLogType jovianLogType, LogCategory logcat, string classType, string msg, Color color, string args = "", UnityEngine.Object reference = null) { + if(!enableGlobalLogging) { + return; + } + + if(!loggerSettingsData.isLoaded && IsMainThread) { + LoadSettings(); + } + + // Check if any filters are present and apply filters + foreach(var filter in loggerSettingsData.globalFilters) { + if(filter == null) { + continue; + } + + if((filter.jovianLogType < jovianLogType || !filter.logCategory.HasFlag(logcat)) || !FilterCaller(filter.callerNames, filter.callerListingType)) { + return; + } + } + +#if UNITY_EDITOR + if(!Environment.CommandLine.Contains("-batchmode")) { + foreach(var filter in loggerSettingsData.LocalFilters) { + if(filter == null) { + continue; + } + + if((filter.jovianLogType < jovianLogType || !filter.logCategory.HasFlag(logcat)) || !FilterCaller(filter.callerNames, filter.callerListingType)) { + return; + } + } + } else { + if(jovianLogType == JovianLogType.Spam) { + return; + } + } +#endif + StringBuilder sb = new(500); + switch(jovianLogType) { + case JovianLogType.Spam: + sb.Append("SPAM -> "); + break; + case JovianLogType.Info: + sb.Append("INFO -> "); + break; + case JovianLogType.Warning: + sb.Append("WARNING -> "); + break; + case JovianLogType.Error: + sb.Append("ERROR -> "); + break; + case JovianLogType.Assert: + sb.Append("ASSERT -> "); + break; + case JovianLogType.Exception: + sb.Append("EXCEPTION -> "); + break; + default: + return; + } + + var isFrameCountEnabled = LoggerUtility.IsFrameCountEnabled; + + // As exceptions can be reported to Unity Exception tracking, we do not want frame numbers in the message. It will prevent Unity from grouping reports + var logTypeShouldIncludeFrameCount = jovianLogType != JovianLogType.Exception; + if(isFrameCountEnabled && logTypeShouldIncludeFrameCount) { + sb.Append("F:"); + sb.Append(LoggerUtility.FrameCount); + sb.Append(" |"); + } + + sb.Append(" ["); + sb.Append(logcat); + sb.Append("] "); + sb.Append("["); + sb.Append(classType); + sb.Append("] "); + sb.Append(msg); + var message = sb.ToString(); + LoggerUtility.FormattedLogCallback?.Invoke((jovianLogType, logcat, message)); + +#if UNITY_EDITOR + //remove the color when not in the editor to avoid cluttering the log files + if(!Environment.CommandLine.Contains("-batchmode") && IsMainThread) { + message = $"{message}"; + } +#endif + Debug.unityLogger.Log(LoggerUtility.GetLogType(jovianLogType), (object)message, reference); + return; + + bool FilterCaller(List filterCallerNames, CallerListingType filterCallerListingType) { + foreach(var caller in filterCallerNames) { + if(!string.IsNullOrEmpty(caller)) { + switch(filterCallerListingType) { + case CallerListingType.Blacklist_Caller: + return !classType.Contains(caller); + case CallerListingType.Whitelist_Caller: + return classType.Contains(caller); + } + } + } + + return true; + } + } + + [Conditional("UNITY_EDITOR")] [MethodImpl(MethodImplOptions.AggressiveInlining)] + internal static void LogSpam(string caller, string msg, LogCategory logcat, UnityEngine.Object reference = null) { + LogInternal(JovianLogType.Spam, logcat, caller, msg, loggerSettingsData.loggerColors.spamColor, "spam", reference); + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + internal static void LogInfo(string caller, string msg, LogCategory logcat, UnityEngine.Object reference = null) { + LogInternal(JovianLogType.Info, logcat, caller, msg, loggerSettingsData.loggerColors.infoColor, "", reference); + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + internal static void LogWarning(string classType, string msg, LogCategory logcat, UnityEngine.Object reference = null) { + LogInternal(JovianLogType.Warning, logcat, classType, msg, loggerSettingsData.loggerColors.warningColor, "", reference); + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + internal static void LogError(string caller, string msg, LogCategory logcat, UnityEngine.Object reference = null) { + LogInternal(JovianLogType.Error, logcat, caller, msg, loggerSettingsData.loggerColors.errorColor, "", reference); + } + + internal static void LogException(string caller, string msg, LogCategory logcat, UnityEngine.Object reference = null) { + LogInternal(JovianLogType.Exception, logcat, caller, msg, loggerSettingsData.loggerColors.exceptionColor, "", reference); + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + internal static void LogAssert(string caller, string msg, LogCategory logcat, UnityEngine.Object reference = null) { + LogInternal(JovianLogType.Assert, logcat, caller, msg, loggerSettingsData.loggerColors.assertColor, "", reference); + } + } +} diff --git a/Packages/com.jovian.logger/Runtime/InternalLogger.cs.meta b/Packages/com.jovian.logger/Runtime/InternalLogger.cs.meta new file mode 100644 index 0000000..d1552f6 --- /dev/null +++ b/Packages/com.jovian.logger/Runtime/InternalLogger.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 1463245ed08381f4688ab74cc4296ba1 \ No newline at end of file diff --git a/Packages/com.jovian.logger/Runtime/Jovian.Logger.asmdef b/Packages/com.jovian.logger/Runtime/Jovian.Logger.asmdef new file mode 100644 index 0000000..31e2d05 --- /dev/null +++ b/Packages/com.jovian.logger/Runtime/Jovian.Logger.asmdef @@ -0,0 +1,14 @@ +{ + "name": "Jovian.Logger", + "rootNamespace": "", + "references": [], + "includePlatforms": [], + "excludePlatforms": [], + "allowUnsafeCode": false, + "overrideReferences": false, + "precompiledReferences": [], + "autoReferenced": true, + "defineConstraints": [], + "versionDefines": [], + "noEngineReferences": false +} \ No newline at end of file diff --git a/Packages/com.jovian.logger/Runtime/Jovian.Logger.asmdef.meta b/Packages/com.jovian.logger/Runtime/Jovian.Logger.asmdef.meta new file mode 100644 index 0000000..60370d2 --- /dev/null +++ b/Packages/com.jovian.logger/Runtime/Jovian.Logger.asmdef.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 9e11523c9d4d45445a0938098559d830 +AssemblyDefinitionImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/com.jovian.logger/Runtime/LogCategory.cs b/Packages/com.jovian.logger/Runtime/LogCategory.cs new file mode 100644 index 0000000..6ba26fa --- /dev/null +++ b/Packages/com.jovian.logger/Runtime/LogCategory.cs @@ -0,0 +1,36 @@ +using System; + +namespace Jovian.Logger { + [Flags] + public enum LogCategory { + None = 0, + General = 1, + Editor = 2, + Core = 4, + GameLogic = 8, + UI = 16, + Input = 32, + Network = 64, + Analytics = 128, + Audio = 256, + Graphics = 512, + Physics = 1024, + AI = 2048, + Internal = 4096, + Testing = 8192 + } + + public enum CallerListingType { + Blacklist_Caller, + Whitelist_Caller + } + + public enum JovianLogType { + Exception, + Assert, + Error, + Warning, + Info, + Spam + } +} diff --git a/Packages/com.jovian.logger/Runtime/LogCategory.cs.meta b/Packages/com.jovian.logger/Runtime/LogCategory.cs.meta new file mode 100644 index 0000000..fa61953 --- /dev/null +++ b/Packages/com.jovian.logger/Runtime/LogCategory.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: edd66ef15598e84448ef22155f418e66 \ No newline at end of file diff --git a/Packages/com.jovian.logger/Runtime/Logger.cs b/Packages/com.jovian.logger/Runtime/Logger.cs new file mode 100644 index 0000000..dfa4bb4 --- /dev/null +++ b/Packages/com.jovian.logger/Runtime/Logger.cs @@ -0,0 +1,280 @@ +using System; +using System.Diagnostics; +using System.Runtime.CompilerServices; + +namespace Jovian.Logger { + /// + /// The recommended way to log messages in Jovian Logger. + /// Create a new instance of this struct in the class you want to log messages. + /// + public struct Logger { + private string caller; + private LogCategory logCategory; + + public Logger(Type caller, LogCategory logCategory) : this() { + this.logCategory = logCategory; + this.caller = caller.Name; + LoggerUtility.PreloadLoggerSettings(); + } + + private string Caller { + get { + if(string.IsNullOrEmpty(caller)) { + caller = "Unspecified"; + } + return caller; + } + } + + private LogCategory LogCategory { + get { + if(logCategory == LogCategory.None) { + logCategory = LogCategory.General; + } + return logCategory; + } + } + + /// + /// Logs messages that are meant to only be seen in the editor + /// + /// Log message + /// Optional Log Category + /// Implicit, do not provide + [Conditional("UNITY_EDITOR")] [MethodImpl(MethodImplOptions.AggressiveInlining)] + public void LogSpam(string msg, LogCategory logCat = LogCategory.None, [CallerMemberName] string callerMethod = "") { + if(logCat == LogCategory.None) { + logCat = LogCategory; + } + + InternalLogger.LogSpam($"{Caller}.{callerMethod}", msg, logCat); + } + + /// + /// Logs messages that are meant to only be seen in the editor with a context object + /// + /// Log message + /// Object to focus on in scene/project window + /// Optional Log Category + /// Implicit, do not provide + [Conditional("UNITY_EDITOR")] [MethodImpl(MethodImplOptions.AggressiveInlining)] + public void LogSpam(string msg, UnityEngine.Object context, LogCategory logCat = LogCategory.None, [CallerMemberName] string callerMethod = "") { + if(logCat == LogCategory.None) { + logCat = LogCategory; + } + + InternalLogger.LogSpam($"{Caller}.{callerMethod}", msg, logCat, context); + } + + /// + /// Logs standard Info/Debug messages + /// + /// + /// Optional Log Category + /// Implicit, do not provide + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public void LogInfo(string msg, LogCategory logCat = LogCategory.None, [CallerMemberName] string callerMethod = "") { + if(logCat == LogCategory.None) { + logCat = LogCategory; + } + + InternalLogger.LogInfo($"{Caller}.{callerMethod}", msg, logCat); + } + + /// + /// Logs standard Info/Debug messages with a context object + /// + /// + /// Optional Log Category + /// Object to focus on in scene/project window + /// Implicit, do not provide + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public void LogInfo(string msg, UnityEngine.Object context, LogCategory logCat = LogCategory.None, [CallerMemberName] string callerMethod = "") { + if(logCat == LogCategory.None) { + logCat = LogCategory; + } + + InternalLogger.LogInfo($"{Caller}.{callerMethod}", msg, logCat, context); + } + + /// + /// Logs standard Warning messages + /// + /// + /// Optional Log Category + /// Implicit, do not provide + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public void LogWarning(string msg, LogCategory logCat = LogCategory.None, [CallerMemberName] string callerMethod = "") { + if(logCat == LogCategory.None) { + logCat = LogCategory; + } + + InternalLogger.LogWarning($"{Caller}.{callerMethod}", msg, logCat); + } + + /// + /// Logs standard Warning messages with a context object + /// + /// + /// Object to focus on in scene/project window + /// Optional Log Category + /// Implicit, do not provide + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public void LogWarning(string msg, UnityEngine.Object context, LogCategory logCat = LogCategory.None, [CallerMemberName] string callerMethod = "") { + if(logCat == LogCategory.None) { + logCat = LogCategory; + } + + InternalLogger.LogWarning($"{Caller}.{callerMethod}", msg, logCat, context); + } + + /// + /// Logs standard Error messages + /// + /// + /// Optional Log Category + /// Implicit, do not provide + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public void LogError(string msg, LogCategory logCat = LogCategory.None, [CallerMemberName] string callerMethod = "") { + if(logCat == LogCategory.None) { + logCat = LogCategory; + } + + InternalLogger.LogError($"{Caller}.{callerMethod}", msg, logCat); + } + + /// + /// Logs standard Error messages with a context object + /// + /// + /// Object to focus on in scene/project window + /// Optional Log Category + /// Implicit, do not provide + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public void LogError(string msg, UnityEngine.Object context, LogCategory logCat = LogCategory.None, [CallerMemberName] string callerMethod = "") { + if(logCat == LogCategory.None) { + logCat = LogCategory; + } + + InternalLogger.LogError($"{Caller}.{callerMethod}", msg, logCat, context); + } + + /// + /// Logs standard Assert messages + /// + /// + /// Optional Log Category + /// Implicit, do not provide + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public void LogAssert(string msg, LogCategory logCat = LogCategory.None, [CallerMemberName] string callerMethod = "") { + if(logCat == LogCategory.None) { + logCat = LogCategory; + } + + InternalLogger.LogAssert($"{Caller}.{callerMethod}", msg, logCat); + } + + /// + /// Logs standard Assert messages with a context object + /// + /// + /// Object to focus on in scene/project window + /// Optional Log Category + /// Implicit, do not provide + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public void LogAssert(string msg, UnityEngine.Object context, LogCategory logCat = LogCategory.None, [CallerMemberName] string callerMethod = "") { + if(logCat == LogCategory.None) { + logCat = LogCategory; + } + + InternalLogger.LogAssert($"{Caller}.{callerMethod}", msg, logCat, context); + } + + /// + /// Logs string Exception messages + /// + /// + /// Optional Log Category + /// Implicit, do not provide + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public void LogException(string msg, LogCategory logCat = LogCategory.None, [CallerMemberName] string callerMethod = "") { + if(logCat == LogCategory.None) { + logCat = LogCategory; + } + + InternalLogger.LogException($"{Caller}.{callerMethod}", msg, logCat); + } + + /// + /// Logs string Exception messages with a context object + /// + /// + /// Object to focus on in scene/project window + /// Optional Log Category + /// Implicit, do not provide + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public void LogException(string msg, UnityEngine.Object context, LogCategory logCat = LogCategory.None, [CallerMemberName] string callerMethod = "") { + if(logCat == LogCategory.None) { + logCat = LogCategory; + } + + InternalLogger.LogException($"{Caller}.{callerMethod}", msg, logCat, context); + } + + /// + /// Logs standard Exception messages + /// + /// + /// Optional Log Category + /// Implicit, do not provide + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public void LogException(Exception e, LogCategory logCat = LogCategory.None, [CallerMemberName] string callerMethod = "") { + if(logCat == LogCategory.None) { + logCat = LogCategory; + } + + InternalLogger.LogException($"{Caller}.{callerMethod}", e.Message, logCat); + } + + /// + /// Logs standard Exception messages with a context object + /// + /// + /// Object to focus on in scene/project window + /// Optional Log Category + /// Implicit, do not provide + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public void LogException(Exception e, UnityEngine.Object context, LogCategory logCat = LogCategory.None, [CallerMemberName] string callerMethod = "") { + if(logCat == LogCategory.None) { + logCat = LogCategory; + } + + InternalLogger.LogException($"{Caller}.{callerMethod}", e.Message, logCat, context); + } + + /// + /// Logs a watch variable that updates in place in the Custom Console instead of creating new entries. + /// Ideal for values that change every frame (positions, FPS, state names, etc.). + /// + /// Unique identifier for this watch (e.g. "playerPos", "fps") + /// The current value to display + /// Optional Log Category + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public void Watch(string key, object value, LogCategory logCat = LogCategory.None) { + if(logCat == LogCategory.None) { + logCat = LogCategory; + } + + LoggerUtility.WatchCallback?.Invoke((key, value?.ToString() ?? "null", logCat)); + } + + /// + /// Removes a watch variable from the Custom Console. + /// + /// The watch key to remove + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public void Unwatch(string key) { + LoggerUtility.UnwatchCallback?.Invoke(key); + } + } +} diff --git a/Packages/com.jovian.logger/Runtime/Logger.cs.meta b/Packages/com.jovian.logger/Runtime/Logger.cs.meta new file mode 100644 index 0000000..db12256 --- /dev/null +++ b/Packages/com.jovian.logger/Runtime/Logger.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: fb1cfb4712ed8a649a051a11d0aa17f5 \ No newline at end of file diff --git a/Packages/com.jovian.logger/Runtime/LoggerSettings.cs b/Packages/com.jovian.logger/Runtime/LoggerSettings.cs new file mode 100644 index 0000000..9b9e357 --- /dev/null +++ b/Packages/com.jovian.logger/Runtime/LoggerSettings.cs @@ -0,0 +1,36 @@ +using System; +using System.Collections.Generic; +using UnityEngine; + +namespace Jovian.Logger { + public class LoggerSettings : ScriptableObject { + public bool enableGlobalLogging = true; + public Filters[] globalFilters = Array.Empty(); + public LoggerColors loggerColors = new(); + +#if UNITY_EDITOR + public List LocalFilters { get; set; } = new(); +#endif + public void ResetColorsToDefault() { + loggerColors = new LoggerColors(); + } + } + + [Serializable] + public class LoggerColors { + public Color infoColor = Color.white; + public Color warningColor = Color.yellow; + public Color errorColor = Color.red; + public Color assertColor = new(1f, 0.3f, 0.2f); + public Color exceptionColor = new(1f, 0.0f, 0.7f); + public Color spamColor = Color.grey; + } + + [Serializable] + public class Filters { + public LogCategory logCategory = (LogCategory)~0; + public JovianLogType jovianLogType = JovianLogType.Spam; + public List callerNames = new(); + public CallerListingType callerListingType = CallerListingType.Blacklist_Caller; + } +} diff --git a/Packages/com.jovian.logger/Runtime/LoggerSettings.cs.meta b/Packages/com.jovian.logger/Runtime/LoggerSettings.cs.meta new file mode 100644 index 0000000..f9af2cf --- /dev/null +++ b/Packages/com.jovian.logger/Runtime/LoggerSettings.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 67fe3f48aa2b3b349a7b99381bebb12d \ No newline at end of file diff --git a/Packages/com.jovian.logger/Runtime/LoggerUtility.cs b/Packages/com.jovian.logger/Runtime/LoggerUtility.cs new file mode 100644 index 0000000..ce907bb --- /dev/null +++ b/Packages/com.jovian.logger/Runtime/LoggerUtility.cs @@ -0,0 +1,152 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using UnityEngine; + +namespace Jovian.Logger { + public static class LoggerUtility { + private static LoggerSettings loggerSettings; + private static bool setByFrameCount = true; + private static int frameCount; + private static bool isLoaded; + private static readonly int mainThreadId = Thread.CurrentThread.ManagedThreadId; + + /// + /// Returns true if the current code is executing on the main Unity thread. + /// + public static bool IsMainThread => Thread.CurrentThread.ManagedThreadId == mainThreadId; + + /// + /// Callback to post formatted log messages. + /// + public static Action<(JovianLogType, LogCategory, string)> FormattedLogCallback { get; set; } + + /// + /// Callback for watch-mode logs that update in place instead of creating new entries. + /// Parameters: (watchKey, value, logCategory) + /// + public static Action<(string key, string value, LogCategory category)> WatchCallback { get; set; } + + /// + /// Callback when a watch key is removed. + /// + public static Action UnwatchCallback { get; set; } + + /// + /// If enabled it will show either the Unity's Time.frameCount or the custom frame count set by the user. This is a global setting. + /// + public static int FrameCount { + get { + if(setByFrameCount) { + try { + frameCount = Time.frameCount; + } catch(UnityException) { + // Time.frameCount can only be called from the main thread. + // Return last known value when called from a background thread. + } + } + + return frameCount; + } + set { + frameCount = value; + setByFrameCount = false; + } + } + + public static LoggerSettings LoadCustomLoggerSettings() { + try { + if(isLoaded) { + return loggerSettings; + } + + loggerSettings = Resources.Load("logger-settings"); + isLoaded = true; + return loggerSettings; + } catch { + //Debug.Log($"[Exception] LoggerSettings could not be loaded."); + } + + return null; + } + + /// + /// Toggle the frame counting feature to be included with the logs on/off + /// + /// + public static void ToggleFrameCount(bool enable) { + IsFrameCountEnabled = enable; + } + + /// + /// Enable/Disable logging globally + /// + /// + public static void ToggleLogging(bool enable) { + loggerSettings.enableGlobalLogging = enable; + } + + /// + /// Check if the frame count feature is enabled + /// + public static bool IsFrameCountEnabled { get; private set; } + + /// + /// Load settings preemptively to avoid asynchronous loading for unity assets. + /// + public static void PreloadLoggerSettings() { + try { + LoadCustomLoggerSettings(); + } catch(Exception e) { + Debug.Log($"[Exception] LoggerSettings could not be loaded. {e}"); + } + } + + /// + /// Adds a custom filter programmatically. Useful for setting up remote filters + /// + /// Required set of log categories + /// The log level, default being level 4, Exception(in unity it is the base log level) + /// A list of classes that should be watched, default null + /// If all preexisting filters should be first removed + public static void AddFilter(LogCategory logCategory, JovianLogType jovianLogLevel = JovianLogType.Spam, List callerNames = null, bool clearAll = false) { + var filter = new Filters() { + jovianLogType = jovianLogLevel, + logCategory = logCategory, + callerNames = callerNames + }; + var loggerSettings = LoadCustomLoggerSettings(); + if(clearAll) { + loggerSettings.globalFilters = new Filters[1]; + loggerSettings.globalFilters[0] = filter; + InternalLogger.LoadSettings(); + return; + } + + var filters = new Filters[loggerSettings.globalFilters.Length + 1]; + for(var i = 0; i < loggerSettings.globalFilters.Length; i++) { + filters[i] = loggerSettings.globalFilters[i]; + } + + filters[^1] = filter; + loggerSettings.globalFilters = filters; + InternalLogger.LoadSettings(); + } + + public static void ReloadLoggerSettings() { + InternalLogger.LoadSettings(); + } + + public static UnityEngine.LogType GetLogType(JovianLogType jovianLogType) { + return jovianLogType switch { + JovianLogType.Info => UnityEngine.LogType.Log, + JovianLogType.Warning => UnityEngine.LogType.Warning, + JovianLogType.Error => UnityEngine.LogType.Error, + JovianLogType.Assert => UnityEngine.LogType.Assert, + JovianLogType.Exception => UnityEngine.LogType.Exception, + JovianLogType.Spam => UnityEngine.LogType.Log, + _ => UnityEngine.LogType.Log + }; + } + } +} diff --git a/Packages/com.jovian.logger/Runtime/LoggerUtility.cs.meta b/Packages/com.jovian.logger/Runtime/LoggerUtility.cs.meta new file mode 100644 index 0000000..f6d1d9a --- /dev/null +++ b/Packages/com.jovian.logger/Runtime/LoggerUtility.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 89f12f254c1d11e418ec326092b0b049 \ No newline at end of file diff --git a/Packages/com.jovian.logger/Runtime/RemoteLogSender.cs b/Packages/com.jovian.logger/Runtime/RemoteLogSender.cs new file mode 100644 index 0000000..bc823ff --- /dev/null +++ b/Packages/com.jovian.logger/Runtime/RemoteLogSender.cs @@ -0,0 +1,270 @@ +using System; +using System.Collections.Concurrent; +using System.Net.Sockets; +using System.Text; +using System.Threading; +using UnityEngine; + +namespace Jovian.Logger { + /// + /// Sends log messages to the Custom Console editor window over TCP. + /// Add this component to a GameObject in your scene (or create one at runtime) + /// to enable remote logging from device builds. + /// + public class RemoteLogSender : MonoBehaviour { + [Tooltip("IP address of the machine running the Unity Editor")] + [SerializeField] private string editorHost = "127.0.0.1"; + [Tooltip("Port the Custom Console is listening on")] + [SerializeField] private int editorPort = 9876; + [Tooltip("Automatically connect on start")] + [SerializeField] private bool autoConnect = true; + [Tooltip("Retry connection every N seconds when disconnected")] + [SerializeField] private float reconnectInterval = 5f; + + private TcpClient client; + private NetworkStream stream; + private readonly ConcurrentQueue sendQueue = new ConcurrentQueue(); + private Thread sendThread; + private volatile bool running; + private volatile bool connected; + private float reconnectTimer; + + private static RemoteLogSender instance; + + /// Whether the sender is currently connected to the editor. + public bool IsConnected => connected; + + /// The editor host address. + public string EditorHost { + get => editorHost; + set => editorHost = value; + } + + /// The editor port. + public int EditorPort { + get => editorPort; + set => editorPort = value; + } + + private void Awake() { + if (instance != null && instance != this) { + Destroy(gameObject); + return; + } + instance = this; + DontDestroyOnLoad(gameObject); + } + + private void OnEnable() { + Application.logMessageReceivedThreaded += OnUnityLogReceived; + LoggerUtility.FormattedLogCallback += OnJovianLogReceived; + LoggerUtility.WatchCallback += OnWatch; + LoggerUtility.UnwatchCallback += OnUnwatch; + + if (autoConnect) { + Connect(); + } + } + + private void OnDisable() { + Application.logMessageReceivedThreaded -= OnUnityLogReceived; + LoggerUtility.FormattedLogCallback -= OnJovianLogReceived; + LoggerUtility.WatchCallback -= OnWatch; + LoggerUtility.UnwatchCallback -= OnUnwatch; + Disconnect(); + } + + private void Update() { + if (!connected && autoConnect) { + reconnectTimer += Time.unscaledDeltaTime; + if (reconnectTimer >= reconnectInterval) { + reconnectTimer = 0f; + Connect(); + } + } + } + + /// Connect to the Custom Console editor. + public void Connect() { + if (connected) return; + + try { + client = new TcpClient(); + client.NoDelay = true; + client.SendTimeout = 2000; + var result = client.BeginConnect(editorHost, editorPort, null, null); + bool success = result.AsyncWaitHandle.WaitOne(TimeSpan.FromSeconds(2)); + if (!success || !client.Connected) { + client.Close(); + client = null; + return; + } + client.EndConnect(result); + stream = client.GetStream(); + connected = true; + running = true; + + sendThread = new Thread(SendLoop) { + IsBackground = true, + Name = "RemoteLogSender" + }; + sendThread.Start(); + + // Send handshake + EnqueueMessage("{\"handshake\":true,\"app\":\"" + Application.productName + "\",\"platform\":\"" + Application.platform + "\"}"); + Debug.Log($"[RemoteLog] Connected to editor at {editorHost}:{editorPort}"); + } catch (Exception e) { + Debug.LogWarning($"[RemoteLog] Failed to connect: {e.Message}"); + CleanupConnection(); + } + } + + /// Disconnect from the editor. + public void Disconnect() { + running = false; + CleanupConnection(); + } + + private void CleanupConnection() { + connected = false; + try { stream?.Close(); } catch { } + try { client?.Close(); } catch { } + stream = null; + client = null; + } + + private void OnJovianLogReceived((JovianLogType logType, LogCategory logCategory, string message) log) { + if (!connected) return; + var json = BuildLogJson(log.message, "", (int)log.logType, (int)log.logCategory, true, + DateTime.Now.ToString("HH:mm:ss.fff"), Time.frameCount); + EnqueueMessage(json); + } + + private static readonly string[] LoggerPrefixes = { + "INFO -> ", "ERROR -> ", "WARNING -> ", + "EXCEPTION -> ", "ASSERT -> ", "SPAM -> " + }; + + private static bool IsLoggerFormattedMessage(string condition) { + foreach (var prefix in LoggerPrefixes) { + if (condition.StartsWith(prefix, StringComparison.Ordinal)) return true; + } + if (condition.StartsWith(" 22) { + var afterTag = condition.AsSpan(15); + foreach (var prefix in LoggerPrefixes) { + if (afterTag.StartsWith(prefix.AsSpan(), StringComparison.Ordinal)) return true; + } + } + return false; + } + + private void OnUnityLogReceived(string condition, string stackTrace, UnityEngine.LogType type) { + if (!connected) return; + // Skip logger-formatted messages — those come via OnCustomLog + if (IsLoggerFormattedMessage(condition)) return; + + int loggerTyper = type switch { + LogType.Error => (int)JovianLogType.Error, + LogType.Assert => (int)JovianLogType.Assert, + LogType.Warning => (int)JovianLogType.Warning, + LogType.Exception => (int)JovianLogType.Exception, + _ => (int)JovianLogType.Info, + }; + + string message = condition; + if (type is LogType.Error or LogType.Assert or LogType.Exception && !string.IsNullOrEmpty(stackTrace)) { + message = $"{condition}\n{stackTrace}"; + } + + var json = BuildLogJson(message, stackTrace ?? "", loggerTyper, (int)LogCategory.General, false, + DateTime.Now.ToString("HH:mm:ss.fff"), Time.frameCount); + EnqueueMessage(json); + } + + private void OnWatch((string key, string value, LogCategory category) watch) { + if (!connected) return; + var sb = new StringBuilder(128); + sb.Append("{\"watch\":true,\"wk\":"); + AppendJsonString(sb, watch.key); + sb.Append(",\"wv\":"); + AppendJsonString(sb, watch.value); + sb.Append(",\"c\":").Append((int)watch.category); + sb.Append(",\"ts\":"); + AppendJsonString(sb, DateTime.Now.ToString("HH:mm:ss.fff")); + sb.Append(",\"fc\":").Append(Time.frameCount); + sb.Append('}'); + EnqueueMessage(sb.ToString()); + } + + private void OnUnwatch(string key) { + if (!connected) return; + var sb = new StringBuilder(64); + sb.Append("{\"unwatch\":true,\"wk\":"); + AppendJsonString(sb, key); + sb.Append('}'); + EnqueueMessage(sb.ToString()); + } + + private static string BuildLogJson(string message, string stackTrace, int type, int category, bool isCustom, string timestamp, int frame) { + // Manual JSON building to avoid allocations from JsonUtility + var sb = new StringBuilder(256); + sb.Append("{\"m\":"); + AppendJsonString(sb, message); + sb.Append(",\"s\":"); + AppendJsonString(sb, stackTrace); + sb.Append(",\"t\":").Append(type); + sb.Append(",\"c\":").Append(category); + sb.Append(",\"f\":").Append(isCustom ? "true" : "false"); + sb.Append(",\"ts\":"); + AppendJsonString(sb, timestamp); + sb.Append(",\"fc\":").Append(frame); + sb.Append('}'); + return sb.ToString(); + } + + private static void AppendJsonString(StringBuilder sb, string value) { + if (value == null) { sb.Append("\"\""); return; } + sb.Append('"'); + foreach (char c in value) { + switch (c) { + case '"': sb.Append("\\\""); break; + case '\\': sb.Append("\\\\"); break; + case '\n': sb.Append("\\n"); break; + case '\r': sb.Append("\\r"); break; + case '\t': sb.Append("\\t"); break; + default: sb.Append(c); break; + } + } + sb.Append('"'); + } + + private void EnqueueMessage(string json) { + // Cap queue size to prevent memory issues if sending is slow + if (sendQueue.Count < 10000) { + sendQueue.Enqueue(json); + } + } + + private void SendLoop() { + while (running) { + try { + if (sendQueue.TryDequeue(out string json)) { + byte[] data = Encoding.UTF8.GetBytes(json + "\n"); + stream.Write(data, 0, data.Length); + } else { + Thread.Sleep(5); + } + } catch (Exception) { + running = false; + connected = false; + break; + } + } + } + + private void OnDestroy() { + if (instance == this) instance = null; + Disconnect(); + } + } +} diff --git a/Packages/com.jovian.logger/Runtime/RemoteLogSender.cs.meta b/Packages/com.jovian.logger/Runtime/RemoteLogSender.cs.meta new file mode 100644 index 0000000..73c31ed --- /dev/null +++ b/Packages/com.jovian.logger/Runtime/RemoteLogSender.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 690a449164ff86c49b1c0c9c3b4ef3b1 \ No newline at end of file diff --git a/Packages/com.jovian.logger/package.json b/Packages/com.jovian.logger/package.json new file mode 100644 index 0000000..9848e66 --- /dev/null +++ b/Packages/com.jovian.logger/package.json @@ -0,0 +1,9 @@ +{ + "name": "com.jovian.logger", + "displayName": "Jovian Logger", + "version": "1.0.0", + "description": "A custom logger package based logger that I created some time in the past.", + "dependencies": { + "com.unity.nuget.newtonsoft-json": "3.2.1" + } +} diff --git a/Packages/com.jovian.logger/package.json.meta b/Packages/com.jovian.logger/package.json.meta new file mode 100644 index 0000000..3d064d1 --- /dev/null +++ b/Packages/com.jovian.logger/package.json.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 701d375fdedcd4741aed611b3054cbc9 +PackageManifestImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/com.jovian.recentassets/Editor.meta b/Packages/com.jovian.recentassets/Editor.meta new file mode 100644 index 0000000..a2b3628 --- /dev/null +++ b/Packages/com.jovian.recentassets/Editor.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 67dd42ef1f8eb4c4d82ef17efd711ead +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/com.jovian.recentassets/Editor/Jovian.AssetsHistory.asmdef b/Packages/com.jovian.recentassets/Editor/Jovian.AssetsHistory.asmdef new file mode 100644 index 0000000..88cf284 --- /dev/null +++ b/Packages/com.jovian.recentassets/Editor/Jovian.AssetsHistory.asmdef @@ -0,0 +1,16 @@ +{ + "name": "Jovian.AssetsHistory", + "rootNamespace": "", + "references": [], + "includePlatforms": [ + "Editor" + ], + "excludePlatforms": [], + "allowUnsafeCode": false, + "overrideReferences": false, + "precompiledReferences": [], + "autoReferenced": true, + "defineConstraints": [], + "versionDefines": [], + "noEngineReferences": false +} diff --git a/Packages/com.jovian.recentassets/Editor/Jovian.AssetsHistory.asmdef.meta b/Packages/com.jovian.recentassets/Editor/Jovian.AssetsHistory.asmdef.meta new file mode 100644 index 0000000..c834502 --- /dev/null +++ b/Packages/com.jovian.recentassets/Editor/Jovian.AssetsHistory.asmdef.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 924d34ce263e4c44189ac3e93726603c +AssemblyDefinitionImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/com.jovian.recentassets/Editor/RecentAssetsMenu.cs b/Packages/com.jovian.recentassets/Editor/RecentAssetsMenu.cs new file mode 100644 index 0000000..3177506 --- /dev/null +++ b/Packages/com.jovian.recentassets/Editor/RecentAssetsMenu.cs @@ -0,0 +1,387 @@ +using System.Collections.Generic; +using UnityEngine; +using UnityEditor; +using System.IO; +using UnityEditor.SceneManagement; +using UnityEngine.SceneManagement; + +namespace Jovian.Recents { + public class RecentAssets : EditorWindow { + + [System.Serializable] + private class AssetSelection { + public bool expanded; + public string assetPath; + + [System.NonSerialized] + private Object actualAsset; + + public Object Asset { + get { + if(actualAsset == null) { + actualAsset = AssetDatabase.LoadMainAssetAtPath(assetPath); + } + return actualAsset; + } + set => actualAsset = value; + } + + public List subAssets; + public bool isPinned; + public string FileName => Path.GetFileName(assetPath); + } + + [System.Serializable] + public struct SubAssetSelection { + // In the current structure of this tool, to reload this object is quite messy. + // The SubAsset doesn't know if the current context, e.g. scene, is the one it was previously selected from. + // There might be another asset in this other scene also called "Main Camera" etc + [System.NonSerialized] + public Object subAsset; + + public string subAssetPath; + } + + private bool subscribeToSelectionEvents; + + private bool SubscribeToSelectionEvents { + set { + if(value) { + Selection.selectionChanged -= OnSelectionChanged; + Selection.selectionChanged += OnSelectionChanged; + } + else { + Selection.selectionChanged -= OnSelectionChanged; + } + subscribeToSelectionEvents = value; + } + } + + private bool subscribeToPrefabOpenEvents; + + private bool SubscribeToPrefabOpen { + set { + PrefabStage.prefabStageOpened -= OnPrefabStageOpened; + PrefabStage.prefabStageOpened += OnPrefabStageOpened; + PrefabStage.prefabStageClosing -= OnPrefabStageClosing; + PrefabStage.prefabStageClosing += OnPrefabStageClosing; + subscribeToPrefabOpenEvents = value; + } + } + + private bool subscribeToSceneOpenEvents; + + private bool SubscribeToSceneOpenEvents { + set { + if(value) { + EditorSceneManager.activeSceneChangedInEditMode -= OnActiveSceneChangedInEditMode; + EditorSceneManager.activeSceneChangedInEditMode += OnActiveSceneChangedInEditMode; + } + else { + EditorSceneManager.activeSceneChangedInEditMode -= OnActiveSceneChangedInEditMode; + } + subscribeToSceneOpenEvents = value; + } + } + + private string EditorPrefsSettingsSelectionKey => $"{Application.dataPath}Jovian_RecentOpenedAssetsWindow_AssetSelections_Selections"; + private string EditorPrefsSettingsPrefabsKey => $"{Application.dataPath}Jovian_RecentOpenedAssetsWindow_AssetSelections_Prefabs"; + private string EditorPrefsSettingsScenesKey => $"{Application.dataPath}Jovian_RecentOpenedAssetsWindow_AssetSelections_Scenes"; + private string EditorPrefsSettingsCountKey => $"{Application.dataPath}Jovian_RecentOpenedAssetsWindow_AssetSelections_MaxHistory"; + private string EditorPrefsSettingsHistoryKey => $"{Application.dataPath}Jovian_RecentOpenedAssetsWindow_AssetSelections"; + + private const string PinnedIcon = "IN LockButton on"; + private const string UnPinnedIcon = "IN LockButton"; + + [System.Serializable] + private class JsonWrapper { + public JsonWrapper(List data) { + this.data = data; + } + + public List data; + } + + private List selectionHistory = new(); + private int maxHistory = 10; + private Vector2 scrollPos; + + private PrefabStage prefabStage; + private GUIStyle guiStyle; + + [MenuItem("Jovian/Assets History...", false, 20)] + private static void Init() { + var window = GetWindow(false, "Assets History"); + window.minSize = new Vector2(100f, 100f); + window.Focus(); + } + + private void OnEnable() { + // In case of Editor reload, perhaps because of recompile, events needs to be resubscribed + LoadSettings(); + SubscribeToSelectionEvents = subscribeToSelectionEvents; + SubscribeToSceneOpenEvents = subscribeToSceneOpenEvents; + SubscribeToPrefabOpen = subscribeToPrefabOpenEvents; + + LoadEntries(); + } + + private void OnDisable() { + SaveEntries(); + SaveSettings(); + } + + private void OnDestroy() { + SubscribeToSelectionEvents = false; + SubscribeToSceneOpenEvents = false; + SubscribeToPrefabOpen = false; + } + private void SaveEntries() { + var jsonList = JsonUtility.ToJson(new JsonWrapper(selectionHistory)); + EditorPrefs.SetString(EditorPrefsSettingsHistoryKey, jsonList); + } + + private void SaveSettings() { + EditorPrefs.SetBool(EditorPrefsSettingsSelectionKey, subscribeToSelectionEvents); + EditorPrefs.SetBool(EditorPrefsSettingsPrefabsKey, subscribeToPrefabOpenEvents); + EditorPrefs.SetBool(EditorPrefsSettingsScenesKey, subscribeToSceneOpenEvents); + EditorPrefs.SetInt(EditorPrefsSettingsCountKey, maxHistory); + } + + private void LoadEntries() { + var jsonList = EditorPrefs.GetString(EditorPrefsSettingsHistoryKey); + var oldList = JsonUtility.FromJson(jsonList)?.data; + if(oldList != null) { + selectionHistory = oldList; + } + } + + private void LoadSettings() { + if(EditorPrefs.HasKey(EditorPrefsSettingsSelectionKey)) { + subscribeToSelectionEvents = EditorPrefs.GetBool(EditorPrefsSettingsSelectionKey); + } + if(EditorPrefs.HasKey(EditorPrefsSettingsPrefabsKey)) { + subscribeToPrefabOpenEvents = EditorPrefs.GetBool(EditorPrefsSettingsPrefabsKey); + } + if(EditorPrefs.HasKey(EditorPrefsSettingsScenesKey)) { + subscribeToSceneOpenEvents = EditorPrefs.GetBool(EditorPrefsSettingsScenesKey); + } + if(EditorPrefs.HasKey(EditorPrefsSettingsCountKey)) { + maxHistory = EditorPrefs.GetInt(EditorPrefsSettingsCountKey); + } + } + + private void OnActiveSceneChangedInEditMode(Scene previousScene, Scene newScene) { + var selectedObject = AssetDatabase.LoadMainAssetAtPath(newScene.path); + if(selectedObject != null) { + OnAssetInteracted(selectedObject, newScene.path); + } + } + + + private void OnPrefabStageOpened(PrefabStage prefab) { + // We actually need to know if we're in prefabStage because of how asset parenting works during prefab editing, e.g. when _subscribeToSelectionEvents==true; + prefabStage = prefab; + if(subscribeToPrefabOpenEvents) { + OnAssetInteracted(prefab.prefabContentsRoot, prefab.assetPath); + } + } + + private void OnPrefabStageClosing(PrefabStage prefab) { + prefabStage = null; + } + + private void OnSelectionChanged() { + var selectedObject = Selection.activeObject; + if(!selectedObject) { + return; // When you select something in e.g. Project View that isn't an asset or object, perhaps the category header "Packages" + } + string assetPath; + if(prefabStage != null && selectedObject is GameObject selectedGameObject && prefabStage.IsPartOfPrefabContents(selectedGameObject)) { + assetPath = prefabStage.assetPath; // If we're in a PrefabStage and selecting a child GO, the assetPath is the actual prefab itself, not this potentially nested entity + } + else { + assetPath = AssetDatabase.GetAssetOrScenePath(selectedObject); + } + + OnAssetInteracted(selectedObject, assetPath); + } + + private void OnAssetInteracted(Object selectedObject, string assetPath) { + AssetSelection selection = null; + for(var i = 0; i < selectionHistory.Count; i++) { + if(selectionHistory[i].assetPath == assetPath) { + selection = selectionHistory[i]; + selectionHistory.RemoveAt(i); + break; + } + } + + if(selection == null) { + // This is a new asset selection + selection = new AssetSelection { + expanded = false, + assetPath = assetPath, + Asset = AssetDatabase.LoadMainAssetAtPath(assetPath), + subAssets = new List() + }; + } + + selectionHistory.Insert(0, selection); // Selection is now first in list + + var subAssetSelection = new SubAssetSelection { + subAsset = selectedObject, + subAssetPath = selectedObject.name + }; + for(var i = 0; i < selection.subAssets.Count; i++) { + if(selection.subAssets[i].subAsset == selectedObject || + // In case of unloading and reloading a scene, we try to "reuse" entries to the same object. + // If several objects exist by the same name, they're already null and become a new entry "anyway" + (selection.subAssets[i].subAsset == null && selection.subAssets[i].subAssetPath == subAssetSelection.subAssetPath)) { + selection.subAssets.RemoveAt(i); + break; + } + } + selection.subAssets.Insert(0, subAssetSelection); + + // while instead of if, since you can change the number of tracked assets + while(selectionHistory.Count > maxHistory) { + selectionHistory.RemoveAt(selectionHistory.Count - 1); + } + UpdatePinned(); + Repaint(); + } + + private void UpdatePinned() { + var numberOfAlreadyPinnedEntries = 0; + for(var i = 0; i < selectionHistory.Count; i++) { + var currSelection = selectionHistory[i]; + if(currSelection.isPinned) { + if(i == numberOfAlreadyPinnedEntries) { + numberOfAlreadyPinnedEntries++; + continue; + } + selectionHistory.RemoveAt(i); + selectionHistory.Insert(numberOfAlreadyPinnedEntries, currSelection); + numberOfAlreadyPinnedEntries++; + } + } + } + + private void OnGUI() { + var originalIconSize = EditorGUIUtility.GetIconSize(); + EditorGUIUtility.SetIconSize(new Vector2(16f, 16f)); + if(guiStyle == null) { + guiStyle = new GUIStyle(GUI.skin.button) { + alignment = TextAnchor.MiddleLeft + }; + } + EditorGUILayout.BeginHorizontal(); + TightLabel("History count", "The number of recent assets currently tracked"); + TightLabel(selectionHistory.Count.ToString()); + if(GUILayout.Button("Clear", GUILayout.ExpandWidth(false))) { + selectionHistory.Clear(); + } + GUILayout.FlexibleSpace(); + TightLabel("What to track", "Select for what interactions you want this list to be updated"); + var onSelection = GUILayout.Toggle(subscribeToSelectionEvents, "On selection", GUILayout.ExpandWidth(false)); + if(onSelection != subscribeToSelectionEvents) { + SubscribeToSelectionEvents = onSelection; + } + EditorGUI.BeginDisabledGroup(subscribeToSelectionEvents); // If we're listening to selection the other two doesn't matter + subscribeToPrefabOpenEvents = GUILayout.Toggle(subscribeToPrefabOpenEvents, "Open prefabs", GUILayout.ExpandWidth(false)); + SubscribeToSceneOpenEvents = GUILayout.Toggle(subscribeToSceneOpenEvents, "Open scenes", GUILayout.ExpandWidth(false)); + EditorGUI.EndDisabledGroup(); + GUILayout.FlexibleSpace(); + TightLabel("Max history", "The number of recent assets to track"); + maxHistory = EditorGUILayout.DelayedIntField(maxHistory, GUILayout.Width(50f)); + GUILayout.FlexibleSpace(); + EditorGUILayout.EndHorizontal(); + scrollPos = EditorGUILayout.BeginScrollView(scrollPos); + var pinStatusChanged = false; + for(var i = 0; i < selectionHistory.Count; i++) { + var selection = selectionHistory[i]; + + EditorGUILayout.BeginHorizontal(); + + // Pinning + var pinIcon = selection.isPinned ? PinnedIcon : UnPinnedIcon; + if(GUILayout.Button(EditorGUIUtility.IconContent(pinIcon), GUILayout.Width(20f), GUILayout.ExpandWidth(false))) { + selection.isPinned = !selection.isPinned; + pinStatusChanged = true; + } + + // Open + if(GUILayout.Button(EditorGUIUtility.IconContent("d_editicon.sml"), GUILayout.Width(26f), GUILayout.ExpandWidth(false))) { + if(Event.current.button == 1) { + // On right click we only ping the object. + EditorGUIUtility.PingObject(selection.Asset); + } + else { + AssetDatabase.OpenAsset(selection.Asset); + } + } + + // Object + GUI.enabled = false; + if(selection.Asset != null) { + EditorGUILayout.ObjectField(selection.Asset, typeof(Object), false); + } + else { + var labelNotFound = new GUIContent($"{selection.FileName} not found.", + EditorGUIUtility.ObjectContent(selection.Asset, typeof(Object)).image, selection.assetPath); + EditorGUILayout.LabelField(labelNotFound); + } + GUI.enabled = true; + + // Sub entries + GUILayout.Label(GUIContent.none, GUILayout.MinWidth(80f), GUILayout.MaxWidth(80f)); + var rect = GUILayoutUtility.GetLastRect(); + selection.expanded = EditorGUI.Foldout(rect, selection.expanded, "Sub entries", true); + EditorGUILayout.EndHorizontal(); + if(selection.expanded) { + EditorGUILayout.BeginVertical(); + EditorGUI.indentLevel++; + for(var y = 0; y < selection.subAssets.Count; y++) { + var subAsset = selection.subAssets[y]; + EditorGUILayout.BeginHorizontal(); + EditorGUILayout.Space(10f, false); // As an indent + var label = new GUIContent(subAsset.subAssetPath, EditorGUIUtility.ObjectContent(subAsset.subAsset, typeof(Object)).image); + if(GUILayout.Button(label, guiStyle, GUILayout.MinWidth(50f), GUILayout.MaxWidth(200f), GUILayout.ExpandWidth(false))) { + if(Event.current.button == 1) { + // On right click we only ping the object. + EditorGUIUtility.PingObject(subAsset.subAsset); + } + else { + Selection.activeObject = subAsset.subAsset; + } + } + EditorGUILayout.EndHorizontal(); + } + EditorGUILayout.EndVertical(); + EditorGUI.indentLevel--; + } + } + EditorGUILayout.EndScrollView(); + EditorGUIUtility.SetIconSize(originalIconSize); + if(pinStatusChanged) { + UpdatePinned(); + } + } + + public static void TightLabel(string labelStr) { + var label = new GUIContent(labelStr); + TightLabel(label); + } + + public static void TightLabel(string labelStr, string tooltip) { + var label = new GUIContent(labelStr, tooltip); + TightLabel(label); + } + + public static void TightLabel(GUIContent label) { + //This is the important bit, we set the width to the calculated width of the content in the GUIStyle of the control + EditorGUILayout.LabelField(label, GUILayout.Width(GUI.skin.label.CalcSize(label).x)); + } + } +} diff --git a/Packages/com.jovian.recentassets/Editor/RecentAssetsMenu.cs.meta b/Packages/com.jovian.recentassets/Editor/RecentAssetsMenu.cs.meta new file mode 100644 index 0000000..d83d8b1 --- /dev/null +++ b/Packages/com.jovian.recentassets/Editor/RecentAssetsMenu.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 537374a9d1704fb408713e76df4b85f9 \ No newline at end of file diff --git a/Packages/com.jovian.recentassets/package.json b/Packages/com.jovian.recentassets/package.json new file mode 100644 index 0000000..fc2aeef --- /dev/null +++ b/Packages/com.jovian.recentassets/package.json @@ -0,0 +1,6 @@ +{ + "name": "com.jovian.assets-history", + "displayName": "Assets History Tracker", + "version": "1.0.0", + "description": "Helper to track recently used assets in the editor and display them in a menu." +} diff --git a/Packages/com.jovian.recentassets/package.json.meta b/Packages/com.jovian.recentassets/package.json.meta new file mode 100644 index 0000000..92448db --- /dev/null +++ b/Packages/com.jovian.recentassets/package.json.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 74e2fe99929f7e84a92d5d34512b6942 +PackageManifestImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/com.jovian.utilties/Editor.meta b/Packages/com.jovian.utilties/Editor.meta new file mode 100644 index 0000000..0086ba8 --- /dev/null +++ b/Packages/com.jovian.utilties/Editor.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 9e079e01a6b0deb44865d02d9d276a3f +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/com.jovian.utilties/Editor/CollisionExtractorEditor.cs b/Packages/com.jovian.utilties/Editor/CollisionExtractorEditor.cs new file mode 100644 index 0000000..cb3c994 --- /dev/null +++ b/Packages/com.jovian.utilties/Editor/CollisionExtractorEditor.cs @@ -0,0 +1,144 @@ +using System; +using System.Linq; +using System.Reflection; +using UnityEditor; +using UnityEngine; + +namespace Jovian.Utilities.Editor { + [CustomEditor(typeof(CollisionExtractor))] + public class CollisionExtractorEditor : UnityEditor.Editor { + + public override void OnInspectorGUI() { + base.OnInspectorGUI(); + + serializedObject.Update(); + if(GUILayout.Button("Extract Colliders")) { + ExtractCollidersIntoPrefab(); + } + } + + private void ExtractCollidersIntoPrefab() { + CollisionExtractor collisionExtractor = (CollisionExtractor)target; + GameObject source = collisionExtractor.root; + GameObject targetPrefab = collisionExtractor.targetPrefab; + + // Verify input + if(source == null) { + Debug.LogWarning("[CollisionExtractor] no Root assigned, please assign a root then try again!"); + return; + } + if(targetPrefab == null) { + Debug.LogWarning("[CollisionExtractor] no TargetPrefab assigned, please assign a root then try again!"); + return; + } + else if(targetPrefab.scene.name != null) { + Debug.LogWarning("[CollisionExtractor] TargetPrefab is not a prefab instance, please assign a proper prefab instance!"); + return; + } + + string prefabPath = PrefabUtility.GetPrefabAssetPathOfNearestInstanceRoot(targetPrefab); + GameObject prefabRoot = PrefabUtility.LoadPrefabContents(prefabPath); + + // Remove any old children and build up a new hierarchy matching the source + prefabRoot.transform.DetachChildren(); + + // Try to remove all components on the prefab root (several iterations due to components sometime requiring each other, thus requiring a certain order of deletion) + for(int i = 0; i < 6; i++) { + foreach(var comp in prefabRoot.GetComponents()) { + //Don't remove the Transform component + if(!(comp is Transform)) { + DestroyImmediate(comp); + } + } + } + + // We make a copy of the source object to ensure it does not get changed, when accessing the properties using reflection Unity notes some of them as changed even though we only read from it. + // For colliders this happens to the physics material, if it is set to "None" it will become an empty value on the source in the editor but properly pick the None value for the target. + GameObject sourceCopy = Instantiate(source); + try { + CopyCollidersRecursive(sourceCopy, prefabRoot); + } + finally { + DestroyImmediate(sourceCopy); + } + Debug.Log("[CollisionExtractor] extraction into target prefab complete."); + + PrefabUtility.SaveAsPrefabAsset(prefabRoot, prefabPath); + PrefabUtility.UnloadPrefabContents(prefabRoot); + } + + private void CopyCollidersRecursive(GameObject sourceNode, GameObject targetNode) { + // Copy all transform settings + targetNode.transform.SetPositionAndRotation(sourceNode.transform.position, sourceNode.transform.rotation); + targetNode.transform.localScale = sourceNode.transform.localScale; + GameObjectUtility.SetStaticEditorFlags(targetNode, GameObjectUtility.GetStaticEditorFlags(sourceNode)); + targetNode.tag = sourceNode.tag; + targetNode.layer = sourceNode.layer; + + // Copy all collider components + Collider[] colliders = sourceNode.GetComponents(); + for(int i = 0; i < colliders.Length; ++i) { + switch(colliders[i]) { + case BoxCollider sourceBoxCollider: + BoxCollider targetBoxCollider = targetNode.AddComponent(); + GetCopyOf(targetBoxCollider, sourceBoxCollider); + break; + case SphereCollider sourceSphereCollider: + SphereCollider targetSphereCollider = targetNode.AddComponent(); + GetCopyOf(targetSphereCollider, sourceSphereCollider); + break; + case CapsuleCollider sourceCapsuleCollider: + CapsuleCollider targetCapsuleCollider = targetNode.AddComponent(); + GetCopyOf(targetCapsuleCollider, sourceCapsuleCollider); + break; + case MeshCollider sourceMeshCollider: + MeshCollider targetMeshCollider = targetNode.AddComponent(); + GetCopyOf(targetMeshCollider, sourceMeshCollider); + break; + default: + Debug.LogError($"[CollisionExtractor] found unsupported collider type on game object {sourceNode.name}!"); + break; + } + } + + // Continue with all the child nodes + for(int i = 0; i < sourceNode.transform.childCount; ++i) { + Transform sourceChildNode = sourceNode.transform.GetChild(i); + GameObject targetChildNode = new GameObject(); + targetChildNode.name = sourceChildNode.name; + targetChildNode.transform.parent = targetNode.transform; + CopyCollidersRecursive(sourceChildNode.gameObject, targetChildNode); + } + + // If we are a leaf node without colliders we are not useful, delete this node + if(colliders.Length == 0 && targetNode.transform.childCount == 0) { + DestroyImmediate(targetNode); + } + } + + // Taken from https://answers.unity.com/questions/530178/how-to-get-a-component-from-an-object-and-add-it-t.html?_ga=2.50760041.112217741.1608192858-1956498980.1555671355 + private T GetCopyOf(Component target, T source) where T : Component { + Type type = target.GetType(); + if(type != source.GetType()) return null; // type mis-match + BindingFlags flags = BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.Default; + var pinfos = from property in type.GetProperties(flags) + where !property.CustomAttributes.Any(attribute => attribute.AttributeType == typeof(ObsoleteAttribute)) + select property; + foreach(var pinfo in pinfos) { + if(pinfo.CanWrite) { + try { + pinfo.SetValue(target, pinfo.GetValue(source, null), null); + } + catch { } // In case of NotImplementedException being thrown. For some reason specifying that exception didn't seem to catch it, so I didn't catch anything specific. + } + } + FieldInfo[] finfos = type.GetFields(flags); + foreach(var finfo in finfos) { + finfo.SetValue(target, finfo.GetValue(source)); + } + return target as T; + } + + + } +} diff --git a/Packages/com.jovian.utilties/Editor/CollisionExtractorEditor.cs.meta b/Packages/com.jovian.utilties/Editor/CollisionExtractorEditor.cs.meta new file mode 100644 index 0000000..ce98775 --- /dev/null +++ b/Packages/com.jovian.utilties/Editor/CollisionExtractorEditor.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 945d56cc182956e4a9dfb70d9051c0d1 \ No newline at end of file diff --git a/Packages/com.jovian.utilties/Editor/CustomRenderQueueMaterialListEditor.cs b/Packages/com.jovian.utilties/Editor/CustomRenderQueueMaterialListEditor.cs new file mode 100644 index 0000000..efbc5a6 --- /dev/null +++ b/Packages/com.jovian.utilties/Editor/CustomRenderQueueMaterialListEditor.cs @@ -0,0 +1,84 @@ +using UnityEngine; +using UnityEditor; + +namespace Jovian.Utilities.Editor { + [CustomEditor(typeof(CustomRenderQueueMaterialList))] + public class CustomRenderQueueMaterialListEditor : UnityEditor.Editor { + + private const int MAXNAMELENGTH = 30; + + public override void OnInspectorGUI() { + if(((CustomRenderQueueMaterialList)target).updateMaterialsInEditor) { + EditorGUILayout.HelpBox("Enabling 'updateMaterialsInEditor' will cause the Editor to change material files. Only use in debug and please review your changelist before submitting.", MessageType.Warning); + } + DrawAddGUI(); + base.OnInspectorGUI(); + DrawAddGUI(); + } + + private void DrawAddGUI() { + GUILayout.BeginHorizontal(); + if(GUILayout.Button("Add Empty")) { + AddEmpty(); + } + if(Selection.activeGameObject) { + if(GUILayout.Button($"Add from '{GetNiceName(Selection.activeGameObject)}'")) { + AddGameObject(Selection.activeGameObject); + } + } + if(Selection.activeObject is Material) { + if(GUILayout.Button($"Add '{GetNiceName(Selection.activeObject)}'")) { + AddMaterial(Selection.activeObject as Material); + } + } + GUILayout.EndHorizontal(); + } + + private string GetNiceName(Object obj) { + var objectName = obj.name; + if(objectName.Length > MAXNAMELENGTH) { + objectName = objectName.Substring(0, MAXNAMELENGTH - 2) + ".."; + } + return objectName; + } + + private void AddEmpty() { + AddElement(null, -1); + } + + private void AddGameObject(GameObject gameObject) { + var renderer = gameObject.GetComponentInChildren(); + if(renderer == null) { + Debug.LogError($"Cannot add Renderer from {gameObject}, there is none."); + return; + } + AddMaterial(renderer.sharedMaterial); + } + + private void AddMaterial(Material material) { + if(material == null) { + Debug.LogError($"Cannot add Material, it is null."); + return; + } + + if(((CustomRenderQueueMaterialList)target).DoesMaterialExistInList(material)) { + Debug.LogError($"Cannot add Material, it is already listed."); + return; + } + + AddElement(material, material.renderQueue); + } + + private void AddElement(Material material, int renderQueue) { + var listProperty = serializedObject.FindProperty("materialRenderQueueList"); + var count = listProperty.arraySize; + listProperty.arraySize = count + 1; + var elementProperty = listProperty.GetArrayElementAtIndex(count); + var materialProperty = elementProperty.FindPropertyRelative("material"); + var renderQueueProperty = elementProperty.FindPropertyRelative("renderQueue"); + materialProperty.objectReferenceValue = material; + renderQueueProperty.intValue = renderQueue; + serializedObject.ApplyModifiedProperties(); + } + } +} diff --git a/Packages/com.jovian.utilties/Editor/CustomRenderQueueMaterialListEditor.cs.meta b/Packages/com.jovian.utilties/Editor/CustomRenderQueueMaterialListEditor.cs.meta new file mode 100644 index 0000000..77ba550 --- /dev/null +++ b/Packages/com.jovian.utilties/Editor/CustomRenderQueueMaterialListEditor.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: fe8e754720c57ef4089250664029c499 \ No newline at end of file diff --git a/Packages/com.jovian.utilties/Editor/CustomRenderQueueMaterialListPropertyDrawer.cs b/Packages/com.jovian.utilties/Editor/CustomRenderQueueMaterialListPropertyDrawer.cs new file mode 100644 index 0000000..398f03c --- /dev/null +++ b/Packages/com.jovian.utilties/Editor/CustomRenderQueueMaterialListPropertyDrawer.cs @@ -0,0 +1,26 @@ +using UnityEngine; +using UnityEditor; + +namespace Jovian.Utilities.Editor { + [CustomPropertyDrawer(typeof(CustomRenderQueueMaterialList.MaterialRenderQueue))] + public class CustomRenderQueueMaterialListPropertyDrawer : PropertyDrawer { + private const float RENDERQUEUE_PROPERTY_WIDTH = 45f; + private const float GUI_PADDING = 2f; + + public override void OnGUI(Rect position, SerializedProperty property, GUIContent label) { + EditorGUI.BeginProperty(position, label, property); + position = EditorGUI.PrefixLabel(position, GUIUtility.GetControlID(FocusType.Passive), label); + var indent = EditorGUI.indentLevel; + EditorGUI.indentLevel = 0; + + var materialRect = new Rect(position.x, position.y, position.width - RENDERQUEUE_PROPERTY_WIDTH, position.height); + var renderQueueRect = new Rect(materialRect.xMax + GUI_PADDING, position.y, RENDERQUEUE_PROPERTY_WIDTH - GUI_PADDING, position.height); + + EditorGUI.PropertyField(materialRect, property.FindPropertyRelative("material"), new GUIContent(string.Empty, "Material")); + EditorGUI.PropertyField(renderQueueRect, property.FindPropertyRelative("renderQueue"), new GUIContent(string.Empty, "RenderQueue")); + + EditorGUI.indentLevel = indent; + EditorGUI.EndProperty(); + } + } +} diff --git a/Packages/com.jovian.utilties/Editor/CustomRenderQueueMaterialListPropertyDrawer.cs.meta b/Packages/com.jovian.utilties/Editor/CustomRenderQueueMaterialListPropertyDrawer.cs.meta new file mode 100644 index 0000000..81abe3b --- /dev/null +++ b/Packages/com.jovian.utilties/Editor/CustomRenderQueueMaterialListPropertyDrawer.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: a5ca9116ca3b8724dad5a329325ff93d \ No newline at end of file diff --git a/Packages/com.jovian.utilties/Editor/FloatRangePropertyDrawer.cs b/Packages/com.jovian.utilties/Editor/FloatRangePropertyDrawer.cs new file mode 100644 index 0000000..e7fb1d7 --- /dev/null +++ b/Packages/com.jovian.utilties/Editor/FloatRangePropertyDrawer.cs @@ -0,0 +1,29 @@ +using UnityEngine; +using UnityEditor; + +namespace Jovian.Utilities.Editor { + public class NumberRangePropertyDrawer : PropertyDrawer { + private const float PADDING = 1f; + public override void OnGUI(Rect position, SerializedProperty property, GUIContent label) { + EditorGUI.BeginProperty(position, label, property); + position = EditorGUI.PrefixLabel(position, GUIUtility.GetControlID(FocusType.Passive), label); + + float width = position.width * 0.5f - PADDING; + Rect minRect = new Rect(position.x, position.y, width, position.height); + Rect maxRect = new Rect(minRect.xMax + PADDING * 2f, position.y, width, position.height); + + int indentLevel = EditorGUI.indentLevel; + EditorGUI.indentLevel = 0; + EditorGUI.PropertyField(minRect, property.FindPropertyRelative("min"), GUIContent.none); + EditorGUI.PropertyField(maxRect, property.FindPropertyRelative("max"), GUIContent.none); + EditorGUI.indentLevel = indentLevel; + EditorGUI.EndProperty(); + } + } + + [CustomPropertyDrawer(typeof(FloatRange))] + public class FloatRangePropertyDrawer : NumberRangePropertyDrawer { } + + [CustomPropertyDrawer(typeof(IntRange))] + public class IntRangePropertyDrawer : NumberRangePropertyDrawer { } +} diff --git a/Packages/com.jovian.utilties/Editor/FloatRangePropertyDrawer.cs.meta b/Packages/com.jovian.utilties/Editor/FloatRangePropertyDrawer.cs.meta new file mode 100644 index 0000000..fe0ebce --- /dev/null +++ b/Packages/com.jovian.utilties/Editor/FloatRangePropertyDrawer.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 225b3cfe03be1ff41b3f72b06ae2fa23 \ No newline at end of file diff --git a/Packages/com.jovian.utilties/Editor/JovianUtilities.Editor.asmdef b/Packages/com.jovian.utilties/Editor/JovianUtilities.Editor.asmdef new file mode 100644 index 0000000..5037211 --- /dev/null +++ b/Packages/com.jovian.utilties/Editor/JovianUtilities.Editor.asmdef @@ -0,0 +1,18 @@ +{ + "name": "JovianUtilities.Editor", + "rootNamespace": "", + "references": [ + "JovianUtilities" + ], + "includePlatforms": [ + "Editor" + ], + "excludePlatforms": [], + "allowUnsafeCode": false, + "overrideReferences": false, + "precompiledReferences": [], + "autoReferenced": true, + "defineConstraints": [], + "versionDefines": [], + "noEngineReferences": false +} \ No newline at end of file diff --git a/Packages/com.jovian.utilties/Editor/JovianUtilities.Editor.asmdef.meta b/Packages/com.jovian.utilties/Editor/JovianUtilities.Editor.asmdef.meta new file mode 100644 index 0000000..a0d33cb --- /dev/null +++ b/Packages/com.jovian.utilties/Editor/JovianUtilities.Editor.asmdef.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: b9747d4f21927cf4c9f4a1c9c0680d86 +AssemblyDefinitionImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/com.jovian.utilties/Runtime.meta b/Packages/com.jovian.utilties/Runtime.meta new file mode 100644 index 0000000..bffe5be --- /dev/null +++ b/Packages/com.jovian.utilties/Runtime.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: b0c62d31f10f1bb4ebd18d23cb9e50b7 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/com.jovian.utilties/Runtime/ArrayUtility.cs b/Packages/com.jovian.utilties/Runtime/ArrayUtility.cs new file mode 100644 index 0000000..b39b7e9 --- /dev/null +++ b/Packages/com.jovian.utilties/Runtime/ArrayUtility.cs @@ -0,0 +1,36 @@ +using System.Collections; +using System.Text; + +namespace Jovian.Utilities { + + public static class ArrayUtility { + public static string ListToString(this IList list, bool newLinePerEntry = false) { + if(list == null) { + return ""; + } + StringBuilder sb = new(); + sb.Append("["); + sb.Append(list.Count); + sb.Append("]{"); + if(newLinePerEntry) { + sb.AppendLine(); + } + for(int i = 0, c = list.Count; i < c; i++) { + sb.Append(list[i]); + if(i < c - 1) { + if(newLinePerEntry) { + sb.AppendLine(","); + } + else { + sb.Append(", "); + } + } + } + if(newLinePerEntry) { + sb.AppendLine(); + } + sb.Append("}"); + return sb.ToString(); + } + } +} \ No newline at end of file diff --git a/Packages/com.jovian.utilties/Runtime/ArrayUtility.cs.meta b/Packages/com.jovian.utilties/Runtime/ArrayUtility.cs.meta new file mode 100644 index 0000000..0b7f487 --- /dev/null +++ b/Packages/com.jovian.utilties/Runtime/ArrayUtility.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: fff0c7e092c6f6a44ba792b369a382dd \ No newline at end of file diff --git a/Packages/com.jovian.utilties/Runtime/BowserLog.cs b/Packages/com.jovian.utilties/Runtime/BowserLog.cs new file mode 100644 index 0000000..882ab2e --- /dev/null +++ b/Packages/com.jovian.utilties/Runtime/BowserLog.cs @@ -0,0 +1,42 @@ +using System.Diagnostics; +using System.Runtime.CompilerServices; +using UnityEngine; +using Debug = UnityEngine.Debug; + +namespace Jovian.Utilities { + + public static class BowserLog { + + private const string PREFIX = "Bowser:"; + + [MethodImpl(MethodImplOptions.AggressiveInlining), DebuggerHidden] + public static void Log(string log, object obj = null) { + Debug.Log(obj == null ? $"{PREFIX}{log}" : $"{PREFIX}[{obj.GetType().Name}] {log}", obj as Object); + } + + [MethodImpl(MethodImplOptions.AggressiveInlining), DebuggerHidden] + public static void LogWarning(string log, object obj = null) { + Debug.LogWarning(obj == null ? $"{PREFIX}{log}" : $"{PREFIX}[{obj.GetType().Name}] {log}", obj as Object); + } + + [MethodImpl(MethodImplOptions.AggressiveInlining), DebuggerHidden] + public static void LogError(string log, object obj = null) { + Debug.LogError(obj == null ? $"{PREFIX}{log}" : $"{PREFIX}[{obj.GetType().Name}] {log}", obj as Object); + } + + [Conditional("UNITY_EDITOR"), Conditional("DEVELOPMENT_BUILD"), DebuggerHidden] + public static void LogDebug(string log, object obj = null) { + Debug.Log(obj == null ? $"{PREFIX}{log}" : $"{PREFIX}[{obj.GetType().Name}] {log}", obj as Object); + } + + [Conditional("UNITY_EDITOR"), Conditional("DEVELOPMENT_BUILD"), DebuggerHidden] + public static void LogWarningDebug(string log, object obj = null) { + Debug.LogWarning(obj == null ? $"{PREFIX}{log}" : $"{PREFIX}[{obj.GetType().Name}] {log}", obj as Object); + } + + [Conditional("UNITY_EDITOR"), Conditional("DEVELOPMENT_BUILD"), DebuggerHidden] + public static void LogErrorDebug(string log, object obj = null) { + Debug.LogError(obj == null ? $"{PREFIX}{log}" : $"{PREFIX}[{obj.GetType().Name}] {log}", obj as Object); + } + } +} diff --git a/Packages/com.jovian.utilties/Runtime/BowserLog.cs.meta b/Packages/com.jovian.utilties/Runtime/BowserLog.cs.meta new file mode 100644 index 0000000..78d6b0a --- /dev/null +++ b/Packages/com.jovian.utilties/Runtime/BowserLog.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 79607e8a014ff1841b183019d9102aff \ No newline at end of file diff --git a/Packages/com.jovian.utilties/Runtime/CachedMainCamera.cs b/Packages/com.jovian.utilties/Runtime/CachedMainCamera.cs new file mode 100644 index 0000000..4b6e5d4 --- /dev/null +++ b/Packages/com.jovian.utilties/Runtime/CachedMainCamera.cs @@ -0,0 +1,38 @@ +using UnityEngine; + +namespace Jovian.Utilities { + public static class CachedMainCamera { + private static int lastFrame = -1; + private static Camera mainCamera; + private static Transform mainCameraTransform; + + public static Camera MainCamera { + get { + if(mainCamera) { + return mainCamera; + } + AssignCameraReferences(); + return mainCamera; + } + } + + public static Transform MainCameraTransform { + get { + if(mainCameraTransform) { + return mainCameraTransform; + } + AssignCameraReferences(); + return mainCameraTransform; + } + } + + private static void AssignCameraReferences() { + int frame = Time.frameCount; + if(lastFrame != frame) { + mainCamera = Camera.main; + mainCameraTransform = (mainCamera ? mainCamera.transform : null); + lastFrame = frame; + } + } + } +} \ No newline at end of file diff --git a/Packages/com.jovian.utilties/Runtime/CachedMainCamera.cs.meta b/Packages/com.jovian.utilties/Runtime/CachedMainCamera.cs.meta new file mode 100644 index 0000000..7b9acd0 --- /dev/null +++ b/Packages/com.jovian.utilties/Runtime/CachedMainCamera.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 0701523b57a704b4780d0a226f0d0d1b \ No newline at end of file diff --git a/Packages/com.jovian.utilties/Runtime/CanvasAutoAssignWorldCamera.cs b/Packages/com.jovian.utilties/Runtime/CanvasAutoAssignWorldCamera.cs new file mode 100644 index 0000000..b6825aa --- /dev/null +++ b/Packages/com.jovian.utilties/Runtime/CanvasAutoAssignWorldCamera.cs @@ -0,0 +1,24 @@ +using UnityEngine; + +namespace Jovian.Utilities { +[RequireComponent(typeof(Canvas))] + public class CanvasAutoAssignWorldCamera : MonoBehaviour { + public Canvas canvas; + public bool autoDisableOnceCameraFound = false; + + #if UNITY_EDITOR + public void Reset() { + SerializedObjectUtility.SaveObjectProperties(this, nameof(canvas), GetComponent()); + } + #endif + + private void Update() { + if(canvas && !canvas.worldCamera) { + canvas.worldCamera = CachedMainCamera.MainCamera; + if(canvas.worldCamera && autoDisableOnceCameraFound) { + enabled = false; + } + } + } + } +} \ No newline at end of file diff --git a/Packages/com.jovian.utilties/Runtime/CanvasAutoAssignWorldCamera.cs.meta b/Packages/com.jovian.utilties/Runtime/CanvasAutoAssignWorldCamera.cs.meta new file mode 100644 index 0000000..6b0095e --- /dev/null +++ b/Packages/com.jovian.utilties/Runtime/CanvasAutoAssignWorldCamera.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 59842e0a1599f854cae803d724ecb1e7 \ No newline at end of file diff --git a/Packages/com.jovian.utilties/Runtime/CollectionUtility.cs b/Packages/com.jovian.utilties/Runtime/CollectionUtility.cs new file mode 100644 index 0000000..f1ee33d --- /dev/null +++ b/Packages/com.jovian.utilties/Runtime/CollectionUtility.cs @@ -0,0 +1,99 @@ +using System; +using System.Collections; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using Random = UnityEngine.Random; + +namespace Jovian.Utilities.Utilities { + public static class CollectionUtility { + public static T RandomElementFromCollection(this ICollection enumerableObject) { + int count = enumerableObject.Count; + if (count == 0) { + throw new IndexOutOfRangeException("Cannot get RandomElement, collection size is 0."); + } + + int index = Random.Range(0, count); + return enumerableObject.ElementAt(index); + } + } +} + +public static class EnumerableUtility { + private static System.Random random; + + public static T RandomElement(this IEnumerable source) { + random ??= new System.Random(); + return source.RandomElement(random); + } + + //https://stackoverflow.com/a/648240/584774 + public static T RandomElement(this IEnumerable source, System.Random rng) { + T current = default(T); + int count = 0; + foreach (T element in source) { + count++; + if (rng.Next(count) == 0) { + current = element; + } + } + + if (count == 0) { + throw new InvalidOperationException("Sequence was empty"); + } + + return current; + } + + public static bool TryGetRandomElement(this IEnumerable source, out T outElement) { + random ??= new System.Random(); + return source.TryGetRandomElement(random, out outElement); + } + + public static bool TryGetRandomElement(this IEnumerable source, System.Random rng, out T outElement) { + T current = default(T); + int count = 0; + foreach (T element in source) { + count++; + if (rng.Next(count) == 0) { + current = element; + } + } + + outElement = current; + if (count == 0) { + return false; + } + + return true; + } + + public static string EnumerableToString(this IEnumerable enumerable, bool newLinePerEntry = false) { + if (enumerable == null) { + return ""; + } + + StringBuilder sb = new(); + sb.Append("{"); + if (newLinePerEntry) { + sb.AppendLine(); + } + + foreach (object item in enumerable) { + sb.Append(item); + if (newLinePerEntry) { + sb.AppendLine(","); + } + else { + sb.Append(", "); + } + } + + if (newLinePerEntry) { + sb.AppendLine(); + } + + sb.Append("}"); + return sb.ToString(); + } +} \ No newline at end of file diff --git a/Packages/com.jovian.utilties/Runtime/CollectionUtility.cs.meta b/Packages/com.jovian.utilties/Runtime/CollectionUtility.cs.meta new file mode 100644 index 0000000..43d4062 --- /dev/null +++ b/Packages/com.jovian.utilties/Runtime/CollectionUtility.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 9a6194020ff13054597ed18112a7acff \ No newline at end of file diff --git a/Packages/com.jovian.utilties/Runtime/ColliderUtilities.cs b/Packages/com.jovian.utilties/Runtime/ColliderUtilities.cs new file mode 100644 index 0000000..3c76054 --- /dev/null +++ b/Packages/com.jovian.utilties/Runtime/ColliderUtilities.cs @@ -0,0 +1,9 @@ +using UnityEngine; + +namespace Jovian.Utilities { + public static class ColliderUtilities { + public static bool ContainsPoint(this Collider collider, Vector3 point) { + return (collider.ClosestPoint(point) - point).sqrMagnitude < Mathf.Epsilon; + } + } +} diff --git a/Packages/com.jovian.utilties/Runtime/ColliderUtilities.cs.meta b/Packages/com.jovian.utilties/Runtime/ColliderUtilities.cs.meta new file mode 100644 index 0000000..479ac7e --- /dev/null +++ b/Packages/com.jovian.utilties/Runtime/ColliderUtilities.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 033c7401511b25948be41b7ab4774a03 \ No newline at end of file diff --git a/Packages/com.jovian.utilties/Runtime/CollisionExtractor.cs b/Packages/com.jovian.utilties/Runtime/CollisionExtractor.cs new file mode 100644 index 0000000..81d9fe6 --- /dev/null +++ b/Packages/com.jovian.utilties/Runtime/CollisionExtractor.cs @@ -0,0 +1,18 @@ +using System.Collections; +using System.Collections.Generic; +using UnityEngine; + +namespace Jovian.Utilities { + public class CollisionExtractor : MonoBehaviour { + + public GameObject root; + public GameObject targetPrefab; + + // Script only used inside the editor + private void Awake() { + if(!Application.isEditor) { + Destroy(this); + } + } + } +} diff --git a/Packages/com.jovian.utilties/Runtime/CollisionExtractor.cs.meta b/Packages/com.jovian.utilties/Runtime/CollisionExtractor.cs.meta new file mode 100644 index 0000000..236b68f --- /dev/null +++ b/Packages/com.jovian.utilties/Runtime/CollisionExtractor.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 1db2adf065c60df4d89c324e77eba81d \ No newline at end of file diff --git a/Packages/com.jovian.utilties/Runtime/CustomRenderQueueMaterialList.cs b/Packages/com.jovian.utilties/Runtime/CustomRenderQueueMaterialList.cs new file mode 100644 index 0000000..ebd32ca --- /dev/null +++ b/Packages/com.jovian.utilties/Runtime/CustomRenderQueueMaterialList.cs @@ -0,0 +1,64 @@ +using System.Collections; +using System.Collections.Generic; +using UnityEngine; + +namespace Jovian.Utilities { + + public class CustomRenderQueueMaterialList : MonoBehaviour { + + [System.Serializable] + public class MaterialRenderQueue { + public Material material; + public int renderQueue; + + public int storedRenderQueue; + } + + public bool updateMaterialsInEditor; + [SerializeField] + private MaterialRenderQueue[] materialRenderQueueList; + + private void Awake() { +#if UNITY_EDITOR + if(updateMaterialsInEditor) { + Debug.LogWarning("Updating Materials will cause asset files to change. Please review your change log to ensure only valid changes are submitted."); + } + else { + return; + } +#endif + + foreach(var materialRenderQueue in materialRenderQueueList) { + if(materialRenderQueue.material) { + materialRenderQueue.storedRenderQueue = materialRenderQueue.material.renderQueue; // store the materials original render queue + materialRenderQueue.material.renderQueue = materialRenderQueue.renderQueue; // overwrite the render queue + } + } + } + + private void OnDestroy() { +#if UNITY_EDITOR + if(updateMaterialsInEditor) { + Debug.LogWarning("Updating Materials will cause asset files to change. Please review your change log to ensure only valid changes are submitted."); + } + else { + return; + } +#endif + foreach(var materialRenderQueue in materialRenderQueueList) { + if(materialRenderQueue.material) { + materialRenderQueue.material.renderQueue = materialRenderQueue.storedRenderQueue; + } + } + } + + public bool DoesMaterialExistInList(Material material) { + foreach(var materialRenderQueue in materialRenderQueueList) { + if(materialRenderQueue.material == material) { + return true; + } + } + return false; + } + } +} \ No newline at end of file diff --git a/Packages/com.jovian.utilties/Runtime/CustomRenderQueueMaterialList.cs.meta b/Packages/com.jovian.utilties/Runtime/CustomRenderQueueMaterialList.cs.meta new file mode 100644 index 0000000..f17d537 --- /dev/null +++ b/Packages/com.jovian.utilties/Runtime/CustomRenderQueueMaterialList.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: a49f06b9d6f75f04fa4ae4f6ee714a83 \ No newline at end of file diff --git a/Packages/com.jovian.utilties/Runtime/CustomRenderQueueRenderer.cs b/Packages/com.jovian.utilties/Runtime/CustomRenderQueueRenderer.cs new file mode 100644 index 0000000..5292f82 --- /dev/null +++ b/Packages/com.jovian.utilties/Runtime/CustomRenderQueueRenderer.cs @@ -0,0 +1,43 @@ +using UnityEngine; + +namespace Jovian.Utilities { + + public class CustomRenderQueueRenderer : MonoBehaviour { + [SerializeField, Tooltip("Override RenderQueue for shader, 2000 = Opaque, 3000 = Transparent, -1 = Shader Default")] + private int renderQueue = -1; + [SerializeField] + private MeshRenderer meshRenderer; + [SerializeField, Tooltip("If true, only this renderer is affected because a new material is created.")] + private bool createMaterialInstance; + + private void Awake() { + var material = createMaterialInstance ? meshRenderer.material : meshRenderer.sharedMaterial; + material.renderQueue = renderQueue; + } + +#if UNITY_EDITOR + private void Reset() { + var serializedObject = new UnityEditor.SerializedObject(this); + var meshRendererProperty = serializedObject.FindProperty("meshRenderer"); + var meshRenderer = gameObject.GetComponent(); + if(meshRenderer != null) { + meshRendererProperty.objectReferenceValue = meshRenderer; + serializedObject.ApplyModifiedProperties(); + CopyRenderQueueFromMaterial(); // applies serializedObject modified properties + } + } + + [ContextMenu("Copy RenderQueue from Material")] + private void CopyRenderQueueFromMaterial() { + if(meshRenderer == null) { + Debug.LogError(@"MeshRenderer is null, cannot copy RenderQueue", this); + return; + } + var serializedObject = new UnityEditor.SerializedObject(this); + var renderQueueProperty = serializedObject.FindProperty("renderQueue"); + renderQueueProperty.intValue = meshRenderer.sharedMaterial.renderQueue; + serializedObject.ApplyModifiedProperties(); + } +#endif + } +} \ No newline at end of file diff --git a/Packages/com.jovian.utilties/Runtime/CustomRenderQueueRenderer.cs.meta b/Packages/com.jovian.utilties/Runtime/CustomRenderQueueRenderer.cs.meta new file mode 100644 index 0000000..a16f4c1 --- /dev/null +++ b/Packages/com.jovian.utilties/Runtime/CustomRenderQueueRenderer.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: c20f17639e020c0448b1481fad636678 \ No newline at end of file diff --git a/Packages/com.jovian.utilties/Runtime/EditorRuntimeAccessible.meta b/Packages/com.jovian.utilties/Runtime/EditorRuntimeAccessible.meta new file mode 100644 index 0000000..6289ff5 --- /dev/null +++ b/Packages/com.jovian.utilties/Runtime/EditorRuntimeAccessible.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 45fad90effb685541beabcc012dca7f5 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/com.jovian.utilties/Runtime/EditorRuntimeAccessible/AssetUtility.cs b/Packages/com.jovian.utilties/Runtime/EditorRuntimeAccessible/AssetUtility.cs new file mode 100644 index 0000000..c08f9ad --- /dev/null +++ b/Packages/com.jovian.utilties/Runtime/EditorRuntimeAccessible/AssetUtility.cs @@ -0,0 +1,299 @@ +using System; +using UnityEngine; +using System.Collections.Generic; +using System.Diagnostics; +using Debug = UnityEngine.Debug; +using Object = UnityEngine.Object; +#if UNITY_EDITOR +using UnityEditor; +#endif + +namespace Jovian.Utilities { + public static class AssetUtility { + + public static TAsset FindAssetInProject(string assetName = "") where TAsset : Object { +#if UNITY_EDITOR + var filter = $"t:{typeof(TAsset).Name} {assetName}"; + var guids = AssetDatabase.FindAssets(filter); + foreach (var guid in guids) + { + var path = AssetDatabase.GUIDToAssetPath(guid); + var asset = AssetDatabase.LoadAssetAtPath(path); + if (asset != null) + { + return asset; + } + else + { + if (ShouldUnloadAsset(asset)) + { + Resources.UnloadAsset(asset); + } + } + } +#else + Debug.LogError("AssetUtility should not be called in non-Editor mode"); +#endif + return default(TAsset); + } + + public static Object FindAssetInProject(Type assetType, string assetName = "") { +#if UNITY_EDITOR + var filter = $"t:{assetType.Name} {assetName}"; + var guids = AssetDatabase.FindAssets(filter); + foreach (var guid in guids) + { + var path = AssetDatabase.GUIDToAssetPath(guid); + var asset = AssetDatabase.LoadAssetAtPath(path); + if (asset != null) + { + return asset; + } + else + { + if (ShouldUnloadAsset(asset)) + { + Resources.UnloadAsset(asset); + } + } + } +#else + Debug.LogError("AssetUtility should not be called in non-Editor mode"); +#endif + return default(Object); + } + + public static List FindAllAssetsInProject(string assetName = "") where TAsset : Object { + var list = new List(); +#if UNITY_EDITOR + var filter = $"t:{typeof(TAsset).Name} {assetName}"; + var guids = AssetDatabase.FindAssets(filter); + foreach (var guid in guids) + { + var path = AssetDatabase.GUIDToAssetPath(guid); + var asset = AssetDatabase.LoadAssetAtPath(path); + if (asset != null) + { + list.Add(asset); + } + else + { + if (ShouldUnloadAsset(asset)) + { + Resources.UnloadAsset(asset); + } + } + } +#else + Debug.LogError("AssetUtility should not be called in non-Editor mode"); +#endif + return list; + } + + public static List FindAllAssetsInProject(Type assetType, string assetName = "") { + var list = new List(); +#if UNITY_EDITOR + var filter = $"t:{assetType.Name} {assetName}"; + var guids = AssetDatabase.FindAssets(filter); + foreach (var guid in guids) + { + var path = AssetDatabase.GUIDToAssetPath(guid); + var asset = AssetDatabase.LoadAssetAtPath(path); + if (asset != null) + { + list.Add(asset); + } + else + { + if (ShouldUnloadAsset(asset)) + { + Resources.UnloadAsset(asset); + } + } + } +#else + Debug.LogError("AssetUtility should not be called in non-Editor mode"); +#endif + return list; + } + + public static List FindAllObjectsInProject(Type objectType, string filter) { + List list = new List(); +#if UNITY_EDITOR + string[] guids = AssetDatabase.FindAssets(filter); + foreach (var guid in guids) + { + string path = AssetDatabase.GUIDToAssetPath(guid); + Object asset = AssetDatabase.LoadAssetAtPath(path); + + if (typeof(Component).IsAssignableFrom(objectType) && + TryGetTypeObjectWithPrefab(asset, objectType, out Component _)) + { + list.Add(asset); + } + else if (asset.GetType().IsAssignableFrom(objectType)) + { + list.Add(asset); + } + else if (ShouldUnloadAsset(asset)) + { + Resources.UnloadAsset(asset); + } + } +#else + Debug.LogError("AssetUtility should not be called in non-Editor mode"); +#endif + return list; + } + + // Prefabs + public static TAsset FindPrefabInProject(string assetName = "") where TAsset : Component { +#if UNITY_EDITOR + var filter = $"t:Prefab {assetName}"; + var guids = AssetDatabase.FindAssets(filter); + foreach (var guid in guids) + { + var path = AssetDatabase.GUIDToAssetPath(guid); + var asset = AssetDatabase.LoadAssetAtPath(path); + if (TryGetTypeObjectWithPrefab(asset, out TAsset component)) + { + return component; + } + else + { + if (ShouldUnloadAsset(asset)) + { + Resources.UnloadAsset(asset); + } + } + } +#else + Debug.LogError("AssetUtility should not be called in non-Editor mode"); +#endif + Debug.LogError($"Failed to find asset '{assetName}' <{typeof(TAsset)}>"); + return default(TAsset); + } + + public static Object FindPrefabInProject(Type assetType, string assetName = "") { +#if UNITY_EDITOR + var filter = $"t:Prefab {assetName}"; + var guids = AssetDatabase.FindAssets(filter); + foreach (var guid in guids) + { + var path = AssetDatabase.GUIDToAssetPath(guid); + var asset = AssetDatabase.LoadAssetAtPath(path); + if (TryGetTypeObjectWithPrefab(asset, assetType, out Component component)) + { + return component; + } + else + { + if (ShouldUnloadAsset(asset)) + { + Resources.UnloadAsset(asset); + } + } + } +#else + Debug.LogError("AssetUtility should not be called in non-Editor mode"); +#endif + return default(Object); + } + + public static List FindAllPrefabsInProject(string assetName = "") where TAsset : Component { + var list = new List(); +#if UNITY_EDITOR + var filter = $"t:Prefab {assetName}"; + var guids = AssetDatabase.FindAssets(filter); + foreach (var guid in guids) + { + var path = AssetDatabase.GUIDToAssetPath(guid); + var asset = AssetDatabase.LoadAssetAtPath(path); + if (TryGetTypeObjectWithPrefab(asset, out TAsset component)) + { + list.Add(component); + } + else + { + if (ShouldUnloadAsset(asset)) + { + Resources.UnloadAsset(asset); + } + } + } +#else + Debug.LogError("AssetUtility should not be called in non-Editor mode"); +#endif + return list; + } + + public static List FindAllPrefabsInProject(Type assetType, string assetName = "") { + var list = new List(); +#if UNITY_EDITOR + var filter = $"t:Prefab {assetName}"; + var guids = AssetDatabase.FindAssets(filter); + foreach (var guid in guids) + { + var path = AssetDatabase.GUIDToAssetPath(guid); + var asset = AssetDatabase.LoadAssetAtPath(path); + if (TryGetTypeObjectWithPrefab(asset, assetType, out Component component)) + { + list.Add(component); + } + else + { + if (ShouldUnloadAsset(asset)) + { + Resources.UnloadAsset(asset); + } + } + } +#else + Debug.LogError("AssetUtility should not be called in non-Editor mode"); +#endif + return list; + } + + // Util + +#if UNITY_EDITOR + public static bool ShouldUnloadAsset(Object asset) { + if (asset == null) + { + return false; + } + + return !(asset is GameObject or Component or AssetBundle || + PrefabUtility.GetPrefabAssetType(asset) != PrefabAssetType.NotAPrefab); + } + + private static bool TryGetTypeObjectWithPrefab(Object prefab, Type type, out Component component) { + if (prefab != null && prefab is GameObject gameObject && gameObject.TryGetComponent(type, out component)) + { + return true; + } + + component = default; + return false; + } + + private static bool TryGetTypeObjectWithPrefab(Object prefab, out TComponent component) + where TComponent : Component { + if (prefab != null && prefab is TComponent prefabAsComponent) + { + component = prefabAsComponent; + return true; + } + + if (prefab != null && prefab is GameObject gameObject && gameObject.TryGetComponent(out component)) + { + return true; + } + + component = default; + return false; + } +#endif + + } +} \ No newline at end of file diff --git a/Packages/com.jovian.utilties/Runtime/EditorRuntimeAccessible/AssetUtility.cs.meta b/Packages/com.jovian.utilties/Runtime/EditorRuntimeAccessible/AssetUtility.cs.meta new file mode 100644 index 0000000..5d7069c --- /dev/null +++ b/Packages/com.jovian.utilties/Runtime/EditorRuntimeAccessible/AssetUtility.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 8f53e63ec5b4b3744bda48fb1159ac40 \ No newline at end of file diff --git a/Packages/com.jovian.utilties/Runtime/EditorRuntimeAccessible/EditorServiceLocator.cs b/Packages/com.jovian.utilties/Runtime/EditorRuntimeAccessible/EditorServiceLocator.cs new file mode 100644 index 0000000..08a49ce --- /dev/null +++ b/Packages/com.jovian.utilties/Runtime/EditorRuntimeAccessible/EditorServiceLocator.cs @@ -0,0 +1,154 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using UnityEngine; +#if UNITY_EDITOR +using UnityEditor; +#endif + +namespace Jovian.Utilities { + /// + /// Editor use-only.
+ /// Allows easy access between different instances. Supports 1 instance per type.
+ /// Add instances and retrieve elsewhere.

+ /// Read more: https://en.wikipedia.org/wiki/Service_locator_pattern + ///
+ public static class EditorServiceLocator { + private sealed class ServiceNotFoundException : Exception { + public ServiceNotFoundException(Type type) : base($"ServiceNotFoundException. Type={type}") { } + } + + private static Dictionary serviceContainer = new(); + public static bool IsDirty { get; private set; } + +#if UNITY_EDITOR + public static Dictionary ServiceContainer => serviceContainer; +#endif + + [RuntimeInitializeOnLoadMethod] + public static void Init() { + serviceContainer = new(); + } + +#if UNITY_EDITOR + [InitializeOnEnterPlayMode] + private static void Reset(EnterPlayModeOptions options) { + serviceContainer?.Clear(); + IsDirty = true; + } +#endif + + /// + /// Add an instance to the locator. Will throw an exception if a type already exists in the locator. + /// + /// + [Conditional("UNITY_EDITOR")] + public static void Add(T service) { +#if UNITY_EDITOR + if (service == null) { + throw new NullReferenceException($"Service is null. Expected instance of type '{typeof(T)}'"); + } + + serviceContainer.Add(typeof(T), service); + IsDirty = true; +#else + UnityEngine.Debug.LogWarning($"EditorServiceLocator should not be used outside of the Editor"); +#endif + } + + /// + /// Add an instance to the locator. Will replace any existing instance without an exception. + /// An alias for + /// + [Conditional("UNITY_EDITOR")] + public static void AddOrReplace(T service) { + Set(service); + } + + /// + /// Add an instance to the locator. Will replace any existing instance without an exception. + /// An alias for + /// + [Conditional("UNITY_EDITOR")] + public static void Set(T service) { +#if UNITY_EDITOR + if (service == null) { + throw new NullReferenceException($"Service is null. Expected instance of type '{typeof(T)}'"); + } + + serviceContainer[typeof(T)] = service; + IsDirty = true; +#else + UnityEngine.Debug.LogWarning($"EditorServiceLocator should not be used outside of the Editor"); +#endif + } + + /// + /// Removes any type matching the instance passed in from the locator. This is good practice. + /// + /// + /// + /// + [Conditional("UNITY_EDITOR")] + public static void Remove(T service) { +#if UNITY_EDITOR + if (service == null) { + throw new NullReferenceException($"Service is null. Expected instance of type '{typeof(T)}'"); + } + + serviceContainer.Remove(typeof(T)); + IsDirty = true; +#else + UnityEngine.Debug.LogWarning($"EditorServiceLocator should not be used outside of the Editor"); +#endif + } + + /// + /// Retrieves an instance from the locator matching the type. Will throw an exception if nothing is found. + /// + /// + public static T Get() { +#if UNITY_EDITOR + if (serviceContainer.TryGetValue(typeof(T), out object service)) { + return (T)service; + } +#endif + throw new ServiceNotFoundException(typeof(T)); + } + + [Obsolete("Use Get or TryGet(out T) since they follow the C# conventions for TryGet")] + public static T TryGet() { +#if UNITY_EDITOR + if (serviceContainer.TryGetValue(typeof(T), out object service)) { + return (T)service; + } +#endif + return default; + } + + /// + /// Retrieves an instance from the locator matching the type. Returns true/false based on success. + /// + public static bool TryGet(out T instance) { +#if UNITY_EDITOR + if (serviceContainer.TryGetValue(typeof(T), out object service)) { + instance = (T)service; + return instance != null; + } +#endif + instance = default; + return false; + } + + [Conditional("UNITY_EDITOR")] + public static void Clear() { + serviceContainer.Clear(); + IsDirty = true; + } + + [Conditional("UNITY_EDITOR")] + public static void Clean() { + IsDirty = false; + } + } +} \ No newline at end of file diff --git a/Packages/com.jovian.utilties/Runtime/EditorRuntimeAccessible/EditorServiceLocator.cs.meta b/Packages/com.jovian.utilties/Runtime/EditorRuntimeAccessible/EditorServiceLocator.cs.meta new file mode 100644 index 0000000..367e115 --- /dev/null +++ b/Packages/com.jovian.utilties/Runtime/EditorRuntimeAccessible/EditorServiceLocator.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: f8316472f7c700f4a9e908d3abde1d1d \ No newline at end of file diff --git a/Packages/com.jovian.utilties/Runtime/EditorRuntimeAccessible/GizmosUtility.cs b/Packages/com.jovian.utilties/Runtime/EditorRuntimeAccessible/GizmosUtility.cs new file mode 100644 index 0000000..c3c6140 --- /dev/null +++ b/Packages/com.jovian.utilties/Runtime/EditorRuntimeAccessible/GizmosUtility.cs @@ -0,0 +1,78 @@ +using System; +using System.Collections.Generic; +using UnityEngine; +#if UNITY_EDITOR +using UnityEditor; +#endif + +namespace Jovian.Utilities.Utilities { + public static class GizmosUtility { + + public static bool IsGameObjectOrChildSelected(GameObject gameObject) { +#if UNITY_EDITOR + return GameObjectUtilities.IsGameObjectAChildOfGameObject(Selection.activeGameObject, gameObject); +#else + return false; +#endif + } + + public static void DrawColliders(IEnumerable colliders) { + foreach(Collider collider in colliders) { + if(collider != null) { + DrawCollider(collider); + } + } + } + + public static void DrawWireColliders(IEnumerable colliders) { + foreach(Collider collider in colliders) { + if(collider != null) { + DrawWireCollider(collider); + } + } + } + + public static void DrawCollider(Collider collider) + => DrawColliderInternal(collider, Gizmos.DrawSphere, Gizmos.DrawCube, Gizmos.DrawMesh); + + public static void DrawWireCollider(Collider collider) + => DrawColliderInternal(collider, Gizmos.DrawWireSphere, Gizmos.DrawWireCube, Gizmos.DrawWireMesh); + + private static void DrawColliderInternal(Collider collider, Action drawSphere, Action drawCube, + Action drawMesh) { + Gizmos.matrix = collider.transform.localToWorldMatrix; + switch(collider) { + case BoxCollider boxCollider: + drawCube(boxCollider.center, boxCollider.size); + break; + case SphereCollider sphereCollider: + drawSphere(sphereCollider.center, sphereCollider.radius); + break; + case CapsuleCollider capsuleCollider: { + Vector3 direction = GetAxis(capsuleCollider.direction); + drawSphere(capsuleCollider.center + direction * capsuleCollider.height * 0.5f, capsuleCollider.radius); + drawSphere(capsuleCollider.center - direction * capsuleCollider.height * 0.5f, capsuleCollider.radius); + break; + } + case MeshCollider meshCollider: + drawMesh(meshCollider.sharedMesh); + break; + default: + throw new NotSupportedException($"Cannot draw collider of type '{typeof(Collider)}'"); + } + } + + private static Vector3 GetAxis(int direction) { + switch(direction) { + case 0: + return Vector3.right; + case 1: + return Vector3.up; + case 2: + return Vector3.forward; + default: + throw new NotSupportedException($"Direction '{direction}' does not map to an axis."); + } + } + } +} diff --git a/Packages/com.jovian.utilties/Runtime/EditorRuntimeAccessible/GizmosUtility.cs.meta b/Packages/com.jovian.utilties/Runtime/EditorRuntimeAccessible/GizmosUtility.cs.meta new file mode 100644 index 0000000..ef29985 --- /dev/null +++ b/Packages/com.jovian.utilties/Runtime/EditorRuntimeAccessible/GizmosUtility.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: e2ce31502182718488f21bcf6137096f \ No newline at end of file diff --git a/Packages/com.jovian.utilties/Runtime/EditorRuntimeAccessible/HierarchyUtility.cs b/Packages/com.jovian.utilties/Runtime/EditorRuntimeAccessible/HierarchyUtility.cs new file mode 100644 index 0000000..155a7d5 --- /dev/null +++ b/Packages/com.jovian.utilties/Runtime/EditorRuntimeAccessible/HierarchyUtility.cs @@ -0,0 +1,32 @@ +using System.Collections.Generic; +using UnityEngine; +#if UNITY_EDITOR +using System; +using UnityEditor.SceneManagement; +#endif + +namespace Jovian.Utilities { + public static class HierarchyUtility { +#if UNITY_EDITOR + public static List FindComponentsInHierarchy(bool includeInactive = false) where TComponent : Component { + PrefabStage prefabStage = PrefabStageUtility.GetCurrentPrefabStage(); + if(prefabStage == null) { + return SceneUtility.FindComponentsInActiveScene(includeInactive); + } + else { + return new List(prefabStage.prefabContentsRoot.GetComponentsInChildren(includeInactive)); + } + } + + public static List FindComponentsInHierarchy(Type componentType, bool includeInactive = false) { + PrefabStage prefabStage = PrefabStageUtility.GetCurrentPrefabStage(); + if(prefabStage == null) { + return SceneUtility.FindComponentsInActiveScene(componentType, includeInactive); + } + else { + return new List(prefabStage.prefabContentsRoot.GetComponentsInChildren(componentType, includeInactive)); + } + } +#endif + } +} diff --git a/Packages/com.jovian.utilties/Runtime/EditorRuntimeAccessible/HierarchyUtility.cs.meta b/Packages/com.jovian.utilties/Runtime/EditorRuntimeAccessible/HierarchyUtility.cs.meta new file mode 100644 index 0000000..a74405e --- /dev/null +++ b/Packages/com.jovian.utilties/Runtime/EditorRuntimeAccessible/HierarchyUtility.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: fce733b284594854aaf7c9720f440333 \ No newline at end of file diff --git a/Packages/com.jovian.utilties/Runtime/EditorRuntimeAccessible/InspectorGUIUtility.cs b/Packages/com.jovian.utilties/Runtime/EditorRuntimeAccessible/InspectorGUIUtility.cs new file mode 100644 index 0000000..5671d1c --- /dev/null +++ b/Packages/com.jovian.utilties/Runtime/EditorRuntimeAccessible/InspectorGUIUtility.cs @@ -0,0 +1,68 @@ +#if UNITY_EDITOR +using System; +using UnityEngine; +using UnityEditor; + +namespace Jovian.Utilities { + + public static class InspectorGUIUtility { + public static object DrawField(string name, Type type, object value) { + GUIContent label = new(name, $"<{type.Name}> = {value}"); + if(type == typeof(string)) { + return EditorGUILayout.TextField(label, (string)value); + } + if(type == typeof(bool)) { + return EditorGUILayout.Toggle(label, (bool)value); + } + if(type == typeof(float)) { + return EditorGUILayout.FloatField(label, (float)value); + } + if(type == typeof(int)) { + return EditorGUILayout.IntField(label, (int)value); + } + if(type == typeof(byte)) { + return (byte)EditorGUILayout.IntField(label, (byte)value); + } + if(type == typeof(Vector2)) { + return EditorGUILayout.Vector2Field(label, (Vector2)value); + } + if(type == typeof(Vector3)) { + return EditorGUILayout.Vector3Field(label, (Vector3)value); + } + if(type == typeof(Vector4)) { + return EditorGUILayout.Vector4Field(label, (Vector4)value); + } + if(type == typeof(Bounds)) { + return EditorGUILayout.BoundsField(label, (Bounds)value); + } + if(type == typeof(BoundsInt)) { + return EditorGUILayout.BoundsIntField(label, (BoundsInt)value); + } + if(type == typeof(Color)) { + return EditorGUILayout.ColorField(label, (Color)value); + } + if(type == typeof(AnimationCurve)) { + return EditorGUILayout.CurveField(label, (AnimationCurve)value); + } + if (type == typeof(double)) { + return EditorGUILayout.DoubleField(label, (double)value); + } + if (type == typeof(Gradient)) { + return EditorGUILayout.GradientField(label, (Gradient)value); + } + if (type == typeof(long)) { + return EditorGUILayout.LongField(label, (long)value); + } + if(type.IsEnum) { + return EditorGUILayout.EnumPopup(label, (Enum)value); + } + if(type.IsSubclassOf(typeof(UnityEngine.Object)) || value is UnityEngine.Object) { + return EditorGUILayout.ObjectField(label, (UnityEngine.Object)value, type, true); + } + string stringValue = value == null ? "" : value.ToString(); + EditorGUILayout.TextField(label, stringValue); + return value; + } + } +} +#endif \ No newline at end of file diff --git a/Packages/com.jovian.utilties/Runtime/EditorRuntimeAccessible/InspectorGUIUtility.cs.meta b/Packages/com.jovian.utilties/Runtime/EditorRuntimeAccessible/InspectorGUIUtility.cs.meta new file mode 100644 index 0000000..7bed64f --- /dev/null +++ b/Packages/com.jovian.utilties/Runtime/EditorRuntimeAccessible/InspectorGUIUtility.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 8b62c711143ba3e4a8d4e941f00a8d2c \ No newline at end of file diff --git a/Packages/com.jovian.utilties/Runtime/EditorRuntimeAccessible/SceneUtility.cs b/Packages/com.jovian.utilties/Runtime/EditorRuntimeAccessible/SceneUtility.cs new file mode 100644 index 0000000..dea004e --- /dev/null +++ b/Packages/com.jovian.utilties/Runtime/EditorRuntimeAccessible/SceneUtility.cs @@ -0,0 +1,203 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using UnityEngine; +using UnityEngine.SceneManagement; +using Object = UnityEngine.Object; + +namespace Jovian.Utilities { + public static class SceneUtility { + + public static List FindGameObjectsInScene(this Scene scene, bool includeInactive = false, Func predicate = null) { + if (scene.IsValid() == false) + { + Debug.LogError("Scene is invalid"); + return null; + } + + var list = new List(); + GameObject[] sceneObjects = scene.GetRootGameObjects(); + GameObject[] loadedObjects = FindDontDestroyOnLoadObjects(); + GameObject[] combinedGameobjects = new GameObject[sceneObjects.Length + loadedObjects.Length]; + sceneObjects.CopyTo(combinedGameobjects, 0); + loadedObjects.CopyTo(combinedGameobjects, sceneObjects.Length); + foreach (var rootObject in combinedGameobjects) + { + var allChildTransforms = rootObject.GetComponentsInChildren(includeInactive); + var allGameObjects = allChildTransforms.Select(transform => transform.gameObject); + if (predicate != null) + { + allGameObjects = allGameObjects.Where(predicate); + } + + list.AddRange(allGameObjects); + } + + return list; + } + + public static List FindGameObjectsInActiveScene(bool includeInactive = false, Func predicate = null) + => FindGameObjectsInScene(SceneManager.GetActiveScene(), includeInactive, predicate); + + // Adding support for finding objects not in the main scenes but in DontDestroyOnLoad + // From https://forum.unity.com/threads/editor-script-how-to-access-objects-under-dontdestroyonload-while-in-play-mode.442014/#post-3570916 + private static GameObject[] FindDontDestroyOnLoadObjects() { + if (!Application.isPlaying) + { + return new GameObject[] { }; // return an empty array as this method creates issues in edit mode + } + + GameObject temp = null; + try + { + temp = new GameObject(); + Object.DontDestroyOnLoad(temp); + UnityEngine.SceneManagement.Scene dontDestroyOnLoad = temp.scene; + Object.DestroyImmediate(temp); + temp = null; + + return dontDestroyOnLoad.GetRootGameObjects(); + } + finally + { + if (temp != null) + Object.DestroyImmediate(temp); + } + } + + public static List FindComponentsInScene(this Scene scene, bool includeInactive = false) where TComponent : Component { + if (scene.IsValid() == false) + { + Debug.LogError("Scene is invalid"); + return null; + } + + var list = new List(); + + foreach (var rootObject in scene.GetRootGameObjects()) + { + list.AddRange(rootObject.GetComponentsInChildren(includeInactive)); + } + + return list; + } + + public static List FindComponentsInScene(this Scene scene, Type componentType, bool includeInactive = false) { + if (scene.IsValid() == false) + { + Debug.LogError("Scene is invalid"); + return null; + } + + var list = new List(); + foreach (GameObject rootObject in scene.GetRootGameObjects()) + { + list.AddRange(rootObject.GetComponentsInChildren(componentType, includeInactive)); + } + + return list; + } + + public static List FindComponentsInActiveScene(bool includeInactive = false) where TComponent : Component + => FindComponentsInScene(SceneManager.GetActiveScene(), includeInactive); + + public static List FindComponentsInActiveScene(Type componentType, bool includeInactive = false) + => FindComponentsInScene(SceneManager.GetActiveScene(), componentType, includeInactive); + + public static List FindComponentsInAllScenes(bool includeInactive = false) where TComponent : Component { + List allComponents = new(); + for (int i = 0, c = SceneManager.sceneCount; i < c; i++) + { + allComponents.AddRange(FindComponentsInScene(SceneManager.GetSceneAt(i), includeInactive)); + } + + return allComponents; + } + + public static TComponent FindComponentInScene(this Scene scene, bool includeInactive = false) where TComponent : Component { + if (scene.IsValid() == false) + { + Debug.LogError("Scene is invalid"); + return null; + } + + var list = new List(); + + foreach (var rootObject in scene.GetRootGameObjects()) + { + list.AddRange(rootObject.GetComponentsInChildren(includeInactive)); + } + + return list.FirstOrDefault(); + } + + public static Component FindComponentInScene(this Scene scene, Type componentType, bool includeInactive = false) { + if (scene.IsValid() == false) + { + Debug.LogError("Scene is invalid"); + return null; + } + + var list = new List(); + + foreach (GameObject rootObject in scene.GetRootGameObjects()) + { + list.AddRange(rootObject.GetComponentsInChildren(componentType, includeInactive)); + } + + return list.FirstOrDefault(); + } + + public static TComponent FindComponentInActiveScene(bool includeInactive = false) where TComponent : Component + => FindComponentInScene(SceneManager.GetActiveScene(), includeInactive); + + public static Component FindComponentInActiveScene(Type componentType, bool includeInactive = false) + => FindComponentInScene(SceneManager.GetActiveScene(), componentType, includeInactive); + + public static TComponent FindComponentInAllScenes(bool includeInactive = false) where TComponent : Component + => FindComponentsInAllScenes(includeInactive).FirstOrDefault(); + + + public static GameObject FindGameObject(string nameQuery) { + char delimiter = '/'; + string[] querySegments = nameQuery.Split(delimiter); + + return FindGameObjectsInActiveScene(true, (gameObject) => { + int queryIndex = querySegments.Length - 1; + Transform compareTransform = gameObject.transform; + do + { + if (compareTransform != null && compareTransform.name.Equals(querySegments[queryIndex])) + { + compareTransform = compareTransform.parent; + queryIndex--; + } + // if compare is null and string is null that means the first query segment was /, so that's the root element - then our parent MUST be null + else if (compareTransform == null && string.IsNullOrEmpty(querySegments[queryIndex])) + { + return true; + } + else + { + return false; + } + } while (queryIndex >= 0); + + return true; + }).FirstOrDefault(); + } + + private static Transform GetChildWithName(this Transform transform, string childName) { + foreach (Transform child in transform.transform) + { + if (child.name.Equals(childName)) + { + return child; + } + } + + return null; + } + + } +} \ No newline at end of file diff --git a/Packages/com.jovian.utilties/Runtime/EditorRuntimeAccessible/SceneUtility.cs.meta b/Packages/com.jovian.utilties/Runtime/EditorRuntimeAccessible/SceneUtility.cs.meta new file mode 100644 index 0000000..60d37b2 --- /dev/null +++ b/Packages/com.jovian.utilties/Runtime/EditorRuntimeAccessible/SceneUtility.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: d2e17c945fb193744abb8d14866b11c0 \ No newline at end of file diff --git a/Packages/com.jovian.utilties/Runtime/EditorRuntimeAccessible/SerializedObjectUtility.cs b/Packages/com.jovian.utilties/Runtime/EditorRuntimeAccessible/SerializedObjectUtility.cs new file mode 100644 index 0000000..2023d41 --- /dev/null +++ b/Packages/com.jovian.utilties/Runtime/EditorRuntimeAccessible/SerializedObjectUtility.cs @@ -0,0 +1,211 @@ +using System; +using System.Collections; +using System.Diagnostics; +using System.Text.RegularExpressions; +using UnityEngine; +using Debug = UnityEngine.Debug; +using Object = UnityEngine.Object; +using Type = System.Type; +#if UNITY_EDITOR +using UnityEditor; +using System.Reflection; +#endif + +namespace Jovian.Utilities { + + public static class SerializedObjectUtility { + [Conditional("UNITY_EDITOR")] + public static void SaveObjectProperties(Object targetObject, params object[] args) { +#if UNITY_EDITOR + EditorSerializedObjectUtility.SaveObjectProperties(targetObject, args); +#endif + } + } +#if UNITY_EDITOR + public static class EditorSerializedObjectUtility { + + //https://answers.unity.com/questions/929293/get-field-type-of-serializedproperty.html + public static Type GetTypeFromProperty(SerializedProperty property) { + //gets parent type info + string[] slices = property.propertyPath.Split('.'); + System.Type type = property.serializedObject.targetObject.GetType(); + + for(int i = 0; i < slices.Length; i++) + if (slices[i] == "Array") + { + i++; //skips "data[x]" + type = type.GetElementType(); //gets info on array elements + } + + //gets info on field and its type + else type = type.GetField(slices[i], BindingFlags.NonPublic | BindingFlags.Public | BindingFlags.FlattenHierarchy | BindingFlags.Instance).FieldType; + + //type is now the type of the property + return type; + } + + private static readonly Regex isArrayElementRegex = new Regex("Array.data\\[\\d+\\]$"); + + public static bool IsPropertyAnArrayElement(SerializedProperty property) { + return isArrayElementRegex.IsMatch(property.propertyPath); + } + + public static SerializedProperty GetArrayPropertyWithElementProperty(SerializedProperty property) { + SerializedObject serializedObject = property.serializedObject; + string propertyPath = property.propertyPath; + int arrayDataIndex = propertyPath.LastIndexOf(".Array.data", StringComparison.Ordinal); + string propertyPathWithoutArray = propertyPath.Substring(0, arrayDataIndex); + int pathDividerIndex = propertyPathWithoutArray.LastIndexOf(".", StringComparison.Ordinal); + + string parentPropertyName = propertyPathWithoutArray; + if(pathDividerIndex != -1) { + parentPropertyName = propertyPathWithoutArray.Substring(pathDividerIndex); + } + return serializedObject.FindProperty(parentPropertyName);; + } + + public static void SaveObjectProperties(Object targetObject, params object[] args) { + SerializedObject serializedObject = new SerializedObject(targetObject); + + for(int i = 0; i < args.Length; i += 2) { + var keyArg = args[i]; + var keyType = keyArg.GetType(); + + if((keyType == typeof(string)) == false) { + throw new System.NotSupportedException(string.Format("Key must be string. {0} is {1}", args[i], keyType)); + } + else { + var property = serializedObject.FindProperty((string)keyArg); + object argValue = args[i + 1]; + + if(property == null) { + throw new System.Exception(string.Format("No property found for key {0}", keyArg)); + } + + if(argValue == null) { + property.objectReferenceValue = null; + } + else { + if(property.isArray) { + property.arraySize = 0; + IEnumerable argArray = (IEnumerable)argValue; + IEnumerator enumerator = argArray.GetEnumerator(); + + int index = 0; + while(enumerator.MoveNext()) { + property.InsertArrayElementAtIndex(index); + var arrayProperty = property.GetArrayElementAtIndex(index); + SetSerializedPropertyValue(arrayProperty, enumerator.Current); + index++; + } + + } + else { + SetSerializedPropertyValue(property, argValue); + } + + } + } + } + + serializedObject.ApplyModifiedProperties(); + } + + private static void SetSerializedPropertyValue(SerializedProperty property, object value) { + if(property == null) { + UnityEngine.Debug.LogError("SetSerializedPropertyValue failed, property is null"); + return; + } + switch(property.propertyType) { + case SerializedPropertyType.AnimationCurve: + property.animationCurveValue = (AnimationCurve)value; + break; + case SerializedPropertyType.Boolean: + property.boolValue = (bool)value; + break; + case SerializedPropertyType.BoundsInt: + property.boundsIntValue = (BoundsInt)value; + break; + case SerializedPropertyType.Character: + property.intValue = (int)(char)value; + break; + case SerializedPropertyType.Color: { + if(value is Color32) { + property.colorValue = (Color)(Color32)value; + } + else { + property.colorValue = (Color)value; + } + break; + } + case SerializedPropertyType.ExposedReference: + case SerializedPropertyType.ObjectReference: + property.objectReferenceValue = (Object)value; + break; + case SerializedPropertyType.Float: + property.floatValue = (float)value; + break; + case SerializedPropertyType.Integer: + property.intValue = (int)value; + break; + case SerializedPropertyType.LayerMask: + property.intValue = ((LayerMask)value).value; + break; + case SerializedPropertyType.Quaternion: + property.quaternionValue = (Quaternion)value; + break; + case SerializedPropertyType.Rect: + property.rectValue = (Rect)value; + break; + case SerializedPropertyType.RectInt: + property.rectIntValue = (RectInt)value; + break; + case SerializedPropertyType.String: + property.stringValue = (string)value; + break; + case SerializedPropertyType.Vector2: + property.vector2Value = (Vector2)value; + break; + case SerializedPropertyType.Vector2Int: + property.vector2IntValue = (Vector2Int)value; + break; + case SerializedPropertyType.Vector3: + property.vector3Value = (Vector3)value; + break; + case SerializedPropertyType.Vector3Int: + property.vector3IntValue = (Vector3Int)value; + break; + case SerializedPropertyType.Vector4: + property.vector4Value = (Vector4)value; + break; + case SerializedPropertyType.Enum: + property.enumValueIndex = (int)value; // need to test this + // flags??? + break; + case SerializedPropertyType.Generic: + SaveGenericProperty(property, value); + break; + default: + throw new System.NotSupportedException($"PropertyType {property.propertyType} is not supported - yet. Array? {property.isArray} Path: {property.propertyPath}"); + } + } + + private static void SaveGenericProperty(SerializedProperty property, object instance) { + Type type = instance.GetType(); + var fields = type.GetRuntimeFields(); + foreach(FieldInfo field in fields) { + if(field.IsNotSerialized || field.IsStatic) { + continue; + } + SerializedProperty fieldProperty = property.FindPropertyRelative(field.Name); + if(fieldProperty != null) { + SetSerializedPropertyValue(property.FindPropertyRelative(field.Name), field.GetValue(instance)); + } + else { + UnityEngine.Debug.Log($"SaveGenericProperty cannot field serializedProperty named '{field.Name}'"); + } + } + } + } +#endif +} \ No newline at end of file diff --git a/Packages/com.jovian.utilties/Runtime/EditorRuntimeAccessible/SerializedObjectUtility.cs.meta b/Packages/com.jovian.utilties/Runtime/EditorRuntimeAccessible/SerializedObjectUtility.cs.meta new file mode 100644 index 0000000..227d51a --- /dev/null +++ b/Packages/com.jovian.utilties/Runtime/EditorRuntimeAccessible/SerializedObjectUtility.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 51917079be732084cbaae41d702e72ce \ No newline at end of file diff --git a/Packages/com.jovian.utilties/Runtime/FloatRange.cs b/Packages/com.jovian.utilties/Runtime/FloatRange.cs new file mode 100644 index 0000000..0aa9c50 --- /dev/null +++ b/Packages/com.jovian.utilties/Runtime/FloatRange.cs @@ -0,0 +1,45 @@ +using System; +using UnityEngine; + +namespace Jovian.Utilities { + + public abstract class NumberRange { + public T min; + public T max; + + public abstract float Lerp(float t); + public abstract float LerpUnclamped(float t); + // returns 0 to 1 + public abstract float InverseLerp(float t); + // returns values -1 to 1 + public abstract float InverseLerpSigned(float t); + public abstract T Random(); + } + + + [Serializable] + public class FloatRange : NumberRange { + public FloatRange(float min, float max) { + this.min = min; + this.max = max; + } + public override float Lerp(float t) => Mathf.Lerp(min, max, t); + public override float LerpUnclamped(float t) => Mathf.LerpUnclamped(min, max, t); + public override float InverseLerp(float t) => Mathf.InverseLerp(min, max, t); + public override float InverseLerpSigned(float t) => Mathf.InverseLerp(min, max, t) * 2f - 1f; + public override float Random() => UnityEngine.Random.Range(min, max); + } + + [Serializable] + public class IntRange : NumberRange { + public IntRange(int min, int max) { + this.min = min; + this.max = max; + } + public override float Lerp(float t) => Mathf.Lerp(min, max, t); + public override float LerpUnclamped(float t) => Mathf.LerpUnclamped(min, max, t); + public override float InverseLerp(float t) => Mathf.InverseLerp(min, max, t); + public override float InverseLerpSigned(float t) => Mathf.InverseLerp(min, max, t) * 2f - 1f; + public override int Random() => UnityEngine.Random.Range(min, max); + } +} \ No newline at end of file diff --git a/Packages/com.jovian.utilties/Runtime/FloatRange.cs.meta b/Packages/com.jovian.utilties/Runtime/FloatRange.cs.meta new file mode 100644 index 0000000..691aa1f --- /dev/null +++ b/Packages/com.jovian.utilties/Runtime/FloatRange.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 01901fe75ec22104b866b8a243adf3c3 \ No newline at end of file diff --git a/Packages/com.jovian.utilties/Runtime/GameObjectUtilities.cs b/Packages/com.jovian.utilties/Runtime/GameObjectUtilities.cs new file mode 100644 index 0000000..07ef926 --- /dev/null +++ b/Packages/com.jovian.utilties/Runtime/GameObjectUtilities.cs @@ -0,0 +1,59 @@ +using System; +using UnityEngine; +using Object = UnityEngine.Object; + +namespace Jovian.Utilities { + public static class GameObjectUtilities { + public static void DestroyGameObjectsOfType() where T : MonoBehaviour { + T[] objects = Object.FindObjectsOfType(); + for (int i = 0; i < objects.Length; ++i) { + Object.Destroy(objects[i].gameObject); + } + } + + public static bool IsGameObjectAChildOfGameObject(this GameObject targetGameObject, GameObject potentialParentGameObject) { + if (targetGameObject == null) { + return false; + } + + Transform potentialParentTransform = potentialParentGameObject.transform; + Transform checkTransform = targetGameObject.transform; + do { + if (checkTransform == potentialParentTransform) { + return true; + } + + checkTransform = checkTransform.parent; + } while (checkTransform != null); + + return false; + } + + public static bool TryFindChildByName(this GameObject gameObject, + string name, + out GameObject childGameObject, + bool includeInactive = false, + bool allowPartialMatch = false, + StringComparison stringComparison = StringComparison.Ordinal) { + childGameObject = null; + if (gameObject == null) { + return false; + } + + Transform[] children = gameObject.GetComponentsInChildren(includeInactive); + foreach (Transform child in children) { + if (allowPartialMatch && child.name.Contains(name, stringComparison)) { + childGameObject = child.gameObject; + return true; + } + + if (!allowPartialMatch && child.name.Equals(name, stringComparison)) { + childGameObject = child.gameObject; + return true; + } + } + + return false; + } + } +} \ No newline at end of file diff --git a/Packages/com.jovian.utilties/Runtime/GameObjectUtilities.cs.meta b/Packages/com.jovian.utilties/Runtime/GameObjectUtilities.cs.meta new file mode 100644 index 0000000..fe63930 --- /dev/null +++ b/Packages/com.jovian.utilties/Runtime/GameObjectUtilities.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 42dbc9bd96270ef4fb84e4df2590e3ad \ No newline at end of file diff --git a/Packages/com.jovian.utilties/Runtime/JovianUtilities.asmdef b/Packages/com.jovian.utilties/Runtime/JovianUtilities.asmdef new file mode 100644 index 0000000..3b2ea1e --- /dev/null +++ b/Packages/com.jovian.utilties/Runtime/JovianUtilities.asmdef @@ -0,0 +1,14 @@ +{ + "name": "JovianUtilities", + "rootNamespace": "", + "references": [], + "includePlatforms": [], + "excludePlatforms": [], + "allowUnsafeCode": false, + "overrideReferences": false, + "precompiledReferences": [], + "autoReferenced": true, + "defineConstraints": [], + "versionDefines": [], + "noEngineReferences": false +} \ No newline at end of file diff --git a/Packages/com.jovian.utilties/Runtime/JovianUtilities.asmdef.meta b/Packages/com.jovian.utilties/Runtime/JovianUtilities.asmdef.meta new file mode 100644 index 0000000..c254491 --- /dev/null +++ b/Packages/com.jovian.utilties/Runtime/JovianUtilities.asmdef.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 6c878acd8a48e2d48ad42c741bc8551d +AssemblyDefinitionImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/com.jovian.utilties/Runtime/LoadingProcessHandler.cs b/Packages/com.jovian.utilties/Runtime/LoadingProcessHandler.cs new file mode 100644 index 0000000..97fcce4 --- /dev/null +++ b/Packages/com.jovian.utilties/Runtime/LoadingProcessHandler.cs @@ -0,0 +1,53 @@ +using System; +using System.Collections.Generic; +using UnityEngine; + +namespace Jovian.Utilities +{ + public interface ILoadingProcess { + bool HasLoaded { get; } + } + + public class LoadingProcessHandler { + private readonly HashSet allProcesses = new(); + private readonly HashSet anyProcesses = new(); + + public event Action OnLoadComplete; + + public bool IsLoadingComplete() { + foreach(ILoadingProcess loadingProcess in anyProcesses) { + if(loadingProcess.HasLoaded) { + return true; + } + } + bool areAllProcessesComplete = true; + foreach(ILoadingProcess loadingProcess in allProcesses) { + if(loadingProcess.HasLoaded == false) { + areAllProcessesComplete = false; + break; + } + } + return areAllProcessesComplete; + } + + public void AddAllProcess(ILoadingProcess loadingProcess) { + allProcesses.Add(loadingProcess); + } + + public void AddAnyProcess(ILoadingProcess loadingProcess) { + anyProcesses.Add(loadingProcess); + } + + public void Complete() { + OnLoadComplete?.Invoke(); + } + } + + public class TimeElapsedLoadingProcess : ILoadingProcess { + private readonly float endTime; + public bool HasLoaded => Time.realtimeSinceStartup > endTime; + public TimeElapsedLoadingProcess(float duration) { + endTime = Time.realtimeSinceStartup + duration; + } + } +} diff --git a/Packages/com.jovian.utilties/Runtime/LoadingProcessHandler.cs.meta b/Packages/com.jovian.utilties/Runtime/LoadingProcessHandler.cs.meta new file mode 100644 index 0000000..0e6b51a --- /dev/null +++ b/Packages/com.jovian.utilties/Runtime/LoadingProcessHandler.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 65bcc9fd235ddb54e991d2799a5e2e2d \ No newline at end of file diff --git a/Packages/com.jovian.utilties/package.json b/Packages/com.jovian.utilties/package.json new file mode 100644 index 0000000..52b89fb --- /dev/null +++ b/Packages/com.jovian.utilties/package.json @@ -0,0 +1,7 @@ + { + "name": "com.jovian.utilities", + "displayName": "Jovian Utilities", + "version": "0.1.18", + "description": "General purpose utility scripts", + "hideInEditor": false +} diff --git a/Packages/com.jovian.utilties/package.json.meta b/Packages/com.jovian.utilties/package.json.meta new file mode 100644 index 0000000..145031e --- /dev/null +++ b/Packages/com.jovian.utilties/package.json.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 1067449040171e9439c6e3aca37af40f +PackageManifestImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/packages-lock.json b/Packages/packages-lock.json index 1eb63ea..5a1aa12 100644 --- a/Packages/packages-lock.json +++ b/Packages/packages-lock.json @@ -6,6 +6,20 @@ "source": "embedded", "dependencies": {} }, + "com.jovian.assets-history": { + "version": "file:com.jovian.recentassets", + "depth": 0, + "source": "embedded", + "dependencies": {} + }, + "com.jovian.logger": { + "version": "file:com.jovian.logger", + "depth": 0, + "source": "embedded", + "dependencies": { + "com.unity.nuget.newtonsoft-json": "3.2.1" + } + }, "com.jovian.savesystem": { "version": "file:com.jovian.savesystem", "depth": 0, @@ -20,6 +34,12 @@ "source": "embedded", "dependencies": {} }, + "com.jovian.utilities": { + "version": "file:com.jovian.utilties", + "depth": 0, + "source": "embedded", + "dependencies": {} + }, "com.jovian.zonesystem": { "version": "file:com.jovian.zonesystem", "depth": 0,