diff --git a/Celeste.Mod.mm/Content/Dialog/English.txt b/Celeste.Mod.mm/Content/Dialog/English.txt index f12eed4e4..a7add94cd 100755 --- a/Celeste.Mod.mm/Content/Dialog/English.txt +++ b/Celeste.Mod.mm/Content/Dialog/English.txt @@ -44,6 +44,13 @@ MENU_MODOPTIONS_MOD_UPDATE_AVAILABLE= An update is available for 1 mod MENU_MODOPTIONS_MOD_UPDATES_AVAILABLE= Updates are available for {0} mods +# File selection + FILESELECT_SESSIONDETAILS_TOGGLE= Ongoing session + FILESELECT_SESSIONDETAILS_NOCURRENTSESSION= There is no ongoing session + FILESELECT_SESSIONDETAILS_MAPUNAVAILABLE= The map is unavailable + FILESELECT_SESSIONDETAILS_ONGOINGSESSION= Ongoing session + FILESELECT_SESSIONDETAILS_FIRSTPLAYTHROUGH= Map never completed yet + # Title Screen MENU_TITLESCREEN_RESTART_VANILLA= Restarting into orig/Celeste.exe MENU_TITLESCREEN_RESTART_VANILLA_SAVES_WARN= WARNING: Because of OS limitations, saves won't be shared! diff --git a/Celeste.Mod.mm/Content/Dialog/French.txt b/Celeste.Mod.mm/Content/Dialog/French.txt index 753e36d1d..97229248b 100755 --- a/Celeste.Mod.mm/Content/Dialog/French.txt +++ b/Celeste.Mod.mm/Content/Dialog/French.txt @@ -25,6 +25,13 @@ MENU_MODOPTIONS_UPDATE_AVAILABLE= Une mise à jour d'Everest est disponible MENU_MODOPTIONS_MOD_UPDATE_AVAILABLE= Mise à jour disponible pour 1 mod MENU_MODOPTIONS_MOD_UPDATES_AVAILABLE= Mises à jour disponibles pour {0} mods + +# File selection + FILESELECT_SESSIONDETAILS_TOGGLE= Session en cours + FILESELECT_SESSIONDETAILS_NOCURRENTSESSION= Aucune session en cours + FILESELECT_SESSIONDETAILS_MAPUNAVAILABLE= Le chapitre n'existe pas + FILESELECT_SESSIONDETAILS_ONGOINGSESSION= Session en cours + FILESELECT_SESSIONDETAILS_FIRSTPLAYTHROUGH= Chapitre jamais terminé # Title Screen MENU_TITLESCREEN_RESTART_VANILLA= Lancement du jeu non modifié diff --git a/Celeste.Mod.mm/Patches/OuiFileSelect.cs b/Celeste.Mod.mm/Patches/OuiFileSelect.cs index 2d1b0d7f7..177078023 100755 --- a/Celeste.Mod.mm/Patches/OuiFileSelect.cs +++ b/Celeste.Mod.mm/Patches/OuiFileSelect.cs @@ -8,15 +8,51 @@ using Mono.Cecil; using Mono.Cecil.Cil; using MonoMod.Cil; +using Microsoft.Xna.Framework; +using Microsoft.Xna.Framework.Graphics; +using static System.Formats.Asn1.AsnWriter; +using System.Dynamic; namespace Celeste { class patch_OuiFileSelect : OuiFileSelect { + public SessionDetailsPage SessionDetailsPage; + public bool ShowingSessionDetails; + + + public float SessionDetailsEase; + /* Used to coordinate the animation + * (also locks inputs for the first half of the animation (expect) for toggling session details, so you can revert your button press instantaneously) + * + * |-----|-----|-----|-----| + * 0 1 2 3 4 + * + * move slots: + * 0 to 2 + * -> calling OuiFileSelecSlot.MoveTo(...) when crossing '2' while decreasing or if already between 0 and 2 + * -> that launches a 0.25 second tween + * change ticketRenderPosition.X: + * 1 to 2 + * change ticketRenderPosition.Y: + * 2 to 3 + * show session details: + * 2 to 4 + * -> calling SessionDetailsPage.Show() when crossing '2' while increasing or if already between 2 and 4 + * -> the timing is then done by SessionDetailsPage + */ + internal bool startingNewFile; [PatchOuiFileSelectSubmenuChecks] // we want to manipulate the orig method with MonoModRules public extern IEnumerator orig_Enter(Oui from); public new IEnumerator Enter(Oui from) { + // ShowingSessionDetails = false; // TODO: check if that's needed + + if (SessionDetailsPage == null) { + SessionDetailsPage = new SessionDetailsPage(); + } + Scene.Add(SessionDetailsPage); + if (!Loaded) { int maxSaveFile; @@ -62,6 +98,9 @@ class patch_OuiFileSelect : OuiFileSelect { [PatchOuiFileSelectSubmenuChecks] // we want to manipulate the orig method with MonoModRules public extern IEnumerator orig_Leave(Oui next); public new IEnumerator Leave(Oui next) { + + Scene.Remove(SessionDetailsPage); + int slotIndex = 0; IEnumerator orig = orig_Leave(next); while (orig.MoveNext()) { @@ -87,23 +126,118 @@ class patch_OuiFileSelect : OuiFileSelect { public extern void orig_Update(); #pragma warning restore CS0626 public override void Update() { - int initialFileIndex = SlotIndex; + float easeBefore = SessionDetailsEase; + SessionDetailsEase = Calc.Approach(SessionDetailsEase, (ShowingSessionDetails) ? 1 : 0, Engine.DeltaTime * 2f); // takes 0.5s (twice as much as usual movements) - orig_Update(); + // That's just to time the sesion details animation (moving the files) : + if (!SlotSelected) { + if (easeBefore < 0.5f && SessionDetailsEase >= 0.5f) { + // showing the details page if we're showing the session details and cross 0.5 easing + SessionDetailsPage.Show(); + } else if (easeBefore > 0.5f && SessionDetailsEase <= 0.5f) { + // move the slots if we're hiding the session details and cross 0.5 easing + for (int i = 0; i < Slots.Length; i++) { + Vector2 pos = Slots[i].IdlePosition; + Slots[i].MoveTo(pos.X, pos.Y); + } + } + } - if (Focused && !SlotSelected) { - if (CoreModule.Settings.MenuPageUp.Pressed && SlotIndex > 0) { - float startY = Slots[SlotIndex].Y; - while (Slots[SlotIndex].Y > startY - 1080f && SlotIndex > 0) { - SlotIndex--; + int initialFileIndex = SlotIndex; // used to catch if the selection moved + + if (ShowingSessionDetails) { + // I don't wan't the orig_Update to catch the inputs + // (this implementation works because Focused isn't changed by orig_Update) + bool focused = Focused; + Focused = false; + orig_Update(); + Focused = focused; + + if (Focused && !SlotSelected) { + if (Input.MenuJournal.Pressed || Input.MenuCancel.Pressed) { + ShowingSessionDetails = false; + SessionDetailsPage.Hide(); + + if (SessionDetailsEase <= 0.5f) { + // If the ease value hase gone past this, then for a "nicer" animation we don't move the slots immediately. The movement is done higher in the Update method when the ease value passes this point + for (int i = 0; i < Slots.Length; i++) { + patch_OuiFileSelectSlot slot = (patch_OuiFileSelectSlot) Slots[i]; + Vector2 pos = slot.IdlePosition; + slot.MoveTo(pos.X, pos.Y); + } + } + } else if (SessionDetailsEase > 0.5f) { + // we don't want the animation to snap, so we don't catch inputs other than for going back if SessionDetailsEase is too low (and I don't know how SlotSelected could be true, but hey...)) + + if (Input.MenuUp.Pressed && SlotIndex > 0) { + Audio.Play("event:/ui/main/savefile_rollover_up"); + SlotIndex--; + SessionDetailsPage.UpdateSessionInfos(((patch_SaveData) Slots[SlotIndex].SaveData), ((patch_SaveData) Slots[SlotIndex].SaveData)?.CurrentSession_Safe); + SessionDetailsPage.QuickShow(); + } else if (Input.MenuDown.Pressed && SlotIndex < Slots.Length - 1) { + Audio.Play("event:/ui/main/savefile_rollover_down"); + SlotIndex++; + SessionDetailsPage.UpdateSessionInfos(((patch_SaveData) Slots[SlotIndex].SaveData), ((patch_SaveData) Slots[SlotIndex].SaveData)?.CurrentSession_Safe); + SessionDetailsPage.QuickShow(); + } else if (CoreModule.Settings.MenuPageUp.Pressed && SlotIndex > 0) { + Audio.Play("event:/ui/main/savefile_rollover_up"); + float startY = Slots[SlotIndex].Y; + while (Slots[SlotIndex].Y > startY - 1080f && SlotIndex > 0) { + SlotIndex--; + } + SessionDetailsPage.UpdateSessionInfos(((patch_SaveData) Slots[SlotIndex].SaveData), ((patch_SaveData) Slots[SlotIndex].SaveData)?.CurrentSession_Safe); + SessionDetailsPage.QuickShow(); + } else if (CoreModule.Settings.MenuPageDown.Pressed && SlotIndex < Slots.Length - 1) { + Audio.Play("event:/ui/main/savefile_rollover_down"); + float startY = Slots[SlotIndex].Y; + while (Slots[SlotIndex].Y < startY + 1080f && SlotIndex < Slots.Length - 1) { + SlotIndex++; + } + SessionDetailsPage.UpdateSessionInfos(((patch_SaveData) Slots[SlotIndex].SaveData), ((patch_SaveData) Slots[SlotIndex].SaveData)?.CurrentSession_Safe); + SessionDetailsPage.QuickShow(); + } } - Audio.Play("event:/ui/main/savefile_rollover_up"); - } else if (CoreModule.Settings.MenuPageDown.Pressed && SlotIndex < Slots.Length - 1) { - float startY = Slots[SlotIndex].Y; - while (Slots[SlotIndex].Y < startY + 1080f && SlotIndex < Slots.Length - 1) { - SlotIndex++; + } + } else { + // we don't want the animation to snap, so we don't catch the inputs if SessionDetailsEase is too high + // (this implementation works because Focused isn't changed by orig_Update) + bool focused = Focused; + Focused = focused && (SessionDetailsEase < 0.5f); + orig_Update(); + Focused = focused; + + if (Focused && !SlotSelected) { + if (Input.MenuJournal.Pressed) { + ShowingSessionDetails = true; + SessionDetailsPage.UpdateSessionInfos(((patch_SaveData) Slots[SlotIndex].SaveData), ((patch_SaveData) Slots[SlotIndex].SaveData)?.CurrentSession_Safe); + + if (SessionDetailsEase >= 0.5f) { + // If the ease value isn't high enough we're not in the rigth part of the grand animation scheme to show the details page yet. It'll be done higher in the function when we pass this point + SessionDetailsPage.Show(); + } + + for (int i = 0; i < Slots.Length; i++) { + patch_OuiFileSelectSlot slot = (patch_OuiFileSelectSlot) Slots[i]; + Vector2 pos = slot.IdlePosition; + slot.MoveTo(pos.X, pos.Y); + } + } else if (SessionDetailsEase < 0.5f) { + // again, we don't want the animation to snap, so we check SessionDetailsEase to lock inputs + + if (CoreModule.Settings.MenuPageUp.Pressed && SlotIndex > 0) { + float startY = Slots[SlotIndex].Y; + while (Slots[SlotIndex].Y > startY - 1080f && SlotIndex > 0) { + SlotIndex--; + } + Audio.Play("event:/ui/main/savefile_rollover_up"); + } else if (CoreModule.Settings.MenuPageDown.Pressed && SlotIndex < Slots.Length - 1) { + float startY = Slots[SlotIndex].Y; + while (Slots[SlotIndex].Y < startY + 1080f && SlotIndex < Slots.Length - 1) { + SlotIndex++; + } + Audio.Play("event:/ui/main/savefile_rollover_down"); + } } - Audio.Play("event:/ui/main/savefile_rollover_down"); } } @@ -114,7 +248,410 @@ public override void Update() { } } } + } + + public class SessionDetailsPage : Entity { + + private MTexture page = GFX.Gui["poempage"]; + private MTexture titleTexture; + private MTexture accentTexture; + private string areaName; + private string chapterString; + private Color titleBaseColor; + private Color titleAccentColor; + private Color titleTextColor; + private MTexture areaIcon; + private MTexture tab; + private Color tabColor = Calc.HexToColor("3c6180"); + private MTexture modeIcon; + private string modeString = ""; // An empty string will result in no mode shown (use a space to show only the modeIcon) + private long time; + private StrawberriesCounter strawberries; + private DeathsCounter deaths; // I don't know why it's public in OuiFileSelectSlot + private VirtualRenderTarget berryList; + private const float maxBerryListHeight = 70f; // TODO: think about this value more carefully (that's a gut feeling one) + private bool mapAlreadyCompleted; + private bool sessionExists; + private bool mapExists; + + private bool easingIn; // false if easing out (used to know if `ease` increases or decreases) + private float ease; // 0.0 to 0.7: position 0.3 to 1.0: banner position 0.7 to 1.0: mode tab position + + private float positionEase { + get { + return Ease.CubeOut(Math.Clamp(10 * ease, 0, 7) / 7); + } + } + + private float bannerEaseOffset { + get { + return positionEase - Ease.CubeOut((Math.Clamp(10 * ease, 3, 10) - 3) / 7); // the "ease offset" with positionEase implied by a 0.1 difference in the original ease + } + } + + private float modeTabEase { + get { + return Ease.CubeInOut((Math.Clamp(10 * ease, 7, 10) - 7) / 3); + } + } + + public SessionDetailsPage() : base() { + Active = true; + Visible = false; + easingIn = false; + Collidable = false; + Depth = -30; // to render it above the hovered slot when showing session details (and so, above the transparent black layer) + Position = new Vector2(2220f, 400f); + Add(deaths = new DeathsCounter(AreaMode.Normal, centeredX: true, 0)); + Add(strawberries = new StrawberriesCounter(true, 0)); + deaths.CanWiggle = false; // we will call deaths.Wiggle() manually on session infos update + strawberries.CanWiggle = false; // it does an annoying sound when it wiggles because of a changing aomunt, but we will call strawberries.Wiggle() manually on session infos update + + AddTag(Tags.HUD); // Aaarg, why did I take so much time to figure it out?! + } + + public override void Added(Scene scene) { + base.Added(scene); + berryList = VirtualContent.CreateRenderTarget("session-details_berry-list", (int) Math.Floor(page.Width * 0.8f), (int) Math.Floor(maxBerryListHeight)); + } + + public override void Removed(Scene scene) { + base.Removed(scene); + berryList.Dispose(); + } + + // This function allow to show details from any session, not only the current one from the savedata (even if we only use it like that, I want to have a more versatile interface here) + // -> TODO: this isn't really true yet (due to savedata.LevelSet) + public void UpdateSessionInfos(patch_SaveData savedata, Session session) { + + if (savedata == null || session == null || !session.InArea) { + sessionExists = false; + deaths.Visible = false; + strawberries.Visible = false; + return; + } + + sessionExists = true; + + // TODO: verify that this map availability check really works (taken from patch_LevelEnter.Routine) + // -> doesn't check if the mode exists (well, at the time of writing Everest doesn't do it either when loading the level from a session, so we don't care for now) + mapExists = (AreaData.Get(session) != null); + + time = session.Time; + strawberries.Amount = session.Strawberries.Count(); + strawberries.Visible = (strawberries.Amount > 0); + strawberries.Wiggle(); + deaths.SetMode(session.Area.Mode); + deaths.Amount = session.Deaths; + deaths.Wiggle(); + deaths.Visible = true; + + string modeIconName; // used later to retrieve the texture (a potentially custom one if the map exists) + switch (session.Area.Mode) { + case AreaMode.BSide: + modeIconName = "remix"; + modeString = Dialog.Clean("overworld_remix"); + break; + case AreaMode.CSide: + modeIconName = "rmx2"; + modeString = Dialog.Clean("overworld_remix2"); + break; + default: + modeIconName = "play"; + modeString = ""; // So the "mode" tab isn't displayed if on the A-Side + break; + } + + mapAlreadyCompleted = session.OldStats.Modes[(int) session.Area.Mode].Completed; + strawberries.ShowOutOf = mapAlreadyCompleted; + + if (mapExists) { + patch_AreaData areadata = patch_AreaData.Get(session); + + bakeBerryListTexture(session, page.Width * 0.8f); + strawberries.OutOf = session.MapData.ModeData.TotalStrawberries; + + titleBaseColor = areadata.TitleBaseColor; + titleAccentColor = areadata.TitleAccentColor; + titleTextColor = areadata.TitleTextColor; + + areaName = Dialog.Clean(areadata.Name); + chapterString = Dialog.Get("area_chapter").Replace("{x}", session.Area.ChapterIndex.ToString().PadLeft(2)); // TODO: I think I read about a way of customizing the chapter string, if that's the case, I need to make it compatible + + // compatibility with custom area title textures + string areaTextureName = $"areaselect/{areadata.Name}_title"; + string levelSetTextureName = $"areaselect/{savedata.LevelSet ?? "Celeste"}/title"; + if (GFX.Gui.Has(areaTextureName)) { + titleTexture = GFX.Gui[areaTextureName]; + } else if (GFX.Gui.Has(levelSetTextureName)) { + titleTexture = GFX.Gui[levelSetTextureName]; + } else { + titleTexture = GFX.Gui["areaselect/title"]; + } + + // compatibility with custom area accent textures + areaTextureName = $"areaselect/{areadata.Name}_accent"; + levelSetTextureName = $"areaselect/{savedata.LevelSet ?? "Celeste"}/accent"; + if (GFX.Gui.Has(areaTextureName)) { + accentTexture = GFX.Gui[areaTextureName]; + } else if (GFX.Gui.Has(levelSetTextureName)) { + accentTexture = GFX.Gui[levelSetTextureName]; + } else { + accentTexture = GFX.Gui["areaselect/accent"]; + } + + // compatibility with custom area tab textures + areaTextureName = $"areaselect/{areadata.Name}_tab"; + levelSetTextureName = $"areaselect/{savedata.LevelSet ?? "Celeste"}/tab"; + if (GFX.Gui.Has(areaTextureName)) { + tab = GFX.Gui[areaTextureName]; + } else if (GFX.Gui.Has(levelSetTextureName)) { + tab = GFX.Gui[levelSetTextureName]; + } else { + tab = GFX.Gui["areaselect/tab"]; + } + + // compatibility with custom mode icons + areaTextureName = $"menu/{areadata.Name}_{modeIconName}"; + levelSetTextureName = $"menu/{savedata.LevelSet ?? "Celeste"}/{modeIconName}"; + if (GFX.Gui.Has(areaTextureName)) { + modeIcon = GFX.Gui[areaTextureName]; + } else if (GFX.Gui.Has(levelSetTextureName)) { + modeIcon = GFX.Gui[levelSetTextureName]; + } else { + modeIcon = GFX.Gui[$"menu/{modeIconName}"]; + } + areaIcon = GFX.Gui[areadata.Icon]; + } else { + titleBaseColor = Color.White; + titleAccentColor = Color.Gray; + titleTextColor = Color.Black; + + areaName = session.Area.GetSID(); + chapterString = Dialog.Clean("FILESELECT_SESSIONDETAILS_MAPUNAVAILABLE"); + titleTexture = GFX.Gui["areaselect/title"]; + accentTexture = GFX.Gui["areaselect/accent"]; + areaIcon = GFX.Gui["areas/new"]; + tab = GFX.Gui["areaselect/tab"]; + modeIcon = GFX.Gui[$"menu/{modeIconName}"]; + } + } + + // mainly taken from GameplayStats + private void bakeBerryListTexture(Session session, float width) { + Engine.Graphics.GraphicsDevice.SetRenderTarget(berryList); + Engine.Graphics.GraphicsDevice.Clear(Color.Transparent); + Draw.SpriteBatch.Begin(); + + ModeProperties mode = session.MapData.ModeData; + + int totalStrawberries = mode.TotalStrawberries; + if (totalStrawberries <= 0) { + Draw.SpriteBatch.End(); + return; + } + + int spacing = 24; + int numberOfLinesLeft = 10; + float fullLinesOffset = 0f; + float lastLineOffset = 0f; + while ((1 + (numberOfLinesLeft - 1) * 1.5f) * spacing > maxBerryListHeight) { // + spacing -= 2; + + int strawbWidth = (totalStrawberries - 1) * spacing; + int checkpointWidth = ((totalStrawberries > 0 && mode.Checkpoints != null) ? (mode.Checkpoints.Length * spacing) : 0); + + numberOfLinesLeft = 0; + + fullLinesOffset = spacing / 2 + (width % spacing) / 2; + lastLineOffset = (width - strawbWidth - checkpointWidth) / 2; + while (lastLineOffset < spacing / 2) { + lastLineOffset += (width - spacing) / 2; + numberOfLinesLeft++; + } + } + + Vector2 pos; + if (numberOfLinesLeft > 0) { + pos = new Vector2(fullLinesOffset, spacing); + } else { + pos = new Vector2(lastLineOffset, spacing); + } + + int checkpoints = ((mode.Checkpoints == null) ? 1 : (mode.Checkpoints.Length + 1)); + for (int c = 0; c < checkpoints; c++) { + int checkpointTotal = ((c == 0) ? mode.StartStrawberries : mode.Checkpoints[c - 1].Strawberries); + for (int i = 0; i < checkpointTotal; i++) { + EntityData atCheckpoint = mode.StrawberriesByCheckpoint[c, i]; + if (atCheckpoint == null) { + continue; + } + bool currentHas = false; + foreach (EntityID strawb2 in session.Strawberries) { + if (atCheckpoint.ID == strawb2.ID && atCheckpoint.Level.Name == strawb2.Level) { + currentHas = true; + } + } + MTexture dot = GFX.Gui["dot"]; + if (currentHas) { + if (session.Area.Mode == AreaMode.CSide) { + dot.DrawOutlineCentered(pos, Calc.HexToColor("f2ff30"), 1.5f * spacing / 32f); + } else { + dot.DrawOutlineCentered(pos, Calc.HexToColor("ff3040"), 1.5f * spacing / 32f); + } + } else { + bool oldHas = false; + foreach (EntityID strawb in session.OldStats.Modes[(int) session.Area.Mode].Strawberries) { + if (atCheckpoint.ID == strawb.ID && atCheckpoint.Level.Name == strawb.Level) { + oldHas = true; + } + } + if (oldHas) { + dot.DrawOutlineCentered(pos, Calc.HexToColor("4193ff"), spacing / 32f); + } else { + Draw.Rect(pos.X - (float) dot.ClipRect.Width * spacing / 32f * 0.5f, pos.Y - 4f, dot.ClipRect.Width * spacing / 32f, 5f, Color.Black * 0.4f); + } + } + pos.X += spacing; + if (pos.X > (width - spacing / 2)) { + numberOfLinesLeft--; + pos.Y += spacing * 1.5f; + if (numberOfLinesLeft > 0) { + pos.X = fullLinesOffset; + } else { + pos.X = lastLineOffset; + } + } + } + if (mode.Checkpoints != null && c < mode.Checkpoints.Length) { + Draw.Rect(pos.X - 3f * spacing / 32f, pos.Y - spacing / 2f, 6f * spacing / 32f, spacing, Color.Black * 0.4f); + pos.X += spacing; + } + } + + Draw.SpriteBatch.End(); + } + + public override void Update() { + float previousEase = ease; + ease = Calc.Approach(ease, (easingIn) ? 1 : 0, Engine.DeltaTime * 2f); + + if (previousEase > 0f && ease == 0f) { + Visible = true; // maybe we should simplify this to `Visible = (ease == 0f)` and remove the Visible manips in the Show/Hide methods + } + + Position = Vector2.Lerp( + new Vector2(2220f, 400f), + new Vector2(1000f, 400f), + positionEase + ); + + base.Update(); + } + + public void Show() { + easingIn = true; + Visible = true; + } + + public void QuickShow() { + easingIn = true; + ease = 0.7f; // just when the page reached it's position (so there is only the banner moving) + Visible = true; + } + + public void Hide() { + easingIn = false; + if (ease == 0f) { + // Can't put Visible to false in most of the cases, since you want to see the hiding animation + // Visible will be set to false in the Update method if the ease value goes from strictly positive to 0 + // here we handle the edge case of calling Hide when Visible is true and ease is 0, just in case (doing something like `detailsPage.Show(); detailsPage.Hide()` could cause that) + Visible = false; + } + } + + public void InstantHide() { + easingIn = false; + ease = 0f; + Visible = false; + } + + public override void Render() { + page.Draw(Position, Vector2.Zero, Color.White, new Vector2(1f, 0.85f)); + + if (sessionExists) { // savedata shouldn't be null if session isn't (if the details have been updated) + Vector2 bannerPosShift = 1220f * bannerEaseOffset * Vector2.UnitX; + Vector2 tabPosShift = - tab.Height/2 * (1-modeTabEase) * Vector2.UnitY; + + if (modeString != "" && modeTabEase > 0f) { + Vector2 tabPos = Position + tabPosShift + new Vector2(760f, 218f); + tab.DrawCentered(tabPos, tabColor); + modeIcon.DrawCentered(tabPos + new Vector2(0f, -10f), Color.White, (float) (tab.Width - 50) / (float) modeIcon.Width); + ActiveFont.DrawOutline(modeString, tabPos + new Vector2(0f, -10 + modeIcon.Height * 0.3f), new Vector2(0.5f, 0f), Vector2.One * 0.7f, Color.White, 2f, Color.Black); + } + + float x = Math.Max( + ActiveFont.Measure(areaName).X * ((mapExists) ? 1f : 0.8f), + ActiveFont.Measure(chapterString).X * ((mapExists) ? 0.8f : 1f) + ); + x = Math.Clamp(x - 570f, -20f, 80f); + Vector2 titleBannerPos = Position + bannerPosShift + new Vector2(-x, 0); + titleTexture.Draw(titleBannerPos, Vector2.Zero, titleBaseColor); + accentTexture.Draw(titleBannerPos, Vector2.Zero, titleAccentColor); + + Vector2 areaIconPos = Position + bannerPosShift + new Vector2(790f, 86f); + float scale = ((mapExists) ? 144f : 100f) / areaIcon.Width; + areaIcon.DrawCentered(areaIconPos, Color.White, Vector2.One * scale); + + if (mapExists) { + ActiveFont.Draw(chapterString, areaIconPos + new Vector2(-100f, -2f), new Vector2(1f, 1f), Vector2.One * 0.6f, titleAccentColor * 0.8f); + ActiveFont.Draw(areaName, areaIconPos + new Vector2(-100f, -18f), new Vector2(1f, 0f), Vector2.One, titleTextColor * 0.8f); + } else { + ActiveFont.Draw(chapterString, areaIconPos + new Vector2(-100f, 18f), new Vector2(1f, 1f), Vector2.One, titleTextColor * 0.8f); + ActiveFont.Draw(areaName, areaIconPos + new Vector2(-100f, 2f), new Vector2(1f, 0f), Vector2.One * 0.6f, titleAccentColor * 0.8f); + } + + Vector2 linePos = new Vector2(page.Width / 2, 160f); + + string text = Dialog.Clean("FILESELECT_SESSIONDETAILS_ONGOINGSESSION"); + ActiveFont.Draw(text, Position + linePos, new Vector2(0.5f, 0f), Vector2.One, Color.Black * 0.8f); + + linePos.Y += ActiveFont.Measure(text).Y + 30; + linePos.X = 230; + + if (strawberries.Amount > 0) { + deaths.Position = linePos; + linePos.Y += 80; + strawberries.Position = linePos; + } else { + linePos.Y += 40; + deaths.Position = linePos; + linePos.Y += 40; + } + + linePos.X = page.Width * 0.9f; + linePos.Y += 20; + + ActiveFont.Draw(Dialog.Time(time), Position + linePos, new Vector2(1f, 0.5f), Vector2.One * 0.8f, Color.Black * 0.6f); + + linePos.Y = page.Height * 0.85f - 40 - maxBerryListHeight; + + if (mapAlreadyCompleted) { + if (mapExists) { + linePos.X = page.Width * 0.1f; + Draw.SpriteBatch.Draw((RenderTarget2D) berryList, Position + linePos, Color.White); + } + } else { + linePos.X = page.Width / 2; + ActiveFont.Draw(Dialog.Clean("FILESELECT_SESSIONDETAILS_FIRSTPLAYTHROUGH"), Position + linePos, new Vector2(0.5f, 0f), Vector2.One * 0.8f, Color.Black * 0.4f); + } + } else { + ActiveFont.Draw(Dialog.Clean("FILESELECT_SESSIONDETAILS_NOCURRENTSESSION"), Position + new Vector2(page.Width / 2, page.Height * 0.4f), new Vector2(0.5f, 0.5f), Vector2.One, Color.Black * 0.6f); + } + + base.Render(); + } } } diff --git a/Celeste.Mod.mm/Patches/OuiFileSelectSlot.cs b/Celeste.Mod.mm/Patches/OuiFileSelectSlot.cs index 4e44abf7e..f77389697 100644 --- a/Celeste.Mod.mm/Patches/OuiFileSelectSlot.cs +++ b/Celeste.Mod.mm/Patches/OuiFileSelectSlot.cs @@ -7,6 +7,7 @@ using Celeste.Mod.UI; using Microsoft.Xna.Framework; using Microsoft.Xna.Framework.Input; +using Microsoft.Xna.Framework.Graphics; using Mono.Cecil; using Mono.Cecil.Cil; using Monocle; @@ -18,6 +19,8 @@ using System.Collections; using System.Collections.Generic; using System.Linq; +using static Monocle.Ease; +using Celeste; namespace Celeste { public class patch_OuiFileSelectSlot : OuiFileSelectSlot { @@ -38,6 +41,7 @@ public interface ISubmenu { } private float selectedEase; private float newgameFade; private Wiggler wiggler; + private float highlightEase; [MonoModIgnore] private bool selected { get; set; } @@ -62,13 +66,61 @@ public interface ISubmenu { } private bool renamed; private bool Golden => !Corrupted && Exists && SaveData.TotalStrawberries >= maxStrawberryCountIncludingUntracked; + + private bool showingSessionDetails { + get { + if (((patch_OuiFileSelect) fileSelect).ShowingSessionDetails) { + return fileSelect.SlotIndex == FileSlot; + } + return false; + } + } + + private float sessionDetailsEase { + get { + return ((patch_OuiFileSelect) fileSelect).SessionDetailsEase; + } + } + + private bool highlighted { + [MonoModReplace] + get { + if (sessionDetailsEase > 0f) + return false; + else + return fileSelect.SlotIndex == FileSlot; + } + } // vanilla: new Vector2(960f, 540 + 310 * (FileSlot - 1)); => slot 1 is centered at all times // if there are 6 slots (0-based): slot 1 should be centered if slot 0 is selected; slot 4 should be centered if slot 5 is selected; the selected slot should be centered otherwise. // this formula doesn't change the behavior with 3 slots, since the slot index will be clamped between 1 and 1. public new Vector2 IdlePosition { [MonoModReplace] - get => new Vector2(960f, 540 + 310 * (FileSlot - Calc.Clamp(fileSelect.SlotIndex, 1, fileSelect.Slots.Length - 2))); + get { + float posX = 960f; + + if (((patch_OuiFileSelect) fileSelect).ShowingSessionDetails) { + // move the slots to the right (with an offset if it's the slot we're hovering) + posX = (fileSelect.SlotIndex == FileSlot) ? 500f : 440f; + } + + return new Vector2(posX, 540 + 310 * (FileSlot - Calc.Clamp(fileSelect.SlotIndex, 1, fileSelect.Slots.Length - 2))); + } + } + + private Vector2 ticketRenderPosition { + get { + + float posX = Position.X + Ease.CubeInOut(highlightEase) * 360f; + float posY = Position.Y; + if (fileSelect.SlotIndex == FileSlot) { + posX += (1400 - (Position.X + Ease.CubeInOut(highlightEase) * 360f)) * Ease.CubeInOut(Math.Clamp(4 * sessionDetailsEase, 1, 2) - 1); + posY += (200 - Position.Y) * Ease.CubeInOut(Math.Clamp(4 * sessionDetailsEase, 2, 3) - 2); + } + + return new Vector2(posX, posY); + } } public patch_OuiFileSelectSlot(int index, OuiFileSelect fileSelect, SaveData data) @@ -209,6 +261,10 @@ public void OnNewGameSelected() { public override void Update() { orig_Update(); + if (showingSessionDetails) { + Depth = -20; // put it above the other slots (Depth = 0 or -10) to render a black trasparent layer + } + if (newGameLevelSetPicker != null && selected && fileSelect.Selected && fileSelect.Focused && !StartingGame && tween == null && inputDelay <= 0f && !StartingGame && !deleting) { @@ -282,6 +338,11 @@ public class Button { [PatchFileSelectSlotRender] // manually manipulate the method via MonoModRules public extern void orig_Render(); public override void Render() { + if (showingSessionDetails) { + // drawing a trasparent black rectangle above the other slots (since Depth = -20 when hovered, -10 when highlighted, and and 0 when nothing) and below the session details (Depth=-30) + Draw.Rect(-10f, -10f, 1940f, 1100f, Color.Black * (float) Ease.CubeInOut(sessionDetailsEase) * 0.6f); + } + orig_Render(); if (selectedEase > 0f) { @@ -326,6 +387,8 @@ namespace MonoMod { /// /// IL-patch the Render method for file select slots instead of reimplementing it, /// to un-hardcode stamps. + /// Added: Also un-hardcode the ticket render position (used to show save details) + /// Added: replace a `highlightEase > 0f` by `(highlightEase > 0f || fileSelect.SessionDetailsEase > 0f)` /// [MonoModCustomMethodAttribute(nameof(MonoModRules.PatchFileSelectSlotRender))] class PatchFileSelectSlotRenderAttribute : Attribute { } @@ -352,6 +415,38 @@ public static void PatchFileSelectSlotRender(ILContext context, CustomAttribute FieldDefinition f_totalCassettes = declaringType.FindField("totalCassettes"); ILCursor cursor = new ILCursor(context); + + // unhardcode the ticket RenderPosition and replace it with a field reference + cursor.GotoNext(MoveType.Before, instr => instr.MatchCallvirt("Monocle.GraphicsComponent", "set_RenderPosition")); + cursor.Index -= 11; + // remove the ticket position calculation(everything between the first `this` and the `stloc`) + cursor.RemoveRange(7); + // replace with `this.ticketRenderPosition` + cursor.Emit(OpCodes.Callvirt, declaringType.FindMethod("get_ticketRenderPosition")); + + // replace `highlightEase > 0f` by `(highlightEase > 0f || fileSelect.SessionDetailsEase > 0f)` + ILLabel endIfLabel = default; + ILLabel endOrLabel = default; + cursor.GotoNext(MoveType.After, + instr => instr.MatchLdfld(declaringType.FullName, "highlightEase"), + instr => instr.MatchLdcR4(0f), + instr => instr.MatchBleUn(out endIfLabel) + ); + cursor.Index--; + cursor.Remove(); // will be replaced by a jump to after the second part of the OR if highlightEase > 0f + // emit the second part of the OR + cursor.Emit(OpCodes.Ldarg_0); + cursor.Emit(OpCodes.Ldfld, declaringType.FindField("fileSelect")); + cursor.Emit(OpCodes.Ldfld, ((TypeDefinition) declaringType.FindField("fileSelect").FieldType).FindField("SessionDetailsEase")); + cursor.Emit(OpCodes.Ldc_R4, 0f); + cursor.Emit(OpCodes.Ble_Un, endIfLabel); + /*cursor.Emit(OpCodes.Ldfld, declaringType.FindField("showingDetails")); + cursor.Emit(OpCodes.Brfalse, endIfLabel);*/ + // retrieve the label and go back to put the OR jump + endOrLabel = cursor.MarkLabel(); + cursor.Index -= 5; + cursor.Emit(OpCodes.Bgt, endOrLabel); + // SaveData.TotalStrawberries replaced by SaveData.TotalStrawberries_Safe with MonoModLinkFrom // Replace hardcoded ARB value with a field reference cursor.GotoNext(MoveType.After, instr => instr.MatchLdcI4(175)); diff --git a/Celeste.Mod.mm/Patches/Overworld.cs b/Celeste.Mod.mm/Patches/Overworld.cs index 140a145e5..0022eb366 100644 --- a/Celeste.Mod.mm/Patches/Overworld.cs +++ b/Celeste.Mod.mm/Patches/Overworld.cs @@ -4,10 +4,72 @@ using Celeste.Mod.Meta; using Celeste.Mod.UI; using Monocle; +using System; using System.Collections.Generic; +using MonoMod; +using Microsoft.Xna.Framework; namespace Celeste { class patch_Overworld : Overworld { + + private float inputEase; + + private float sessionDetailsInputEase; // Added + + private bool transitioning; + + private class patch_InputEntity : Entity { + + + // "exposing" fields for the patch + public patch_Overworld Overworld; + private Wiggler confirmWiggle; + private Wiggler cancelWiggle; + private float confirmWiggleDelay; + private float cancelWiggleDelay; + + // Added fields + private Wiggler sessionDetailsWiggle; + private float sessionDetailsWiggleDelay; + + public extern void orig_ctor(Overworld overworld); + + [MonoModConstructor] + public void ctor(Overworld overworld) { + orig_ctor(overworld); + sessionDetailsWiggle = Wiggler.Create(0.4f, 4f); + Add(sessionDetailsWiggle); + } + + public patch_InputEntity() + : base() { + // no-op. MonoMod ignores this - we only need this to make the compiler shut up. + } + + public extern void orig_Update(); + public override void Update() { + if (Input.MenuJournal.Pressed && sessionDetailsWiggleDelay <= 0f) { + sessionDetailsWiggle.Start(); + sessionDetailsWiggleDelay = 0.5f; + } + sessionDetailsWiggleDelay -= Engine.DeltaTime; + orig_Update(); + } + + public extern void orig_Render(); + public override void Render() { + orig_Render(); + + if (Overworld.sessionDetailsInputEase > 0f) { + string sessionDetailsLabel = Dialog.Clean("FILESELECT_SESSIONDETAILS_TOGGLE"); + float sessionDetailsWidth = ButtonUI.Width(sessionDetailsLabel, Input.MenuJournal); + + Vector2 pos = new Vector2(1880f, 1024f) + new Vector2((40 + sessionDetailsWidth) * (1f - Ease.CubeOut(Overworld.sessionDetailsInputEase)), -48f); + ButtonUI.Render(pos, sessionDetailsLabel, Input.MenuJournal, 0.5f, 1f, sessionDetailsWiggle.Value * 0.05f); + } + } + } + private bool customizedChapterSelectMusic = false; #pragma warning disable CS0649 // variable defined in vanilla @@ -35,6 +97,13 @@ public override void Update() { lock (AssetReloadHelper.AreaReloadLock) { orig_Update(); + bool showSessionDetailsUI = (Current is OuiFileSelect) + && !(Current as OuiFileSelect).SlotSelected; + if (Overlay == null && !transitioning || !showSessionDetailsUI) // TODO: why did I copy that, what does it mean? + { + sessionDetailsInputEase = Calc.Approach(sessionDetailsInputEase, (showSessionDetailsUI && !Input.GuiInputController(Input.PrefixMode.Latest)) ? 1 : 0, Engine.DeltaTime * 4f); + } + // if the mountain model is currently fading, use the one currently displayed, not the one currently selected, which is different if the fade isn't done yet. patch_AreaData currentAreaData = null; string currentlyDisplayedSID = (Mountain?.Model as patch_MountainModel)?.PreviousSID;