diff --git a/Celeste.Mod.mm/Mod/Everest/Everest.Events.cs b/Celeste.Mod.mm/Mod/Everest/Everest.Events.cs index 55d5dfacd..eac6933d1 100644 --- a/Celeste.Mod.mm/Mod/Everest/Everest.Events.cs +++ b/Celeste.Mod.mm/Mod/Everest/Everest.Events.cs @@ -1,6 +1,7 @@ using Celeste.Mod.UI; using Microsoft.Xna.Framework; using System; +using System.Collections; using System.Collections.Generic; using _Decal = Celeste.Decal; using _EventTrigger = Celeste.EventTrigger; @@ -13,6 +14,8 @@ using _Seeker = Celeste.Seeker; using _AngryOshiro = Celeste.AngryOshiro; using _SubHudRenderer = Celeste.Mod.UI.SubHudRenderer; +using _FancyText = Celeste.FancyText; +using _Textbox = Celeste.Textbox; using Monocle; namespace Celeste.Mod { @@ -133,6 +136,13 @@ internal static void Pause(_Level level, int startIndex, bool minimal, bool quic public static event UnpauseHandler OnUnpause; internal static void Unpause(_Level level) => OnUnpause?.Invoke(level); + public delegate void SkipCutsceneHandler(_Level level); + /// + /// Called before skipping a cutscene. + /// + public static SkipCutsceneHandler OnSkipCutscene; + internal static void SkipCutscene(_Level level) => OnSkipCutscene?.Invoke(level); + public delegate void CreatePauseMenuButtonsHandler(_Level level, patch_TextMenu menu, bool minimal); /// /// Called when the Level's pause menu is created. @@ -382,6 +392,57 @@ public static class SubHudRenderer { internal static void BeforeRender(_SubHudRenderer renderer, Scene scene) => OnBeforeRender?.Invoke(renderer, scene); } + + public static class FancyText { + public delegate bool ParseCustomCommandHandler(_FancyText fancyText, string command, List args, Stack colorStack, _FancyText.Portrait[] lastPortrait); + public static event ParseCustomCommandHandler OnParseCustomCommand; + internal static bool ParseCustomCommand(_FancyText fancyText, string command, List args, Stack colorStack, _FancyText.Portrait[] lastPortrait) + => OnParseCustomCommand?.InvokeWhileFalse(fancyText, command, args, colorStack, lastPortrait) ?? false; + + public delegate void BeforeParseHandler(_FancyText fancyText); + public static event BeforeParseHandler OnBeforeParse; + internal static void BeforeParse(_FancyText fancyText) + => OnBeforeParse?.Invoke(fancyText); + + public delegate void AfterParseHandler(_FancyText fancyText); + public static event AfterParseHandler OnAfterParse; + internal static void AfterParse(_FancyText fancyText) + => OnAfterParse?.Invoke(fancyText); + + public delegate void WordAddedHandler(_FancyText fancyText, string word, int[] codepoints, List<_FancyText.Char> chars); + public static event WordAddedHandler OnWordAdded; + internal static void WordAdded(_FancyText fancyText, string word, int[] codepoints, List<_FancyText.Char> chars) + => OnWordAdded?.Invoke(fancyText, word, codepoints, chars); + + public delegate void BeforeDrawHandler(_FancyText fancyText, Vector2 position, Vector2 justify, Vector2 scale, float alpha, int start, int end); + public static event BeforeDrawHandler OnBeforeDraw; + internal static void BeforeDraw(_FancyText fancyText, Vector2 position, Vector2 justify, Vector2 scale, float alpha, int start, int end) + => OnBeforeDraw?.Invoke(fancyText, position, justify, scale, alpha, start, end); + + public delegate void AfterDrawHandler(_FancyText fancyText, Vector2 position, Vector2 justify, Vector2 scale, float alpha, int start, int end); + public static event AfterDrawHandler OnAfterDraw; + internal static void AfterDraw(_FancyText fancyText, Vector2 position, Vector2 justify, Vector2 scale, float alpha, int start, int end) + => OnAfterDraw?.Invoke(fancyText, position, justify, scale, alpha, start, end); + } + + public static class Textbox { + public delegate IEnumerable> AddCustomEventsHandler(_Textbox textbox, string dialog, Language language); + public static event AddCustomEventsHandler OnAddCustomEvents; + internal static List> AddCustomEvents(_Textbox textbox, string dialog, Language language) { + List> extraEvents = new(); + + if (OnAddCustomEvents is null) + return extraEvents; + + foreach (Delegate del in OnAddCustomEvents.GetInvocationList()) { + var res = del.DynamicInvoke(new object[] {textbox, dialog, language}); + if (res is IEnumerable> events) + extraEvents.AddRange(events); + } + + return extraEvents; + } + } } } } diff --git a/Celeste.Mod.mm/Patches/FancyText.cs b/Celeste.Mod.mm/Patches/FancyText.cs index d317fcbe3..7d1eefcdb 100644 --- a/Celeste.Mod.mm/Patches/FancyText.cs +++ b/Celeste.Mod.mm/Patches/FancyText.cs @@ -1,15 +1,32 @@ #pragma warning disable CS0626 // Method, operator, or accessor is marked external and has no attributes on it +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; using Celeste.Mod; +using Celeste.Mod.Helpers; using Microsoft.Xna.Framework; +using Mono.Cecil; +using Mono.Cecil.Cil; using Monocle; using MonoMod; +using MonoMod.Cil; +using MonoMod.InlineRT; +using MonoMod.Utils; namespace Celeste { - // The FancyText ctor is private. + // The FancyText ctor is private, so this cannot inherit from it. class patch_FancyText { + // helper class because manipulating generic types in IL is annoying + internal class ListChar { + public List elements = new(); + public static void Add(FancyText.Char c, ListChar self) => self.elements.Add(c); + } + [PatchTextIteration] + [PatchFancyTextAddWord] private extern void orig_AddWord(string word); private void AddWord(string word) { word = Emoji.Apply(word); @@ -33,5 +50,198 @@ public class patch_Char : FancyText.Char { } } + [MonoModIgnore] // We don't want to change anything about the method... + [PatchFancyTextParse] // ... except for manually manipulating the method via MonoModRules + private extern FancyText.Text Parse(); + + private static Color ApplyOpacityArgument(Color col, List args) { + if (args.Count > 0 && float.TryParse(args[0], NumberStyles.Float, CultureInfo.InvariantCulture, out float opacity)) + return col * opacity; + return col; + } + + private void ParseCustomCommand(string command, List args, Stack colorStack, FancyText.Portrait[] lastPortrait) { + if (Everest.Events.FancyText.ParseCustomCommand((FancyText)(object)this, command, args, colorStack, lastPortrait)) + return; + + Logger.Warn("EventTrigger", $"FancyText command '{command}' does not exist!"); + } + + private void BeforeParse() => Everest.Events.FancyText.BeforeParse((FancyText)(object)this); + private void AfterParse() => Everest.Events.FancyText.AfterParse((FancyText)(object)this); + + private void WordAdded(string word, UnicodeStringHelper.ListInt codepoints, ListChar chars) + => Everest.Events.FancyText.WordAdded((FancyText)(object)this, word, codepoints.Elements, chars.elements); + + public extern void orig_Draw(Vector2 position, Vector2 justify, Vector2 scale, float alpha, int start, int end); + public void Draw(Vector2 position, Vector2 justify, Vector2 scale, float alpha, int start = 0, int end = int.MaxValue) { + Everest.Events.FancyText.BeforeDraw((FancyText)(object)this, position, justify, scale, alpha, start, end); + orig_Draw(position, justify, scale, alpha, start, end); + Everest.Events.FancyText.AfterDraw((FancyText)(object)this, position, justify, scale, alpha, start, end); + } + } +} + +namespace MonoMod { + /// + /// A patch for FancyText parsing, allowing mods to register custom commands. + /// + [MonoModCustomMethodAttribute(nameof(MonoModRules.PatchFancyTextParse))] + class PatchFancyTextParse : Attribute { } + + /// + /// A patch for FancyText word-adding, allowing mods to manipulate each word. + /// + [MonoModCustomMethodAttribute(nameof(MonoModRules.PatchFancyTextAddWord))] + class PatchFancyTextAddWord : Attribute { } + + static partial class MonoModRules { + + public static void PatchFancyTextParse(ILContext context, CustomAttribute attrib) { + TypeDefinition t_FancyText = MonoModRule.Modder.FindType("Celeste.FancyText").Resolve(); + TypeDefinition t_Calc = MonoModRule.Modder.FindType("Monocle.Calc").Resolve(); + + MethodDefinition m_BeforeParse = t_FancyText.FindMethod("BeforeParse"); + MethodDefinition m_FancyText_ParseCustomCommand = t_FancyText.FindMethod("ParseCustomCommand"); + MethodDefinition m_FancyText_ApplyOpacityArgument= t_FancyText.FindMethod("ApplyOpacityArgument"); + MethodDefinition m_AfterParse = t_FancyText.FindMethod("AfterParse"); + MethodDefinition m_Calc_HexToColorWithAlpha = t_Calc.FindMethod("HexToColorWithAlpha"); + + VariableDefinition v_command = context.Body.Variables.First(v => v.VariableType.FullName == "System.String"); + VariableDefinition v_args = context.Body.Variables.First(v => v.VariableType.FullName == "System.Collections.Generic.List`1"); + VariableDefinition v_colorStack = context.Body.Variables.First(v => v.VariableType.FullName.StartsWith("System.Collections.Generic.Stack`1")); + VariableDefinition v_lastPortrait = context.Body.Variables.First(v => v.VariableType.FullName == "Celeste.FancyText/Portrait[]"); + + ILCursor cursor = new ILCursor(context); + + // + ldarg.0 + // + call Events.FancyText.BeforeParse + + cursor.EmitLdarg0(); + cursor.EmitCall(m_BeforeParse); + + // ... (getting color from {#hex}) + // - call Calc::HexToColor + // + call Calc::HexToColorWithAlpha + // + ldloc.8 (args) + // + call FancyText::ApplyOpacityArgument + // stfld valuetype Color FancyText::currentColor + + cursor.GotoNext(MoveType.After, + instr => instr.MatchCallOrCallvirt("Monocle.Calc", "HexToColor")); + + cursor.Previous.Operand = m_Calc_HexToColorWithAlpha; + cursor.EmitLdloc(v_args); + cursor.EmitCall(m_FancyText_ApplyOpacityArgument); + + // ldstr "savedata" + // callvirt System.Boolean System.String::Equals(System.String) + // - brfalse continue + // + // + brtrue savedata + // + ldarg.0 + // + ldloc.7 (command) + // + ldloc.8 (args) + // + ldloc.3 (stack) + // + ldloc.4 (lastPortrait) + // + call FancyText.ParseCustomCommand(this, command, args, colorStack, lastPortrait) + // + br continue + // + // + savedata: + // (code for handling {savedata}) + // continue: + + ILLabel label_continue = null; + ILLabel label_savedata = cursor.DefineLabel(); + + cursor.GotoNext(MoveType.After, + instr => instr.MatchLdstr("savedata"), + instr => instr.MatchCallOrCallvirt(out var _), + instr => instr.MatchBrfalse(out label_continue)); + + cursor.Index--; + cursor.Remove(); + + cursor.EmitBrtrue(label_savedata); + cursor.EmitLdarg0(); + cursor.EmitLdloc(v_command); + cursor.EmitLdloc(v_args); + cursor.EmitLdloc(v_colorStack); + cursor.EmitLdloc(v_lastPortrait); + cursor.EmitCall(m_FancyText_ParseCustomCommand); + cursor.EmitBr(label_continue); + + cursor.MarkLabel(label_savedata); + + // + ldarg.0 + // + call Events.FancyText.AfterParse + // ldarg.0 + // ldfld FancyText::group + // ret + + cursor.GotoNext(MoveType.Before, + instr => instr.MatchLdarg0(), + instr => instr.MatchLdfld(out var _), + instr => instr.MatchRet()); + + cursor.EmitLdarg0(); + cursor.EmitCall(m_AfterParse); + } + + public static void PatchFancyTextAddWord(ILContext context, CustomAttribute attrib) { + TypeDefinition t_FancyText = MonoModRule.Modder.FindType("Celeste.FancyText").Resolve(); + TypeDefinition t_ListChar = t_FancyText.NestedTypes.First(t => t.Name == "ListChar"); + + MethodDefinition m_FancyText_WordAdded = t_FancyText.FindMethod("WordAdded"); + MethodDefinition m_ListChar_ctor = t_ListChar.FindMethod(".ctor", true); + MethodDefinition m_ListChar_add = t_ListChar.FindMethod("Add"); + + VariableDefinition v_listOfCodePoints = context.Body.Variables.First(v => v.VariableType.FullName == "Celeste.Mod.Helpers.UnicodeStringHelper/ListInt"); + VariableDefinition v_chars = new VariableDefinition(t_ListChar); + context.Body.Variables.Add(v_chars); + + ILCursor cursor = new ILCursor(context); + + // ... (creating the codepoint list) + // + newobj FancyText/ListChar + // + stloc.7 (chars) + + cursor.GotoNext(MoveType.After, + instr => instr.MatchStloc(out int loc) && loc == v_listOfCodePoints.Index); + + cursor.EmitNewobj(m_ListChar_ctor); + cursor.EmitStloc(v_chars); + + // ... (creating this Char) + // + dup + // + ldloc.7 (chars) + // + call FancyText/ListChar::Add + // callvirt List::Add + + cursor.GotoNext(MoveType.Before, + instr => instr.MatchCallOrCallvirt(out var method) + && method.DeclaringType.FullName == "System.Collections.Generic.List`1" + && method.Name == "Add"); + + cursor.EmitDup(); + cursor.EmitLdloc(v_chars); + cursor.EmitCall(m_ListChar_add); + + // + ldarg.0 + // + ldarg.1 (word) + // + ldloc.6 (listOfCodePoints) + // + ldloc.7 (chars) + // + call FancyText/WordAdded + // ret + + cursor.GotoNext(MoveType.Before, + instr => instr.MatchRet()); + + cursor.EmitLdarg0(); + cursor.EmitLdarg1(); + cursor.EmitLdloc(v_listOfCodePoints); + cursor.EmitLdloc(v_chars); + cursor.EmitCall(m_FancyText_WordAdded); + } } } diff --git a/Celeste.Mod.mm/Patches/Level.cs b/Celeste.Mod.mm/Patches/Level.cs index b32cfaf6e..bde3446bd 100644 --- a/Celeste.Mod.mm/Patches/Level.cs +++ b/Celeste.Mod.mm/Patches/Level.cs @@ -217,6 +217,12 @@ public void Unpause() { } } + public extern void orig_SkipCutscene(); + public new void SkipCutscene() { + Everest.Events.Level.SkipCutscene(this); + orig_SkipCutscene(); + } + public extern void orig_TransitionTo(LevelData next, Vector2 direction); public new void TransitionTo(LevelData next, Vector2 direction) { orig_TransitionTo(next, direction); diff --git a/Celeste.Mod.mm/Patches/Textbox.cs b/Celeste.Mod.mm/Patches/Textbox.cs new file mode 100644 index 000000000..7ffcbdf7f --- /dev/null +++ b/Celeste.Mod.mm/Patches/Textbox.cs @@ -0,0 +1,36 @@ +#pragma warning disable CS0626 // Method, operator, or accessor is marked external and has no attributes on it +#pragma warning disable CS0649 // Field is never assigned to, and will always have its default value + +using System; +using System.Collections; +using Celeste.Mod; +using MonoMod; + +namespace Celeste { + class patch_Textbox : Textbox { + + // We're effectively in Textbox, but still need to "expose" private fields to our mod. + private Func[] events; + + public patch_Textbox(string dialog, Language language, params Func[] events) + : base(dialog, language, events) { + // no-op. MonoMod ignores this - we only need this to make the compiler shut up. + } + + public extern void orig_ctor(string dialog, Language language, params Func[] events); + [MonoModConstructor] + public void ctor(string dialog, Language language, params Func[] events) { + orig_ctor(dialog, language, events); + + var extraEvents = Everest.Events.Textbox.AddCustomEvents(this, dialog, language); + if (extraEvents.Count > 0) { + int initialCount = events.Length; + Array.Resize(ref events, initialCount + extraEvents.Count); + + for (int i = 0; i < extraEvents.Count; i++) + events[initialCount + i] = extraEvents[i]; + } + } + + } +}