Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Feature: External (mod-provided) data layers #1020

Open
wants to merge 7 commits into
base: develop
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 47 additions & 0 deletions DataLayers/Framework/Api.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
using System.Collections.Generic;
using StardewModdingAPI;

namespace Pathoschild.Stardew.DataLayers.Framework;

/// <summary>
/// Implementation of the Data Layers API.
/// </summary>
/// <param name="colorRegistry">Color registry containing available schemes and colors.</param>
/// <param name="monitor">Monitor instance for logging.</param>
public class Api : IDataLayersApi, ILayerRegistry
{
private readonly ColorRegistry ColorRegistry;
private readonly IMonitor Monitor;
private readonly Dictionary<string, LayerRegistration> Registrations = [];

internal Api(ColorRegistry colorRegistry, IMonitor monitor)
{
this.ColorRegistry = colorRegistry;
this.Monitor = monitor;
}

// Explicit interface implementation so that we don't need to make `ILayerRegistry` public and
// subsequently have Pintail try to pick it up (and choke).
IEnumerable<LayerRegistration> ILayerRegistry.GetAllRegistrations()
{
return this.Registrations.Values;
}

public void RegisterColorSchemes(Dictionary<string, Dictionary<string, string?>> schemeData, string assetName)
{
this.ColorRegistry.LoadSchemes(schemeData, assetName);
}

public void RegisterLayer(IManifest mod, string id, IDataLayer layer)
{
string globalId = $"{mod.UniqueID}:{id}";
var registration = new LayerRegistration(globalId, id, layer);
if (!this.Registrations.TryAdd(globalId, registration))
{
this.Monitor.Log(
$"Couldn't register layer with ID '{id}' for mod '{mod.UniqueID}' because the " +
"mod already has another layer with the same ID.",
LogLevel.Error);
}
}
}
85 changes: 85 additions & 0 deletions DataLayers/Framework/ColorRegistry.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
using System;
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using Microsoft.Xna.Framework;
using StardewModdingAPI;
using StardewValley;

namespace Pathoschild.Stardew.DataLayers.Framework
{
/// <summary>Tracks loaded color schemes and colors.</summary>
/// <param name="monitor">The monitor with which to log error messages.</param>
internal class ColorRegistry(IMonitor monitor)
{
/*********
** Accessors
*********/
/// <summary>The collection of all available scheme IDs.</summary>
public IEnumerable<string> SchemeIds => this.Schemes.Keys;

/*********
** Fields
*********/
/// <summary>The monitor with which to log error messages.</summary>
private readonly IMonitor Monitor = monitor;

/// <summary>The color schemes available to apply.</summary>
private readonly Dictionary<string, ColorScheme> Schemes = new(StringComparer.OrdinalIgnoreCase);

/// <summary>Load the default color schemes from mod assets.</summary>
/// <param name="dataHelper">Helper for local mod assets.</param>
public void LoadDefaultSchemes(IDataHelper dataHelper)
{
var rawData = dataHelper.ReadJsonFile<Dictionary<string, Dictionary<string, string?>>>(ColorScheme.AssetName);
this.LoadSchemes(rawData);
}

/// <summary>Load color schemes from an alternate source, generally another mod via the API.</summary>
/// <param name="schemeData">Raw dictionary data from the color scheme JSON. Each entry is a pair whose key is the scheme ID and whose value is a map of color names to color values for that scheme.</param>
/// <param name="assetName">Name of the asset used to load the data, if not the default asset. Only used for logging errors and does not affect behavior.</param>
public void LoadSchemes(Dictionary<string, Dictionary<string, string?>>? schemeData, string? assetName = null)
{
schemeData = schemeData is not null
? new(schemeData, StringComparer.OrdinalIgnoreCase)
: new(StringComparer.OrdinalIgnoreCase);
foreach ((string schemeId, Dictionary<string, string?> rawColors) in schemeData)
{
Dictionary<string, Color> colors = new(StringComparer.OrdinalIgnoreCase);

foreach ((string name, string? rawColor) in rawColors)
{
Color? color = Utility.StringToColor(rawColor);

if (color is null)
{
this.Monitor.Log(
$"Can't load color '{name}' from{(!ColorScheme.IsDefaultColorScheme(schemeId) ? $" color scheme '{schemeId}'" : "")} '{assetName ?? ColorScheme.AssetName}'. " +
$"The value '{rawColor}' isn't a valid color format.", LogLevel.Warn);
continue;
}

colors[name] = color.Value;
}
if (this.Schemes.TryGetValue(schemeId, out var registeredColors))
{
registeredColors.Merge(colors);
}
else
{
this.Schemes[schemeId] = new ColorScheme(schemeId, colors, this.Monitor);
}
}
}

/// <summary>
/// Tries to retrieve a color scheme by its ID.
/// </summary>
/// <param name="schemeId">The ID of the scheme.</param>
/// <param name="scheme">The matching <see cref="ColorScheme"/>, if found.</param>
/// <returns><c>true</c> if a scheme was found, otherwise <c>false</c>.</returns>
public bool TryGetScheme(string schemeId, [MaybeNullWhen(false)] out ColorScheme scheme)
{
return this.Schemes.TryGetValue(schemeId, out scheme);
}
}
}
11 changes: 11 additions & 0 deletions DataLayers/Framework/ColorScheme.cs
Original file line number Diff line number Diff line change
Expand Up @@ -63,5 +63,16 @@ public static bool IsDefaultColorScheme(string id)
{
return string.Equals(id, "Default", StringComparison.OrdinalIgnoreCase);
}

