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(command); + } + + public static class AssetReload { + public delegate void ReloadHandler(bool silent); + public static event ReloadHandler OnBeforeReload, OnAfterReload; + + public static event ReloadHandler OnBeforeNextReload, OnAfterNextReload; + + internal static void BeforeReload(bool silent) { + OnBeforeReload?.Invoke(silent); + + var beforeNextReload = OnBeforeNextReload; + OnBeforeNextReload = null; + beforeNextReload?.Invoke(silent); + } + + internal static void AfterReload(bool silent) { + OnAfterReload?.Invoke(silent); + + var afterNextReload = OnAfterNextReload; + OnAfterNextReload = null; + afterNextReload?.Invoke(silent); + } + + public delegate void ReloadLevelHandler(global::Celeste.Level level); + public static ReloadLevelHandler OnReloadLevel; + internal static void ReloadLevel(global::Celeste.Level level) + => OnReloadLevel?.Invoke(level); + + public static Action OnReloadAllMaps; + internal static void ReloadAllMaps() + => OnReloadAllMaps?.Invoke(); + } + + public static class SubHudRenderer { + public delegate void BeforeRenderHandler(_SubHudRenderer renderer, Scene scene); + public static event BeforeRenderHandler OnBeforeRender; + internal static void BeforeRender(_SubHudRenderer renderer, Scene scene) + => OnBeforeRender?.Invoke(renderer, scene); + } + } + } +} diff --git a/Celeste.Mod.mm/Mod/Everest/Everest.Loader.cs b/Celeste.Mod.mm/Mod/Everest/Everest.Loader.cs index 72a1f7eff..0e181ade8 100644 --- a/Celeste.Mod.mm/Mod/Everest/Everest.Loader.cs +++ b/Celeste.Mod.mm/Mod/Everest/Everest.Loader.cs @@ -777,11 +777,7 @@ internal static void ProcessAssembly(EverestModuleMetadata meta, Assembly asm, T // we already are in the overworld. Register new Ouis real quick! if (Engine.Instance != null && Engine.Scene is Overworld overworld && typeof(Oui).IsAssignableFrom(type) && !type.IsAbstract) { Logger.Verbose("core", $"Instantiating UI from {meta}: {type.FullName}"); - - Oui oui = (Oui) Activator.CreateInstance(type); - oui.Visible = false; - overworld.Add(oui); - overworld.UIs.Add(oui); + ((patch_Overworld) overworld).RegisterOui(type); } } // We should run the map data processors again if new berry types are registered, so that CoreMapDataProcessor assigns them checkpoint IDs and orders. diff --git a/Celeste.Mod.mm/Mod/Registry/DecalRegistry.cs b/Celeste.Mod.mm/Mod/Registry/DecalRegistry.cs index 13dac6b2d..78d709601 100644 --- a/Celeste.Mod.mm/Mod/Registry/DecalRegistry.cs +++ b/Celeste.Mod.mm/Mod/Registry/DecalRegistry.cs @@ -26,6 +26,18 @@ public static class DecalRegistry { /// Stores whether Everest's DecalRegistryHandlers have been registered already. /// 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); + } + } +}