diff --git a/CelesteNet.Client/CelesteNetClientFontMono.cs b/CelesteNet.Client/CelesteNetClientFontMono.cs index 86e142b1..14d2c39a 100644 --- a/CelesteNet.Client/CelesteNetClientFontMono.cs +++ b/CelesteNet.Client/CelesteNetClientFontMono.cs @@ -22,8 +22,10 @@ namespace Celeste.Mod.CelesteNet.Client { // Copy of ActiveFont that always uses a font with monospace Latin characters / Arabic numbers. public static class CelesteNetClientFontMono { - // English is always loaded. Other language fonts must be loaded manually. Load only loads once. - public static PixelFont Font => Fonts.Load(Dialog.Languages["japanese"].FontFace); + private static string FontFace => Dialog.Languages["japanese"].FontFace; + + // English is always loaded. Other language fonts must be loaded manually. Only load once. + public static PixelFont Font => Fonts.Get(FontFace) ?? Fonts.Load(FontFace); public static PixelFontSize FontSize => Font.Get(BaseSize); diff --git a/CelesteNet.Client/Components/CelesteNetChatComponent.cs b/CelesteNet.Client/Components/CelesteNetChatComponent.cs index 04829a22..1de0ebdb 100644 --- a/CelesteNet.Client/Components/CelesteNetChatComponent.cs +++ b/CelesteNet.Client/Components/CelesteNetChatComponent.cs @@ -142,6 +142,9 @@ public void Send(string text) { } public void Handle(CelesteNetConnection con, DataChat msg) { + if (Client == null) + return; + lock (Log) { if (msg.Player?.ID == Client.PlayerInfo?.ID) { foreach (DataChat pending in Pending.Values) { @@ -177,12 +180,20 @@ public void Handle(CelesteNetConnection con, DataChat msg) { public override void Update(GameTime gameTime) { base.Update(gameTime); + if (Client == null) { + Active = false; + return; + } + _Time += Engine.RawDeltaTime; _TimeSinceCursorMove += Engine.RawDeltaTime; + Overworld overworld = Engine.Scene as Overworld; bool isRebinding = Engine.Scene == null || Engine.Scene.Entities.FindFirst() != null || - Engine.Scene.Entities.FindFirst() != null; + Engine.Scene.Entities.FindFirst() != null || + ((overworld?.Current ?? overworld?.Next) is OuiFileNaming naming && naming.UseKeyboardInput) || + ((overworld?.Current ?? overworld?.Next) is UI.OuiModOptionString stringInput && stringInput.UseKeyboardInput); if (!(Engine.Scene?.Paused ?? true) || isRebinding) { string typing = Typing; diff --git a/CelesteNet.Client/Components/CelesteNetEmoteComponent.cs b/CelesteNet.Client/Components/CelesteNetEmoteComponent.cs index aa486d2a..f32e7c2e 100644 --- a/CelesteNet.Client/Components/CelesteNetEmoteComponent.cs +++ b/CelesteNet.Client/Components/CelesteNetEmoteComponent.cs @@ -103,7 +103,8 @@ public override void Update(GameTime gameTime) { if (Wheel == null) level.Add(Wheel = new(Player)); - if (!level.Paused && Settings.EmoteWheel && !Player.Dead) { + // TimeRate check is for Prologue Dash prompt freeze + if (!level.Paused && Settings.EmoteWheel && !Player.Dead && Engine.TimeRate > 0.05f) { Wheel.Shown = CelesteNetClientModule.Instance.JoystickEmoteWheel.Value.LengthSquared() >= 0.36f; int selected = Wheel.Selected; if (Wheel.Shown && selected != -1 && CelesteNetClientModule.Instance.ButtonEmoteSend.Pressed) { @@ -148,7 +149,9 @@ public override void Update(GameTime gameTime) { private void OnHeartGemCollect(On.Celeste.HeartGem.orig_Collect orig, HeartGem self, Player player) { orig(self, player); - Wheel?.TimeRateSkip.Add("HeartGem"); + Wheel?.TimeRateSkip.Add(self.IsFake ? "EmptySpaceHeart" : "HeartGem"); + if (self.IsFake && Wheel != null) + Wheel.timeSkipForcedDelay = 10f; } private void OnHeartGemEndCutscene(On.Celeste.HeartGem.orig_EndCutscene orig, HeartGem self) { diff --git a/CelesteNet.Client/Components/CelesteNetMainComponent.cs b/CelesteNet.Client/Components/CelesteNetMainComponent.cs index f58480cb..414f90b0 100644 --- a/CelesteNet.Client/Components/CelesteNetMainComponent.cs +++ b/CelesteNet.Client/Components/CelesteNetMainComponent.cs @@ -38,6 +38,7 @@ public class CelesteNetMainComponent : CelesteNetGameComponent { private AreaKey? MapEditorArea; private bool WasIdle; private bool WasInteractive; + private int SentHairLength = 0; public HashSet ForceIdle = new(); public bool StateUpdated; @@ -128,15 +129,7 @@ protected override void Dispose(bool disposing) { } public void Cleanup() { - Player = null; - PlayerBody = null; - Session = null; - WasIdle = false; - WasInteractive = false; - - foreach (Ghost ghost in Ghosts.Values) - ghost?.RemoveSelf(); - Ghosts.Clear(); + ResetState(); if (IsGrabbed && Player.StateMachine.State == Player.StFrozen) Player.StateMachine.State = Player.StNormal; @@ -168,8 +161,7 @@ public void Handle(CelesteNetConnection con, DataPlayerInfo player) { return; if (string.IsNullOrEmpty(player.DisplayName)) { - ghost.RunOnUpdate(ghost => ghost.NameTag.Name = ""); - Ghosts.TryRemove(player.ID, out _); + RemoveGhost(player); LastFrames.TryRemove(player.ID, out _); Client.Data.FreeOrder(player.ID); return; @@ -193,13 +185,9 @@ public void Handle(CelesteNetConnection con, DataChannelMove move) { } } else { - if (!Ghosts.TryGetValue(move.Player.ID, out Ghost ghost) || - ghost == null) + if (!RemoveGhost(move.Player)) return; - ghost.RunOnUpdate(ghost => ghost.NameTag.Name = ""); - Ghosts.TryRemove(move.Player.ID, out _); - foreach (DataType data in Client.Data.GetBoundRefs(move.Player)) if (data.TryGet(Client.Data, out MetaPlayerPrivateState state)) Client.Data.FreeBoundRef(data); @@ -219,10 +207,12 @@ public void Handle(CelesteNetConnection con, DataPlayerState state) { ghost == null) return; + if (Settings.Interactions != state.Interactive && ghost == GrabbedBy) + SendReleaseMe(); + Session session = Session; if (session != null && (state.SID != session.Area.SID || state.Mode != session.Area.Mode || state.Level == LevelDebugMap)) { - ghost.RunOnUpdate(ghost => ghost.NameTag.Name = ""); - Ghosts.TryRemove(id, out _); + RemoveGhost(state.Player); // If we get here, id must belong to a valid ghost, so it can't be uint.MaxValue and state.Player mustn't be null return; } @@ -278,7 +268,7 @@ public void Handle(CelesteNetConnection con, DataPlayerFrame frame) { UpdateIdleTag(ghost, ref ghost.IdleTag, state.Idle); ghost.UpdateGeneric(frame.Position, frame.Scale, frame.Color, frame.Facing, frame.Speed); ghost.UpdateAnimation(frame.CurrentAnimationID, frame.CurrentAnimationFrame); - ghost.UpdateHair(frame.Facing, frame.HairColors, frame.HairTexture0, frame.HairSimulateMotion); + ghost.UpdateHair(frame.Facing, frame.HairColors, frame.HairTexture0, frame.HairSimulateMotion && !state.Idle); ghost.UpdateDash(frame.DashWasB, frame.DashDir); // TODO: Get rid of this, sync particles separately! ghost.UpdateDead(frame.Dead && state.Level == session.Level); ghost.UpdateFollowers((Settings.Entities & CelesteNetClientSettings.SyncMode.Receive) == 0 ? Dummy.EmptyArray : frame.Followers); @@ -447,9 +437,12 @@ public void Handle(CelesteNetConnection con, DataMoveTo target) { public void Handle(CelesteNetConnection con, DataPlayerGrabPlayer grab) { Player player = Player; - if (Engine.Scene is not Level level || level.Paused || player == null || !Settings.Interactions) + if (player != null && !Settings.Interactions && (grab.Player.ID == Client.PlayerInfo.ID || grab.Grabbing.ID == Client.PlayerInfo.ID)) goto Release; + if (Engine.Scene is not Level level || level.Paused || player == null || !Settings.Interactions) + return; + if (grab.Player.ID != Client.PlayerInfo.ID && grab.Grabbing.ID == Client.PlayerInfo.ID) { if (GrabCooldown > 0f) { GrabCooldown = GrabCooldownMax; @@ -589,7 +582,7 @@ protected Ghost CreateGhost(Level level, DataPlayerInfo player, DataPlayerGraphi UnsupportedSpriteModes.Add(graphics.SpriteMode); RunOnMainThread(() => { level.Add(ghost); - level.OnEndOfFrame += () => ghost.Active = true; + //level.OnEndOfFrame += () => ghost.Active = true; ghost.UpdateGraphics(graphics); }); ghost.UpdateGraphics(graphics); @@ -597,9 +590,11 @@ protected Ghost CreateGhost(Level level, DataPlayerInfo player, DataPlayerGraphi return ghost; } - protected void RemoveGhost(DataPlayerInfo info) { - Ghosts.TryRemove(info.ID, out Ghost ghost); + protected bool RemoveGhost(DataPlayerInfo player) { + if (!Ghosts.TryRemove(player.ID, out Ghost ghost)) + return false; ghost?.RunOnUpdate(g => g.NameTag.Name = ""); + return true; } public void UpdateIdleTag(Entity target, ref GhostEmote idleTag, bool idle) { @@ -670,30 +665,27 @@ public override void Update(GameTime gameTime) { GrabbedBy = null; if (ready && Engine.Scene is MapEditor) { - Player = null; - PlayerBody = null; - Session = null; - WasIdle = false; - WasInteractive = false; + ResetState(); AreaKey area = (AreaKey) f_MapEditor_area.GetValue(null); if (MapEditorArea == null || MapEditorArea.Value.SID != area.SID || MapEditorArea.Value.Mode != area.Mode) { MapEditorArea = area; + // FIXME: NOTE BEFORE MERGING: can we move the ResetState() call here, which would be more inline with the below if? SendState(); } } if (Player != null && MapEditorArea == null) { - Player = null; - PlayerBody = null; - Session = null; - WasIdle = false; - WasInteractive = false; + ResetState(); SendState(); } return; } + foreach (Ghost g in Ghosts.Values) + if (g != null) + g.Active = true; + bool grabReleased = false; grabReleased |= IsGrabbed && (GrabTimeout += Engine.RawDeltaTime) >= GrabTimeoutMax; grabReleased |= GrabbedBy != null && GrabbedBy.Scene != level; @@ -725,17 +717,17 @@ public override void Update(GameTime gameTime) { } if (Player == null || Player.Scene != level) { - Player = level.Tracker.GetEntity(); - if (Player != null) { - PlayerBody = Player; - Session = level.Session; - WasIdle = false; - WasInteractive = false; + Player player = level.Tracker.GetEntity(); + if (player != null) { + ResetState(player, level.Session); StateUpdated |= true; SendGraphics(); } } + if (Player != null && Player.Sprite != null && SentHairLength != Player.Sprite.HairCount) + SendGraphics(); + bool idle = level.FrozenOrPaused || level.Overlay != null; if (WasIdle != idle) { WasIdle = idle; @@ -797,17 +789,14 @@ public void OnSetActualDepth(On.Monocle.Scene.orig_SetActualDepth orig, Scene sc public void OnLoadLevel(On.Celeste.Level.orig_LoadLevel orig, Level level, Player.IntroTypes playerIntro, bool isFromLoader = false) { orig(level, playerIntro, isFromLoader); - Session = level.Session; - WasIdle = false; - WasInteractive = false; - - if (Client == null) - return; + Player player = null; + if (Client != null) + player = level.Tracker.GetEntity(); - Player = level.Tracker.GetEntity(); - PlayerBody = Player; + ResetState(player, level.Session); - SendState(); + if (Client != null) + SendState(); } public void OnExitLevel(Level level, LevelExit exit, LevelExit.Mode mode, Session session, HiresSnow snow) { @@ -832,17 +821,15 @@ private Player OnLoadNewPlayer(On.Celeste.Level.orig_LoadNewPlayer orig, Vector2 private void OnPlayerAdded(On.Celeste.Player.orig_Added orig, Player self, Scene scene) { orig(self, scene); - Session = (scene as Level)?.Session; - WasIdle = false; - WasInteractive = false; - Player = self; - PlayerBody = self; - + ResetState(self, (scene as Level)?.Session); SendState(); SendGraphics(); - foreach (DataPlayerFrame frame in LastFrames.Values.ToArray()) - Handle(null, frame); + // We can't directly handle the frames here, as then ghost creation logic could fail if we're currently loading a level in OnEndOfFrame + scene.OnEndOfFrame += () => { + foreach (DataPlayerFrame frame in LastFrames.Values.ToArray()) + Handle(null, frame); + }; } private PlayerDeadBody OnPlayerDie(On.Celeste.Player.orig_Die orig, Player self, Vector2 direction, bool evenIfInvincible, bool registerDeathInStats) { @@ -908,6 +895,22 @@ private void ILTransitionRoutine(ILContext il) { #endregion + public void ResetState(Player player = null, Session ses = null) { + // Clear ghosts if the scene changed + if (player != null && player.Scene != Player?.Scene) { + lock (Ghosts) { + foreach (Ghost ghost in Ghosts.Values) + ghost?.RemoveSelf(); + Ghosts.Clear(); + } + } + + Player = player; + PlayerBody = player; + Session = ses; + WasIdle = false; + WasInteractive = false; + } #region Send @@ -966,6 +969,7 @@ public void SendGraphics() { HairScales = hairScales, HairTextures = hairTextures }); + SentHairLength = hairCount; } catch (Exception e) { Logger.Log(LogLevel.INF, "client-main", $"Error in SendGraphics:\n{e}"); Context.DisposeSafe(); diff --git a/CelesteNet.Client/Components/CelesteNetPlayerListComponent.cs b/CelesteNet.Client/Components/CelesteNetPlayerListComponent.cs index 322ff3e6..2cfd9923 100644 --- a/CelesteNet.Client/Components/CelesteNetPlayerListComponent.cs +++ b/CelesteNet.Client/Components/CelesteNetPlayerListComponent.cs @@ -67,6 +67,9 @@ public class CelesteNetPlayerListComponent : CelesteNetGameComponent { public bool ShowPing => Settings.PlayerListShowPing; private bool LastShowPing; + public bool AllowSplit => Settings.PlayerListAllowSplit; + private bool LastAllowSplit; + private float? SpaceWidth; private float? LocationSeparatorWidth; private float? IdleIconWidth; @@ -79,7 +82,7 @@ public class CelesteNetPlayerListComponent : CelesteNetGameComponent { private bool _splitViewPartially = false; private bool SplitViewPartially { get { - if (ListMode != ListModes.Channels || !Settings.PlayerListAllowSplit) + if (ListMode != ListModes.Channels || !AllowSplit) return _splitViewPartially = false; // only flip value after passing threshold to prevent flipping on +1/-1s at threshold if (!_splitViewPartially && SplittablePlayerCount > SplitThresholdUpper) @@ -157,6 +160,9 @@ public void RebuildListClassic(ref List list, ref DataPlayerInfo[] all) { if (Client.Data.TryGetBoundRef(player, out DataPlayerState state)) GetState(blob, state); + if (ShowPing && Client.Data.TryGetBoundRef(player, out DataConnectionInfo conInfo)) + blob.PingMs = conInfo.UDPPingMs ?? conInfo.TCPPingMs; + list.Add(blob); } @@ -386,6 +392,9 @@ private DataPlayerInfo ListPlayerUnderChannel(BlobPlayer blob, DataPlayerInfo pl if (locationMode != LocationModes.OFF && Client.Data.TryGetBoundRef(player, out DataPlayerState state)) GetState(blob, state); + if (ShowPing && locationMode != LocationModes.OFF && Client.Data.TryGetBoundRef(player, out DataConnectionInfo conInfo)) + blob.PingMs = conInfo.UDPPingMs ?? conInfo.TCPPingMs; + return player; } else { @@ -473,34 +482,25 @@ public void Handle(CelesteNetConnection con, DataConnectionInfo info) { // Don't rebuild the entire list // Try to find the player's blob - BlobPlayer playerBlob = (BlobPlayer) List?.First(b => b is BlobPlayer pb && pb.Player == info.Player); + BlobPlayer playerBlob = (BlobPlayer) List?.FirstOrDefault(b => b is BlobPlayer pb && pb.Player == info.Player); if (playerBlob == null) return; + DataChannelList.Channel own = Channels.List.FirstOrDefault(c => c.Players.Contains(Client.PlayerInfo.ID)); + if (ListMode == ListModes.Channels && !own.Players.Contains(info.Player.ID)) + return; + + PrepareRenderLayout(out float scale, out float y, out Vector2 sizeAll, out float spaceWidth, out float locationSeparatorWidth, out float idleIconWidth); + // Update the player's ping playerBlob.PingMs = info.UDPPingMs ?? info.TCPPingMs; // Regenerate the player blob playerBlob.Generate(); - // Re-measure the list - // This doesn't handle line splitting/etc, but is good enough - if (!SpaceWidth.HasValue || !LocationSeparatorWidth.HasValue || !IdleIconWidth.HasValue) { - // This should never happen, as the list has already been rendered at least once - // Still check just in case - Logger.Log(LogLevel.WRN, "playerlist", "!!!DEAD CODE REACHED!!! Player list layout values still uninitalized in ping update code!"); - return; - } + Vector2 size = playerBlob.Measure(spaceWidth, locationSeparatorWidth, idleIconWidth); - Vector2 sizeAll = Vector2.Zero; - foreach (Blob blob in List) { - Vector2 size = blob.Measure(SpaceWidth.Value, LocationSeparatorWidth.Value, IdleIconWidth.Value); - sizeAll.X = Math.Max(sizeAll.X, size.X); - sizeAll.Y += size.Y + 10f * Scale; - } - SizeAll = sizeAll; - SizeUpper = sizeAll; - SizeColumn = Vector2.Zero; + SizeAll.X = Math.Max(size.X, SizeAll.X); }); } @@ -522,10 +522,12 @@ public override void Update(GameTime gameTime) { if (LastListMode != ListMode || LastLocationMode != LocationMode || LastShowPing != ShowPing || + LastAllowSplit != AllowSplit || ShouldRebuild) { LastListMode = ListMode; LastLocationMode = LocationMode; LastShowPing = ShowPing; + LastAllowSplit = AllowSplit; ShouldRebuild = false; RebuildList(); } @@ -694,10 +696,10 @@ protected override void Generate(StringBuilder sb) { if (PingMs.HasValue) { int ping = PingMs.Value; - if (0 < ping) + if (ping > 0) PingBlob.Name = $"{ping}ms"; else - PingBlob.Name = "SPOOFED!"; // Someone messed with the packets + PingBlob.Name = "???ms"; // Someone messed with the packets, or server has no data yet } else PingBlob.Name = string.Empty; diff --git a/CelesteNet.Client/Components/CelesteNetRenderHelperComponent.cs b/CelesteNet.Client/Components/CelesteNetRenderHelperComponent.cs index a96ab2fa..cb9cd758 100644 --- a/CelesteNet.Client/Components/CelesteNetRenderHelperComponent.cs +++ b/CelesteNet.Client/Components/CelesteNetRenderHelperComponent.cs @@ -56,11 +56,15 @@ public void Rect(float x, float y, float width, float height, Color color) { Rectangle rect = new(xi, yi, wi, hi); - MDraw.SpriteBatch.Draw( - BlurRT, - rect, rect, - Color.White * Math.Min(1f, color.A / 255f * 2f) - ); + if (BlurRT != null) { + MDraw.SpriteBatch.Draw( + BlurRT, + rect, rect, + Color.White * Math.Min(1f, color.A / 255f * 2f) + ); + } else { + Logger.LogDetailed("cnet-rndrhlp", "BlurRT is null!"); + } MDraw.Rect(xi, yi, wi, hi, color); } diff --git a/CelesteNet.Client/Entities/GhostEmoteWheel.cs b/CelesteNet.Client/Entities/GhostEmoteWheel.cs index d285c1a1..f02eb548 100644 --- a/CelesteNet.Client/Entities/GhostEmoteWheel.cs +++ b/CelesteNet.Client/Entities/GhostEmoteWheel.cs @@ -2,9 +2,6 @@ using Monocle; using System; using System.Collections.Generic; -using System.Linq; -using System.Text; -using System.Threading.Tasks; namespace Celeste.Mod.CelesteNet.Client.Entities { // TODO: This is taken mostly as is from GhostNet and can be improved. @@ -22,6 +19,7 @@ public class GhostEmoteWheel : Entity { protected bool timeRateSet = false; public HashSet TimeRateSkip = new(); + public float timeSkipForcedDelay = -1f; public bool ForceSetTimeRate; public float Angle = 0f; @@ -48,7 +46,18 @@ public GhostEmoteWheel(Entity tracking) public override void Update() { // Update only runs while the level is "alive" (scene not paused or frozen). - if (TimeRateSkip.Count == 0 || ForceSetTimeRate) { + if (TimeRateSkip.Contains("EmptySpaceHeart") && + timeSkipForcedDelay <= 0f && + Engine.Scene is Level l && !l.InCutscene) { + TimeRateSkip.Remove("EmptySpaceHeart"); + } + + if (timeSkipForcedDelay >= 0f) { + timeSkipForcedDelay -= Engine.RawDeltaTime; + } + + // TimeRate check is for Prologue Dash prompt freeze + if (Engine.TimeRate > 0.05f && (TimeRateSkip.Count == 0 || ForceSetTimeRate)) { if (Shown && !timeRateSet) { Engine.TimeRate = 0.25f; timeRateSet = true; diff --git a/CelesteNet.Client/Entities/GhostNameTag.cs b/CelesteNet.Client/Entities/GhostNameTag.cs index 857f43ce..25e2dd70 100644 --- a/CelesteNet.Client/Entities/GhostNameTag.cs +++ b/CelesteNet.Client/Entities/GhostNameTag.cs @@ -45,7 +45,7 @@ public override void Render() { float scale = level.GetScreenScale(); - Vector2 pos = Tracking?.Position ?? Position; + Vector2 pos = Tracking?.BottomCenter ?? Position; pos.Y -= 16f; pos = level.WorldToScreen(pos);