/// <summary>Merges the colors in this color scheme with those from another color scheme.</summary>
/// <remarks>If both schemes define the same color, <paramref name="other"/> takes precedence.</remarks>
/// <param name="other">The colors from the scheme to be merged.</param>
public void Merge(Dictionary<string, Color> colors)
{
foreach (var (name, color) in colors)
{
this.Colors[name] = color;
}
}
}
}
105 changes: 73 additions & 32 deletions DataLayers/Framework/GenericModConfigMenuIntegrationForDataLayers.cs
Original file line number Diff line number Diff line change
Expand Up @@ -18,25 +18,30 @@ internal class GenericModConfigMenuIntegrationForDataLayers
/// <summary>The default mod settings.</summary>
private readonly ModConfig DefaultConfig = new();

/// <summary>The color schemes available to apply.</summary>
private readonly Dictionary<string, ColorScheme> ColorSchemes;
/// <summary>The color registry holding available schemes and colors.</summary>
private readonly ColorRegistry ColorRegistry;

/// <summary>Layers registered by other mods.</summary>
private readonly ILayerRegistry LayerRegistry;


/*********
** Public methods
*********/
/// <summary>Construct an instance.</summary>
/// <param name="modRegistry">An API for fetching metadata about loaded mods.</param>
/// <param name="layerRegistry">Layers registered by other mods.</param>
/// <param name="monitor">Encapsulates monitoring and logging.</param>
/// <param name="manifest">The mod manifest.</param>
/// <param name="getConfig">Get the current config model.</param>
/// <param name="reset">Reset the config model to the default values.</param>
/// <param name="saveAndApply">Save and apply the current config model.</param>
/// <param name="colorSchemes">The color schemes available to apply.</param>
public GenericModConfigMenuIntegrationForDataLayers(IModRegistry modRegistry, IMonitor monitor, IManifest manifest, Func<ModConfig> getConfig, Action reset, Action saveAndApply, Dictionary<string, ColorScheme> colorSchemes)
public GenericModConfigMenuIntegrationForDataLayers(IModRegistry modRegistry, ILayerRegistry layerRegistry, IMonitor monitor, IManifest manifest, Func<ModConfig> getConfig, Action reset, Action saveAndApply, ColorRegistry colorRegistry)
{
this.ConfigMenu = new GenericModConfigMenuIntegration<ModConfig>(modRegistry, monitor, manifest, getConfig, reset, saveAndApply);
this.ColorSchemes = colorSchemes;
this.LayerRegistry = layerRegistry;
this.ColorRegistry = colorRegistry;
}

/// <summary>Register the config menu if available.</summary>
Expand Down Expand Up @@ -66,7 +71,7 @@ public void Register()
tooltip: I18n.Config_ColorSchene_Desc,
get: config => config.ColorScheme,
set: (config, value) => config.ColorScheme = value,
allowedValues: this.ColorSchemes.Keys.ToArray(),
allowedValues: this.ColorRegistry.SchemeIds.ToArray(),
formatAllowedValue: key => I18n.GetByKey($"config.color-schemes.{key}").Default(key)
)

Expand All @@ -90,65 +95,101 @@ public void Register()
set: (config, value) => config.Controls.NextLayer = value
);

