Stateful Scriptable Objects Here's an example of a Scriptable Object with state. Part of TilePlus Toolkit. Note this is free on the asset store but is copyrighted. // *********************************************************************** // Assembly : TilePlus // Created : 03-25-2023 // // Last Modified On : 04-03-2023 // *********************************************************************** // // Copyright (c) All rights reserved. // // // *********************************************************************** #nullable enable using System; using System.Collections.Generic; using System.Linq; // ReSharper disable once RedundantUsingDirective using UnityEditor; using UnityEngine; using UnityEngine.Tilemaps; using static TilePlus.TpLib; // ReSharper disable MemberCanBePrivate.Global namespace TilePlus { /// /// TpZoneManager is used to manage square areas of tilemaps called Zones. /// public class TpZoneManager : ScriptableObject { #region subscriptions /// /// Notify me when a Zone Reg is added. /// /// Be aware that when the ZoneManager instance is deleted this subscription expires public event Action? OnZoneRegAdded; /// /// Notify me when a Zone Reg is deleted. /// /// Be aware that when the ZoneManager instance is deleted this subscription expires public event Action? OnZoneRegDeleted; /// /// Notify me that a list of TPT tiles will be deleted. /// /// Be aware that when the ZoneManager instance is deleted this subscription expires public event Action>? OnTptTilesWillBeDeleted; #endregion #region privateFields /// /// Mapping between RectInts (locator) and Zone Registrations. /// private Dictionary chunkMap = new (); /// /// The default ChunkLocator from the size param to Initialize. /// This defines the size of each chunk - it's size.x and size.y params /// (both should be equal). This is available via a property. /// private RectInt defaultLocator; /// /// The starting position, the position of chunk zero. /// private Vector2Int worldOrigin; /// /// indicates that chunking is enabled after a call to Initialize. /// private bool chunkingConfigured; //backup field for property //holds the return value for RestoreFromRegistrationJson private readonly List currentLoadresults = new(8); //temporary list but use the same one repeatedly to reduce garbage private List getZoneRegForChunkInternal = new(32); //maps for this instance. clients need to use only these maps. private Dictionary monitoredTilemaps = new(); //the name of this instance. private string instanceName = string.Empty; #endregion #region properties /// /// Get the all loading results /// public IEnumerable GetAllZoneRegistrations => chunkMap.Values.OrderBy(zr => zr.dex); /// /// Is chunking configured? /// public bool ChunkingConfigured => chunkingConfigured; /// /// Size of a chunk as set during initialization. /// public int ChunkSize => defaultLocator.size.x; /// /// The number of chunks in the chunkmap. /// public int NumChunksInUse => chunkMap.Count; /// /// Get a collection of the MonitoredTilemaps dictionary values: Tilemap instances. These are /// the only ones that clients of this instance should be using. /// public Dictionary.ValueCollection MonitoredTilemaps => monitoredTilemaps.Values; /// /// Access to the Monitored Tilemaps for this instance. DO NOT ALTER THIS DICTIONARY. DON'T SAVE A REFERENCE. /// public Dictionary MonitoredTilemapDict => monitoredTilemaps; /// /// Get the default locator. This is the value used when /// you don't specify a dimensions value when GetZoneReg /// or GetLocator with dimensions = null. /// // ReSharper disable once ConvertToAutoProperty public RectInt DefaultLocator => defaultLocator; /// /// Get the ChunkMapAnchorPosition. This is the base or startingPosition from where all Chunks begin. /// You can find the center of a chunk by adding multiples of ChunkMapAnchorSize to ChunkMapAnchorPosition. /// public Vector2Int WorldOrigin => worldOrigin; /// /// The name of this instance. Read only /// public string InstanceName => instanceName; /// /// A ref to the ZoneLayout, if used with chunking system. Null otherwise. /// public TpZoneLayout? ZoneLayoutComponent { get; set; } #endregion #region access /// /// Reset all registrations, reset registrationIndex /// /// Reset event descriptions (default=true) public void ResetInstance(bool resetEvents = true ) { chunkMap.Clear(); chunkingConfigured = false; currentLoadresults.Clear(); getZoneRegForChunkInternal.Clear(); monitoredTilemaps.Clear(); if (!resetEvents) return; OnZoneRegDeleted = null; OnZoneRegAdded = null; OnTptTilesWillBeDeleted = null; } /// /// Add a TileFab chunk to the database. In general this should ONLY be /// called from TileFabLib. CHUNKS ONLY. /// /// The TileFab to load /// mark the ZoneReg as immortal /// placement offset /// rotation /// remapping dictionary /// Asset GUIDs /// Asset names /// List of prefabs spawned when the TileFab was loaded. Note: not serialized /// in the ZoneReg class instance created herein. /// Tuple of ZoneReg and RectInt (locator) or null for error. AssetReg is null if error. internal (ZoneReg? reg, RectInt locator) AddZone(TpTileFab? tileFab, bool createAsImmortal, Vector3Int offset, TpTileBundle.TilemapRotation rotation, Dictionary[]? posToGuidMaps, string[]? bundleAssetGuids, string[]? bundleAssetNames, List? spawnedPrefabs) { if (!chunkingConfigured) { TpLogError("Cannot add Zones to TpZoneManager before it is configured. Use 'Initialize' first!!!"); return(null,defaultLocator); } if (!tileFab || posToGuidMaps == null || bundleAssetGuids == null || bundleAssetNames == null) { TpLogError("null TileFab, posToGuidMaps, bundleAssetGuids or bundleAssetName was passed to TpZoneLayout.AddZone."); return(null,defaultLocator); } if (!tileFab.m_FromGridSelection) { TpLogError("Cannot use TpZoneManager.AddZone with a non-chunk TileFab!!! And you can't just click the 'From Grid Selection checkbox on the asset: that won't work correctly. PLease recreate the TileFabs using GridSelections!"); return(null,defaultLocator); } // ReSharper disable once ConditionIsAlwaysTrueOrFalseAccordingToNullableAPIContract if(tileFab.m_TileAssets.Count == 0 || tileFab.m_TileAssets[0] == null || !tileFab.m_TileAssets[0].m_Asset) { TpLogError($"Invalid TileFab [{tileFab.name}]: does not have any bundles."); return(null,defaultLocator); } //need a boundsInt for the chunk in order to create a 'locator' RectInt. //we know that the TileFab has to have at least one Chunk. //all chunk boundsInts are identical. var chunkBoundsInt = tileFab.IsChunkified ? new BoundsInt(0,0,0,ChunkSize,ChunkSize,1) : tileFab.LargestBounds; //this method does exactly that when a Fab is a chunk. //update map from BoundsInt to reg (for camera-region culling) //now compute the locator for this chunk: Basically it's a RectInt encompassing the entire Chunk as placed. //NOTE THAT the position of a RectInt is NOT the center. It's the lower-left corner. This is fine //as long as we're consistent. var locator = GetLocatorForGridPosition(offset); if (chunkMap.ContainsKey(locator)) { TpLogError($"The locator [{locator}] already exists! Can't place at offset {offset}"); return(null,defaultLocator); } var reg = new ZoneReg(TileFabLib.RegistrationIndex, locator, tileFab.AssetGuidString, tileFab.name, offset, rotation, posToGuidMaps, bundleAssetGuids, bundleAssetNames, chunkBoundsInt, spawnedPrefabs); if (createAsImmortal) reg.imm = true; var hash = new AssetGuidPositionHash(tileFab.TileFabGuid, offset); return !AddRegistration(reg, hash) ? (null,defaultLocator) : (reg,locator); } /// /// Add a registration. Only use if you're not using AddZone and creating your own ZoneRegs /// /// The ZoneReg /// An AssetGuidPositionHash instance /// false for failure: means that there was an entry already existing for this locator. public bool AddRegistration(ZoneReg reg, AssetGuidPositionHash hash) { var locator = reg.m_MyLocator; if (!chunkMap.TryAdd(locator, reg)) { #if UNITY_EDITOR TpLogError($"Fatal: duplicate key {locator} in ChunkMap for reg {reg}. "); #endif return false; } TileFabLib.S_LoadedGuids?.Add(hash); TileFabLib.IncrementRegistrationIndex(); OnZoneRegAdded?.Invoke(reg,this); return true; } /// /// Is there a registration for this ZoneReg? /// /// A ZoneReg /// public bool HasZone(ZoneReg reg) { if (chunkingConfigured) return chunkMap.ContainsKey(reg.m_MyLocator); TpLogError("Cannot use TpZoneManager before it is configured. Use 'Initialize' first!!!"); return false; } /// /// Unload a list of Zones /// /// List of ZoneRegs to delete /// destroy tiles (default) /// destroy prefabs (default) /// false if failed public bool UnloadZones(List regs, bool destroyTiles = true, bool destroyPrefabs = true) { var error = false; foreach (var reg in regs) error |= UnloadZone(reg, destroyTiles, destroyPrefabs); return error; } /// /// Unload a list of Zones, Async /// Does one reg per frame. /// /// List of ZoneRegs to delete /// destroy tiles (default) /// destroy prefabs (default) /// false if failed public async Awaitable UnloadZonesAsync(List regs, bool destroyTiles = true, bool destroyPrefabs = true) { var success = true; foreach (var reg in regs) { success &= UnloadZone(reg, destroyTiles, destroyPrefabs); if (success) await Awaitable.NextFrameAsync(); } return success; } /// /// Unload ALL zones, including all parented prefabs. /// /// public bool UnloadAllZones() { return UnloadZones(chunkMap.Values.ToList()); } /// /// Unload a chunk /// /// corresponding ZoneReg for the chunk you want to delete. /// destroy tiles if true (default) /// destroy prefabs if true (default) /// true if successful. /// runtime use ONLY public bool UnloadZone(ZoneReg? reg, bool destroyTiles = true, bool destroyPrefabs = true) { if (reg == null) { TpLogError("Null ZoneReg passed to UnloadZone."); return false; } //reserved zones are handled simply since there aren't any tiles/prefabs to delete. if (reg.m_Reserved) { if (!DeleteZoneRegistration(reg)) TpLogWarning($"Could not delete this zonereg: {reg}"); OnZoneRegDeleted?.Invoke(reg,this); return true; } if (!chunkingConfigured) { TpLogError("Cannot delete Zones from TpZoneManager before it is configured. Use 'Initialize' first!!!"); return false; } if (!chunkMap.ContainsKey(reg.m_MyLocator)) { TpLogError($"Unknown ZoneReg [{reg}], can't delete zone!"); return false; } if (destroyTiles) { //area is 'largestbounds' from the asset var eraseBounds = reg.lb; //offset it eraseBounds.position += reg.offs; var sz = eraseBounds.size; sz.z = 1; eraseBounds.size = sz; var ri = TpTileUtils.RectIntFromBoundsInt(eraseBounds, Vector3Int.zero); //Debug.Log($"bounds {eraseBounds} rectint {ri}"); //todo: could cache depending on chunk size. var nulls = new TileBase[sz.x * sz.y]; //these should all be null. //get a list of TPBs (which is cleared as required for using (TpLib.S_TilePlusBaseList_Pool.Get(out var pTiles)) { if (pTiles != null) { foreach (var map in monitoredTilemaps.Values) { var pos = map.transform.position; var gridPos = map.WorldToCell(pos); var ri2 = new RectInt((Vector2Int)gridPos + ri.position, ri.size); TpLib.GetAllTilesInRegionForMap(map, pTiles, ri2); OnTptTilesWillBeDeleted?.Invoke(map, pTiles); //event. map.SetTilesBlock(eraseBounds, nulls); } } } } if (destroyPrefabs && reg.m_Prefabs != null) { //destroy any prefabs if (reg.m_Prefabs.Count != 0) { foreach (var gameObj in reg.m_Prefabs) { if (gameObj.TryGetComponent(out var link)) { link.DespawnMe(); continue; } #if UNITY_EDITOR UnityEngine.Object.DestroyImmediate(gameObj, false); #else UnityEngine.Object.Destroy(gameObj); #endif } } } if (!DeleteZoneRegistration(reg)) TpLogWarning($"Could not delete this zonereg: {reg}"); OnZoneRegDeleted?.Invoke(reg,this); return true; } #endregion #region chunking /// /// Required if you want to use chunking. No chunking data is accumulated and /// chunking will not work if this isn't used. Note that you should use this again for /// every new scene. WIPES OUT ANY EXISTING TILEFAB REGISTRATION DATA WHEN USED!! /// /// Size of a chunk. Must be even, rounded up if not. /// Min=4. 4x4, 6x6, 8x8 ... 16x16 chunks etc /// The origin position, the base position, such as /// Vector3Int.zero, where the chunk numbers should be centered. If null then Vector3Int.zero is used. /// sets certain data structures' initial size. Base this on the total /// number of chunks of 'size' that would be in your camera's FOV at one time (for example). There's no problem /// if the max num chunks is exceeded, this just allocates memory early on given your best estimate of what's /// required as set in this method. public void Initialize(int size, Vector3Int? origin = null, int initialMaxNumChunks = 64) { origin ??= Vector3Int.zero; //default for origin position if (size < 4) //this would be a 4x4 TileFab which is ridiculously (?) small. size = 4; //test for an even number. if (size % 2 != 0) //remainder should be zero if this is a multiple of 2. size++; //allocate memory for arrays. chunkMap = new Dictionary(initialMaxNumChunks); getZoneRegForChunkInternal = new List(initialMaxNumChunks / 4); defaultLocator = new RectInt(Vector2Int.zero, new Vector2Int(size, size)); worldOrigin = new Vector2Int(origin.Value.x, origin.Value.y); chunkingConfigured = true; } /// /// Get the ZoneRegs for a Zone locator RectInt /// /// the RectInt chunk locator /// ZoneReg List, list is Empty for error /// return empty list if chunking not enabled or chunklocator is null. /// Note that the same list is cleared and re-used every time that this is called. public List GetZoneRegsForRegion(RectInt? locator) { getZoneRegForChunkInternal.Clear(); if (!chunkingConfigured || !locator.HasValue || locator.Value.size == Vector2Int.zero) return getZoneRegForChunkInternal; var loc = locator.Value; // ReSharper disable once ForeachCanBeConvertedToQueryUsingAnotherGetEnumerator foreach (var item in chunkMap.Keys) { if (item.Overlaps(loc)) getZoneRegForChunkInternal.Add(chunkMap[item]); } return getZoneRegForChunkInternal; } /// /// Obtain two datasets: one is a List of ZoneRegs that are outside of an area and another /// is a HashSet of ZoneRegs that are inside the area. /// /// the RectInt Locator describing the area. /// ref HashSet for inside /// ref List for outside /// false if any error occurs public bool FindRegionalZoneRegs(RectInt? locator, ref HashSet inside, ref List outside) { if (!chunkingConfigured || !locator.HasValue) return false; var loc = locator.Value; inside.Clear(); outside.Clear(); // return other.xMin < this.xMax && other.xMax > this.xMin && other.yMin < this.yMax && other.yMax > this.yMin; // ReSharper disable once ForeachCanBeConvertedToQueryUsingAnotherGetEnumerator foreach ((var zone, var reg) in chunkMap) { if (zone.Overlaps(loc)) //if ANY part of the locator/zone overlaps the 'locator' RectInt inside.Add(zone); //inside the locator's region else outside.Add(reg); //outside the locator's region } return true; } /// /// Get the ZoneRegs for a chunk located at a Grid position. /// /// The position to use when searching for Zone registrations /// [Nullable] if not null, this is the search area. If null, ChunkMapAnchorPosition as set by Initialize /// Align to grid. Default=true /// a list of ZoneRegs. Empty list is valid and means error or nothing found. public List GetZoneRegsForGridPosition(Vector3Int gridPosition, Vector2Int? dimensions = null, bool align = true) { return GetZoneRegsForRegion(GetLocatorForGridPosition(gridPosition,dimensions,align)); } /// /// Get the Zone registration for a chunk located at a World position. /// /// The position to use when searching for Zone registrations /// Tilemap to use for translating world to grid positions. If null, an empty list is returned. /// [Nullable] if not null, this is the search area. If null, ChunkMapAnchorPosition as set by Initialize /// Align to grid. Default=true /// a list of ZoneRegs. Empty list is valid and means error or nothing found. public List GetZoneRegsForWorldPosition(Vector3 worldPosition, Tilemap? map, Vector2Int? dimensions = null, bool align = true) { if (map) return GetZoneRegsForRegion(GetLocatorForWorldPosition(worldPosition, map, dimensions, align)); getZoneRegForChunkInternal.Clear(); return getZoneRegForChunkInternal; } /// /// Create an Zone registration locator from a grid position /// /// position on a Tilemap /// [Nullable] optional size of locator. If null, ChunkMapAnchorPosition as set by Initialize /// Align to grid. Default=true /// RectInt Zone registration locator public RectInt GetLocatorForGridPosition(Vector3Int gridPosition, Vector2Int ?dimensions = null, bool align = true) { if (align) gridPosition = AlignToGrid(gridPosition); return new RectInt((Vector2Int) gridPosition + worldOrigin, dimensions ?? defaultLocator.size); } /// /// Create an Zone registration locator from a world position /// /// world position /// Tilemap to use for translating world to grid positions. If null, new RectInt() is returned. /// [Nullable] optional size of locator. If null, ChunkMapAnchorPosition as set by Initialize /// Align to grid. Default=true /// RectInt Zone registration locator public RectInt GetLocatorForWorldPosition(Vector3 position, Tilemap? map, Vector2Int? dimensions = null, bool align = true) { return !map ? new RectInt() : GetLocatorForGridPosition(map.WorldToCell(position), dimensions,align); } /// /// Is there a Zone registration associated with a RectInt locator? /// /// the RectInt to check /// true if there's already a locator there. public bool HasZoneRegForLocator(RectInt locator) { return chunkMap.ContainsKey(locator); } /// /// Get a Zone Reg for a locator RectInt /// /// a locator /// a registration /// true if reg was found. Note: if false the reg is default public bool GetZoneRegForLocator(RectInt locator, out ZoneReg? reg) { return chunkMap.TryGetValue(locator, out reg); } /// /// Convert a super-grid position to a locator /// /// s sGrid postion /// dimensions of locator or null for default /// a Locator. public RectInt GetLocatorForSgridPosition(Vector2Int sGridPosition, Vector2Int? dimensions = null) { var chunkSize = defaultLocator.size.x; var gridPos = new Vector3Int(sGridPosition.x * chunkSize, sGridPosition.y * chunkSize); return GetLocatorForGridPosition(gridPos, dimensions); } /// /// Get a Tilemap grid position from a sGrid position. /// /// a sGrid position /// a Tilemap grid position public Vector3Int GetGridPositionForSgridPosition(Vector2Int sGridPosition) { var chunkSize = defaultLocator.size.x; return new Vector3Int(sGridPosition.x * chunkSize, sGridPosition.y * chunkSize); } #endregion #region utils /// /// Get a JSON version of the Zone registrations /// /// /// public string GetZoneRegJson(bool prettyPrint = true) { var registrations= chunkMap.Values.OrderBy(zr => zr.dex).ToArray(); var loadWrapper = new LoadWrapper(registrations); return JsonUtility.ToJson(loadWrapper, prettyPrint); } /// /// Restore all Tilefabs or Bundles based on a json-archived dataset. /// /// data string to decode /// optional mapping from tilemap name to Tilemap instance. Speeds tilemap lookups greatly /// a Func of [(enum)FilterDataSource, object] returning a bool. See also LoadTileFab. /// if true (default) only applies filters to TilePLus tiles, which is usually sufficient and saves much time. /// A list of the TilefabLoadResults from however many Bundles are in the TileFab. Null is returned for errors /// The list of TilefabLoadResults is cleared the next time that this method is used. public List? RestoreFromZoneRegJson(string jsonString, Dictionary? targetMap = null, Func? filter = null, bool filterOnlyTilePlusTiles = true ) { var data = JsonUtility.FromJson(jsonString); if(data == null) return null; var loadResultsArray = data.m_Res.ToList(); loadResultsArray.Sort(Comparison); //ensure sorted in ascending index order. var numLoadsToMake = loadResultsArray.Count; var numPrevLoads = currentLoadresults.Count; currentLoadresults.Clear(); //absolutely required to avoid exception from next line if count < capacity (no this was thought of in advance) if(numLoadsToMake > numPrevLoads) //don't play with Capacity unless enlarging. currentLoadresults.Capacity = loadResultsArray.Count; var loadFlags = filterOnlyTilePlusTiles ? FabOrBundleLoadFlags.NormalWithFilter : FabOrBundleLoadFlags.Normal; foreach (var r in loadResultsArray) { if(!TileFabLib.GetTileFabFromGuid(r.g, out var fab) || !fab) continue; var result = TileFabLib.LoadTileFab(null, fab, r.offs, r.rot, loadFlags, filter, targetMap, this); if(result == null) continue; currentLoadresults.Add(result); TileFabLib.UpdateGuidLookup(r, result.ZoneReg!); } return currentLoadresults; } private int Comparison(ZoneReg x, ZoneReg y) { if (x.dex < y.dex) return -1; return x.dex == y.dex ? 0 : 1; } /// /// Sets the name and managed Tilemaps for this instance. /// Note you can only do this once. /// /// instance name /// Dictionary of tilemap names to tilemap instances. /// false if this has been called already. /// This is only called by TileFabLib when creating a ZoneManager instance. internal bool SetNameAndMap(string iName, Dictionary stringToTilemap) { if (!string.IsNullOrEmpty(instanceName) || string.IsNullOrEmpty(iName)) return false; instanceName = iName; monitoredTilemaps = stringToTilemap; return true; } /// /// Set or change the monitored Tilemaps dict. /// /// Dict of stringTilemapName -> TilemapInstance internal void SetMaps(Dictionary stringToTilemap) { monitoredTilemaps = stringToTilemap; } /// /// Remove an Zone registration given an instance of one /// /// ZoneReg instance /// true if found public bool DeleteZoneRegistration(ZoneReg reg) { var locator = reg.m_MyLocator; if (!chunkMap.Remove(locator)) { TpLogError($"Could not delete ZoneReg {reg}"); return false; } if (reg.m_Reserved) return true; //need to delete all entries from the s_LoadedGuidLookup that were originally added. //this is done by getting the new GUIDs from the registration, then doing a //reverse lookup. That gets us the OLD guid which is the key for the LoadedGuidLookup dictionary. //of course also need to remove the corresponding item in the reverse-lookup dictionary // ReSharper disable once LoopCanBePartlyConvertedToQuery foreach (var bundleGuidMap in reg.ptgm) //these are the GUIDs assigned when loaded via LoadTileFab TileFabLib.RemoveGuidLookup(bundleGuidMap); TileFabLib.S_LoadedGuids!.Remove(new AssetGuidPositionHash(new Guid(reg.g), reg.offs)); return true; } /// /// Get the last N Zone registrations. /// /// # of results desired. For a tilefab that should be 1 /// Enumerable of registrations, which could be empty. public IEnumerable GetLastRegistrations(int numResults = 1) { return chunkMap.Values.OrderBy(zr => zr.dex).TakeLast(numResults); } /// /// Get the very last Zone Reg. Handy when you know there is only one. /// /// the last zone reg used or a new one (index will be 0) if there aren't any regs in the ChunkMap for this ZM. public ZoneReg? GetLastRegistration() { return chunkMap.Count == 0 ? new ZoneReg() : chunkMap.Values.OrderBy(zr => zr.dex).Last(); } /// /// Get all zone registrations with optional filtering and ordering /// /// order by ZoneReg index if true /// Func of ZoneReg returning bool. If ret val true then zm returned else it is skipped. /// IEnumerable of ZoneReg instances. DON'T HOLD REFERENCES TO THESE!!! will make a memory leak. public IEnumerable GetAllZoneRegistrationsFiltered(bool orderByIndex=false, Func? filter = null) { if (filter != null) return orderByIndex ? chunkMap.Values.OrderBy(zr => zr.dex).Where(filter) : chunkMap.Values.Where(filter); if(orderByIndex) return chunkMap.Values.OrderBy(zr => zr.dex); return chunkMap.Values; } /// /// Is this grid position aligned to the super-grid? /// /// position to test /// true if aligned public bool IsAlignedToGrid(Vector3Int position) { var relativePosition = position - (Vector3Int)worldOrigin; var size = defaultLocator.size.x; return relativePosition.x % size == 0 && relativePosition.y % size == 0; } /// /// Align a position to the super-grid. Note: positions are aligned to the lower-left corner of a rectint. /// /// position to adjust /// Adjusted position. Won't change if already aligned. public Vector3Int AlignToGrid(Vector3Int position) { if (IsAlignedToGrid(position)) return position; var relPos = position - (Vector3Int)worldOrigin; var size = defaultLocator.size.x; var diffX = relPos.x % size; var diffY = relPos.y % size; return new Vector3Int(relPos.x - diffX, relPos.y - diffY, position.z); } #endregion } }