summaryrefslogtreecommitdiff
path: root/src/SMAPI/Framework
diff options
context:
space:
mode:
Diffstat (limited to 'src/SMAPI/Framework')
-rw-r--r--src/SMAPI/Framework/Content/AssetDataForMap.cs186
-rw-r--r--src/SMAPI/Framework/Content/AssetDataForObject.cs8
-rw-r--r--src/SMAPI/Framework/ContentCoordinator.cs27
-rw-r--r--src/SMAPI/Framework/ModHelpers/ContentHelper.cs14
-rw-r--r--src/SMAPI/Framework/Monitor.cs4
-rw-r--r--src/SMAPI/Framework/Patching/PatchHelper.cs34
-rw-r--r--src/SMAPI/Framework/SCore.cs3
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);