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
*********/
/// Normalises an asset key to match the cache key.
private readonly Func GetNormalisedPath;
/// Simplifies access to private game code.
private readonly Reflector Reflection;
/// Encapsulates monitoring and logging.
private readonly IMonitor Monitor;
/// Optimised bucket categories for batch reloading assets.
private enum AssetBucket
{
/// NPC overworld sprites.
Sprite,
/// Villager dialogue portraits.
Portrait,
/// Any other asset.
Other
};
/*********
** Public methods
*********/
/// Initialise the core asset data.
/// Normalises an asset key to match the cache key.
/// Simplifies access to private code.
/// Encapsulates monitoring and logging.
public CoreAssetPropagator(Func getNormalisedPath, Reflector reflection, IMonitor monitor)
{
this.GetNormalisedPath = getNormalisedPath;
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 optimised 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.GetNormalisedPath(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.GetNormalisedPath(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.GetNormalisedPath(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("/", "\\")) // normalised 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\\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);
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\Critters
****/
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()
.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 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 dispositions = content.Load>(key);
foreach (NPC character in this.GetCharacters())
{
if (!character.isVillager() || !dispositions.ContainsKey(character.Name))
continue;
NPC clone = new NPC(null, character.Position, character.DefaultMap, character.FacingDirection, character.Name, null, character.Portrait, eventActor: false);
character.Age = clone.Age;
character.Manners = clone.Manners;
character.SocialAnxiety = clone.SocialAnxiety;
character.Optimism = clone.Optimism;
character.Gender = clone.Gender;
character.datable.Value = clone.datable.Value;
character.homeRegion = clone.homeRegion;
character.Birthday_Season = clone.Birthday_Season;
character.Birthday_Day = clone.Birthday_Day;
character.id = clone.id;
character.displayName = clone.displayName;
}
return true;
}
/// 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.GetNormalisedPath(npc.Sprite.textureName.Value)))
.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 =
(
from npc in this.GetCharacters()
where npc.isVillager()
let textureKey = this.GetNormalisedPath($"Portraits\\{this.getTextureName(npc)}")
where lookup.Contains(textureKey)
select new { npc, textureKey }
)
.ToArray();
if (!villagers.Any())
return 0;
// update portrait
int reloaded = 0;
foreach (var entry in villagers)
{
entry.npc.resetPortrait();
entry.npc.Portrait = content.Load(entry.textureKey);
reloaded++;
}
return reloaded;
}
private string getTextureName(NPC npc)
{
string name = npc.Name;
string str = name == "Old Mariner" ? "Mariner" : (name == "Dwarf King" ? "DwarfKing" : (name == "Mister Qi" ? "MrQi" : (name == "???" ? "Monsters\\Shadow Guy" : name)));
return str;
}
/// 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)
this.Reflection.GetField>(tree, "texture").SetValue(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
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;
}
}
}
}
/// Get whether a key starts with a substring after the substring is normalised.
/// The key to check.
/// The substring to normalise and find.
private bool KeyStartsWith(string key, string rawSubstring)
{
return key.StartsWith(this.GetNormalisedPath(rawSubstring), StringComparison.InvariantCultureIgnoreCase);
}
/// Get whether a normalised asset key is in the given folder.
/// The normalised asset key (like Animals/cat).
/// The key folder (like Animals); doesn't need to be normalised.
/// 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;
}
}
}