diff options
Diffstat (limited to 'src/SMAPI/Framework')
-rw-r--r-- | src/SMAPI/Framework/Content/AssetDataForMap.cs | 186 | ||||
-rw-r--r-- | src/SMAPI/Framework/Content/AssetDataForObject.cs | 8 | ||||
-rw-r--r-- | src/SMAPI/Framework/ContentCoordinator.cs | 27 | ||||
-rw-r--r-- | src/SMAPI/Framework/ModHelpers/ContentHelper.cs | 14 | ||||
-rw-r--r-- | src/SMAPI/Framework/Monitor.cs | 4 | ||||
-rw-r--r-- | src/SMAPI/Framework/Patching/PatchHelper.cs | 34 | ||||
-rw-r--r-- | src/SMAPI/Framework/SCore.cs | 3 |
7 files changed, 269 insertions, 7 deletions
diff --git a/src/SMAPI/Framework/Content/AssetDataForMap.cs b/src/SMAPI/Framework/Content/AssetDataForMap.cs new file mode 100644 index 00000000..f66013ba --- /dev/null +++ b/src/SMAPI/Framework/Content/AssetDataForMap.cs @@ -0,0 +1,186 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Microsoft.Xna.Framework; +using StardewModdingAPI.Toolkit.Utilities; +using xTile; +using xTile.Layers; +using xTile.Tiles; + +namespace StardewModdingAPI.Framework.Content +{ + /// <summary>Encapsulates access and changes to image content being read from a data file.</summary> + internal class AssetDataForMap : AssetData<Map>, IAssetDataForMap + { + /********* + ** Public methods + *********/ + /// <summary>Construct an instance.</summary> + /// <param name="locale">The content's locale code, if the content is localized.</param> + /// <param name="assetName">The normalized asset name being read.</param> + /// <param name="data">The content data being read.</param> + /// <param name="getNormalizedPath">Normalizes an asset key to match the cache key.</param> + /// <param name="onDataReplaced">A callback to invoke when the data is replaced (if any).</param> + public AssetDataForMap(string locale, string assetName, Map data, Func<string, string> getNormalizedPath, Action<Map> onDataReplaced) + : base(locale, assetName, data, getNormalizedPath, onDataReplaced) { } + + /// <summary>Copy layers, tiles, and tilesheets from another map onto the asset.</summary> + /// <param name="source">The map from which to copy.</param> + /// <param name="sourceArea">The tile area within the source map to copy, or <c>null</c> for the entire source map size. This must be within the bounds of the <paramref name="source"/> map.</param> + /// <param name="targetArea">The tile area within the target map to overwrite, or <c>null</c> to patch the whole map. The original content within this area will be erased. This must be within the bounds of the existing map.</param> + /// <remarks>Derived from <see cref="StardewValley.GameLocation.ApplyMapOverride"/> with a few changes: + /// - can be applied directly to the maps when loading, before the location is created; + /// - added support for source/target areas; + /// - added disambiguation if source has a modified version of the same tilesheet, instead of copying tiles into the target tilesheet; + /// - changed to always overwrite tiles within the target area (to avoid edge cases where some tiles are only partly applied); + /// - fixed copying tilesheets (avoid "The specified TileSheet was not created for use with this map" error); + /// - fixed tilesheets not added at the end (via z_ prefix), which can cause crashes in game code which depends on hardcoded tilesheet indexes; + /// - fixed issue where different tilesheets are linked by ID. + /// </remarks> + public void PatchMap(Map source, Rectangle? sourceArea = null, Rectangle? targetArea = null) + { + var target = this.Data; + + // get areas + { + Rectangle sourceBounds = this.GetMapArea(source); + Rectangle targetBounds = this.GetMapArea(target); + sourceArea ??= new Rectangle(0, 0, sourceBounds.Width, sourceBounds.Height); + targetArea ??= new Rectangle(0, 0, Math.Min(sourceArea.Value.Width, targetBounds.Width), Math.Min(sourceArea.Value.Height, targetBounds.Height)); + + // validate + if (sourceArea.Value.X < 0 || sourceArea.Value.Y < 0 || sourceArea.Value.Right > sourceBounds.Width || sourceArea.Value.Bottom > sourceBounds.Height) + throw new ArgumentOutOfRangeException(nameof(sourceArea), $"The source area ({sourceArea}) is outside the bounds of the source map ({sourceBounds})."); + if (targetArea.Value.X < 0 || targetArea.Value.Y < 0 || targetArea.Value.Right > targetBounds.Width || targetArea.Value.Bottom > targetBounds.Height) + throw new ArgumentOutOfRangeException(nameof(targetArea), $"The target area ({targetArea}) is outside the bounds of the target map ({targetBounds})."); + if (sourceArea.Value.Width != targetArea.Value.Width || sourceArea.Value.Height != targetArea.Value.Height) + throw new InvalidOperationException($"The source area ({sourceArea}) and target area ({targetArea}) must be the same size."); + } + + // apply tilesheets + IDictionary<TileSheet, TileSheet> tilesheetMap = new Dictionary<TileSheet, TileSheet>(); + foreach (TileSheet sourceSheet in source.TileSheets) + { + // copy tilesheets + TileSheet targetSheet = target.GetTileSheet(sourceSheet.Id); + if (targetSheet == null || this.NormalizeTilesheetPathForComparison(targetSheet.ImageSource) != this.NormalizeTilesheetPathForComparison(sourceSheet.ImageSource)) + { + // change ID if needed so new tilesheets are added after vanilla ones (to avoid errors in hardcoded game logic) + string id = sourceSheet.Id; + if (!id.StartsWith("z_", StringComparison.InvariantCultureIgnoreCase)) + id = $"z_{id}"; + + // change ID if it conflicts with an existing tilesheet + if (target.GetTileSheet(id) != null) + { + int disambiguator = Enumerable.Range(2, int.MaxValue - 1).First(p => target.GetTileSheet($"{id}_{p}") == null); + id = $"{id}_{disambiguator}"; + } + + // add tilesheet + targetSheet = new TileSheet(id, target, sourceSheet.ImageSource, sourceSheet.SheetSize, sourceSheet.TileSize); + for (int i = 0, tileCount = sourceSheet.TileCount; i < tileCount; ++i) + targetSheet.TileIndexProperties[i].CopyFrom(sourceSheet.TileIndexProperties[i]); + target.AddTileSheet(targetSheet); + } + + tilesheetMap[sourceSheet] = targetSheet; + } + + // get layer map + IDictionary<Layer, Layer> layerMap = source.Layers.ToDictionary(p => p, p => target.GetLayer(p.Id)); + + // apply tiles + for (int x = 0; x < sourceArea.Value.Width; x++) + { + for (int y = 0; y < sourceArea.Value.Height; y++) + { + // calculate tile positions + Point sourcePos = new Point(sourceArea.Value.X + x, sourceArea.Value.Y + y); + Point targetPos = new Point(targetArea.Value.X + x, targetArea.Value.Y + y); + + // merge layers + foreach (Layer sourceLayer in source.Layers) + { + // get layer + Layer targetLayer = layerMap[sourceLayer]; + if (targetLayer == null) + { + target.AddLayer(targetLayer = new Layer(sourceLayer.Id, target, target.Layers[0].LayerSize, Layer.m_tileSize)); + layerMap[sourceLayer] = target.GetLayer(sourceLayer.Id); + } + + // copy layer properties + targetLayer.Properties.CopyFrom(sourceLayer.Properties); + + // copy tiles + Tile sourceTile = sourceLayer.Tiles[sourcePos.X, sourcePos.Y]; + Tile targetTile; + switch (sourceTile) + { + case StaticTile _: + targetTile = new StaticTile(targetLayer, tilesheetMap[sourceTile.TileSheet], sourceTile.BlendMode, sourceTile.TileIndex); + break; + + case AnimatedTile animatedTile: + { + StaticTile[] tileFrames = new StaticTile[animatedTile.TileFrames.Length]; + for (int frame = 0; frame < animatedTile.TileFrames.Length; ++frame) + { + StaticTile frameTile = animatedTile.TileFrames[frame]; + tileFrames[frame] = new StaticTile(targetLayer, tilesheetMap[frameTile.TileSheet], frameTile.BlendMode, frameTile.TileIndex); + } + targetTile = new AnimatedTile(targetLayer, tileFrames, animatedTile.FrameInterval); + } + break; + + default: // null or unhandled type + targetTile = null; + break; + } + targetTile?.Properties.CopyFrom(sourceTile.Properties); + targetLayer.Tiles[targetPos.X, targetPos.Y] = targetTile; + } + } + } + } + + + /********* + ** Private methods + *********/ + /// <summary>Normalize a map tilesheet path for comparison. This value should *not* be used as the actual tilesheet path.</summary> + /// <param name="path">The path to normalize.</param> + private string NormalizeTilesheetPathForComparison(string path) + { + if (string.IsNullOrWhiteSpace(path)) + return string.Empty; + + path = PathUtilities.NormalizePathSeparators(path.Trim()); + if (path.StartsWith($"Maps{PathUtilities.PreferredPathSeparator}", StringComparison.OrdinalIgnoreCase)) + path = path.Substring($"Maps{PathUtilities.PreferredPathSeparator}".Length); + if (path.EndsWith(".png", StringComparison.OrdinalIgnoreCase)) + path = path.Substring(0, path.Length - 4); + + return path; + } + + /// <summary>Get a rectangle which encompasses all layers for a map.</summary> + /// <param name="map">The map to check.</param> + private Rectangle GetMapArea(Map map) + { + // get max map size + int maxWidth = 0; + int maxHeight = 0; + foreach (Layer layer in map.Layers) + { + if (layer.LayerWidth > maxWidth) + maxWidth = layer.LayerWidth; + if (layer.LayerHeight > maxHeight) + maxHeight = layer.LayerHeight; + } + + return new Rectangle(0, 0, maxWidth, maxHeight); + } + } +} diff --git a/src/SMAPI/Framework/Content/AssetDataForObject.cs b/src/SMAPI/Framework/Content/AssetDataForObject.cs index 4dbc988c..f00ba124 100644 --- a/src/SMAPI/Framework/Content/AssetDataForObject.cs +++ b/src/SMAPI/Framework/Content/AssetDataForObject.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using Microsoft.Xna.Framework.Graphics; +using xTile; namespace StardewModdingAPI.Framework.Content { @@ -41,6 +42,13 @@ namespace StardewModdingAPI.Framework.Content return new AssetDataForImage(this.Locale, this.AssetName, this.GetData<Texture2D>(), this.GetNormalizedPath, this.ReplaceWith); } + /// <summary>Get a helper to manipulate the data as a map.</summary> + /// <exception cref="InvalidOperationException">The content being read isn't a map.</exception> + public IAssetDataForMap AsMap() + { + return new AssetDataForMap(this.Locale, this.AssetName, this.GetData<Map>(), this.GetNormalizedPath, this.ReplaceWith); + } + /// <summary>Get the data as a given type.</summary> /// <typeparam name="TData">The expected data type.</typeparam> /// <exception cref="InvalidCastException">The data can't be converted to <typeparamref name="TData"/>.</exception> diff --git a/src/SMAPI/Framework/ContentCoordinator.cs b/src/SMAPI/Framework/ContentCoordinator.cs index 0b1ccc3c..47ef30d4 100644 --- a/src/SMAPI/Framework/ContentCoordinator.cs +++ b/src/SMAPI/Framework/ContentCoordinator.cs @@ -14,6 +14,7 @@ using StardewModdingAPI.Metadata; using StardewModdingAPI.Toolkit.Serialization; using StardewModdingAPI.Toolkit.Utilities; using StardewValley; +using xTile; namespace StardewModdingAPI.Framework { @@ -228,16 +229,32 @@ namespace StardewModdingAPI.Framework public IEnumerable<string> InvalidateCache(Func<string, Type, bool> predicate, bool dispose = false) { // invalidate cache & track removed assets - IDictionary<string, ISet<object>> removedAssets = new Dictionary<string, ISet<object>>(StringComparer.InvariantCultureIgnoreCase); + IDictionary<string, Type> removedAssets = new Dictionary<string, Type>(StringComparer.InvariantCultureIgnoreCase); this.ContentManagerLock.InReadLock(() => { + // cached assets foreach (IContentManager contentManager in this.ContentManagers) { foreach (var entry in contentManager.InvalidateCache(predicate, dispose)) { - if (!removedAssets.TryGetValue(entry.Key, out ISet<object> assets)) - removedAssets[entry.Key] = assets = new HashSet<object>(new ObjectReferenceComparer<object>()); - assets.Add(entry.Value); + if (!removedAssets.TryGetValue(entry.Key, out Type type)) + removedAssets[entry.Key] = entry.Value.GetType(); + } + } + + // special case: maps may be loaded through a temporary content manager that's removed while the map is still in use. + // This notably affects the town and farmhouse maps. + if (Game1.locations != null) + { + foreach (GameLocation location in Game1.locations) + { + if (location.map == null || string.IsNullOrWhiteSpace(location.mapPath.Value)) + continue; + + // get map path + string mapPath = this.MainContentManager.AssertAndNormalizeAssetName(location.mapPath.Value); + if (!removedAssets.ContainsKey(mapPath) && predicate(mapPath, typeof(Map))) + removedAssets[mapPath] = typeof(Map); } } }); @@ -245,7 +262,7 @@ namespace StardewModdingAPI.Framework // reload core game assets if (removedAssets.Any()) { - IDictionary<string, bool> propagated = this.CoreAssets.Propagate(this.MainContentManager, removedAssets.ToDictionary(p => p.Key, p => p.Value.First().GetType())); // use an intercepted content manager + IDictionary<string, bool> propagated = this.CoreAssets.Propagate(this.MainContentManager, removedAssets.ToDictionary(p => p.Key, p => p.Value)); // use an intercepted content manager this.Monitor.Log($"Invalidated {removedAssets.Count} asset names ({string.Join(", ", removedAssets.Keys.OrderBy(p => p, StringComparer.InvariantCultureIgnoreCase))}); propagated {propagated.Count(p => p.Value)} core assets.", LogLevel.Trace); } else diff --git a/src/SMAPI/Framework/ModHelpers/ContentHelper.cs b/src/SMAPI/Framework/ModHelpers/ContentHelper.cs index e9b70845..23e45fd1 100644 --- a/src/SMAPI/Framework/ModHelpers/ContentHelper.cs +++ b/src/SMAPI/Framework/ModHelpers/ContentHelper.cs @@ -7,6 +7,7 @@ using System.IO; using System.Linq; using Microsoft.Xna.Framework.Content; using Microsoft.Xna.Framework.Graphics; +using StardewModdingAPI.Framework.Content; using StardewModdingAPI.Framework.ContentManagers; using StardewModdingAPI.Framework.Exceptions; using StardewValley; @@ -164,6 +165,19 @@ namespace StardewModdingAPI.Framework.ModHelpers return this.ContentCore.InvalidateCache(predicate).Any(); } + /// <summary>Get a patch helper for arbitrary data.</summary> + /// <typeparam name="T">The data type.</typeparam> + /// <param name="data">The asset data.</param> + /// <param name="assetName">The asset name. This is only used for tracking purposes and has no effect on the patch helper.</param> + public IAssetData GetPatchHelper<T>(T data, string assetName = null) + { + if (data == null) + throw new ArgumentNullException(nameof(data), "Can't get a patch helper for a null value."); + + assetName ??= $"temp/{Guid.NewGuid():N}"; + return new AssetDataForObject(this.CurrentLocale, assetName, data, this.NormalizeAssetName); + } + /********* ** Private methods diff --git a/src/SMAPI/Framework/Monitor.cs b/src/SMAPI/Framework/Monitor.cs index f630c7fe..44eeabe6 100644 --- a/src/SMAPI/Framework/Monitor.cs +++ b/src/SMAPI/Framework/Monitor.cs @@ -15,8 +15,8 @@ namespace StardewModdingAPI.Framework /// <summary>The name of the module which logs messages using this instance.</summary> private readonly string Source; - /// <summary>Handles writing color-coded text to the console.</summary> - private readonly ColorfulConsoleWriter ConsoleWriter; + /// <summary>Handles writing text to the console.</summary> + private readonly IConsoleWriter ConsoleWriter; /// <summary>Manages access to the console output.</summary> private readonly ConsoleInterceptionManager ConsoleInterceptor; diff --git a/src/SMAPI/Framework/Patching/PatchHelper.cs b/src/SMAPI/Framework/Patching/PatchHelper.cs new file mode 100644 index 00000000..4cb436f0 --- /dev/null +++ b/src/SMAPI/Framework/Patching/PatchHelper.cs @@ -0,0 +1,34 @@ +using System; +using System.Collections.Generic; + +namespace StardewModdingAPI.Framework.Patching +{ + /// <summary>Provides generic methods for implementing Harmony patches.</summary> + internal class PatchHelper + { + /********* + ** Fields + *********/ + /// <summary>The interception keys currently being intercepted.</summary> + private static readonly HashSet<string> InterceptingKeys = new HashSet<string>(StringComparer.OrdinalIgnoreCase); + + + /********* + ** Public methods + *********/ + /// <summary>Track a method that will be intercepted.</summary> + /// <param name="key">The intercept key.</param> + /// <returns>Returns false if the method was already marked for interception, else true.</returns> + public static bool StartIntercept(string key) + { + return PatchHelper.InterceptingKeys.Add(key); + } + + /// <summary>Track a method as no longer being intercepted.</summary> + /// <param name="key">The intercept key.</param> + public static void StopIntercept(string key) + { + PatchHelper.InterceptingKeys.Remove(key); + } + } +} diff --git a/src/SMAPI/Framework/SCore.cs b/src/SMAPI/Framework/SCore.cs index 50e6ea1c..de9c955d 100644 --- a/src/SMAPI/Framework/SCore.cs +++ b/src/SMAPI/Framework/SCore.cs @@ -32,6 +32,7 @@ using StardewModdingAPI.Toolkit.Framework.Clients.WebApi; using StardewModdingAPI.Toolkit.Framework.ModData; using StardewModdingAPI.Toolkit.Serialization; using StardewModdingAPI.Toolkit.Utilities; +using StardewModdingAPI.Utilities; using StardewValley; using Object = StardewValley.Object; using ThreadState = System.Threading.ThreadState; @@ -176,6 +177,8 @@ namespace StardewModdingAPI.Framework SCore.DeprecationManager = new DeprecationManager(this.Monitor, this.ModRegistry); + SDate.Translations = this.Translator; + // redirect direct console output if (this.MonitorForGame.WriteToConsole) this.ConsoleManager.OnMessageIntercepted += message => this.HandleConsoleMessage(this.MonitorForGame, message); |