this.AddLayerConfig(config => config.Layers.Accessible, "accessible");
this.AddLayerConfig(config => config.Layers.Buildable, "buildable");
this.AddLayerConfig(config => config.Layers.CoverageForBeeHouses, "bee-houses");
this.AddLayerConfig(config => config.Layers.CoverageForJunimoHuts, "junimo-huts");
this.AddLayerConfig(config => config.Layers.CoverageForScarecrows, "scarecrows");
this.AddLayerConfig(config => config.Layers.CoverageForSprinklers, "sprinklers");
this.AddLayerConfig(config => config.Layers.CropHarvest, "crop-harvest");
this.AddLayerConfig(config => config.Layers.CropWater, "crop-water");
this.AddLayerConfig(config => config.Layers.CropPaddyWater, "crop-paddy-water");
this.AddLayerConfig(config => config.Layers.CropFertilizer, "crop-fertilizer");
this.AddLayerConfig(config => config.Layers.Machines, "machines");
this.AddLayerConfig(config => config.Layers.TileGrid, "grid");
this.AddLayerConfig(config => config.Layers.Tillable, "tillable");
List<LayerConfigSection> configSections = [
GetBuiltInSection(config => config.Layers.Accessible, "accessible"),
GetBuiltInSection(config => config.Layers.Buildable, "buildable"),
GetBuiltInSection(config => config.Layers.CoverageForBeeHouses, "bee-houses"),
GetBuiltInSection(config => config.Layers.CoverageForJunimoHuts, "junimo-huts"),
GetBuiltInSection(config => config.Layers.CoverageForScarecrows, "scarecrows"),
GetBuiltInSection(config => config.Layers.CoverageForSprinklers, "sprinklers"),
GetBuiltInSection(config => config.Layers.CropHarvest, "crop-harvest"),
GetBuiltInSection(config => config.Layers.CropWater, "crop-water"),
GetBuiltInSection(config => config.Layers.CropPaddyWater, "crop-paddy-water"),
GetBuiltInSection(config => config.Layers.CropFertilizer, "crop-fertilizer"),
GetBuiltInSection(config => config.Layers.Machines, "machines"),
GetBuiltInSection(config => config.Layers.TileGrid, "grid"),
GetBuiltInSection(config => config.Layers.Tillable, "tillable"),
];
foreach (var registration in this.LayerRegistry.GetAllRegistrations())
{
configSections.Add(new(
config => config.GetModLayerConfig(registration.UniqueId),
() => I18n.GetByKey(
"config.section.layer",
new { LayerName = registration.Layer.Name })));
}
// Language can change while the game is running, but usually doesn't. This gives us
// alphabetical order most of the time.
configSections.Sort((a, b) => a.GetTitle().CompareTo(b.GetTitle()));
foreach (var section in configSections)
{
this.AddLayerConfigSection(section);
}
}

/// <summary>
/// Derives <see cref="LayerConfigSection"/> data for an internal (built-in) layer type.
/// </summary>
/// <param name="getLayer">Function to get the layer field from a config model.</param>
/// <param name="translationKey">The translation key for this layer.</param>
/// <returns></returns>
private static LayerConfigSection GetBuiltInSection(
Func<ModConfig, LayerConfig> getLayer,
string translationKey)
{
return new(getLayer, () => GetLayerSectionTitle(translationKey));
}

/// <summary>Information about a single layer's configuration settings.</summary>
/// <param name="GetLayer">Function to get the layer field from a config model.</param>
/// <param name="GetTitle">Function to get the (localized) section title.</param>
private record LayerConfigSection(
Func<ModConfig, LayerConfig> GetLayer,
Func<string> GetTitle);


