diff --git a/Celeste.Mod.mm/Mod/Entities/EntityCollider.cs b/Celeste.Mod.mm/Mod/Entities/EntityCollider.cs
new file mode 100644
index 000000000..63a21513e
--- /dev/null
+++ b/Celeste.Mod.mm/Mod/Entities/EntityCollider.cs
@@ -0,0 +1,71 @@
+using Monocle;
+using System;
+using Microsoft.Xna.Framework;
+
+namespace Celeste.Mod.Entities {
+ ///
+ /// Allows for Collision with any type of entity in the game, similar to a PlayerCollider or PufferCollider.
+ /// Performs the Action provided on collision.
+ ///
+ /// The specific type of Entity this component should try to collide with
+ public class EntityCollider : Component where T : Entity {
+ ///
+ /// The Action invoked on Collision, with the Entity collided with passed as a parameter
+ ///
+ public Action OnEntityAction;
+
+ public Collider Collider;
+
+ public EntityCollider(Action onEntityAction, Collider collider = null)
+ : base(active: true, visible: true) {
+ OnEntityAction = onEntityAction;
+ Collider = collider;
+ }
+
+ public override void Added(Entity entity) {
+ base.Added(entity);
+ //Only called if Component is added post Scene Begin and Entity Adding and Awake time.
+ if (Scene != null) {
+ if (!Scene.Tracker.IsEntityTracked()) {
+ patch_Tracker.AddTypeToTracker(typeof(T));
+ }
+ patch_Tracker.Refresh(Scene);
+ }
+ }
+
+ public override void EntityAdded(Scene scene) {
+ if (!scene.Tracker.IsEntityTracked()) {
+ patch_Tracker.AddTypeToTracker(typeof(T));
+ }
+ base.EntityAdded(scene);
+ }
+
+ public override void EntityAwake() {
+ patch_Tracker.Refresh(Scene);
+ }
+
+ public override void Update() {
+ if (OnEntityAction == null) {
+ return;
+ }
+
+ Collider collider = Entity.Collider;
+ if (Collider != null) {
+ Entity.Collider = Collider;
+ }
+
+ Entity.CollideDo(OnEntityAction);
+
+ Entity.Collider = collider;
+ }
+
+ public override void DebugRender(Camera camera) {
+ if (Collider != null) {
+ Collider collider = Entity.Collider;
+ Entity.Collider = Collider;
+ Collider.Render(camera, Color.HotPink);
+ Entity.Collider = collider;
+ }
+ }
+ }
+}
diff --git a/Celeste.Mod.mm/Mod/Entities/EntityColliderByComponent.cs b/Celeste.Mod.mm/Mod/Entities/EntityColliderByComponent.cs
new file mode 100644
index 000000000..c8e9a98d4
--- /dev/null
+++ b/Celeste.Mod.mm/Mod/Entities/EntityColliderByComponent.cs
@@ -0,0 +1,72 @@
+using Monocle;
+using System;
+using Microsoft.Xna.Framework;
+
+namespace Celeste.Mod.Entities {
+ ///
+ /// Allows for Collision with any type of entity in the game, similar to a PlayerCollider or PufferCollider.
+ /// Collision is done by component, as in, it will get all the components of the type and try to collide with their entities.
+ /// Performs the Action provided on collision.
+ ///
+ /// The specific type of Component this component should try to collide with
+ public class EntityColliderByComponent : Component where T : Component {
+ ///
+ /// The Action invoked on Collision, with the Component collided with passed as a parameter
+ ///
+ public Action OnComponentAction;
+
+ public Collider Collider;
+
+ public EntityColliderByComponent(Action onComponentAction, Collider collider = null)
+ : base(active: true, visible: true) {
+ OnComponentAction = onComponentAction;
+ Collider = collider;
+ }
+
+ public override void Added(Entity entity) {
+ base.Added(entity);
+ //Only called if Component is added post Scene Begin and Entity Adding and Awake time.
+ if (Scene != null) {
+ if (!Scene.Tracker.IsComponentTracked()) {
+ patch_Tracker.AddTypeToTracker(typeof(T));
+ }
+ patch_Tracker.Refresh(Scene);
+ }
+ }
+
+ public override void EntityAdded(Scene scene) {
+ if (!scene.Tracker.IsComponentTracked()) {
+ patch_Tracker.AddTypeToTracker(typeof(T));
+ }
+ base.EntityAdded(scene);
+ }
+
+ public override void EntityAwake() {
+ patch_Tracker.Refresh(Scene);
+ }
+
+ public override void Update() {
+ if (OnComponentAction == null) {
+ return;
+ }
+
+ Collider collider = Entity.Collider;
+ if (Collider != null) {
+ Entity.Collider = Collider;
+ }
+
+ Entity.CollideDoByComponent(OnComponentAction);
+
+ Entity.Collider = collider;
+ }
+
+ public override void DebugRender(Camera camera) {
+ if (Collider != null) {
+ Collider collider = Entity.Collider;
+ Entity.Collider = Collider;
+ Collider.Render(camera, Color.HotPink);
+ Entity.Collider = collider;
+ }
+ }
+ }
+}
diff --git a/Celeste.Mod.mm/Mod/Everest/Everest.Content.cs b/Celeste.Mod.mm/Mod/Everest/Everest.Content.cs
index d592cfd9c..a2d60c64e 100644
--- a/Celeste.Mod.mm/Mod/Everest/Everest.Content.cs
+++ b/Celeste.Mod.mm/Mod/Everest/Everest.Content.cs
@@ -758,7 +758,7 @@ public static string GuessType(string file, out Type type, out string format) {
return fileSpan[..^4].ToString();
}
- if (MatchExtension(fileSpan, fileNameSpan, "obj.export", ref warningAlreadySent)) {
+ if (MatchMultipartExtension(fileSpan, fileNameSpan, "obj.export", ref warningAlreadySent)) {
type = typeof(AssetTypeObjModelExport);
return fileSpan[..^7].ToString();
}
diff --git a/Celeste.Mod.mm/Mod/Everest/Everest.Events.cs b/Celeste.Mod.mm/Mod/Everest/Everest.Events.cs
index 4d22a17d3..cddee232c 100644
--- a/Celeste.Mod.mm/Mod/Everest/Everest.Events.cs
+++ b/Celeste.Mod.mm/Mod/Everest/Everest.Events.cs
@@ -322,17 +322,17 @@ internal static void BeforeRender(_SubHudRenderer renderer, Scene scene)
public static class PlayerSprite {
public delegate void GetIdsUsedFillAnimForIdHandler(List ids, string id);
- ///
- /// Called during , unless contains its given string.
- ///
- /// Mods using this should always add an id to ids whether what the id was given, to avoid certain id missing their animations
- /// e.g:
- /// Everest.Event.PlayerSprite.OnGetIdsUsedFillAnimForId += ((ids, id) => {
- /// if (id == "player_no_backpack")
- /// ids.Add("MyHelper_Anims_NB")
- /// else
- /// ids.Add("MyHelper_Anims")
- /// });
+ ///
+ /// Called during , unless contains its given string.
+ ///
+ /// Mods using this should always add an id to ids whether what the id was given, to avoid certain id missing their animations
+ /// e.g:
+ /// Everest.Event.PlayerSprite.OnGetIdsUsedFillAnimForId += ((ids, id) => {
+ /// if (id == "player_no_backpack")
+ /// ids.Add("MyHelper_Anims_NB")
+ /// else
+ /// ids.Add("MyHelper_Anims")
+ /// });
///
public static event GetIdsUsedFillAnimForIdHandler OnGetIdsUsedFillAnimForId;
@@ -342,13 +342,202 @@ internal static List GetIdsUsedFillAnimFor(string id) {
return ids;
}
- // Put this field here make it so visible
- ///
- /// Filter IDs that should not be fill anim by other IDs during ,
- /// prevent e.g "MyHelper_Anims", ""MyHelper_Anims_NB", "OthersHelper_Anims" ids from senseless filling each other's animations
- ///
- public static HashSet DoNotFillAnimFor = new(StringComparer.CurrentCultureIgnoreCase);
- }
- }
- }
-}
+ ///
+ /// Filter IDs that should not be fill anim by other IDs during ,
+ /// prevent e.g "MyHelper_Anims", ""MyHelper_Anims_NB", "OthersHelper_Anims" ids from senseless filling each other's animations
+ ///
+ public static HashSet DoNotFillAnimFor = new(StringComparer.CurrentCultureIgnoreCase);
+
+ public static event LoadEntityHandler OnLoadEntity;
+ internal static bool LoadEntity(_Level level, LevelData levelData, Vector2 offset, EntityData entityData) {
+ LoadEntityHandler onLoadEntity = OnLoadEntity;
+
+ if (onLoadEntity == null)
+ return false;
+
+ // replicates the InvokeWhileFalse extension method, but hardcoding the type to avoid dynamic dispatch
+ foreach (LoadEntityHandler handler in onLoadEntity.GetInvocationList()) {
+ if (handler(level, levelData, offset, entityData))
+ return true;
+ }
+
+ return false;
+ }
+
+ public delegate Backdrop LoadBackdropHandler(MapData map, BinaryPacker.Element child, BinaryPacker.Element above);
+ public static event LoadBackdropHandler OnLoadBackdrop;
+ internal static Backdrop LoadBackdrop(MapData map, BinaryPacker.Element child, BinaryPacker.Element above)
+ => OnLoadBackdrop?.InvokeWhileNull(map, child, above);
+
+ public delegate void LoadLevelHandler(_Level level, _Player.IntroTypes playerIntro, bool isFromLoader);
+ ///
+ /// Called after .
+ /// This event is invoked every time a room is entered - transition, respawn, teleport, etc.
+ ///
+ ///
+ public static event LoadLevelHandler OnLoadLevel;
+ internal static void LoadLevel(_Level level, _Player.IntroTypes playerIntro, bool isFromLoader)
+ => OnLoadLevel?.Invoke(level, playerIntro, isFromLoader);
+
+ public delegate void EnterHandler(_Session session, bool fromSaveData);
+ public static event EnterHandler OnEnter;
+ internal static void Enter(_Session session, bool fromSaveData)
+ => OnEnter?.Invoke(session, fromSaveData);
+
+ public delegate void ExitHandler(_Level level, LevelExit exit, LevelExit.Mode mode, _Session session, HiresSnow snow);
+ public static event ExitHandler OnExit;
+ internal static void Exit(_Level level, LevelExit exit, LevelExit.Mode mode, _Session session, HiresSnow snow)
+ => OnExit?.Invoke(level, exit, mode, session, snow);
+
+ public delegate void CompleteHandler(_Level level);
+ public static event CompleteHandler OnComplete;
+ internal static void Complete(_Level level)
+ => OnComplete?.Invoke(level);
+ }
+
+ public static class Session {
+ public static event Action OnSliderChanged;
+ internal static void SliderChanged(patch_Session session, patch_Session.Slider slider, float? previous)
+ => OnSliderChanged?.Invoke(session, slider, previous);
+ }
+
+ public static class Player {
+ public static event Action<_Player> OnSpawn;
+ internal static void Spawn(_Player player)
+ => OnSpawn?.Invoke(player);
+
+ public static event Action<_Player> OnDie;
+ internal static void Die(_Player player)
+ => OnDie?.Invoke(player);
+
+ public static event Action<_Player> OnRegisterStates;
+ internal static void RegisterStates(_Player player)
+ => OnRegisterStates?.Invoke(player);
+ }
+
+ public static class Seeker {
+ public static event Action<_Seeker> OnRegisterStates;
+ internal static void RegisterStates(_Seeker seeker)
+ => OnRegisterStates?.Invoke(seeker);
+ }
+
+ public static class AngryOshiro {
+ public static event Action<_AngryOshiro> OnRegisterStates;
+ internal static void RegisterStates(_AngryOshiro oshiro)
+ => OnRegisterStates?.Invoke(oshiro);
+ }
+
+ public static class Input {
+ public static event Action OnInitialize;
+ internal static void Initialize()
+ => OnInitialize?.Invoke();
+
+ public static event Action OnDeregister;
+ internal static void Deregister()
+ => OnDeregister?.Invoke();
+ }
+
+ [Obsolete("Use Journal instead.")]
+ public static class OuiJournal {
+ public delegate void EnterHandler(_OuiJournal journal, Oui from);
+ public static event EnterHandler OnCreateButtons {
+ add {
+ Journal.OnEnter += (Journal.EnterHandler) value.CastDelegate(typeof(Journal.EnterHandler));
+ }
+ remove {
+ Journal.OnEnter -= (Journal.EnterHandler) value.CastDelegate(typeof(Journal.EnterHandler));
+ }
+ }
+ }
+
+ public static class Journal {
+ public delegate void EnterHandler(_OuiJournal journal, Oui from);
+ public static event EnterHandler OnEnter;
+ internal static void Enter(_OuiJournal journal, Oui from)
+ => OnEnter?.Invoke(journal, from);
+ }
+
+ public static class Decal {
+ public delegate void DecalRegistryHandler(_Decal decal, DecalRegistry.DecalInfo decalInfo);
+ public static event DecalRegistryHandler OnHandleDecalRegistry;
+ internal static void HandleDecalRegistry(_Decal decal, DecalRegistry.DecalInfo decalInfo)
+ => OnHandleDecalRegistry?.Invoke(decal, decalInfo);
+ }
+
+ public static class FileSelectSlot {
+ public delegate void CreateButtonsHandler(List buttons, OuiFileSelectSlot slot, EverestModuleSaveData modSaveData, bool fileExists);
+ public static event CreateButtonsHandler OnCreateButtons;
+ internal static void HandleCreateButtons(List buttons, OuiFileSelectSlot slot, bool fileExists) {
+ if (OnCreateButtons == null) {
+ return;
+ }
+
+ foreach (Delegate del in OnCreateButtons.GetInvocationList()) {
+ // find the Everest module this delegate belongs to, and load the mod save data from it for the current slot.
+ EverestModule matchingModule = _Modules.Find(module => module.GetType().Assembly == del.Method.DeclaringType.Assembly);
+ EverestModuleSaveData modSaveData = null;
+ if (matchingModule != null) {
+ modSaveData = matchingModule._SaveData;
+ }
+
+ // call the delegate.
+ del.DynamicInvoke(new object[] { buttons, slot, modSaveData, fileExists });
+ }
+ }
+ }
+
+ public static class EventTrigger {
+ public delegate bool TriggerEventHandler(_EventTrigger trigger, _Player player, string eventID);
+ public static event TriggerEventHandler OnEventTrigger;
+ internal static bool TriggerEvent(_EventTrigger trigger, _Player player, string eventID)
+ => OnEventTrigger?.InvokeWhileFalse(trigger, player, eventID) ?? false;
+ }
+
+ public static class CustomBirdTutorial {
+ public delegate object ParseCommandHandler(string command);
+ public static event ParseCommandHandler OnParseCommand;
+ internal static object ParseCommand(string command)
+ => OnParseCommand?.InvokeWhileNull
internal static bool EverestHandlersRegistered;
+
+ ///
+ /// Whether the Decal Registry has been fully loaded,
+ /// used to detect when it needs to be reloaded after AddPropertyHandler gets called.
+ ///
+ private static bool _loaded;
+
+ ///
+ /// Whether the Decal Registry will be hot-reloaded on the end of this frame / on the end of the current reload,
+ /// due to AddPropertyHandler being called after the registry was already loaded.
+ ///
+ private static bool _willHotReload;
///
/// Adds a custom property to the decal registry. See the Celeste.Mod.Registry.DecalRegistryHandlers namespace to see Everest-defined properties.
@@ -40,6 +52,7 @@ public static void AddPropertyHandler(string propertyName, Action) method for property {propertyName}!");
PropertyHandlerFactories[propertyName] = () => new LegacyDecalRegistryHandler(propertyName, action);
+ ScheduleHotReloadIfNeeded();
}
///
@@ -53,6 +66,35 @@ public static void AddPropertyHandler(string propertyName, Action new T();
+ ScheduleHotReloadIfNeeded();
+ }
+
+ private static void ScheduleHotReloadIfNeeded() {
+ if (!_loaded || _willHotReload) {
+ return;
+ }
+
+ // Decal Registry has already been fully loaded, we need to reload it because of new handlers.
+ // Delay this until next frame/end of hot reload though, so that subsequent calls to AddPropertyHandler
+ // don't cause several decal registry reloads.
+
+ _willHotReload = true;
+
+ if (AssetReloadHelper.IsReloading) {
+ // AssetReloadHelper can skip OnEndOfFrame events, register for a post-reload event instead.
+ Everest.Events.AssetReload.OnAfterNextReload += _ => DoReload();
+ } else {
+ Engine.Scene.OnEndOfFrame += DoReload;
+ }
+
+ return;
+
+ static void DoReload() {
+ Logger.Info("Decal Registry", "Reloading Decal Registry due to new handlers getting registered");
+ LoadDecalRegistry();
+ AssetReloadHelper.ReloadLevel();
+ _willHotReload = false;
+ }
}
internal static DecalRegistryHandler CreateHandlerOrNull(string decalName, string propertyName, XmlAttributeCollection xmlAttributes) {
@@ -119,6 +161,8 @@ public static void ScaleRectangle(this Decal self, ref int x, ref int y, ref int
/// Loads the decal registry for every enabled mod.
///
internal static void LoadDecalRegistry() {
+ _loaded = false;
+
RegisterEverestHandlers();
foreach (ModContent mod in Everest.Content.Mods) {
@@ -126,6 +170,8 @@ internal static void LoadDecalRegistry() {
LoadModDecalRegistry(asset);
}
}
+
+ _loaded = true;
}
internal static void RegisterEverestHandlers() {
diff --git a/Celeste.Mod.mm/Mod/UI/OuiPropertiesAttribute.cs b/Celeste.Mod.mm/Mod/UI/OuiPropertiesAttribute.cs
new file mode 100644
index 000000000..3a4d60682
--- /dev/null
+++ b/Celeste.Mod.mm/Mod/UI/OuiPropertiesAttribute.cs
@@ -0,0 +1,19 @@
+using System;
+
+namespace Celeste.Mod.UI {
+ [AttributeUsage(AttributeTargets.Class, AllowMultiple = false, Inherited = false)]
+ public class OuiPropertiesAttribute : Attribute {
+ ///
+ /// Whether the mountain music for the current map should play in this menu.
+ ///
+ public bool PlayCustomMusic { get; }
+
+ ///
+ /// Configures extra properties for this Oui.
+ ///
+ /// A list of unique identifiers for this Backdrop.
+ public OuiPropertiesAttribute(bool playCustomMusic = false) {
+ PlayCustomMusic = playCustomMusic;
+ }
+ }
+}
diff --git a/Celeste.Mod.mm/Patches/Language.cs b/Celeste.Mod.mm/Patches/Language.cs
index 4b8a89680..c2eea09b3 100644
--- a/Celeste.Mod.mm/Patches/Language.cs
+++ b/Celeste.Mod.mm/Patches/Language.cs
@@ -8,13 +8,12 @@
using System.IO;
using System.Linq;
using System.Text;
-using Mono.Cecil;
-using Mono.Cecil.Cil;
-using MonoMod.Cil;
-using MonoMod.Utils;
+using System.Globalization;
+using System.Runtime.InteropServices;
+using System.Text.RegularExpressions;
namespace Celeste {
- class patch_Language : Language {
+ partial class patch_Language : Language {
internal static Language LoadingLanguage;
internal static bool LoadOrigLanguage;
@@ -24,9 +23,165 @@ class patch_Language : Language {
internal Dictionary ReadCount;
internal string CurrentlyReadingFrom;
- [MonoModIgnore]
- [PatchLoadLanguage]
- public static extern new Language FromTxt(string path);
+ [GeneratedRegex(@"^(?:\{.*?\})+$")]
+ private static partial Regex WholeLineIsCommandsRegex();
+
+ [GeneratedRegex(@"\{(.*?)\}", RegexOptions.RightToLeft)]
+ private static partial Regex CommandRegex();
+
+ [GeneratedRegex(@"\[(?[^\[\\]*(?:\\.[^\]\\]*)*)\]", RegexOptions.IgnoreCase)]
+ private static partial Regex PortraitRegex();
+
+ [GeneratedRegex(@"^\w+\=.*")]
+ private static partial Regex VariableRegex();
+
+ [GeneratedRegex(@"\{\+\s*(.*?)\}")]
+ private static partial Regex InsertRegex();
+
+ ///
+ /// Splits text like 'key=value' into two spans.
+ /// If the separator is not found, 'left' contains the entire string and 'right' is empty.
+ ///
+ private static bool SplitPair(ReadOnlySpan from, char separator, out ReadOnlySpan left, out ReadOnlySpan right) {
+ int idx = from.IndexOf(separator);
+ if (idx == -1) {
+ left = from;
+ right = Span.Empty;
+ return false;
+ }
+
+ left = from[..idx];
+ right = from[(idx + 1)..];
+ return true;
+ }
+
+ [MonoModReplace] // Rewrite the method to optimise it and fix issues with multiple equals signs being in the same line.
+ public new static Language FromTxt(string path) {
+ Language language = null;
+ string nextKey = "";
+ StringBuilder nextEntryBuilder = new();
+ string prevLine = "";
+ ReadOnlySpan lastAddedNonEmptyLine = "";
+
+ foreach (string lineUntrimmed in _GetLanguageText(path, Encoding.UTF8)) {
+ var line = lineUntrimmed.Trim();
+ if (line.Length <= 0 || line[0] == '#') {
+ continue;
+ }
+
+ if (line.IndexOf('[') >= 0) {
+ line = PortraitRegex().Replace(line, "{portrait ${content}}");
+ }
+
+ line = line.Replace("\\#", "#", StringComparison.Ordinal);
+ if (line.Length <= 0) {
+ continue;
+ }
+
+ // See if this line starts a new dialog key
+ if (VariableRegex().IsMatch(line)) {
+ if (!string.IsNullOrEmpty(nextKey)) {
+ // end the previous dialog key
+ _SetItem(language.Dialog, nextKey, nextEntryBuilder.ToString(), language);
+ }
+
+ SplitPair(line, '=', out var cmd, out var argument);
+
+ if (cmd.Equals("language", StringComparison.OrdinalIgnoreCase)) {
+ language = _NewLanguage();
+ language.FontFace = null;
+ language.FilePath = Path.GetFileName(path);
+
+ if (SplitPair(argument, ',', out var id, out var label)) {
+ language.Id = id.ToString();
+ language.Label = label.ToString();
+ } else {
+ language.Id = argument.ToString();
+ }
+ } else if (cmd.Equals("icon", StringComparison.OrdinalIgnoreCase)) {
+ string argStr = argument.ToString();
+ VirtualTexture texture = VirtualContent.CreateTexture(Path.Combine("Dialog", argStr));
+ language.IconPath = argStr;
+ language.Icon = new MTexture(texture);
+ } else if (cmd.Equals("order", StringComparison.OrdinalIgnoreCase)) {
+ language.Order = int.Parse(argument);
+ } else if (cmd.Equals("font", StringComparison.OrdinalIgnoreCase)) {
+ if (SplitPair(argument, ',', out var face, out var faceSize)) {
+ language.FontFace = face.ToString();
+ language.FontFaceSize = float.Parse(faceSize, CultureInfo.InvariantCulture);
+ }
+ } else if (cmd.Equals("SPLIT_REGEX", StringComparison.OrdinalIgnoreCase)) {
+ language.SplitRegex = argument.ToString();
+ } else if (cmd.Equals("commas", StringComparison.OrdinalIgnoreCase)) {
+ language.CommaCharacters = argument.ToString();
+ } else if (cmd.Equals("periods", StringComparison.OrdinalIgnoreCase)) {
+ language.PeriodCharacters = argument.ToString();
+ } else {
+ // This is just a normal dialog.
+ // By this point, we've already added the previous entry to the Dialog dictionary.
+ nextKey = cmd.ToString();
+ nextEntryBuilder.Clear();
+ nextEntryBuilder.Append(argument);
+ lastAddedNonEmptyLine = argument;
+ }
+ } else {
+ // Continue the previously started dialog
+
+ if (nextEntryBuilder.Length > 0) {
+ // Auto-add linebreaks if the previous line wasn't entirely commands and had no line break commands.
+ if (!lastAddedNonEmptyLine.EndsWith("{break}", StringComparison.Ordinal)
+ && !lastAddedNonEmptyLine.EndsWith("{n}", StringComparison.Ordinal)
+ && !WholeLineIsCommandsRegex().IsMatch(prevLine)
+ ) {
+ nextEntryBuilder.Append("{break}");
+ lastAddedNonEmptyLine = "{break}";
+ }
+ }
+
+ nextEntryBuilder.Append(line);
+ lastAddedNonEmptyLine = line.Length > 0 ? line : lastAddedNonEmptyLine;
+ }
+
+ prevLine = line;
+ }
+
+ // Make sure to add the final key in the lang file
+ if (!string.IsNullOrEmpty(nextKey)) {
+ _SetItem(language.Dialog, nextKey, nextEntryBuilder.ToString(), language);
+ }
+
+ var keys = language.Dialog.Keys;
+
+ // Handle {+DIALOG_ID} constructs, recursively
+ foreach (string key in keys) {
+ string dialog = GetDialogWithResolvedInserts(language, language.Dialog[key]);
+ _SetItem(language.Dialog, key, dialog, language);
+
+ static string GetDialogWithResolvedInserts(Language language, string dialog) {
+ return InsertRegex().Replace(dialog, match => {
+ string keyToReplaceWith = match.Groups[1].Value;
+
+ return GetDialogWithResolvedInserts(language, language.Dialog.GetValueOrDefault(keyToReplaceWith, "[XXX]"));
+ });
+ }
+ }
+
+ language.Lines = 0;
+ language.Words = 0;
+
+ // Create cleaned entries
+ foreach (string key in keys) {
+ string dialog = language.Dialog[key];
+
+ if (dialog.Contains('{')) {
+ dialog = CommandRegex().Replace(dialog, match => match.ValueSpan is "{n}" or "{break}" ? "\n" : "");
+ }
+
+ language.Cleaned[key] = dialog;
+ }
+
+ return language;
+ }
public static extern Language orig_FromExport(string path);
public static new Language FromExport(string path) {
@@ -79,15 +234,14 @@ private static IEnumerable _GetLanguageText(string path, Encoding encodi
yield return text;
}
- path = path.Substring(Everest.Content.PathContentOrig.Length + 1);
+ path = path[(Everest.Content.PathContentOrig.Length + 1)..];
path = path.Replace('\\', '/');
- path = path.Substring(0, path.Length - 4);
- string dummy = string.Format("LANGUAGE={0}", path.Substring(7).ToLowerInvariant());
+ path = path[..^".txt".Length];
if (!ready) {
ready = true;
// Feed a dummy language line. All empty languages are removed afterwards.
- yield return dummy;
+ yield return $"LANGUAGE={path["Dialog/".Length..].ToLowerInvariant()}";
}
if (!LoadModLanguage)
@@ -95,13 +249,13 @@ private static IEnumerable _GetLanguageText(string path, Encoding encodi
foreach (ModContent content in Everest.Content.Mods) {
foreach (ModAsset asset in content.Map
- .Where(entry => entry.Value.Type == typeof(AssetTypeDialog) && entry.Key.Equals(path, StringComparison.InvariantCultureIgnoreCase))
+ .Where(entry => entry.Value.Type == typeof(AssetTypeDialog) && entry.Key.Equals(path, StringComparison.OrdinalIgnoreCase))
.Select(entry => entry.Value)) {
lang.CurrentlyReadingFrom = asset.Source?.Name ?? "???";
using (StreamReader reader = new StreamReader(asset.Stream, encoding))
while (reader.Peek() != -1)
- yield return reader.ReadLine().Trim('\r', '\n').Trim();
+ yield return reader.ReadLine().Trim();
// Feed a new key to be sure that the last key in the file is cut off.
// That will prevent mod B from corrupting the last key of mod A if its language txt is bad.
@@ -112,7 +266,7 @@ private static IEnumerable _GetLanguageText(string path, Encoding encodi
}
private static Language _NewLanguage() {
- return LoadingLanguage ?? (LoadingLanguage = new Language());
+ return LoadingLanguage ??= new Language();
}
private static void _SetItem(Dictionary dict, string key, string value, Language _lang) {
@@ -124,13 +278,12 @@ private static void _SetItem(Dictionary dict, string key, string
// Skip conflict checking when the dictionary is from an unknown source.
} else {
- if (!lang.ReadCount.TryGetValue(key, out int count))
+ ref int count = ref CollectionsMarshal.GetValueRefOrAddDefault(lang.ReadCount, key, out bool existed);
+ if (!existed)
count = lang.Dialog.ContainsKey(key) ? 1 : 0;
count++;
- lang.ReadCount[key] = count;
- if (!lang.LineSources.TryGetValue(key, out string sourcePrev))
- sourcePrev = "?!?!?!";
+ string sourcePrev = lang.LineSources.GetValueOrDefault(key, "?!?!?!");
lang.LineSources[key] = lang.CurrentlyReadingFrom;
if (count >= 2)
@@ -143,44 +296,3 @@ private static void _SetItem(Dictionary dict, string key, string
}
}
-
-namespace MonoMod {
- ///
- /// Patch the Language.LoadTxt method instead of reimplementing it in Everest.
- ///
- [MonoModCustomMethodAttribute(nameof(MonoModRules.PatchLoadLanguage))]
- class PatchLoadLanguageAttribute : Attribute { }
-
- static partial class MonoModRules {
-
- public static void PatchLoadLanguage(ILContext context, CustomAttribute attrib) {
- MethodDefinition m_GetLanguageText = context.Method.DeclaringType.FindMethod("System.Collections.Generic.IEnumerable`1 _GetLanguageText(System.String,System.Text.Encoding)");
- MethodDefinition m_NewLanguage = context.Method.DeclaringType.FindMethod("Celeste.Language _NewLanguage()");
- MethodDefinition m_SetItem = context.Method.DeclaringType.FindMethod("System.Void _SetItem(System.Collections.Generic.Dictionary`2,System.String,System.String,Celeste.Language)");
-
- ILCursor cursor = new ILCursor(context);
- cursor.GotoNext(instr => instr.MatchCall("System.IO.File", "ReadLines"));
- cursor.Next.Operand = m_GetLanguageText;
-
- cursor.GotoNext(instr => instr.MatchNewobj("Celeste.Language"));
- cursor.Next.OpCode = OpCodes.Call;
- cursor.Next.Operand = m_NewLanguage;
-
- // Start again from the top
- cursor.Goto(cursor.Instrs[0]);
- int matches = 0;
- while (cursor.TryGotoNext(instr => instr.MatchCallvirt("System.Collections.Generic.Dictionary`2", "set_Item"))) {
- matches++;
- // Push the language object. Should always be stored in the first local var.
- cursor.Emit(OpCodes.Ldloc_0);
- // Replace the method call.
- cursor.Next.OpCode = OpCodes.Call;
- cursor.Next.Operand = m_SetItem;
- }
- if (matches != 3) {
- throw new Exception("Incorrect number of matches for language.Dialog.set_Item");
- }
- }
-
- }
-}
diff --git a/Celeste.Mod.mm/Patches/Monocle/Tracker.cs b/Celeste.Mod.mm/Patches/Monocle/Tracker.cs
index c3165c558..bcb7b028e 100644
--- a/Celeste.Mod.mm/Patches/Monocle/Tracker.cs
+++ b/Celeste.Mod.mm/Patches/Monocle/Tracker.cs
@@ -39,15 +39,26 @@ class patch_Tracker : Tracker {
private static Type[] _temporaryAllTypes;
private static Type[] GetAllTypesUncached() => FakeAssembly.GetFakeEntryAssembly().GetTypesSafe();
-
+
+ private static int TrackedTypeVersion;
+
+ private int currentVersion;
+
+ public extern void orig_ctor();
+
+ [MonoModConstructor]
+ public void ctor() {
+ orig_ctor();
+ currentVersion = TrackedTypeVersion;
+ }
+
[MonoModReplace]
private static List GetSubclasses(Type type) {
bool shouldNullOutCache = _temporaryAllTypes is null;
_temporaryAllTypes ??= GetAllTypesUncached();
-
+
List subclasses = new();
- foreach (Type otherType in _temporaryAllTypes)
- {
+ foreach (Type otherType in _temporaryAllTypes) {
if (type != otherType && type.IsAssignableFrom(otherType))
subclasses.Add(otherType);
}
@@ -56,72 +67,117 @@ private static List GetSubclasses(Type type) {
// Let's do that now instead.
if (shouldNullOutCache)
_temporaryAllTypes = null;
-
+
return subclasses;
}
public static extern void orig_Initialize();
public new static void Initialize() {
_temporaryAllTypes = GetAllTypesUncached();
-
+
orig_Initialize();
// search for entities with [TrackedAs]
+ int oldVersion = TrackedTypeVersion;
foreach (Type type in _temporaryAllTypes) {
object[] customAttributes = type.GetCustomAttributes(typeof(TrackedAsAttribute), inherit: false);
foreach (object customAttribute in customAttributes) {
TrackedAsAttribute trackedAs = customAttribute as TrackedAsAttribute;
- Type trackedAsType = trackedAs.TrackedAsType;
- bool inherited = trackedAs.Inherited;
- if (typeof(Entity).IsAssignableFrom(type)) {
- if (!type.IsAbstract) {
- // this is an entity. copy the registered types for the target entity
- if (!TrackedEntityTypes.ContainsKey(type)) {
- TrackedEntityTypes.Add(type, new List());
- }
- TrackedEntityTypes[type].AddRange(TrackedEntityTypes.TryGetValue(trackedAsType, out List list) ? list : new List());
- TrackedEntityTypes[type] = TrackedEntityTypes[type].Distinct().ToList();
- }
- if (inherited) {
- // do the same for subclasses
- foreach (Type subclass in GetSubclasses(type)) {
- if (!subclass.IsAbstract) {
- if (!TrackedEntityTypes.ContainsKey(subclass))
- TrackedEntityTypes.Add(subclass, new List());
- TrackedEntityTypes[subclass].AddRange(TrackedEntityTypes.TryGetValue(trackedAsType, out List list) ? list : new List());
- TrackedEntityTypes[subclass] = TrackedEntityTypes[subclass].Distinct().ToList();
- }
- }
- }
- } else if (typeof(Component).IsAssignableFrom(type)) {
- if (!type.IsAbstract) {
- // this is an component. copy the registered types for the target component
- if (!TrackedComponentTypes.ContainsKey(type)) {
- TrackedComponentTypes.Add(type, new List());
- }
- TrackedComponentTypes[type].AddRange(TrackedComponentTypes.TryGetValue(trackedAsType, out List list) ? list : new List());
- TrackedComponentTypes[type] = TrackedComponentTypes[type].Distinct().ToList();
- }
- if (inherited) {
- // do the same for subclasses
- foreach (Type subclass in GetSubclasses(type)) {
- if (!subclass.IsAbstract) {
- if (!TrackedComponentTypes.ContainsKey(subclass))
- TrackedComponentTypes.Add(subclass, new List());
- TrackedComponentTypes[subclass].AddRange(TrackedComponentTypes.TryGetValue(trackedAsType, out List list) ? list : new List());
- TrackedComponentTypes[subclass] = TrackedComponentTypes[subclass].Distinct().ToList();
- }
- }
- }
- } else {
- // this is neither an entity nor a component. Help!
- throw new Exception("Type '" + type.Name + "' cannot be TrackedAs because it does not derive from Entity or Component");
- }
+ AddTypeToTracker(type, trackedAs.TrackedAsType, trackedAs.Inherited);
}
}
-
+ TrackedTypeVersion = oldVersion;
// don't hold references to all the types anymore
_temporaryAllTypes = null;
}
+
+ public static void AddTypeToTracker(Type type, Type trackedAs = null, bool inheritAll = false) {
+ AddTypeToTracker(type, trackedAs, inheritAll ? GetSubclasses(type).ToArray() : Array.Empty());
+ }
+
+ public static void AddTypeToTracker(Type type, Type trackedAs = null, params Type[] subtypes) {
+ Type trackedAsType = trackedAs != null && trackedAs.IsAssignableFrom(type) ? trackedAs : type;
+ bool? trackedEntity = typeof(Entity).IsAssignableFrom(type) ? true : typeof(Component).IsAssignableFrom(type) ? false : null;
+ if (trackedEntity == null) {
+ // this is neither an entity nor a component. Help!
+ throw new Exception("Type '" + type.Name + "' cannot be Tracked" + (trackedAsType != type ? "As" : "") + " because it does not derive from Entity or Component");
+ }
+ bool updated = false;
+ // copy the registered types for the target type
+ ((bool) trackedEntity ? StoredEntityTypes : StoredComponentTypes).Add(type);
+ Dictionary> tracked = (bool) trackedEntity ? TrackedEntityTypes : TrackedComponentTypes;
+ if (!type.IsAbstract) {
+ if (!tracked.TryGetValue(type, out List value)) {
+ value = new List();
+ tracked.Add(type, value);
+ }
+ int cnt = value.Count;
+ value.AddRange(tracked.TryGetValue(trackedAsType, out List list) ? list : new List());
+ List result = tracked[type] = value.Distinct().ToList();
+ updated = cnt != result.Count;
+ }
+ // do the same for subclasses
+ foreach (Type subtype in subtypes) {
+ if (trackedAsType.IsAssignableFrom(subtype) && !subtype.IsAbstract) {
+ if (!tracked.TryGetValue(subtype, out List value)) {
+ value = new List();
+ tracked.Add(subtype, value);
+ }
+ int cnt = value.Count;
+ value.AddRange(tracked.TryGetValue(trackedAsType, out List list) ? list : new List());
+ List result = tracked[subtype] = value.Distinct().ToList();
+ updated = cnt != result.Count;
+ }
+ }
+ if (updated) {
+ TrackedTypeVersion++;
+ }
+ }
+
+ ///
+ /// Ensures the 's tracker contains all entities of all tracked Types from the .
+ /// Must be called if a type is added to the tracker manually and if the 's Tracker isn't refreshed.
+ /// If called back to back without a type added to the Tracker, it won't go through again, for performance.
+ /// will make ensure the Refresh happens, even if run back to back.
+ /// Only the 's Tracker's refreshed state is changed.
+ /// If is null, it will default to Engine.Scene.
+ ///
+ public static void Refresh(Scene toUpdate = null, bool force = false) {
+ Scene scene = toUpdate ?? Engine.Scene;
+ if ((scene.Tracker as patch_Tracker).currentVersion >= TrackedTypeVersion && !force) {
+ return;
+ }
+ (scene.Tracker as patch_Tracker).currentVersion = TrackedTypeVersion;
+ foreach (Type entityType in StoredEntityTypes) {
+ if (!scene.Tracker.Entities.ContainsKey(entityType)) {
+ scene.Tracker.Entities.Add(entityType, new List());
+ }
+ }
+ foreach (Type componentType in StoredComponentTypes) {
+ if (!scene.Tracker.Components.ContainsKey(componentType)) {
+ scene.Tracker.Components.Add(componentType, new List());
+ }
+ }
+ foreach (Entity entity in scene.Entities) {
+ foreach (Component component in entity.Components) {
+ Type componentType = component.GetType();
+ if (!TrackedComponentTypes.TryGetValue(componentType, out List componentTypes)
+ || scene.Tracker.Components[componentType].Contains(component)) {
+ continue;
+ }
+ foreach (Type trackedType in componentTypes) {
+ scene.Tracker.Components[trackedType].Add(component);
+ }
+ }
+ Type entityType = entity.GetType();
+ if (!TrackedEntityTypes.TryGetValue(entityType, out List entityTypes)
+ || scene.Tracker.Entities[entityType].Contains(entity)) {
+ continue;
+ }
+ foreach (Type trackedType in entityTypes) {
+ scene.Tracker.Entities[trackedType].Add(entity);
+ }
+ }
+ }
}
}
diff --git a/Celeste.Mod.mm/Patches/Overworld.cs b/Celeste.Mod.mm/Patches/Overworld.cs
index 140a145e5..30f86a904 100644
--- a/Celeste.Mod.mm/Patches/Overworld.cs
+++ b/Celeste.Mod.mm/Patches/Overworld.cs
@@ -1,10 +1,18 @@
#pragma warning disable CS0626 // Method, operator, or accessor is marked external and has no attributes on it
+using Celeste;
using Celeste.Mod;
using Celeste.Mod.Meta;
using Celeste.Mod.UI;
+using Mono.Cecil;
using Monocle;
+using MonoMod;
+using MonoMod.Cil;
+using MonoMod.InlineRT;
+using MonoMod.Utils;
+using System;
using System.Collections.Generic;
+using System.Reflection;
namespace Celeste {
class patch_Overworld : Overworld {
@@ -14,6 +22,8 @@ class patch_Overworld : Overworld {
private Snow3D Snow3D;
#pragma warning restore CS0649
+ public Dictionary UIProperties { get; set; }
+
public patch_Overworld(OverworldLoader loader)
: base(loader) {
// no-op. MonoMod ignores this - we only need this to make the compiler shut up.
@@ -54,9 +64,7 @@ public override void Update() {
return;
}
- if (SaveData.Instance != null && (IsCurrent() || IsCurrent()
- || IsCurrent() || IsCurrent() || IsCurrent())) {
-
+ if (SaveData.Instance != null && IsCurrent(o => UIProperties?.GetValueOrDefault(o.GetType())?.PlayCustomMusic ?? false)) {
string backgroundMusic = mountainMetadata?.BackgroundMusic;
string backgroundAmbience = mountainMetadata?.BackgroundAmbience;
if (backgroundMusic != null || backgroundAmbience != null) {
@@ -79,6 +87,35 @@ public override void Update() {
}
}
+ public bool IsCurrent(Func predicate) {
+ if (Current != null) {
+ return predicate(Current);
+ }
+ return predicate(Last);
+ }
+
+ public Oui RegisterOui(Type type) {
+ Oui oui = (Oui) Activator.CreateInstance(type);
+ oui.Visible = false;
+ Add(oui);
+ UIs.Add(oui);
+ UIProperties ??= new() {
+ { typeof(OuiChapterSelect), new(true) },
+ { typeof(OuiChapterPanel), new(true) },
+ { typeof(OuiMapList), new(true) },
+ { typeof(OuiMapSearch), new(true) },
+ { typeof(OuiJournal), new(true) }
+ };
+ foreach (OuiPropertiesAttribute attrib in type.GetCustomAttributes()) {
+ UIProperties[type] = attrib;
+ }
+ return oui;
+ }
+
+ [MonoModIgnore]
+ [PatchOverworldRegisterOui]
+ public new extern void ReloadMenus(StartMode startMode = StartMode.Titlescreen);
+
public extern void orig_ReloadMountainStuff();
public new void ReloadMountainStuff() {
orig_ReloadMountainStuff();
@@ -108,3 +145,38 @@ private void restoreNormalMusicIfCustomized() {
}
}
}
+
+namespace MonoMod {
+ ///
+ /// Adjust the Overworld.ReloadMenus method to use the RegisterOui function, rather than registering manually
+ ///
+ [MonoModCustomMethodAttribute(nameof(MonoModRules.PatchOverworldRegisterOuiFunction))]
+ class PatchOverworldRegisterOuiAttribute : Attribute { }
+
+ static partial class MonoModRules {
+ public static void PatchOverworldRegisterOuiFunction(ILContext il, CustomAttribute attrib) {
+ MethodDefinition registerOuiMethod = MonoModRule.Modder.Module.GetType("Celeste.Overworld").FindMethod(nameof(patch_Overworld.RegisterOui));
+ //MethodInfo registerOuiMethod = typeof(patch_Overworld).GetMethod(nameof(patch_Overworld.RegisterOui));
+
+ ILCursor c = new(il);
+ c.GotoNext(MoveType.Before,
+ instr => instr.MatchLdloc(4),
+ instr => instr.MatchCall("System.Activator", nameof(Activator.CreateInstance)));
+ // We have Oui oui = (Oui)Activator.CreateInstance(type);
+ // Replace the right side of the equals with our register function
+
+ // this.
+ c.EmitLdarg0();
+ // Skip past `type` argument
+ c.GotoNext().GotoNext();
+ c.Remove();
+ c.Remove();
+ // RegisterOui()
+ c.EmitCall(registerOuiMethod);
+ // Skip past the "Oui oui ="
+ c.GotoNext().GotoNext();
+ // The next 10 instructions are already present in RegisterOui and can be removed
+ c.RemoveRange(10);
+ }
+ }
+}