using System; using System.Collections.Generic; using System.IO; using System.Linq; using Microsoft.Xna.Framework.Graphics; using StardewModdingAPI.Framework.Reflection; using StardewValley; using StardewValley.BellsAndWhistles; using StardewValley.Buildings; using StardewValley.Characters; using StardewValley.Locations; using StardewValley.Menus; using StardewValley.Objects; using StardewValley.Projectiles; using StardewValley.TerrainFeatures; using xTile; using xTile.Tiles; namespace StardewModdingAPI.Metadata { /// Propagates changes to core assets to the game state. internal class CoreAssetPropagator { /********* ** Fields *********/ /// Normalizes an asset key to match the cache key and assert that it's valid. private readonly Func AssertAndNormalizeAssetName; /// Simplifies access to private game code. private readonly Reflector Reflection; /// Encapsulates monitoring and logging. private readonly IMonitor Monitor; /// Optimized bucket categories for batch reloading assets. private enum AssetBucket { /// NPC overworld sprites. Sprite, /// Villager dialogue portraits. Portrait, /// Any other asset. Other }; /********* ** Public methods *********/ /// Initialize the core asset data. /// Normalizes an asset key to match the cache key and assert that it's valid. /// Simplifies access to private code. /// Encapsulates monitoring and logging. public CoreAssetPropagator(Func assertAndNormalizeAssetName, Reflector reflection, IMonitor monitor) { this.AssertAndNormalizeAssetName = assertAndNormalizeAssetName; this.Reflection = reflection; this.Monitor = monitor; } /// Reload one of the game's core assets (if applicable). /// The content manager through which to reload the asset. /// The asset keys and types to reload. /// Returns the number of reloaded assets. public int Propagate(LocalizedContentManager content, IDictionary assets) { // group into optimized lists var buckets = assets.GroupBy(p => { if (this.IsInFolder(p.Key, "Characters") || this.IsInFolder(p.Key, "Characters\\Monsters")) return AssetBucket.Sprite; if (this.IsInFolder(p.Key, "Portraits")) return AssetBucket.Portrait; return AssetBucket.Other; }); // reload assets int reloaded = 0; foreach (var bucket in buckets) { switch (bucket.Key) { case AssetBucket.Sprite: reloaded += this.ReloadNpcSprites(content, bucket.Select(p => p.Key)); break; case AssetBucket.Portrait: reloaded += this.ReloadNpcPortraits(content, bucket.Select(p => p.Key)); break; default: reloaded += bucket.Count(p => this.PropagateOther(content, p.Key, p.Value)); break; } } return reloaded; } /********* ** Private methods *********/ /// Reload one of the game's core assets (if applicable). /// The content manager through which to reload the asset. /// The asset key to reload. /// The asset type to reload. /// Returns whether an asset was loaded. The return value may be true or false, or a non-null value for true. private bool PropagateOther(LocalizedContentManager content, string key, Type type) { key = this.AssertAndNormalizeAssetName(key); /**** ** Special case: current map tilesheet ** We only need to do this for the current location, since tilesheets are reloaded when you enter a location. ** Just in case, we should still propagate by key even if a tilesheet is matched. ****/ if (Game1.currentLocation?.map?.TileSheets != null) { foreach (TileSheet tilesheet in Game1.currentLocation.map.TileSheets) { if (this.NormalizeAssetNameIgnoringEmpty(tilesheet.ImageSource) == key) Game1.mapDisplayDevice.LoadTileSheet(tilesheet); } } /**** ** Propagate map changes ****/ if (type == typeof(Map)) { bool anyChanged = false; foreach (GameLocation location in this.GetLocations()) { if (!string.IsNullOrWhiteSpace(location.mapPath.Value) && this.NormalizeAssetNameIgnoringEmpty(location.mapPath.Value) == key) { // general updates location.reloadMap(); location.updateSeasonalTileSheets(); location.updateWarps(); // update interior doors location.interiorDoors.Clear(); foreach (var entry in new InteriorDoorDictionary(location)) location.interiorDoors.Add(entry); // update doors location.doors.Clear(); location.updateDoors(); anyChanged = true; } } return anyChanged; } /**** ** Propagate by key ****/ Reflector reflection = this.Reflection; switch (key.ToLower().Replace("/", "\\")) // normalized key so we can compare statically { /**** ** Animals ****/ case "animals\\cat": return this.ReloadPetOrHorseSprites(content, key); case "animals\\dog": return this.ReloadPetOrHorseSprites(content, key); case "animals\\horse": return this.ReloadPetOrHorseSprites(content, key); /**** ** Buildings ****/ case "buildings\\houses": // Farm reflection.GetField(typeof(Farm), nameof(Farm.houseTextures)).SetValue(content.Load(key)); return true; /**** ** Content\Characters\Farmer ****/ case "characters\\farmer\\accessories": // Game1.LoadContent FarmerRenderer.accessoriesTexture = content.Load(key); return true; case "characters\\farmer\\farmer_base": // Farmer if (Game1.player == null || !Game1.player.IsMale) return false; Game1.player.FarmerRenderer = new FarmerRenderer(key, Game1.player); return true; case "characters\\farmer\\farmer_girl_base": // Farmer if (Game1.player == null || Game1.player.IsMale) return false; Game1.player.FarmerRenderer = new FarmerRenderer(key, Game1.player); return true; case "characters\\farmer\\hairstyles": // Game1.LoadContent FarmerRenderer.hairStylesTexture = content.Load(key); return true; case "characters\\farmer\\hats": // Game1.LoadContent FarmerRenderer.hatsTexture = content.Load(key); return true; case "characters\\farmer\\shirts": // Game1.LoadContent FarmerRenderer.shirtsTexture = content.Load(key); return true; /**** ** Content\Data ****/ case "data\\achievements": // Game1.LoadContent Game1.achievements = content.Load>(key); return true; case "data\\bigcraftablesinformation": // Game1.LoadContent Game1.bigCraftablesInformation = content.Load>(key); return true; case "data\\cookingrecipes": // CraftingRecipe.InitShared CraftingRecipe.cookingRecipes = content.Load>(key); return true; case "data\\craftingrecipes": // CraftingRecipe.InitShared CraftingRecipe.craftingRecipes = content.Load>(key); return true; case "data\\farmanimals": // FarmAnimal constructor return this.ReloadFarmAnimalData(); case "data\\npcdispositions": // NPC constructor return this.ReloadNpcDispositions(content, key); case "data\\npcgifttastes": // Game1.LoadContent Game1.NPCGiftTastes = content.Load>(key); return true; case "data\\objectinformation": // Game1.LoadContent Game1.objectInformation = content.Load>(key); return true; /**** ** Content\Fonts ****/ case "fonts\\spritefont1": // Game1.LoadContent Game1.dialogueFont = content.Load(key); return true; case "fonts\\smallfont": // Game1.LoadContent Game1.smallFont = content.Load(key); return true; case "fonts\\tinyfont": // Game1.LoadContent Game1.tinyFont = content.Load(key); return true; case "fonts\\tinyfontborder": // Game1.LoadContent Game1.tinyFontBorder = content.Load(key); return true; /**** ** Content\LooseSprites\Lighting ****/ case "loosesprites\\lighting\\greenlight": // Game1.LoadContent Game1.cauldronLight = content.Load(key); return true; case "loosesprites\\lighting\\indoorwindowlight": // Game1.LoadContent Game1.indoorWindowLight = content.Load(key); return true; case "loosesprites\\lighting\\lantern": // Game1.LoadContent Game1.lantern = content.Load(key); return true; case "loosesprites\\lighting\\sconcelight": // Game1.LoadContent Game1.sconceLight = content.Load(key); return true; case "loosesprites\\lighting\\windowlight": // Game1.LoadContent Game1.windowLight = content.Load(key); return true; /**** ** Content\LooseSprites ****/ case "loosesprites\\controllermaps": // Game1.LoadContent Game1.controllerMaps = content.Load(key); return true; case "loosesprites\\cursors": // Game1.LoadContent Game1.mouseCursors = content.Load(key); foreach (DayTimeMoneyBox menu in Game1.onScreenMenus.OfType()) { foreach (ClickableTextureComponent button in new[] { menu.questButton, menu.zoomInButton, menu.zoomOutButton }) button.texture = Game1.mouseCursors; } return true; case "loosesprites\\cursors2": // Game1.LoadContent Game1.mouseCursors2 = content.Load(key); return true; case "loosesprites\\daybg": // Game1.LoadContent Game1.daybg = content.Load(key); return true; case "loosesprites\\font_bold": // Game1.LoadContent SpriteText.spriteTexture = content.Load(key); return true; case "loosesprites\\font_colored": // Game1.LoadContent SpriteText.coloredTexture = content.Load(key); return true; case "loosesprites\\nightbg": // Game1.LoadContent Game1.nightbg = content.Load(key); return true; case "loosesprites\\shadow": // Game1.LoadContent Game1.shadowTexture = content.Load(key); return true; /**** ** Content\TileSheets ****/ case "tilesheets\\critters": // Critter constructor this.ReloadCritterTextures(content, key); return true; case "tilesheets\\crops": // Game1.LoadContent Game1.cropSpriteSheet = content.Load(key); return true; case "tilesheets\\debris": // Game1.LoadContent Game1.debrisSpriteSheet = content.Load(key); return true; case "tilesheets\\emotes": // Game1.LoadContent Game1.emoteSpriteSheet = content.Load(key); return true; case "tilesheets\\furniture": // Game1.LoadContent Furniture.furnitureTexture = content.Load(key); return true; case "tilesheets\\projectiles": // Game1.LoadContent Projectile.projectileSheet = content.Load(key); return true; case "tilesheets\\rain": // Game1.LoadContent Game1.rainTexture = content.Load(key); return true; case "tilesheets\\tools": // Game1.ResetToolSpriteSheet Game1.ResetToolSpriteSheet(); return true; case "tilesheets\\weapons": // Game1.LoadContent Tool.weaponsTexture = content.Load(key); return true; /**** ** Content\Maps ****/ case "maps\\menutiles": // Game1.LoadContent Game1.menuTexture = content.Load(key); return true; case "maps\\springobjects": // Game1.LoadContent Game1.objectSpriteSheet = content.Load(key); return true; case "maps\\walls_and_floors": // Wallpaper Wallpaper.wallpaperTexture = content.Load(key); return true; /**** ** Content\Minigames ****/ case "minigames\\clouds": // TitleMenu { if (Game1.activeClickableMenu is TitleMenu titleMenu) { titleMenu.cloudsTexture = content.Load(key); return true; } } return false; case "minigames\\titlebuttons": // TitleMenu { if (Game1.activeClickableMenu is TitleMenu titleMenu) { Texture2D texture = content.Load(key); titleMenu.titleButtonsTexture = texture; foreach (TemporaryAnimatedSprite bird in titleMenu.birds) bird.texture = texture; return true; } } return false; /**** ** Content\TileSheets ****/ case "tilesheets\\animations": // Game1.LoadContent Game1.animations = content.Load(key); return true; case "tilesheets\\buffsicons": // Game1.LoadContent Game1.buffsIcons = content.Load(key); return true; case "tilesheets\\bushes": // new Bush() Bush.texture = new Lazy(() => content.Load(key)); return true; case "tilesheets\\craftables": // Game1.LoadContent Game1.bigCraftableSpriteSheet = content.Load(key); return true; case "tilesheets\\fruittrees": // FruitTree FruitTree.texture = content.Load(key); return true; /**** ** Content\TerrainFeatures ****/ case "terrainfeatures\\flooring": // Flooring Flooring.floorsTexture = content.Load(key); return true; case "terrainfeatures\\hoedirt": // from HoeDirt HoeDirt.lightTexture = content.Load(key); return true; case "terrainfeatures\\hoedirtdark": // from HoeDirt HoeDirt.darkTexture = content.Load(key); return true; case "terrainfeatures\\hoedirtsnow": // from HoeDirt HoeDirt.snowTexture = content.Load(key); return true; case "terrainfeatures\\mushroom_tree": // from Tree return this.ReloadTreeTextures(content, key, Tree.mushroomTree); case "terrainfeatures\\tree_palm": // from Tree return this.ReloadTreeTextures(content, key, Tree.palmTree); case "terrainfeatures\\tree1_fall": // from Tree case "terrainfeatures\\tree1_spring": // from Tree case "terrainfeatures\\tree1_summer": // from Tree case "terrainfeatures\\tree1_winter": // from Tree return this.ReloadTreeTextures(content, key, Tree.bushyTree); case "terrainfeatures\\tree2_fall": // from Tree case "terrainfeatures\\tree2_spring": // from Tree case "terrainfeatures\\tree2_summer": // from Tree case "terrainfeatures\\tree2_winter": // from Tree return this.ReloadTreeTextures(content, key, Tree.leafyTree); case "terrainfeatures\\tree3_fall": // from Tree case "terrainfeatures\\tree3_spring": // from Tree case "terrainfeatures\\tree3_winter": // from Tree return this.ReloadTreeTextures(content, key, Tree.pineTree); } // dynamic textures if (this.IsInFolder(key, "Animals")) return this.ReloadFarmAnimalSprites(content, key); if (this.IsInFolder(key, "Buildings")) return this.ReloadBuildings(content, key); if (this.KeyStartsWith(key, "LooseSprites\\Fence")) return this.ReloadFenceTextures(key); // dynamic data if (this.IsInFolder(key, "Characters\\Dialogue")) return this.ReloadNpcDialogue(key); if (this.IsInFolder(key, "Characters\\schedules")) return this.ReloadNpcSchedules(key); return false; } /********* ** Private methods *********/ /**** ** Reload texture methods ****/ /// Reload the sprites for matching pets or horses. /// The animal type. /// The content manager through which to reload the asset. /// The asset key to reload. /// Returns whether any textures were reloaded. private bool ReloadPetOrHorseSprites(LocalizedContentManager content, string key) where TAnimal : NPC { // find matches TAnimal[] animals = this.GetCharacters() .OfType() .Where(p => key == this.NormalizeAssetNameIgnoringEmpty(p.Sprite?.Texture?.Name)) .ToArray(); if (!animals.Any()) return false; // update sprites Texture2D texture = content.Load(key); foreach (TAnimal animal in animals) this.SetSpriteTexture(animal.Sprite, texture); return true; } /// Reload the sprites for matching farm animals. /// The content manager through which to reload the asset. /// The asset key to reload. /// Returns whether any textures were reloaded. /// Derived from . private bool ReloadFarmAnimalSprites(LocalizedContentManager content, string key) { // find matches FarmAnimal[] animals = this.GetFarmAnimals().ToArray(); if (!animals.Any()) return false; // update sprites Lazy texture = new Lazy(() => content.Load(key)); foreach (FarmAnimal animal in animals) { // get expected key string expectedKey = animal.age.Value < animal.ageWhenMature.Value ? $"Baby{(animal.type.Value == "Duck" ? "White Chicken" : animal.type.Value)}" : animal.type.Value; if (animal.showDifferentTextureWhenReadyForHarvest.Value && animal.currentProduce.Value <= 0) expectedKey = $"Sheared{expectedKey}"; expectedKey = $"Animals\\{expectedKey}"; // reload asset if (expectedKey == key) this.SetSpriteTexture(animal.Sprite, texture.Value); } return texture.IsValueCreated; } /// Reload building textures. /// The content manager through which to reload the asset. /// The asset key to reload. /// Returns whether any textures were reloaded. private bool ReloadBuildings(LocalizedContentManager content, string key) { // get buildings string type = Path.GetFileName(key); Building[] buildings = Game1.locations .OfType() .SelectMany(p => p.buildings) .Where(p => p.buildingType.Value == type) .ToArray(); // reload buildings if (buildings.Any()) { Lazy texture = new Lazy(() => content.Load(key)); foreach (Building building in buildings) building.texture = texture; return true; } return false; } /// Reload critter textures. /// The content manager through which to reload the asset. /// The asset key to reload. /// Returns the number of reloaded assets. private int ReloadCritterTextures(LocalizedContentManager content, string key) { // get critters Critter[] critters = ( from location in this.GetLocations() let locCritters = this.Reflection.GetField>(location, "critters").GetValue() where locCritters != null from Critter critter in locCritters where this.NormalizeAssetNameIgnoringEmpty(critter.sprite?.Texture?.Name) == key select critter ) .ToArray(); if (!critters.Any()) return 0; // update sprites Texture2D texture = content.Load(key); foreach (var entry in critters) this.SetSpriteTexture(entry.sprite, texture); return critters.Length; } /// Reload the data for matching farm animals. /// Returns whether any farm animals were affected. /// Derived from the constructor. private bool ReloadFarmAnimalData() { bool changed = false; foreach (FarmAnimal animal in this.GetFarmAnimals()) { animal.reloadData(); changed = true; } return changed; } /// Reload the sprites for a fence type. /// The asset key to reload. /// Returns whether any textures were reloaded. private bool ReloadFenceTextures(string key) { // get fence type if (!int.TryParse(this.GetSegments(key)[1].Substring("Fence".Length), out int fenceType)) return false; // get fences Fence[] fences = ( from location in this.GetLocations() from fence in location.Objects.Values.OfType() where fence.whichType.Value == fenceType || (fence.isGate.Value && fenceType == 1) // gates are hardcoded to draw fence type 1 select fence ) .ToArray(); // update fence textures foreach (Fence fence in fences) fence.fenceTexture = new Lazy(fence.loadFenceTexture); return true; } /// Reload the disposition data for matching NPCs. /// The content manager through which to reload the asset. /// The asset key to reload. /// Returns whether any NPCs were affected. private bool ReloadNpcDispositions(LocalizedContentManager content, string key) { IDictionary data = content.Load>(key); bool changed = false; foreach (NPC npc in this.GetCharacters()) { if (npc.isVillager() && data.ContainsKey(npc.Name)) { npc.reloadData(); changed = true; } } return changed; } /// Reload the sprites for matching NPCs. /// The content manager through which to reload the asset. /// The asset keys to reload. /// Returns the number of reloaded assets. private int ReloadNpcSprites(LocalizedContentManager content, IEnumerable keys) { // get NPCs HashSet lookup = new HashSet(keys, StringComparer.InvariantCultureIgnoreCase); NPC[] characters = this.GetCharacters() .Where(npc => npc.Sprite != null && lookup.Contains(this.NormalizeAssetNameIgnoringEmpty(npc.Sprite?.Texture?.Name))) .ToArray(); if (!characters.Any()) return 0; // update sprite int reloaded = 0; foreach (NPC npc in characters) { this.SetSpriteTexture(npc.Sprite, content.Load(npc.Sprite.textureName.Value)); reloaded++; } return reloaded; } /// Reload the portraits for matching NPCs. /// The content manager through which to reload the asset. /// The asset key to reload. /// Returns the number of reloaded assets. private int ReloadNpcPortraits(LocalizedContentManager content, IEnumerable keys) { // get NPCs HashSet lookup = new HashSet(keys, StringComparer.InvariantCultureIgnoreCase); var villagers = this .GetCharacters() .Where(npc => npc.isVillager() && lookup.Contains(this.NormalizeAssetNameIgnoringEmpty(npc.Portrait?.Name))) .ToArray(); if (!villagers.Any()) return 0; // update portrait int reloaded = 0; foreach (NPC npc in villagers) { npc.Portrait = content.Load(npc.Portrait.Name); reloaded++; } return reloaded; } /// Reload tree textures. /// The content manager through which to reload the asset. /// The asset key to reload. /// The type to reload. /// Returns whether any textures were reloaded. private bool ReloadTreeTextures(LocalizedContentManager content, string key, int type) { Tree[] trees = Game1.locations .SelectMany(p => p.terrainFeatures.Values.OfType()) .Where(tree => tree.treeType.Value == type) .ToArray(); if (trees.Any()) { Lazy texture = new Lazy(() => content.Load(key)); foreach (Tree tree in trees) tree.texture = texture; return true; } return false; } /**** ** Reload data methods ****/ /// Reload the dialogue data for matching NPCs. /// The asset key to reload. /// Returns whether any assets were reloaded. private bool ReloadNpcDialogue(string key) { // get NPCs string name = Path.GetFileName(key); NPC[] villagers = this.GetCharacters().Where(npc => npc.Name == name && npc.isVillager()).ToArray(); if (!villagers.Any()) return false; // update dialogue foreach (NPC villager in villagers) villager.resetSeasonalDialogue(); // doesn't only affect seasonal dialogue return true; } /// Reload the schedules for matching NPCs. /// The asset key to reload. /// Returns whether any assets were reloaded. private bool ReloadNpcSchedules(string key) { // get NPCs string name = Path.GetFileName(key); NPC[] villagers = this.GetCharacters().Where(npc => npc.Name == name && npc.isVillager()).ToArray(); if (!villagers.Any()) return false; // update schedule foreach (NPC villager in villagers) { // reload schedule this.Reflection.GetField(villager, "_hasLoadedMasterScheduleData").SetValue(false); this.Reflection.GetField>(villager, "_masterScheduleData").SetValue(null); villager.Schedule = villager.getSchedule(Game1.dayOfMonth); if (villager.Schedule == null) { this.Monitor.Log($"A mod set an invalid schedule for {villager.Name ?? key}, so the NPC may not behave correctly.", LogLevel.Warn); return true; } // switch to new schedule if needed int lastScheduleTime = villager.Schedule.Keys.Where(p => p <= Game1.timeOfDay).OrderByDescending(p => p).FirstOrDefault(); if (lastScheduleTime != 0) { villager.scheduleTimeToTry = NPC.NO_TRY; // use time that's passed in to checkSchedule villager.checkSchedule(lastScheduleTime); } } return true; } /**** ** Helpers ****/ /// Reload the texture for an animated sprite. /// The animated sprite to update. /// The texture to set. private void SetSpriteTexture(AnimatedSprite sprite, Texture2D texture) { this.Reflection.GetField(sprite, "spriteTexture").SetValue(texture); } /// Get all NPCs in the game (excluding farm animals). private IEnumerable GetCharacters() { return this.GetLocations().SelectMany(p => p.characters); } /// Get all farm animals in the game. private IEnumerable GetFarmAnimals() { foreach (GameLocation location in this.GetLocations()) { if (location is Farm farm) { foreach (FarmAnimal animal in farm.animals.Values) yield return animal; } else if (location is AnimalHouse animalHouse) foreach (FarmAnimal animal in animalHouse.animals.Values) yield return animal; } } /// Get all locations in the game. private IEnumerable GetLocations() { // get available root locations IEnumerable rootLocations = Game1.locations; if (SaveGame.loaded?.locations != null) rootLocations = rootLocations.Concat(SaveGame.loaded.locations); // yield root + child locations foreach (GameLocation location in rootLocations) { yield return location; if (location is BuildableGameLocation buildableLocation) { foreach (Building building in buildableLocation.buildings) { GameLocation indoors = building.indoors.Value; if (indoors != null) yield return indoors; } } } } /// Normalize an asset key to match the cache key and assert that it's valid, but don't raise an error for null or empty values. /// The asset key to normalize. private string NormalizeAssetNameIgnoringEmpty(string path) { if (string.IsNullOrWhiteSpace(path)) return null; return this.AssertAndNormalizeAssetName(path); } /// Get whether a key starts with a substring after the substring is normalized. /// The key to check. /// The substring to normalize and find. private bool KeyStartsWith(string key, string rawSubstring) { if (string.IsNullOrWhiteSpace(key) || string.IsNullOrWhiteSpace(rawSubstring)) return false; return key.StartsWith(this.NormalizeAssetNameIgnoringEmpty(rawSubstring), StringComparison.InvariantCultureIgnoreCase); } /// Get whether a normalized asset key is in the given folder. /// The normalized asset key (like Animals/cat). /// The key folder (like Animals); doesn't need to be normalized. /// Whether to return true if the key is inside a subfolder of the . private bool IsInFolder(string key, string folder, bool allowSubfolders = false) { return this.KeyStartsWith(key, $"{folder}\\") && (allowSubfolders || this.CountSegments(key) == this.CountSegments(folder) + 1); } /// Get the segments in a path (e.g. 'a/b' is 'a' and 'b'). /// The path to check. private string[] GetSegments(string path) { if (path == null) return new string[0]; return path.Split(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); } /// Count the number of segments in a path (e.g. 'a/b' is 2). /// The path to check. private int CountSegments(string path) { return this.GetSegments(path).Length; } } }