/*********
** Private methods
*********/
/// <summary>Add the config section for a layer.</summary>
/// <param name="getLayer">Get the layer field from a config model.</param>
/// <param name="translationKey">The translation key for this layer.</param>
private void AddLayerConfig(Func<ModConfig, LayerConfig> getLayer, string translationKey)
/// <param name="section">Contains the information about this layer/config section.</param>
private void AddLayerConfigSection(LayerConfigSection section)
{
LayerConfig defaultConfig = getLayer(this.DefaultConfig);
LayerConfig defaultConfig = section.GetLayer(this.DefaultConfig);

this.ConfigMenu
.AddSectionTitle(() => this.GetLayerSectionTitle(translationKey))
.AddSectionTitle(section.GetTitle)
.AddCheckbox(
name: I18n.Config_LayerEnabled_Name,
tooltip: I18n.Config_LayerEnabled_Desc,
get: config => getLayer(config).Enabled,
set: (config, value) => getLayer(config).Enabled = value
get: config => section.GetLayer(config).Enabled,
set: (config, value) => section.GetLayer(config).Enabled = value
)
.AddCheckbox(
name: I18n.Config_LayerUpdateOnViewChange_Name,
tooltip: I18n.Config_LayerUpdateOnViewChange_Desc,
get: config => getLayer(config).UpdateWhenViewChange,
set: (config, value) => getLayer(config).UpdateWhenViewChange = value
get: config => section.GetLayer(config).UpdateWhenViewChange,
set: (config, value) => section.GetLayer(config).UpdateWhenViewChange = value
)
.AddNumberField(
name: I18n.Config_LayerUpdatesPerSecond_Name,
tooltip: () => I18n.Config_LayerUpdatesPerSecond_Desc(defaultValue: defaultConfig.UpdatesPerSecond),
get: config => (float)getLayer(config).UpdatesPerSecond,
set: (config, value) => getLayer(config).UpdatesPerSecond = (decimal)value,
get: config => (float)section.GetLayer(config).UpdatesPerSecond,
set: (config, value) => section.GetLayer(config).UpdatesPerSecond = (decimal)value,
min: 0.1f,
max: 60f
)
.AddKeyBinding(
name: I18n.Config_LayerShortcut_Name,
tooltip: I18n.Config_LayerShortcut_Desc,
get: config => getLayer(config).ShortcutKey,
set: (config, value) => getLayer(config).ShortcutKey = value
get: config => section.GetLayer(config).ShortcutKey,
set: (config, value) => section.GetLayer(config).ShortcutKey = value
);
}

/// <summary>Get the translated section title for a layer.</summary>
/// <param name="translationKey">The layer ID.</param>
private string GetLayerSectionTitle(string translationKey)
private static string GetLayerSectionTitle(string translationKey)
{
string layerName = I18n.GetByKey($"{translationKey}.name");
return I18n.Config_Section_Layer(layerName);
Expand Down
28 changes: 28 additions & 0 deletions DataLayers/Framework/ILayerRegistry.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
using System.Collections.Generic;

namespace Pathoschild.Stardew.DataLayers.Framework;

/// <summary>
/// Data for a single mod-provided layer registered through the API.
/// </summary>
/// <param name="UniqueId">Unique ID for this layer, including both the unique mod ID and the local layer
/// ID specified by that mod.</param>
/// <param name="LocalId">Local layer ID for the mod; used in color schemes.</param>
/// <param name="Layer">The layer configuration provided by the mod.</param>
internal record LayerRegistration(string UniqueId, string LocalId, IDataLayer Layer);

/// <summary>
/// Provides access to registered layers.
/// </summary>
/// <remarks>
/// This interface is intended as the internal "reader" side to the singleton API instance, which
/// also handles registrations (writes). Generally, most internal code only needs this read-only
/// registry and does not need or want the entire <see cref="Api"/>.
/// </remarks>
internal interface ILayerRegistry
{
/// <summary>
/// Returns the layers registered so far.
/// </summary>
IEnumerable<LayerRegistration> GetAllRegistrations();
}
21 changes: 21 additions & 0 deletions DataLayers/Framework/ModConfig.cs
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
using System;
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.Runtime.Serialization;
using Pathoschild.Stardew.Common;
Expand Down Expand Up @@ -25,6 +27,9 @@ internal class ModConfig
/// <summary>The generic settings for each layer.</summary>
public ModConfigLayers Layers { get; set; } = new();

/// <summary>The generic settings for mod-added layers, keyed by mod ID and layer name.</summary>
public Dictionary<string, LayerConfig> ModLayers { get; set; } = [];


/*********
** Public methods
Expand All @@ -39,5 +44,21 @@ public void OnDeserialized(StreamingContext context)
this.Controls ??= new ModConfigKeys();
this.Layers ??= new ModConfigLayers();
}

/// <summary>
/// Retrieves the layer configuration for a specific mod-registered layer, creating one if
/// it does not already exist.
/// </summary>
/// <param name="id">Unique ID for the layer; the <see cref="LayerRegistration.Id"/>.</param>
/// <returns>The configuration for the layer with specified <paramref name="id"/>.</returns>
public LayerConfig GetModLayerConfig(string id)
{
if (!this.ModLayers.TryGetValue(id, out var layer))
{
layer = new();
this.ModLayers.Add(id, layer);
}
return layer;
}
}
}
Loading