diff --git a/Defs/KeyBindings.xml b/Defs/KeyBindings.xml index 8c98be508..29c6cba02 100644 --- a/Defs/KeyBindings.xml +++ b/Defs/KeyBindings.xml @@ -31,4 +31,10 @@ Keypad0 + + + MpTogglePingMenu + + + \ No newline at end of file diff --git a/Source/Client/Comp/Game/MultiplayerGameComp.cs b/Source/Client/Comp/Game/MultiplayerGameComp.cs index 2f3909cc7..73c29512f 100644 --- a/Source/Client/Comp/Game/MultiplayerGameComp.cs +++ b/Source/Client/Comp/Game/MultiplayerGameComp.cs @@ -1,9 +1,12 @@ -using System.Collections.Generic; +using System; +using System.Collections.Generic; using System.Linq; using HarmonyLib; using Multiplayer.API; using Multiplayer.Client.Saving; using Multiplayer.Common; +using Multiplayer.Common.Networking.Packet; +using UnityEngine; using Verse; namespace Multiplayer.Client.Comp @@ -21,6 +24,45 @@ public class MultiplayerGameComp : IExposable, IHasSessionData public string idBlockBase64; + // Bucketed by placer faction loadID; SortedDictionary guarantees identical enumeration on every client. + public SortedDictionary> markersByFaction = new(); + public int nextMarkerId; + + // Bumped on every markersByFaction mutation; read by PingMenuWindow's row cache. Runtime-only. + public int markersVersion; + + // Host-authoritative, copied from ServerSettings at game start - every client must agree (drives FIFO eviction). + public int markerCapPerPlayer = PingMarkerCap.Default; + + // Materialized merge of markersByFaction.Values; rebuilt on markersVersion change. + private readonly List cachedAllMarkers = new(); + private int cachedAllMarkersVersion = -1; + + public IReadOnlyList AllMarkers + { + get + { + if (cachedAllMarkersVersion != markersVersion) + { + cachedAllMarkers.Clear(); + foreach (var bucket in markersByFaction.Values) + cachedAllMarkers.AddRange(bucket); + cachedAllMarkersVersion = markersVersion; + } + return cachedAllMarkers; + } + } + + public List GetOrCreateFactionMarkers(int factionLoadId) + { + if (!markersByFaction.TryGetValue(factionLoadId, out var bucket)) + { + bucket = new List(); + markersByFaction[factionLoadId] = bucket; + } + return bucket; + } + public bool IsLowestWins => timeControl == TimeControl.LowestWins; public PlayerData LocalPlayerDataOrNull => playerData.GetValueOrDefault(Multiplayer.session.playerId); @@ -35,6 +77,27 @@ public void ExposeData() Scribe_Values.Look(ref timeControl, "timeControl"); Scribe_Values.Look(ref nextSessionId, "nextSessionId"); + // Re-bucket must run in LoadingVars - Scribe_Collections.Look only populates the ref then. + List markersFlat = Scribe.mode == LoadSaveMode.Saving ? AllMarkers.ToList() : null; + Scribe_Collections.Look(ref markersFlat, "mpMarkers", LookMode.Deep); + if (Scribe.mode == LoadSaveMode.LoadingVars) + { + // Always drop stale select-times; loaded ids may overlap last-session marker ids. + LocationPings.DropStaleSelectTimes(); + if (markersFlat != null) + { + markersByFaction = new SortedDictionary>(); + foreach (var m in markersFlat) + GetOrCreateFactionMarkers(m.placedByFactionLoadId).Add(m); + markersVersion++; + } + } + Scribe_Values.Look(ref nextMarkerId, "mpNextMarkerId"); + Scribe_Values.Look(ref markerCapPerPlayer, "mpMarkerCapPerPlayer", PingMarkerCap.Default); + if (Scribe.mode == LoadSaveMode.LoadingVars) + // Hand-edited saves can land out-of-range values. + markerCapPerPlayer = PingMarkerCap.Clamp(markerCapPerPlayer); + // Store for back-compat conversion in GameExposeComponentsPatch if (Scribe.mode == LoadSaveMode.LoadingVars) Scribe_Values.Look(ref idBlockBase64, "globalIdBlock"); @@ -43,12 +106,30 @@ public void ExposeData() public void WriteSessionData(ByteWriter writer) { SyncSerialization.WriteSync(writer, playerData); + + // Joiner needs host's live marker list, not the autosave - markers placed between + // save and join would otherwise be missing, and nextMarkerId would diverge. + SyncSerialization.WriteSync(writer, AllMarkers.ToList()); + SyncSerialization.WriteSync(writer, Math.Max(0, nextMarkerId)); + SyncSerialization.WriteSync(writer, PingMarkerCap.Clamp(markerCapPerPlayer)); } public void ReadSessionData(ByteReader reader) { playerData = SyncSerialization.ReadSync>(reader); DebugSettings.godMode = LocalPlayerDataOrNull?.godMode ?? false; + + var markersFlat = SyncSerialization.ReadSync>(reader); + // Negative ids would alias with legacy markerId == 0 rows. + nextMarkerId = Math.Max(0, SyncSerialization.ReadSync(reader)); + markerCapPerPlayer = PingMarkerCap.Clamp(SyncSerialization.ReadSync(reader)); + + // Session data is fresher than the autosave the joiner just loaded - overwrite. + markersByFaction = new SortedDictionary>(); + if (markersFlat != null) + foreach (var m in markersFlat) + GetOrCreateFactionMarkers(m.placedByFactionLoadId).Add(m); + markersVersion++; } [SyncMethod(debugOnly = true)] @@ -57,6 +138,12 @@ public void SetGodMode(int playerId, bool godMode) playerData[playerId].godMode = godMode; } + [SyncMethod] + public void SetMarkerCapPerPlayer(int newCap) + { + markerCapPerPlayer = PingMarkerCap.Clamp(newCap); + } + public TimeSpeed GetLowestTimeVote(int tickableId, bool excludePaused = false) { return (TimeSpeed)playerData.Values diff --git a/Source/Client/Debug/DebugActions.cs b/Source/Client/Debug/DebugActions.cs index 46d05147b..b98e4f02a 100644 --- a/Source/Client/Debug/DebugActions.cs +++ b/Source/Client/Debug/DebugActions.cs @@ -231,7 +231,8 @@ public static void ShowDesync() { Find.WindowStack.Add(new DesyncedWindow( "Debug action", - new SaveableDesyncInfo(Multiplayer.game.sync, new ClientSyncOpinion(0), new ClientSyncOpinion(0), 0, true) + new SaveableDesyncInfo(Multiplayer.game.sync, new ClientSyncOpinion(0), new ClientSyncOpinion(0), 0, true, + new SaveableDesyncInfo.SnapshotFreshness(IsFresh: false, ElapsedMs: 0, SnapshotTick: -1, DesyncTick: -1, FallbackReason: "debug action - no live snapshot")) )); } diff --git a/Source/Client/Desyncs/SaveableDesyncInfo.cs b/Source/Client/Desyncs/SaveableDesyncInfo.cs index 200c3dfee..148d90111 100644 --- a/Source/Client/Desyncs/SaveableDesyncInfo.cs +++ b/Source/Client/Desyncs/SaveableDesyncInfo.cs @@ -22,11 +22,13 @@ public class SaveableDesyncInfo( ClientSyncOpinion local, ClientSyncOpinion remote, int diffAt, - bool diffAtFound) + bool diffAtFound, + SaveableDesyncInfo.SnapshotFreshness snapshotFreshness) { public readonly ClientSyncOpinion local = local; public readonly ClientSyncOpinion remote = remote; public readonly int diffAt = diffAt; + public readonly SnapshotFreshness snapshotFreshness = snapshotFreshness; private readonly Task metadata = Task.Run(MetadataGenerator.Generate); private readonly Task replay = Task.Run(SaveReplayIfApplicable); @@ -60,9 +62,10 @@ public void Save([CanBeNull] HostInfo hostInfo) } catch (AggregateException e) { - if (e.InnerExceptions.SingleOrDefault(inner => inner is TaskCanceledException) == null) throw; + if (!e.InnerExceptions.Any(inner => inner is TaskCanceledException)) throw; } - if (replay.IsCompletedSuccessfully) { + if (replay.IsCompletedSuccessfully) + { var replayFile = replay.Result; zip.CreateEntryFromFile(replayFile.FullName, "replay.rwmts", CompressionLevel.NoCompression); DeleteFileSilent(replayFile); @@ -71,9 +74,11 @@ public void Save([CanBeNull] HostInfo hostInfo) catch (Exception e) { Log.Error($"Exception writing desync info: {e}"); + if (replay.IsCompletedSuccessfully) + DeleteFileSilent(replay.Result); } - Log.Message($"Desync info writing took {watch.ElapsedMilliseconds}"); + Log.Message($"Desync info writing took {watch.ElapsedMilliseconds} ms"); } private string GetLocalTraces() @@ -106,6 +111,17 @@ private string GetDesyncDetails() { var desyncInfo = new StringBuilder(); + // gameComp can be null if the session was torn down between desync and Save click. + var comp = Multiplayer.game?.gameComp; + var markerCount = comp?.AllMarkers.Count.ToStringSafe() ?? "n/a"; + var nextMarkerId = comp?.nextMarkerId.ToStringSafe() ?? "n/a"; + var markerCap = comp?.markerCapPerPlayer.ToStringSafe() ?? "n/a"; + + // SnapshotTick == -1 means we have no snapshot to compare against (e.g. DebugActions.ShowDesync). + var lag = snapshotFreshness.SnapshotTick >= 0 + ? (snapshotFreshness.DesyncTick - snapshotFreshness.SnapshotTick).ToStringSafe() + : "n/a"; + desyncInfo .AppendLine("###Tick Data###") .AppendLine($"Arbiter Connected And Playing|||{Multiplayer.session.ArbiterPlaying}") @@ -123,6 +139,16 @@ private string GetDesyncDetails() .AppendLine($"Async time active|||{Multiplayer.GameComp.asyncTime}") .AppendLine($"Multifaction active|||{Multiplayer.GameComp.multifaction}") .AppendLine($"Map Count|||{Find.Maps?.Count.ToStringSafe()}") + .AppendLine($"Marker Count|||{markerCount}") + .AppendLine($"Next Marker Id|||{nextMarkerId}") + .AppendLine($"Marker Cap Per Player|||{markerCap}") + .AppendLine("\n###Replay Snapshot Freshness###") + .AppendLine($"Snapshot Tick|||{snapshotFreshness.SnapshotTick}") + .AppendLine($"Desync Tick|||{snapshotFreshness.DesyncTick}") + .AppendLine($"Snapshot Lag Ticks|||{lag}") + .AppendLine($"Refresh Succeeded|||{snapshotFreshness.IsFresh}") + .AppendLine($"Refresh Elapsed (ms)|||{snapshotFreshness.ElapsedMs}") + .AppendLine($"Fallback Reason|||{snapshotFreshness.FallbackReason ?? "n/a"}") .AppendLine("\n###CPU Info###") .AppendLine($"Processor Name|||{SystemInfo.processorType}") .AppendLine($"Processor Speed (MHz)|||{SystemInfo.processorFrequency}") @@ -196,4 +222,7 @@ private static void DeleteFileSilent(FileInfo file) } public record HostInfo([CanBeNull] string Traces, [CanBeNull] string JittedMethods); + + // SnapshotTick == DesyncTick when refresh succeeded; otherwise stale (autosave-aligned). + public record SnapshotFreshness(bool IsFresh, long ElapsedMs, int SnapshotTick, int DesyncTick, [CanBeNull] string FallbackReason); } diff --git a/Source/Client/Desyncs/SyncCoordinator.cs b/Source/Client/Desyncs/SyncCoordinator.cs index 0714065d5..3f7943c73 100644 --- a/Source/Client/Desyncs/SyncCoordinator.cs +++ b/Source/Client/Desyncs/SyncCoordinator.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Diagnostics; using System.Linq; using Multiplayer.Client.Desyncs; using Multiplayer.Client.Util; @@ -128,13 +129,60 @@ private void HandleDesync(ClientSyncOpinion oldOpinion, ClientSyncOpinion newOpi var diffAt = FindTraceHashesDiffTick(local, remote, out var found); Multiplayer.Client.Send(new ClientDesyncedPacket(local.startTick, diffAt)); + var snapshotInfo = TryRefreshSnapshotForDesync(); + MpUI.ClearWindowStack(); Find.WindowStack.Add(new DesyncedWindow( desyncMessage, - new SaveableDesyncInfo(this, local, remote, diffAt, found) + new SaveableDesyncInfo(this, local, remote, diffAt, found, snapshotInfo) )); } + // Refresh dataSnapshot synchronously on the main thread so the embedded replay.rwmts + // captures the divergent tick instead of the (default 5 min stale) autosave-aligned join + // point. SaveGameData isn't thread-safe (Scribe, Find.Maps). No SendGameData - peers must + // not see a join-point broadcast spawned by a desync. If the capture overruns the budget + // below we keep the stale snapshot, since we can't predict the cost ahead of time. + private const int SnapshotRefreshBudgetMs = 2000; + + private static SaveableDesyncInfo.SnapshotFreshness TryRefreshSnapshotForDesync() + { + var staleAt = Multiplayer.session.dataSnapshot?.CachedAtTime ?? -1; + var nowTick = TickPatch.Timer; + var watch = Stopwatch.StartNew(); + + try + { + var fresh = Replay.CaptureLocalSnapshot(); + watch.Stop(); + + if (watch.ElapsedMilliseconds > SnapshotRefreshBudgetMs) + { + Log.Warning( + $"[MP] Desync snapshot capture took {watch.ElapsedMilliseconds} ms " + + $"(budget {SnapshotRefreshBudgetMs} ms); keeping stale snapshot from tick {staleAt}."); + return new SaveableDesyncInfo.SnapshotFreshness( + IsFresh: false, ElapsedMs: watch.ElapsedMilliseconds, SnapshotTick: staleAt, + DesyncTick: nowTick, FallbackReason: $"capture exceeded {SnapshotRefreshBudgetMs} ms budget"); + } + + Multiplayer.session.dataSnapshot = fresh; + return new SaveableDesyncInfo.SnapshotFreshness( + IsFresh: true, ElapsedMs: watch.ElapsedMilliseconds, SnapshotTick: fresh.CachedAtTime, + DesyncTick: nowTick, FallbackReason: null); + } + catch (Exception e) + { + watch.Stop(); + Log.Warning( + $"[MP] Desync snapshot capture threw after {watch.ElapsedMilliseconds} ms; " + + $"keeping stale snapshot from tick {staleAt}. Exception: {e.Message}"); + return new SaveableDesyncInfo.SnapshotFreshness( + IsFresh: false, ElapsedMs: watch.ElapsedMilliseconds, SnapshotTick: staleAt, + DesyncTick: nowTick, FallbackReason: $"exception: {e.GetType().Name}: {e.Message}"); + } + } + private static int FindTraceHashesDiffTick(ClientSyncOpinion local, ClientSyncOpinion remote, out bool found) { found = true; diff --git a/Source/Client/EarlyInit.cs b/Source/Client/EarlyInit.cs index 35d89bb7e..af0795ea9 100644 --- a/Source/Client/EarlyInit.cs +++ b/Source/Client/EarlyInit.cs @@ -59,6 +59,10 @@ internal static void InitSync() internal static void LatePatches() { + // Inject runtime translation keys (e.g. MarkerInspectTab labelKey) into the active language. + // Needed for keys that travel through vanilla code paths which hardcode .Translate(). + PingRuntimeTranslations.Register(); + if (MpVersion.IsDebug) Log.Message("== Structure == \n" + SyncDict.syncWorkers.PrintStructure()); } diff --git a/Source/Client/Multiplayer.cs b/Source/Client/Multiplayer.cs index 5ea4b1760..88da627bc 100644 --- a/Source/Client/Multiplayer.cs +++ b/Source/Client/Multiplayer.cs @@ -210,6 +210,26 @@ public static void StopMultiplayer() OnMainThread.ClearScheduled(); LongEventHandler.ClearQueuedEvents(); + // Faction loadIDs are per-world; mutes by loadID would silently apply to unrelated + // factions in the next session. Username mutes survive - usernames are stable. + if (settings is { } s && s.hiddenFactionLoadIds is { Count: > 0 }) + { + s.hiddenFactionLoadIds.Clear(); + MultiplayerLoader.Multiplayer.instance?.WriteSettings(); + } + // PingInfo instances die with the session; sweep vanilla's static selectTimes dict. + LocationPings.DropStaleSelectTimes(); + // Close ping windows individually so each runs PostClose (rect persistence). + // ClearWindowStack bypasses PostClose and their OnGUI dereferences soon-dead state. + var ws = Find.WindowStack; + if (ws != null) + { + ws.WindowOfType()?.Close(false); + ws.WindowOfType()?.Close(false); + ws.WindowOfType()?.Close(false); + ws.WindowOfType()?.Close(false); + } + if (session != null) { session.Stop(); diff --git a/Source/Client/MultiplayerGame.cs b/Source/Client/MultiplayerGame.cs index ae9f3431d..784e0b798 100644 --- a/Source/Client/MultiplayerGame.cs +++ b/Source/Client/MultiplayerGame.cs @@ -142,6 +142,16 @@ public void ChangeRealPlayerFaction(Faction newFaction, bool regenMapDrawers = t Find.MainTabsRoot?.EscapeCurrentTab(); Find.ColonistBar?.MarkColonistsDirty(); + + // regenMapDrawers:false is the internal SaveAndReload faction-switch (transient). + // Only close the rename modal and drop the gizmo cache on a real switch. + if (regenMapDrawers) + { + var loc = Multiplayer.session?.locationPings; + if (loc != null) + loc.cachedGizmos = null; // Next BuildGizmos rebuilds the key alongside. + Find.WindowStack?.WindowOfType()?.Close(false); + } } } } diff --git a/Source/Client/MultiplayerStatic.cs b/Source/Client/MultiplayerStatic.cs index 49519f9b9..92e08e1ab 100644 --- a/Source/Client/MultiplayerStatic.cs +++ b/Source/Client/MultiplayerStatic.cs @@ -28,9 +28,325 @@ public static class MultiplayerStatic { public static KeyBindingDef ToggleChatDef = KeyBindingDef.Named("MpToggleChat"); public static KeyBindingDef PingKeyDef = KeyBindingDef.Named("MpPingKey"); + // No default bind - user assigns via Keyboard Config. + public static KeyBindingDef TogglePingMenuDef = KeyBindingDef.Named("MpTogglePingMenu"); public static readonly Texture2D PingBase = ContentFinder.Get("Multiplayer/PingBase"); public static readonly Texture2D PingPin = ContentFinder.Get("Multiplayer/PingPin"); + + // Procedural, antialiased, white - tint at draw time. + public static readonly Texture2D PingCircle = MakeCircleTex(256, outerRadius: 127.5f, innerRadius: 0f); + public static readonly Texture2D PingRing = MakeCircleTex(256, outerRadius: 127.5f, innerRadius: 108f); + + // Pre-rotated wheel sector textures live next to LocationPings.WheelOptions so the slot + // count can't desync - see LocationPings.PingSectors / PingSectorArcs. + public static readonly Texture2D PingChevronUp = MakeChevronUpTex(64); + + // reportFailure=false so a missing path returns null and the renderer falls back to Glyph(). + public static readonly Texture2D PingIconAttack = ContentFinder.Get("UI/Commands/AttackMelee", false); + public static readonly Texture2D PingIconDefend = ContentFinder.Get("UI/Designators/HomeAreaOn", false); + public static readonly Texture2D PingIconHelp = ContentFinder.Get("UI/Commands/AsMedical", false); + public static readonly Texture2D PingIconLoot = ContentFinder.Get("UI/Buttons/TradeMode", false); + public static readonly Texture2D PingIconRally = ContentFinder.Get("UI/Commands/GatherSpotActive", false); + + // Gizmo action icons reuse vanilla UI/ atlases (visibility toggles, reset arrows). + public static readonly Texture2D PingHideForMeIcon = ContentFinder.Get("UI/Designators/PlanHide"); + public static readonly Texture2D PingShowForMeIcon = ContentFinder.Get("UI/Designators/PlanOn"); + public static readonly Texture2D PingResetViewIcon = ContentFinder.Get("UI/Commands/TempReset"); + // Procedural - half-faded disc for the transparency gizmo. + public static readonly Texture2D PingTransparencyIcon = MakeFadeDiscTex(128); + // Procedural - selection corners with central X for the deselect gizmo. + public static readonly Texture2D PingDeselectIcon = MakeDeselectTex(128); + // Procedural - speaker + knockout slash. Shared by all mute actions; the label carries the distinction. + public static readonly Texture2D PingMuteIcon = MakeMuteTex(128); + + private static Texture2D MakeCircleTex(int size, float outerRadius, float innerRadius) + { + var tex = new Texture2D(size, size, TextureFormat.RGBA32, false); + var pixels = new Color32[size * size]; + var center = (size - 1) / 2f; + + for (int y = 0; y < size; y++) + { + for (int x = 0; x < size; x++) + { + var dx = x - center; + var dy = y - center; + var d = Mathf.Sqrt(dx * dx + dy * dy); + + float alpha; + if (innerRadius <= 0f) + { + alpha = Mathf.Clamp01(outerRadius - d + 0.5f); + } + else + { + var inA = Mathf.Clamp01(d - innerRadius + 0.5f); + var outA = Mathf.Clamp01(outerRadius - d + 0.5f); + alpha = Mathf.Min(inA, outA); + } + + pixels[y * size + x] = new Color32(255, 255, 255, (byte)(alpha * 255f)); + } + } + + tex.SetPixels32(pixels); + tex.filterMode = FilterMode.Bilinear; + tex.wrapMode = TextureWrapMode.Clamp; + tex.Apply(); + return tex; + } + + // Annular sector with axis at centerAngleDeg clockwise from screen-up, half-width halfAngleDeg. + // Convention: high py = top of rect on screen, so +dy is "screen up" here. + internal static Texture2D MakeSectorTex(int size, float outerRadius, float innerRadius, float halfAngleDeg, float centerAngleDeg) + { + var tex = new Texture2D(size, size, TextureFormat.RGBA32, false); + var pixels = new Color32[size * size]; + var center = (size - 1) / 2f; + var halfA = halfAngleDeg * Mathf.Deg2Rad; + var centerA = centerAngleDeg * Mathf.Deg2Rad; + + // Outward normals of the right/left radial boundary lines (+x right, +y up). + var cosR = Mathf.Cos(centerA + halfA); + var sinR = Mathf.Sin(centerA + halfA); + var cosL = Mathf.Cos(centerA - halfA); + var sinL = Mathf.Sin(centerA - halfA); + + for (int py = 0; py < size; py++) + { + for (int px = 0; px < size; px++) + { + var dx = px - center; + var dy = py - center; + var d = Mathf.Sqrt(dx * dx + dy * dy); + + var inA = Mathf.Clamp01(d - innerRadius + 0.5f); + var outA = Mathf.Clamp01(outerRadius - d + 0.5f); + var radialAlpha = Mathf.Min(inA, outA); + + var dRight = dx * cosR - dy * sinR; + var dLeft = -dx * cosL + dy * sinL; + var angularAlpha = Mathf.Clamp01(0.5f - Mathf.Max(dRight, dLeft)); + + var alpha = radialAlpha * angularAlpha; + pixels[py * size + px] = new Color32(255, 255, 255, (byte)(alpha * 255f)); + } + } + + tex.SetPixels32(pixels); + tex.filterMode = FilterMode.Bilinear; + tex.wrapMode = TextureWrapMode.Clamp; + tex.Apply(); + return tex; + } + + // Distance-to-line field with AA band so the texture scales cleanly without re-baking. + // Apex (V's point) at HIGH py, arm ends at LOW py - matches MakeSectorTex convention. + private static Texture2D MakeChevronUpTex(int size) + { + var tex = new Texture2D(size, size, TextureFormat.RGBA32, false); + var pixels = new Color32[size * size]; + var center = (size - 1) / 2f; + var strokeHalf = size * 0.10f; + var apexY = size * 0.78f; + var armEndY = size * 0.22f; + var armEndDx = size * 0.36f; + + for (int py = 0; py < size; py++) + { + for (int px = 0; px < size; px++) + { + var dx = px - center; + var dy = py; + + var distR = DistToSegment(dx, dy, 0f, apexY, armEndDx, armEndY); + var distL = DistToSegment(dx, dy, 0f, apexY, -armEndDx, armEndY); + var d = Mathf.Min(distR, distL); + var alpha = Mathf.Clamp01(strokeHalf - d + 0.5f); + + pixels[py * size + px] = new Color32(255, 255, 255, (byte)(alpha * 255f)); + } + } + + tex.SetPixels32(pixels); + tex.filterMode = FilterMode.Bilinear; + tex.wrapMode = TextureWrapMode.Clamp; + tex.Apply(); + return tex; + } + + // Disc with horizontal alpha gradient - opaque on the left half, fading to ~15% on the right. + private static Texture2D MakeFadeDiscTex(int size) + { + var tex = new Texture2D(size, size, TextureFormat.RGBA32, false); + var pixels = new Color32[size * size]; + var center = (size - 1) / 2f; + var outerRadius = size * 0.46f; + + for (int y = 0; y < size; y++) + { + for (int x = 0; x < size; x++) + { + var dx = x - center; + var dy = y - center; + var d = Mathf.Sqrt(dx * dx + dy * dy); + var circleAlpha = Mathf.Clamp01(outerRadius - d + 0.5f); + + // Left edge (x=0) opaque, right edge (x=size-1) at minAlpha. + var t = (float)x / (size - 1); + var horizontalAlpha = Mathf.Lerp(1f, 0.18f, t); + + var alpha = circleAlpha * horizontalAlpha; + pixels[y * size + x] = new Color32(255, 255, 255, (byte)(alpha * 255f)); + } + } + + tex.SetPixels32(pixels); + tex.filterMode = FilterMode.Bilinear; + tex.wrapMode = TextureWrapMode.Clamp; + tex.Apply(); + return tex; + } + + // Selection-corner brackets at the four corners + a central X. + private static Texture2D MakeDeselectTex(int size) + { + var tex = new Texture2D(size, size, TextureFormat.RGBA32, false); + var pixels = new Color32[size * size]; + var center = (size - 1) / 2f; + + var inset = size * 0.14f; + var armLen = size * 0.22f; + var bracketStroke = size * 0.085f; + var xHalf = size * 0.16f; + var xStroke = size * 0.085f; + + float far = (size - 1) - inset; + + for (int py = 0; py < size; py++) + { + for (int px = 0; px < size; px++) + { + // 8 L-arm segments - horizontal + vertical at each of the 4 corners. + var d = float.MaxValue; + d = Mathf.Min(d, DistToSegment(px, py, inset, inset, inset + armLen, inset)); + d = Mathf.Min(d, DistToSegment(px, py, inset, inset, inset, inset + armLen)); + d = Mathf.Min(d, DistToSegment(px, py, far, inset, far - armLen, inset)); + d = Mathf.Min(d, DistToSegment(px, py, far, inset, far, inset + armLen)); + d = Mathf.Min(d, DistToSegment(px, py, inset, far, inset + armLen, far)); + d = Mathf.Min(d, DistToSegment(px, py, inset, far, inset, far - armLen)); + d = Mathf.Min(d, DistToSegment(px, py, far, far, far - armLen, far)); + d = Mathf.Min(d, DistToSegment(px, py, far, far, far, far - armLen)); + var bracketAlpha = Mathf.Clamp01(bracketStroke - d + 0.5f); + + var dXa = DistToSegment(px, py, center - xHalf, center - xHalf, center + xHalf, center + xHalf); + var dXb = DistToSegment(px, py, center - xHalf, center + xHalf, center + xHalf, center - xHalf); + var xAlpha = Mathf.Clamp01(xStroke - Mathf.Min(dXa, dXb) + 0.5f); + + var alpha = Mathf.Max(bracketAlpha, xAlpha); + pixels[py * size + px] = new Color32(255, 255, 255, (byte)(alpha * 255f)); + } + } + + tex.SetPixels32(pixels); + tex.filterMode = FilterMode.Bilinear; + tex.wrapMode = TextureWrapMode.Clamp; + tex.Apply(); + return tex; + } + + // Speaker (rectangular stand + trapezoidal horn) + two sound arcs + diagonal slash. + // Slash is drawn with a knockout band so it reads against the speaker body (which is + // also white) at gizmo scale. + private static Texture2D MakeMuteTex(int size) + { + var tex = new Texture2D(size, size, TextureFormat.RGBA32, false); + var pixels = new Color32[size * size]; + var center = (size - 1) / 2f; + + // Speaker geometry. + var standLeft = size * 0.18f; + var standRight = size * 0.34f; + var standTop = size * 0.42f; + var standBottom = size * 0.58f; + + var hornNarrowX = standRight; + var hornWideX = size * 0.56f; + var hornNarrowHalfH = (standBottom - standTop) / 2f; + var hornWideHalfH = size * 0.22f; + + // Sound arcs. + var arcCenterX = size * 0.56f; + var arcCenterY = center; + var arcR1 = size * 0.11f; + var arcR2 = size * 0.21f; + var arcStroke = size * 0.055f; + + // Slash: from upper-right to lower-left. Knockout band carves the speaker so the + // slash itself reads as a dark gap with a thin white line through it. + var slashAx = size * 0.93f; + var slashAy = size * 0.07f; + var slashBx = size * 0.07f; + var slashBy = size * 0.93f; + var slashGap = size * 0.075f; + var slashLine = size * 0.035f; + + for (int py = 0; py < size; py++) + { + for (int px = 0; px < size; px++) + { + float a = 0f; + + if (px >= standLeft && px <= standRight && py >= standTop && py <= standBottom) + a = 1f; + + if (px >= hornNarrowX && px <= hornWideX) + { + var t = (px - hornNarrowX) / Mathf.Max(0.0001f, hornWideX - hornNarrowX); + var halfH = Mathf.Lerp(hornNarrowHalfH, hornWideHalfH, t); + if (Mathf.Abs(py - center) <= halfH) a = 1f; + } + + var rdx = px - arcCenterX; + var rdy = py - arcCenterY; + if (rdx > 0f) + { + var rd = Mathf.Sqrt(rdx * rdx + rdy * rdy); + var wedge = Mathf.Abs(rdy) <= rdx ? 1f : 0f; + var arc1 = Mathf.Clamp01(arcStroke - Mathf.Abs(rd - arcR1) + 0.5f); + var arc2 = Mathf.Clamp01(arcStroke - Mathf.Abs(rd - arcR2) + 0.5f); + a = Mathf.Max(a, Mathf.Max(arc1, arc2) * wedge); + } + + var slashD = DistToSegment(px, py, slashAx, slashAy, slashBx, slashBy); + if (slashD < slashGap) a = 0f; + var slashAlpha = Mathf.Clamp01(slashLine - slashD + 0.5f); + a = Mathf.Max(a, slashAlpha); + + pixels[py * size + px] = new Color32(255, 255, 255, (byte)(a * 255f)); + } + } + + tex.SetPixels32(pixels); + tex.filterMode = FilterMode.Bilinear; + tex.wrapMode = TextureWrapMode.Clamp; + tex.Apply(); + return tex; + } + + private static float DistToSegment(float px, float py, float ax, float ay, float bx, float by) + { + var dx = bx - ax; + var dy = by - ay; + var len2 = dx * dx + dy * dy; + if (len2 < 1e-6f) return Mathf.Sqrt((px - ax) * (px - ax) + (py - ay) * (py - ay)); + var t = Mathf.Clamp01(((px - ax) * dx + (py - ay) * dy) / len2); + var qx = ax + t * dx; + var qy = ay + t * dy; + return Mathf.Sqrt((px - qx) * (px - qx) + (py - qy) * (py - qy)); + } + public static readonly Texture2D WebsiteIcon = ContentFinder.Get("Multiplayer/Website"); public static readonly Texture2D DiscordIcon = ContentFinder.Get("Multiplayer/Discord"); public static readonly Texture2D Pulse = ContentFinder.Get("Multiplayer/Pulse"); diff --git a/Source/Client/Networking/HostUtil.cs b/Source/Client/Networking/HostUtil.cs index 0b736217e..8ee961f7c 100644 --- a/Source/Client/Networking/HostUtil.cs +++ b/Source/Client/Networking/HostUtil.cs @@ -9,6 +9,7 @@ using Multiplayer.Client.Networking; using Multiplayer.Client.Util; using Multiplayer.Common; +using Multiplayer.Common.Networking.Packet; using RimWorld; using UnityEngine; using Verse; @@ -99,6 +100,7 @@ private static void SetGameState(ServerSettings settings) Multiplayer.GameComp.asyncTime = settings.asyncTime; Multiplayer.GameComp.multifaction = settings.multifaction; + Multiplayer.GameComp.markerCapPerPlayer = PingMarkerCap.Clamp(settings.markerCapPerPlayer); Multiplayer.GameComp.debugMode = settings.debugMode; Multiplayer.GameComp.logDesyncTraces = settings.desyncTraces; Multiplayer.GameComp.pauseOnLetter = settings.pauseOnLetter; diff --git a/Source/Client/Networking/State/ClientPlayingState.cs b/Source/Client/Networking/State/ClientPlayingState.cs index da91769d8..b8cc1fc94 100644 --- a/Source/Client/Networking/State/ClientPlayingState.cs +++ b/Source/Client/Networking/State/ClientPlayingState.cs @@ -135,6 +135,15 @@ public void HandleSelected(ServerSelectedPacket packet) [TypedPacketHandler] public void HandlePing(ServerPingLocPacket packet) => Session.locationPings.ReceivePing(packet); + [TypedPacketHandler] + public void HandleClearMarkers(ServerClearMarkersPacket packet) => Session.locationPings.ReceiveClearMarkers(packet); + + [TypedPacketHandler] + public void HandleDeleteMarker(ServerDeleteMarkerPacket packet) => Session.locationPings.ReceiveDeleteMarker(packet); + + [TypedPacketHandler] + public void HandleRenameMarker(ServerRenameMarkerPacket packet) => Session.locationPings.ReceiveRenameMarker(packet); + [PacketHandler(Packets.Server_MapResponse, allowFragmented: true)] public void HandleMapResponse(ByteReader data) { diff --git a/Source/Client/Patches/Pings.cs b/Source/Client/Patches/Pings.cs new file mode 100644 index 000000000..22816eb53 --- /dev/null +++ b/Source/Client/Patches/Pings.cs @@ -0,0 +1,406 @@ +using System; +using System.Collections.Generic; +using System.Reflection; +using HarmonyLib; +using Multiplayer.Client.Util; +using RimWorld; +using RimWorld.Planet; +using UnityEngine; +using Verse; +using Verse.Sound; + +namespace Multiplayer.Client +{ + // Click-on-marker selection + armed placement. + [HarmonyPatch(typeof(MapInterface), nameof(MapInterface.HandleMapClicks))] + static class PingMapClickPatch + { + [HarmonyPriority(MpPriority.MpFirst)] + static bool Prefix() + { + if (Multiplayer.Client == null || Multiplayer.arbiterInstance || TickPatch.Simulating) return true; + if (Find.CurrentMap == null) return true; + // CurrentMap stays non-null on planet view - without this, armed LMB projects onto the hidden map camera. + if (WorldRendererUtility.WorldSelected) return true; + + if (Find.DesignatorManager?.SelectedDesignator != null) return true; + if ((Find.Targeter?.IsTargeting ?? false) + || (Find.WorldTargeter?.IsTargeting ?? false)) return true; + + var ev = Event.current; + var loc = Multiplayer.session?.locationPings; + if (loc == null) return true; + var mouse = UI.MousePositionOnUIInverted; + var size = LocationPings.OnScreenPingSize; + + var onWheelUi = IsMouseOverWheelOrDrawer(loc, mouse); + + // Swallow LMB on the wheel so vanilla doesn't world-select behind it. + if (ev.type == EventType.MouseDown && ev.button == 0 && onWheelUi) + { + ev.Use(); + return false; + } + + if (ev.type == EventType.MouseDown && ev.button == 1 && loc.armedCategory != null && !onWheelUi) + { + loc.DisarmPlacement(); + ev.Use(); + return false; + } + + if (ev.type == EventType.MouseDown && ev.button == 0 && loc.armedCategory != null && !onWheelUi) + { + var mapLoc = UI.MouseMapPosition(); + if (loc.FireArmedAtMap(Find.CurrentMap.uniqueID, PlanetTile.Invalid, mapLoc)) + { + ev.Use(); + return false; + } + } + + // Double-click on MouseDown is safe (no drag-box); single-click lives in PingSelectUnderMousePatch on MouseUp. + if (ev.type == EventType.MouseDown && ev.button == 0 && ev.clickCount == 2) + { + if (TryHitTest(loc, mouse, size, out var hit) && hit.isMarker) + { + if (!Selector.ShiftIsHeld) + Find.Selector?.ClearSelection(); + SelectAllMatchingMarkersOnScreen(loc, hit); + SoundDefOf.Click.PlayOneShotOnCamera(); + ev.Use(); + return false; + } + } + + return true; + } + + private static void SelectAllMatchingMarkersOnScreen(LocationPings loc, PingInfo hit) + { + loc.ClearSelection(); + + var screenRect = new Rect(0f, 0f, UI.screenWidth, UI.screenHeight); + var mapId = Find.CurrentMap.uniqueID; + + foreach (var m in loc.Markers) + { + if (m.mapId != mapId) continue; + if (m.category != hit.category) continue; + if (!screenRect.Contains(m.mapLoc.MapToUIPosition())) continue; + loc.SelectInfo(m, additive: true); + } + } + + internal static bool IsMouseOverWheelOrDrawer(LocationPings loc, Vector2 mouse) + { + // Wheel area is only "over UI" while up - otherwise the last wheel position would block marker clicks forever. + if (loc.wheelActive) + { + var dx = mouse.x - loc.wheelScreenOrigin.x; + var dy = mouse.y - loc.wheelScreenOrigin.y; + const float ChevronStackH = 32f; + if (Mathf.Abs(dx) <= LocationPings.WheelBackdropR + && dy >= -(LocationPings.WheelBackdropR + ChevronStackH) + && dy <= LocationPings.WheelBackdropR) + return true; + } + + // PingInspectPane intentionally excluded; its body click is a no-op and markers under it must stay clickable. + var w = Find.WindowStack?.GetWindowAt(mouse); + return w is PingMenuWindow or PingFiltersDialog or PingHostSettingsDialog; + } + + internal static bool TryHitTest(LocationPings loc, Vector2 mouse, float size, out PingInfo hit) + { + var mapId = Find.CurrentMap.uniqueID; + + foreach (var m in loc.Markers) + { + if (m.mapId != mapId) continue; + if (!m.IsVisible()) continue; + if (HitMarker(m, mouse, size)) { hit = m; return true; } + } + for (var i = loc.pings.Count - 1; i >= 0; i--) + { + var p = loc.pings[i]; + if (p.mapId != mapId) continue; + if (p.PlayerInfo == null) continue; + if (!p.IsVisible()) continue; + if (HitMarker(p, mouse, size)) { hit = p; return true; } + } + + hit = null; + return false; + } + + // DrawAt anchors the pin above mapLoc; a circle test at mapLoc would miss the pin. + private static bool HitMarker(PingInfo info, Vector2 mouse, float size) + { + var screen = info.mapLoc.MapToUIPosition(); + var halfW = size * 0.6f; + if (Math.Abs(mouse.x - screen.x) > halfW) return false; + var pinTop = screen.y - size - info.y * size; + var ringBot = screen.y + size * 0.6f; + return mouse.y >= pinTop && mouse.y <= ringBot; + } + } + + // Inject marker-action gizmos - DrawGizmoGridFor accepts any Gizmo in selectedObjects. + [HarmonyPatch(typeof(GizmoGridDrawer), nameof(GizmoGridDrawer.DrawGizmoGridFor))] + static class PingGizmoInjectPatch + { + [HarmonyPriority(MpPriority.MpLast)] + static void Prefix(ref IEnumerable selectedObjects) + { + if (Multiplayer.Client == null || Multiplayer.arbiterInstance || TickPatch.Simulating) return; + if (WorldRendererUtility.WorldSelected) return; + var loc = Multiplayer.session?.locationPings; + if (loc == null || !loc.HasSelection) return; + + var selected = PingSelectionUI.CollectSelectedOnCurrentMap(loc); + if (selected.Count == 0) return; + + var gizmos = PingSelectionUI.BuildGizmos(selected, loc); + if (gizmos.Count == 0) return; + + // Materialize into a per-session reused buffer so we don't allocate every frame, and + // so a later patch can't mutate the source between our return and vanilla's AddRange. + var buffer = loc.gizmoInjectionBuffer; + buffer.Clear(); + buffer.AddRange(selectedObjects); + foreach (var g in gizmos) buffer.Add(g); + selectedObjects = buffer; + } + } + + // Marker equivalent of vanilla's plain-clear-on-click; runs on MouseUp so drag-box can still start near a marker. + [HarmonyPatch] + static class PingSelectUnderMousePatch + { + // Prepare()=false is canonical skip - returning null from TargetMethod() trips PatchClassProcessor. + static bool Prepare() + { + if (AccessTools.Method(typeof(Selector), "SelectUnderMouse", Type.EmptyTypes) == null) + { + Log.Warning("[Multiplayer] PingSelectUnderMousePatch: Selector.SelectUnderMouse not found; marker selection won't follow vanilla's MouseUp-without-drag clear."); + return false; + } + return true; + } + + static MethodBase TargetMethod() + { + return AccessTools.Method(typeof(Selector), "SelectUnderMouse", Type.EmptyTypes); + } + + static void Postfix() + { + if (PingDragBoxSelectPatch.InsideDragBox) return; + if (Multiplayer.Client == null || Multiplayer.arbiterInstance || TickPatch.Simulating) return; + if (Find.CurrentMap == null) return; + if (WorldRendererUtility.WorldSelected) return; + var loc = Multiplayer.session?.locationPings; + if (loc == null) return; + + var mouse = UI.MousePositionOnUIInverted; + var size = LocationPings.OnScreenPingSize; + var shift = Selector.ShiftIsHeld; + + if (PingMapClickPatch.TryHitTest(loc, mouse, size, out var hit)) + { + if (!shift) Find.Selector?.ClearSelection(); + + var alreadySelected = hit.isMarker + ? loc.IsMarkerSelected(hit.markerId) + : loc.IsPingSelected(hit.player); + if (shift && alreadySelected) + loc.ToggleSelection(hit); + else + loc.SelectInfo(hit, additive: shift); + SoundDefOf.Click.PlayOneShotOnCamera(); + } + else if (!shift && loc.HasSelection) + { + loc.ClearSelection(); + } + } + } + + // Planet-view armed-placement. Vanilla HandleWorldClicks consumes LMB MouseDown for drag-box, + // so a postfix on SelectUnderMouse is too late. + [HarmonyPatch(typeof(WorldSelector), "HandleWorldClicks")] + static class PingWorldClickPatch + { + [HarmonyPriority(MpPriority.MpFirst)] + static bool Prefix() + { + if (Multiplayer.Client == null || Multiplayer.arbiterInstance || TickPatch.Simulating) return true; + if (!WorldRendererUtility.WorldSelected) return true; + if ((Find.WorldTargeter?.IsTargeting ?? false) + || (Find.Targeter?.IsTargeting ?? false) + || Find.DesignatorManager?.SelectedDesignator != null) return true; + + var loc = Multiplayer.session?.locationPings; + if (loc?.armedCategory == null) return true; + + var mouse = UI.MousePositionOnUIInverted; + if (PingMapClickPatch.IsMouseOverWheelOrDrawer(loc, mouse)) return true; + + var ev = Event.current; + if (ev.type == EventType.MouseDown && ev.button == 1) + { + loc.DisarmPlacement(); + ev.Use(); + return false; + } + if (ev.type == EventType.MouseDown && ev.button == 0) + { + var tile = GenWorld.MouseTile(); + if (!tile.Valid) tile = GenWorld.MouseTile(true); + if (tile.Valid && loc.FireArmedAtMap(-1, tile, Vector3.zero)) + { + ev.Use(); + return false; + } + } + return true; + } + } + + // Planet-view companion to PingMapClickPatch: pull markers on the clicked tile into selection so PingInspectPane shows them. + [HarmonyPatch(typeof(WorldSelector), "SelectUnderMouse")] + static class PingPlanetSelectUnderMousePatch + { + static void Postfix() + { + if (Multiplayer.Client == null || Multiplayer.arbiterInstance || TickPatch.Simulating) return; + if (!WorldRendererUtility.WorldSelected) return; + var loc = Multiplayer.session?.locationPings; + if (loc == null) return; + + var tile = GenWorld.MouseTile(); + if (!tile.Valid) return; + + // KeyNotFoundException-safe: PlanetTile.Layer throws on unknown layerIds. + try + { + if (tile.Layer == null) return; + } + catch (KeyNotFoundException) + { + return; + } + + var shift = Selector.ShiftIsHeld; + if (!shift) loc.ClearSelection(); + + var anyHit = false; + foreach (var m in loc.Markers) + { + if (m.mapId != -1) continue; + if (m.planetTile != tile) continue; + if (!m.IsVisible()) continue; + loc.SelectInfo(m, additive: true); + anyHit = true; + } + foreach (var p in loc.pings) + { + if (p.mapId != -1) continue; + if (p.planetTile != tile) continue; + if (p.PlayerInfo == null) continue; + if (!p.IsVisible()) continue; + loc.SelectInfo(p, additive: true); + anyHit = true; + } + + // Empty-tile click leaves an empty selection - PingInspectPane stays closed. + if (!anyHit && !shift && loc.HasSelection) + loc.ClearSelection(); + } + } + + // Append MarkerInspectTab so the vanilla tile inspector stays visible alongside marker actions. + [HarmonyPatch(typeof(WorldInspectPane), nameof(WorldInspectPane.CurTabs), MethodType.Getter)] + static class PingWorldInspectPaneCurTabsPatch + { + // Reused across frames so the per-frame getter doesn't allocate a fresh list and Concat + // iterator. Vanilla calls CurTabs from PaneWidthFor / UpdateTabs / ExtraOnGUI; each call + // consumes the result fully before the next, so reuse is safe. + private static readonly List mergedTabs = new(); + + static void Postfix(ref IEnumerable __result) + { + if (Multiplayer.Client == null || Multiplayer.arbiterInstance || TickPatch.Simulating) return; + if (MarkerInspectTab.CollectSelectedPlanetMarkers() == null) return; + var tab = InspectTabManager.GetSharedInstance(typeof(MarkerInspectTab)); + + mergedTabs.Clear(); + if (__result != null) mergedTabs.AddRange(__result); + mergedTabs.Add(tab); + __result = mergedTabs; + } + } + + // Suppress vanilla's bell/alert-bounce - ReceivePing plays our per-category sound and on-map cue. + [HarmonyPatch(typeof(Alert), nameof(Alert.Notify_Started))] + static class AlertPingNotifyStartedPatch + { + [HarmonyPriority(MpPriority.MpFirst)] + static bool Prefix(Alert __instance) + { + if (Multiplayer.Client == null || Multiplayer.arbiterInstance) return true; + return __instance is not AlertPing; + } + } + + // Drag-box completion: pull markers/pings inside the rect into the selection. + [HarmonyPatch(typeof(Selector), nameof(Selector.SelectInsideDragBox))] + static class PingDragBoxSelectPatch + { + // True while SelectInsideDragBox is on the stack - lets PingSelectUnderMousePatch skip its hit-test on vanilla's internal call. + public static bool InsideDragBox; + + static void Prefix() => InsideDragBox = true; + static void Finalizer() => InsideDragBox = false; + + // MpLast - let other patches finish selecting vanilla objects before we read NumSelected. + [HarmonyPriority(MpPriority.MpLast)] + static void Postfix(Selector __instance) + { + if (Multiplayer.Client == null || Multiplayer.arbiterInstance || TickPatch.Simulating) return; + if (Find.CurrentMap == null) return; + if (WorldRendererUtility.WorldSelected) return; + + if (Find.DesignatorManager?.SelectedDesignator != null) return; + if ((Find.Targeter?.IsTargeting ?? false) + || (Find.WorldTargeter?.IsTargeting ?? false)) return; + + var loc = Multiplayer.session?.locationPings; + if (loc == null) return; + var rect = __instance.dragBox.ScreenRect; + var mapId = Find.CurrentMap.uniqueID; + + if (!Selector.ShiftIsHeld) loc.ClearSelection(); + + // Markers always join drag-selection regardless of vanilla picks - use the + // Deselect gizmo / inline action to drop them from a mixed selection. + foreach (var m in loc.Markers) + { + if (m.mapId != mapId) continue; + if (!m.IsVisible()) continue; + if (rect.Contains(m.mapLoc.MapToUIPosition())) + loc.SelectInfo(m, additive: true); + } + for (var i = 0; i < loc.pings.Count; i++) + { + var p = loc.pings[i]; + if (p.mapId != mapId) continue; + if (p.PlayerInfo == null) continue; + if (!p.IsVisible()) continue; + if (rect.Contains(p.mapLoc.MapToUIPosition())) + loc.SelectInfo(p, additive: true); + } + } + } +} diff --git a/Source/Client/Saving/ConvertToSp.cs b/Source/Client/Saving/ConvertToSp.cs index 1dc65d74a..3d8938166 100644 --- a/Source/Client/Saving/ConvertToSp.cs +++ b/Source/Client/Saving/ConvertToSp.cs @@ -1,4 +1,5 @@ -using Verse; +using System; +using Verse; using Verse.Profile; namespace Multiplayer.Client.Saving; @@ -37,6 +38,9 @@ private static void PrepareSingleplayer() private static void PrepareLoading() { + // SP reload: MP-only scribed fields (markers, playerData, etc.) are intentionally + // dropped because Multiplayer.Client is null past StopMultiplayer. The pre-convert + // replay saved above preserves them if the player ever wants to re-host. Multiplayer.StopMultiplayer(); var doc = SaveLoad.SaveGameToDoc(); @@ -50,6 +54,6 @@ private static void PrepareLoading() } }; - LoadPatch.gameToLoad = new TempGameData(doc, new byte[0]); + LoadPatch.gameToLoad = new TempGameData(doc, Array.Empty()); } } diff --git a/Source/Client/Saving/Replay.cs b/Source/Client/Saving/Replay.cs index 19587f82f..d3e9549c9 100644 --- a/Source/Client/Saving/Replay.cs +++ b/Source/Client/Saving/Replay.cs @@ -5,6 +5,7 @@ using System.Linq; using Multiplayer.Client.Saving; using Multiplayer.Common; +using Multiplayer.Common.Networking.Packet; using Multiplayer.Common.Util; using RimWorld; using Verse; @@ -43,6 +44,10 @@ public void WriteData(GameDataSnapshot gameData) zip.AddEntry($"world/{sectionId}_save", gameData.GameData); info.sections.Add(new ReplaySection(gameData.CachedAtTime, TickPatch.Timer)); + // Refresh the header's cap so it reflects the latest section, not just the first. + if (Multiplayer.game?.gameComp is { } comp) + info.markerCapPerPlayer = PingMarkerCap.Clamp(comp.markerCapPerPlayer); + zip.AddEntry("info", ReplayInfo.Write(info)); } @@ -91,6 +96,11 @@ public GameDataSnapshot LoadGameData(int sectionId) ); } + // Save-only snapshot; SaveAndReload mutates sim, SendGameData mutates peers. Used by the + // desync zip path to capture the divergent tick instead of the stale autosave snapshot. + public static GameDataSnapshot CaptureLocalSnapshot() + => SaveLoad.CreateGameDataSnapshot(SaveLoad.SaveGameData(), Multiplayer.GameComp.multifaction); + public static FileInfo SavedReplayFile(string fileName, string folder = null) => new(Path.Combine(folder ?? Multiplayer.ReplaysDir, $"{fileName}.zip")); @@ -112,7 +122,8 @@ public static Replay ForSaving(FileInfo file) modIds = LoadedModManager.RunningModsListForReading.Select(m => m.PackageId).ToList(), modNames = LoadedModManager.RunningModsListForReading.Select(m => m.Name).ToList(), asyncTime = Multiplayer.GameComp.asyncTime, - multifaction = Multiplayer.GameComp.multifaction + multifaction = Multiplayer.GameComp.multifaction, + markerCapPerPlayer = Multiplayer.GameComp.markerCapPerPlayer } }; diff --git a/Source/Client/Settings/MpSettings.cs b/Source/Client/Settings/MpSettings.cs index 2faded769..dc4769c81 100644 --- a/Source/Client/Settings/MpSettings.cs +++ b/Source/Client/Settings/MpSettings.cs @@ -25,10 +25,19 @@ public class MpSettings : ModSettings public bool hideTranslationMods = true; public bool enablePings = true; public bool enableCrossPlanetLayerPings = true; + public bool enablePingWheel = true; + public float pingWheelHoldDelay = 0.15f; + public PingPlaceMode pingPlaceMode = PingPlaceMode.Ping; + // Pre-arms last fired category when drawer opens; lastUsedCategory is per-launch, not scribed. + public bool rememberLastCategory = true; public KeyCode? sendPingButton = KeyCode.Mouse4; public KeyCode? jumpToPingButton = KeyCode.Mouse3; public Rect chatRect; public Vector2 resolutionForChat; + // Empty Rect = "never dragged", falls back to default placement. + public Rect pingMenuWindowRect; + public Rect pingFiltersDialogRect; + public Rect pingHostSettingsDialogRect; public bool showMainMenuAnim = true; public DesyncTracingMode desyncTracingMode = DesyncTracingMode.Fast; public bool transparentPlayerCursors = true; @@ -37,6 +46,14 @@ public class MpSettings : ModSettings public bool hideOtherPlayersInColonistBar = false; public bool hideOtherPlayersQuests = false; + // Per-client render-only filter; markers still relay/bucket on every receiver. Spectator is a master toggle so freshly-joined players don't pollute the layer. + public bool showSpectatorMarkers = true; + public HashSet hiddenFactionLoadIds = new(); + public HashSet hiddenPlayerNames = new(); + + // Per-marker local overrides. Entries outlive the marker (markerIds reused across sessions); ReceiveDeleteMarker sweeps stale rows. + public Dictionary localMarkerAlpha = new(); + public HashSet locallyHiddenMarkers = new(); internal static readonly ColorRGBClient[] DefaultPlayerColors = { @@ -54,8 +71,6 @@ public class MpSettings : ModSettings public override void ExposeData() { - // Remember to mirror the default values - Scribe_Values.Look(ref username, "username"); Scribe_Values.Look(ref showCursors, "showCursors", true); Scribe_Values.Look(ref autoAcceptSteam, "autoAcceptSteam"); @@ -70,10 +85,17 @@ public override void ExposeData() Scribe_Values.Look(ref hideTranslationMods, "hideTranslationMods", true); Scribe_Values.Look(ref enablePings, "enablePings", true); Scribe_Values.Look(ref enableCrossPlanetLayerPings, "enableCrossPlanetLayerPings", true); + Scribe_Values.Look(ref enablePingWheel, "enablePingWheel", true); + Scribe_Values.Look(ref pingWheelHoldDelay, "pingWheelHoldDelay", 0.15f); + Scribe_Values.Look(ref pingPlaceMode, "pingPlaceMode", PingPlaceMode.Ping); + Scribe_Values.Look(ref rememberLastCategory, "rememberLastCategory", true); Scribe_Values.Look(ref sendPingButton, "sendPingButton", KeyCode.Mouse4); Scribe_Values.Look(ref jumpToPingButton, "jumpToPingButton", KeyCode.Mouse3); Scribe_Custom.LookRect(ref chatRect, "chatRect"); Scribe_Values.Look(ref resolutionForChat, "resolutionForChat"); + Scribe_Custom.LookRect(ref pingMenuWindowRect, "pingMenuWindowRect"); + Scribe_Custom.LookRect(ref pingFiltersDialogRect, "pingFiltersDialogRect"); + Scribe_Custom.LookRect(ref pingHostSettingsDialogRect, "pingHostSettingsDialogRect"); Scribe_Values.Look(ref showMainMenuAnim, "showMainMenuAnim", true); Scribe_Values.Look(ref appendNameToAutosave, "appendNameToAutosave"); Scribe_Values.Look(ref transparentPlayerCursors, "transparentPlayerCursors", true); @@ -85,6 +107,17 @@ public override void ExposeData() if (Scribe.mode == LoadSaveMode.PostLoadInit) PlayerManager.PlayerColors = playerColors.Select(c => (ColorRGB)c).ToArray(); + Scribe_Values.Look(ref showSpectatorMarkers, "showSpectatorMarkers", true); + Scribe_Collections.Look(ref hiddenFactionLoadIds, "mpHiddenFactionLoadIds", LookMode.Value); + Scribe_Collections.Look(ref hiddenPlayerNames, "mpHiddenPlayerNames", LookMode.Value); + hiddenFactionLoadIds ??= new HashSet(); + hiddenPlayerNames ??= new HashSet(); + + Scribe_Collections.Look(ref localMarkerAlpha, "mpLocalMarkerAlpha", LookMode.Value, LookMode.Value); + Scribe_Collections.Look(ref locallyHiddenMarkers, "mpLocallyHiddenMarkers", LookMode.Value); + localMarkerAlpha ??= new Dictionary(); + locallyHiddenMarkers ??= new HashSet(); + Scribe_Deep.Look(ref serverSettingsClient, "serverSettings"); serverSettingsClient ??= new ServerSettingsClient(); } @@ -95,6 +128,12 @@ public enum DesyncTracingMode None, Fast, Slow } + public enum PingPlaceMode + { + Ping = 0, // ephemeral, fades after PingDuration + Marker = 1, // persistent, stays until cleared + } + public struct ColorRGBClient : IExposable { public byte r, g, b; diff --git a/Source/Client/Settings/MpSettingsUI.cs b/Source/Client/Settings/MpSettingsUI.cs index 1d1e30d89..a2e1fe88c 100644 --- a/Source/Client/Settings/MpSettingsUI.cs +++ b/Source/Client/Settings/MpSettingsUI.cs @@ -76,6 +76,16 @@ public static void DoGeneralSettings(MpSettings settings, Rect inRect, Rect page listing.CheckboxLabeled("MpEnablePingsSetting".Translate(), ref settings.enablePings); listing.CheckboxLabeled("MpEnableCrossPlanetLayerPings".Translate(), ref settings.enableCrossPlanetLayerPings, "MpEnableCrossPlanetLayerPingsDesc".Translate()); + listing.CheckboxLabeled(MpPingWheelLabel(), ref settings.enablePingWheel, MpPingWheelDesc()); + + using (MpStyle.Set(TextAnchor.MiddleCenter)) + if (listing.ButtonTextLabeled(MpPingPlaceModeLabel(), MpPingPlaceModeValue(settings.pingPlaceMode))) + { + settings.pingPlaceMode = settings.pingPlaceMode == PingPlaceMode.Ping + ? PingPlaceMode.Marker + : PingPlaceMode.Ping; + } + listing.CheckboxLabeled("MpShowMainMenuAnimation".Translate(), ref settings.showMainMenuAnim); const string buttonOff = "Off"; @@ -228,6 +238,22 @@ private static bool DrawColorRow(MpSettings settings, int pos, ref ColorRGBClien return false; } + // Keys land in rwmt/Multiplayer-Locale; MpTranslate.Fallback keeps the UI readable until they ship. + private static string MpPingWheelLabel() + => MpTranslate.Fallback("MpEnablePingWheel", "Enable ping selection wheel"); + + private static string MpPingWheelDesc() + => MpTranslate.Fallback("MpEnablePingWheelDesc", + "Hold the ping key to open a radial menu of ping categories. Quick tap fires a default ping. Not available when the ping is bound to Mouse2."); + + private static string MpPingPlaceModeLabel() + => MpTranslate.Fallback("MpPingPlaceModeSetting", "Default ping place-mode"); + + private static string MpPingPlaceModeValue(PingPlaceMode mode) + => mode == PingPlaceMode.Marker + ? MpTranslate.Fallback("MpPingMode_Marker", "Marker") + : MpTranslate.Fallback("MpPingMode_Ping", "Ping"); + const string UsernameField = "UsernameField"; private static void DoUsernameField(MpSettings settings, Listing_Standard listing) diff --git a/Source/Client/UI/AlertPing.cs b/Source/Client/UI/AlertPing.cs index c1322d87e..c194e7796 100644 --- a/Source/Client/UI/AlertPing.cs +++ b/Source/Client/UI/AlertPing.cs @@ -1,84 +1,96 @@ -using System.Collections.Generic; +using System.Collections.Generic; using System.Linq; -using HarmonyLib; using RimWorld; using RimWorld.Planet; using UnityEngine; using Verse; -namespace Multiplayer.Client +namespace Multiplayer.Client; + +public class AlertPing : Alert { + // A freshly placed marker counts as alert-worthy for the same window a ping stays visible. + private const float FreshMarkerWindow = PingInfo.PingDuration; - public class AlertPing : Alert + public AlertPing() { - public AlertPing() - { - defaultPriority = AlertPriority.Critical; - } + defaultPriority = AlertPriority.Critical; + } - public override Color BGColor + public override Color BGColor + { + get { - get - { - float num = Pulser.PulseBrightness(0.5f, Pulser.PulseBrightness(0.5f, 0.6f)); - return new Color(num, num, num) * Color.red; - } + float num = Pulser.PulseBrightness(0.5f, Pulser.PulseBrightness(0.5f, 0.6f)); + return new Color(num, num, num) * Color.red; } + } - public override string GetLabel() - { - return "MpAlertPing".Translate(); - } + public override string GetLabel() + { + return "MpAlertPing".Translate(); + } - public override TaggedString GetExplanation() - { - if (Multiplayer.Client == null) - return ""; + public override TaggedString GetExplanation() + { + if (Multiplayer.Client == null) + return ""; - var players = Multiplayer.session.locationPings.pings.Select(p => p.PlayerInfo?.username).AllNotNull().JoinStringsAtMost(); - return $"{"MpAlertPingDesc1".Translate(players)}\n\n{"MpAlertPingDesc2".Translate()}"; - } + // Union of recent ping-placers and recent marker-placers - both feed Culprits. + var loc = Multiplayer.session.locationPings; + var pingNames = loc.pings.Select(p => p.PlayerInfo?.username); + var freshMarkerNames = loc.Markers + .Where(IsFreshMarker) + .Select(m => m.placedByUsername); + var players = pingNames.Concat(freshMarkerNames).AllNotNull().Distinct().JoinStringsAtMost(); + return $"{"MpAlertPingDesc1".Translate(players)}\n\n{"MpAlertPingDesc2".Translate()}"; + } - private List culpritList = new(); + private List culpritList = new(); - private List Culprits + private List Culprits + { + get { - get - { - culpritList.Clear(); - - if (Multiplayer.Client != null && !Multiplayer.session.locationPings.alertHidden) - foreach (var ping in Multiplayer.session.locationPings.pings) - { - if (ping.PlayerInfo == null) continue; - if (ping.Target.HasValue) - culpritList.Add(ping.Target.Value); - } + culpritList.Clear(); - return culpritList; + if (Multiplayer.Client != null && !Multiplayer.session.locationPings.alertHidden) + { + var loc = Multiplayer.session.locationPings; + foreach (var ping in loc.pings) + { + if (ping.PlayerInfo == null) continue; + if (!ping.IsVisible()) continue; + if (ping.Target.HasValue) + culpritList.Add(ping.Target.Value); + } + foreach (var marker in loc.Markers) + { + if (!IsFreshMarker(marker)) continue; + if (!marker.IsVisible()) continue; + if (marker.Target.HasValue) + culpritList.Add(marker.Target.Value); + } } - } - public override AlertReport GetReport() - { - return AlertReport.CulpritsAre(Culprits); + return culpritList; } + } - public override void OnClick() - { - if (Input.GetKey(KeyCode.LeftShift) || Input.GetKey(KeyCode.RightShift)) - Multiplayer.session.locationPings.alertHidden = true; - else - base.OnClick(); - } + // placedAt == 0 = restored from save/session-data, not placed live. + private static bool IsFreshMarker(PingInfo m) + => m.isMarker && m.placedAt > 0f && Time.realtimeSinceStartup - m.placedAt < FreshMarkerWindow; + + public override AlertReport GetReport() + { + return AlertReport.CulpritsAre(Culprits); } - [HarmonyPatch(typeof(Alert), nameof(Alert.Notify_Started))] - static class PreventAlertPingBounce + public override void OnClick() { - static bool Prefix(Alert __instance) - { - return __instance is not AlertPing; - } + if (Input.GetKey(KeyCode.LeftShift) || Input.GetKey(KeyCode.RightShift)) + Multiplayer.session.locationPings.alertHidden = true; + else + base.OnClick(); } } diff --git a/Source/Client/UI/DrawPingMap.cs b/Source/Client/UI/DrawPingMap.cs index 5502f1f7b..75ed7e6fe 100644 --- a/Source/Client/UI/DrawPingMap.cs +++ b/Source/Client/UI/DrawPingMap.cs @@ -1,25 +1,38 @@ -using System; using HarmonyLib; +using RimWorld; +using RimWorld.Planet; +using UnityEngine; using Verse; namespace Multiplayer.Client { - - [HarmonyPatch(typeof(BeautyDrawer), nameof(BeautyDrawer.BeautyDrawerOnGUI))] + [HarmonyPatch(typeof(MapInterface), nameof(MapInterface.MapInterfaceOnGUI_BeforeMainTabs))] static class DrawPingMap { static void Postfix() { - if (Multiplayer.Client == null || TickPatch.Simulating) return; + if (Multiplayer.Client == null || Multiplayer.arbiterInstance || TickPatch.Simulating) return; + if (Find.CurrentMap == null) return; + if (Event.current.type != EventType.Repaint) return; + if (WorldRendererUtility.WorldSelected) return; - var size = Math.Min(UI.CurUICellSize() * 4, 32f); + var size = LocationPings.OnScreenPingSize; + var loc = Multiplayer.session?.locationPings; + if (loc == null) return; - foreach (var ping in Multiplayer.session.locationPings.pings) + // Markers under pings so a fresh signal isn't hidden behind a static annotation. + var mapId = Find.CurrentMap.uniqueID; + foreach (var marker in loc.Markers) { - if (ping.mapId != Find.CurrentMap.uniqueID) continue; - if (ping.PlayerInfo is not { } player) continue; + if (marker.mapId != mapId) continue; + marker.DrawAt(marker.mapLoc.MapToUIPosition(), size); + } - ping.DrawAt(ping.mapLoc.MapToUIPosition(), player.color, size); + foreach (var ping in loc.pings) + { + if (ping.mapId != mapId) continue; + if (ping.PlayerInfo == null) continue; + ping.DrawAt(ping.mapLoc.MapToUIPosition(), size); } } } diff --git a/Source/Client/UI/DrawPingPlanet.cs b/Source/Client/UI/DrawPingPlanet.cs index 3bd6233b9..e948c77dd 100644 --- a/Source/Client/UI/DrawPingPlanet.cs +++ b/Source/Client/UI/DrawPingPlanet.cs @@ -1,48 +1,147 @@ -using HarmonyLib; +using System.Collections.Generic; +using HarmonyLib; +using Multiplayer.Client.Util; using RimWorld.Planet; +using UnityEngine; using Verse; namespace Multiplayer.Client { - [HarmonyPatch(typeof(ExpandableWorldObjectsUtility), nameof(ExpandableWorldObjectsUtility.ExpandableWorldObjectsOnGUI))] static class DrawPingPlanet { + // Per-frame reused so the cluster pass doesn't allocate during render. + private static readonly Dictionary> tileGroups = new(); + static void Postfix() { - if (Multiplayer.Client == null || TickPatch.Simulating) return; + if (Multiplayer.Client == null || Multiplayer.arbiterInstance || TickPatch.Simulating) return; + if (Event.current.type != EventType.Repaint) return; + if (!WorldRendererUtility.WorldSelected) return; - foreach (var ping in Multiplayer.session.locationPings.pings) - { - if (ping.mapId != -1) continue; - if (ping.PlayerInfo is not { } player) continue; - if (ping.planetTile.Layer == null) continue; + var loc = Multiplayer.session?.locationPings; + if (loc == null) return; - var layer = Find.WorldSelector.SelectedLayer; - // Only display pings on the current layer or (if enabled) on layers we can zoom to. - if (Multiplayer.settings.enableCrossPlanetLayerPings) + // Cluster co-located markers per tile so labels don't stack. Locally-hidden markers + // bypass clustering so their collapsed-dot branch still runs. + tileGroups.Clear(); + foreach (var marker in loc.Markers) + { + if (marker.mapId != -1) continue; + if (!marker.IsVisible()) continue; + if (marker.IsLocallyHidden()) { - // We can either start with the ping layer, and keep zooming out, - // or start with the current player layer, and keep zooming in. - // Or both. This implementation tries to zoom in from the current player's layer. - - // Infinite loop prevention. - for (var i = 0; i < 25; i++) - { - // Either can't zoom in more, or we found our target - if (layer == null || layer == ping.planetTile.Layer) - break; - - layer = layer.zoomInToLayer; - } + DrawOneOnPlanet(marker); + continue; } - if (ping.planetTile.Layer != layer) continue; + if (!tileGroups.TryGetValue(marker.planetTile, out var list)) + { + list = new List(); + tileGroups[marker.planetTile] = list; + } + list.Add(marker); + } - var tileCenter = GenWorldUI.WorldToUIPosition(Find.WorldGrid.GetTileCenter(ping.planetTile)); - const float size = 30f; + foreach (var kv in tileGroups) + { + if (kv.Value.Count == 1) + DrawOneOnPlanet(kv.Value[0]); + else + DrawClusterOnPlanet(kv.Key, kv.Value); + } + tileGroups.Clear(); + + // Pings stay individual - short-lived, bounce-animated, and clustering would lose that. + foreach (var ping in loc.pings) DrawOneOnPlanet(ping); + } + + private static void DrawOneOnPlanet(PingInfo ping) + { + if (ping.mapId != -1) return; + // Markers keep their durable color snapshot if placer left; pings need live PlayerInfo. + if (!ping.isMarker && ping.PlayerInfo == null) return; + if (!TryResolveLayerForRender(ping.planetTile)) return; + + var grid = Find.WorldGrid; + if (grid == null) return; + var tileCenter = GenWorldUI.WorldToUIPosition(grid.GetTileCenter(ping.planetTile)); + const float size = 30f; + ping.DrawAt(tileCenter, size); + } - ping.DrawAt(tileCenter, player.color, size); + // Tile renders if currently-selected layer matches (cross-layer zoom traversal optional). + private static bool TryResolveLayerForRender(PlanetTile tile) + { + PlanetLayer pingLayer; + // PlanetTile.Layer throws KeyNotFoundException on unknown layerId - see ReceivePing. + try { pingLayer = tile.Layer; } + catch (System.Collections.Generic.KeyNotFoundException) { return false; } + if (pingLayer == null) return false; + + var layer = Find.WorldSelector?.SelectedLayer; + if (layer == null) return false; + if (Multiplayer.settings.enableCrossPlanetLayerPings) + { + // Cap at 25 to defend against a malformed layer graph forming a cycle. + for (var i = 0; i < 25; i++) + { + if (layer == null || layer == pingLayer) break; + layer = layer.zoomInToLayer; + } } + return pingLayer == layer; + } + + // Cluster pin: ring + neutral pin head, count badge, single "N markers" label. + private static void DrawClusterOnPlanet(PlanetTile tile, List group) + { + if (!TryResolveLayerForRender(tile)) return; + + var grid = Find.WorldGrid; + if (grid == null) return; + var tileCenter = GenWorldUI.WorldToUIPosition(grid.GetTileCenter(tile)); + const float size = 30f; + + // Show selection brackets if ANY group member is selected; anchor on a selected one + // so the bracket animation locks to a stable key. + PingInfo selectedSample = null; + for (var i = 0; i < group.Count; i++) + if (PingSelectionUI.IsSelected(group[i])) { selectedSample = group[i]; break; } + if (selectedSample != null) + PingSelectionUI.DrawSelectionBrackets(selectedSample, tileCenter, size); + + // Ring tint: selected member's color (so the local player's own color comes through + // when they're part of the cluster), else first group member as a stable fallback. + var ringColor = (selectedSample ?? group[0]).BaseColor; + var ringSize = size * 1.12f; + var ringRect = new Rect(tileCenter - new Vector2(ringSize / 2f - 1f, ringSize / 2f), + new Vector2(ringSize, ringSize)); + using (MpStyle.Set(new Color(0f, 0f, 0f, 0.45f))) + GUI.DrawTexture(ringRect.ExpandedBy(1.5f), MultiplayerStatic.PingBase); + var groundRingColor = ringColor; groundRingColor.a = 0.85f; + using (MpStyle.Set(groundRingColor)) + GUI.DrawTexture(ringRect, MultiplayerStatic.PingBase); + + // Pin head - neutral gray so the count badge stays legible regardless of placer color. + var pinRect = new Rect(tileCenter - new Vector2(size / 2f, size), new Vector2(size, size)); + using (MpStyle.Set(new Color(0.82f, 0.82f, 0.82f, 1f))) + GUI.DrawTexture(pinRect, MultiplayerStatic.PingPin); + + // Numeric badge centered on the pin head. + var countRect = new Rect(pinRect.x, pinRect.y + size * 0.10f, size, size * 0.42f); + using (MpStyle.Set(GameFont.Small).Set(TextAnchor.MiddleCenter)) + MpUI.LabelOutlined(countRect, group.Count.ToString(), + new Color(1f, 1f, 1f, 1f), + new Color(0f, 0f, 0f, 0.95f)); + + // Single "N markers" label per tile cluster. + var labelRect = new Rect(tileCenter.x - PingInfo.LabelWidth / 2f, tileCenter.y + size * 0.42f, PingInfo.LabelWidth, 18f); + using (MpStyle.Set(GameFont.Small).Set(TextAnchor.MiddleCenter)) + MpUI.LabelOutlined(labelRect, + MpTranslate.Fallback("MpPingCluster_Label", + $"{group.Count} markers", group.Count), + new Color(1f, 1f, 1f, 1f), + new Color(0f, 0f, 0f, 0.95f)); } } } diff --git a/Source/Client/UI/IngameUI.cs b/Source/Client/UI/IngameUI.cs index 9bf95985c..c4c3696b2 100644 --- a/Source/Client/UI/IngameUI.cs +++ b/Source/Client/UI/IngameUI.cs @@ -38,11 +38,6 @@ static bool Prefix() { Text.Font = GameFont.Small; - // Legacy debug printout disabled - now handled by SyncDebugPanel - // if (MpVersion.IsDebug) { - // IngameDebug.DoDebugPrintout(); - // } - if (Multiplayer.Client != null && Find.CurrentMap != null && Time.time - lastTicksAt > 0.5f) { var async = Find.CurrentMap.AsyncTime(); @@ -62,6 +57,13 @@ static bool Prefix() if (Multiplayer.IsReplay && Multiplayer.session.showTimeline || TickPatch.Simulating) ReplayTimeline.DrawTimeline(); + if (Multiplayer.Client != null && !TickPatch.Simulating && !Multiplayer.arbiterInstance) + { + Multiplayer.session.locationPings.DrawWheelOverlay(); + Multiplayer.session.locationPings.DrawArmedCursor(); + PingSelectionUI.UpdatePingInspectPaneVisibility(); + } + if (TickPatch.Simulating) { IngameModal.DrawModalWindow( @@ -100,6 +102,20 @@ static bool Prefix() ChatWindow.OpenChat(); } + // Drawer hotkey is default-unbound; users opt in via Keyboard Config. + if (Multiplayer.Client != null + && !Multiplayer.IsReplay + && !Multiplayer.arbiterInstance + && MultiplayerStatic.TogglePingMenuDef.KeyDownEvent) + { + Event.current.Use(); + + if (PingMenuWindow.Opened != null) + PingMenuWindow.Opened.Close(); + else + Find.WindowStack.Add(new PingMenuWindow()); + } + return Find.Maps.Count > 0; } diff --git a/Source/Client/UI/LocationPings.Receive.cs b/Source/Client/UI/LocationPings.Receive.cs new file mode 100644 index 000000000..718d809e2 --- /dev/null +++ b/Source/Client/UI/LocationPings.Receive.cs @@ -0,0 +1,284 @@ +using System; +using System.Collections.Generic; +using Multiplayer.Client.Comp; +using Multiplayer.Common.Networking.Packet; +using RimWorld; +using RimWorld.Planet; +using UnityEngine; +using Verse; +using Verse.Sound; + +namespace Multiplayer.Client; + +public partial class LocationPings +{ + // Re-validate every wire field - defends against crafted packets, keeps arbiter in lock-step. + public void ReceivePing(ServerPingLocPacket packet) + { + var data = packet.data; + var planetTile = new PlanetTile(data.planetTileId, data.planetTileLayer); + if (data.mapId == -1 && !planetTile.Valid) + return; + if (!float.IsFinite(data.x) || !float.IsFinite(data.y) || !float.IsFinite(data.z)) + return; + // Sender stamps TicksGame >= 0; a crafted negative would render as "in the future" in the pane. + if (data.placedAtTick < 0) + return; + // PlanetTile.Layer throws on unknown layerId - guard against host having a layer mod the joiner lacks. + if (data.mapId == -1) + { + if (data.planetTileLayer < 0) return; + var layers = Find.WorldGrid?.PlanetLayers; + if (layers == null || !layers.ContainsKey(data.planetTileLayer)) + return; + } + + var category = PingCategoryWire.IsValid(data.category) ? (PingCategory)data.category : PingCategory.Default; + var label = SanitizeLabel(data.label); + + var info = new PingInfo + { + player = packet.playerId, + mapId = data.mapId, + planetTile = planetTile, + mapLoc = new Vector3(data.x, data.y, data.z), + category = category, + label = label, + isMarker = data.isMarker, + placedByUsername = string.IsNullOrEmpty(packet.username) ? null : packet.username, + placedByFactionLoadId = packet.factionId, + placedByR = packet.r / 255f, + placedByG = packet.g / 255f, + placedByB = packet.b / 255f, + placedAtTick = data.placedAtTick, + }; + + if (data.isMarker) + { + // Scribed - must run on every receiver INCLUDING arbiter, ignoring enablePings. + var comp = Multiplayer.game?.gameComp; + if (comp == null) return; + var bucket = comp.GetOrCreateFactionMarkers(packet.factionId); + EnforceMarkerCap(comp, bucket, packet.playerId, info.placedByUsername); + info.markerId = ++comp.nextMarkerId; + // UI-only wall-clock - restored markers stay at 0 so the fresh-marker alert window stays closed. + if (!Multiplayer.arbiterInstance) info.placedAt = Time.realtimeSinceStartup; + bucket.Add(info); + comp.markersVersion++; + if (!Multiplayer.arbiterInstance) alertHidden = false; + } + else + { + // Pings are ephemeral - arbiter is gated below so its list doesn't grow unbounded. + if (!Multiplayer.settings.enablePings) return; + if (Multiplayer.arbiterInstance) return; + pings.RemoveAll(p => p.player == packet.playerId); + pings.Add(info); + pingsVersion++; + alertHidden = false; + } + + // Mute also suppresses SFX; IsVisible covers the three filter axes plus spectator toggle. + if (Multiplayer.settings.enablePings + && Multiplayer.session != null && packet.playerId != Multiplayer.session.playerId + && !Multiplayer.arbiterInstance + && info.IsVisible()) + category.Sound().PlayOneShotOnCamera(); + } + + // Mutates scribed state - runs on every receiver INCLUDING the arbiter (no early return). + public void ReceiveDeleteMarker(ServerDeleteMarkerPacket packet) + { + var comp = Multiplayer.game?.gameComp; + if (comp == null) return; + var ids = packet.data.markerIds; + if (ids == null || ids.Length == 0) return; + + var idSet = new HashSet(ids); + if (idSet.Count == 0) return; + + foreach (var bucket in comp.markersByFaction.Values) + { + for (int i = bucket.Count - 1; i >= 0; i--) + { + var m = bucket[i]; + if (idSet.Contains(m.markerId) + && m.CanBeModifiedBy(packet.playerId, packet.username, packet.factionId, comp.multifaction, packet.senderIsHost)) + { + SelectionDrawer.selectTimes.Remove(m); + DropLocalAppearanceFor(m.markerId); + bucket.RemoveAt(i); + comp.markersVersion++; + } + } + } + foreach (var id in idSet) + selectedMarkerIds.Remove(id); + PruneEmptyFactionBuckets(comp); + } + + // Per-marker overrides are keyed by markerId; sweep when the marker dies. + private static void DropLocalAppearanceFor(int markerId) + { + var s = Multiplayer.settings; + if (s == null || markerId == 0) return; + s.localMarkerAlpha?.Remove(markerId); + s.locallyHiddenMarkers?.Remove(markerId); + } + + public void ReceiveRenameMarker(ServerRenameMarkerPacket packet) + { + var comp = Multiplayer.game?.gameComp; + if (comp == null) return; + var markerId = packet.data.markerId; + if (markerId == 0) return; + + var label = SanitizeLabel(packet.data.label); + if (label.Length > PingCategoryWire.MaxLabelChars) + label = label.Substring(0, PingCategoryWire.MaxLabelChars); + + foreach (var bucket in comp.markersByFaction.Values) + { + for (int i = 0; i < bucket.Count; i++) + { + var m = bucket[i]; + if (m.markerId == markerId + && m.CanBeModifiedBy(packet.playerId, packet.username, packet.factionId, comp.multifaction, packet.senderIsHost)) + { + m.label = label; + comp.markersVersion++; + return; + } + } + } + } + + public void ReceiveClearMarkers(ServerClearMarkersPacket packet) + { + if (!PingMarkerClearWire.IsValid(packet.data.mode)) return; + var comp = Multiplayer.game?.gameComp; + if (comp == null) return; + + var mode = (PingMarkerClearMode)packet.data.mode; + var senderId = packet.playerId; + var senderUsername = packet.username; + var mapId = packet.data.mapId; + + // Clear is placer-only - letting a faction-mate wipe your markers isn't a useful action. + switch (mode) + { + case PingMarkerClearMode.Mine: + foreach (var bucket in comp.markersByFaction.Values) + RemoveMarkersWhere(comp, bucket, m => m.IsPlacedBy(senderId, senderUsername)); + break; + case PingMarkerClearMode.OnMap: + // mapId == -1 is the planet sentinel - never a valid OnMap target. + if (mapId < 0) break; + foreach (var bucket in comp.markersByFaction.Values) + RemoveMarkersWhere(comp, bucket, m => m.mapId == mapId && m.IsPlacedBy(senderId, senderUsername)); + break; + case PingMarkerClearMode.FromPlayer: + // Username is canonical across sessions; anyone can wipe by name (self-policing). + var target = packet.data.targetUsername; + if (string.IsNullOrEmpty(target)) break; + foreach (var bucket in comp.markersByFaction.Values) + RemoveMarkersWhere(comp, bucket, m => m.placedByUsername == target); + break; + case PingMarkerClearMode.AllMarkers: + // Host-only blanket wipe; receiver re-checks senderIsHost. + if (!packet.senderIsHost) break; + foreach (var bucket in comp.markersByFaction.Values) + RemoveMarkersWhere(comp, bucket, _ => true); + break; + case PingMarkerClearMode.AllPings: + if (!packet.senderIsHost) break; + if (pings.Count > 0) + { + foreach (var p in pings) + SelectionDrawer.selectTimes.Remove(p); + selectedPingPlayerIds.Clear(); + pings.Clear(); + pingsVersion++; + } + break; + } + PruneEmptyFactionBuckets(comp); + } + + // Counts across all buckets - faction-switchers' old markers still count toward their cap. + private static void EnforceMarkerCap(MultiplayerGameComp comp, List targetBucket, int playerId, string username) + { + var cap = MarkerCap; + bool MatchesPlacer(PingInfo m) => m.IsPlacedBy(playerId, username); + + int Count() + { + int n = 0; + foreach (var b in comp.markersByFaction.Values) + for (int i = 0; i < b.Count; i++) + if (MatchesPlacer(b[i])) n++; + return n; + } + + var loc = Multiplayer.session?.locationPings; + while (Count() >= cap) + { + if (!EvictOneOldest(comp, targetBucket, MatchesPlacer, loc)) break; + } + } + + // SortedDictionary enumeration order is the same on every client - deterministic fallback. + private static bool EvictOneOldest(MultiplayerGameComp comp, List preferred, Predicate match, LocationPings loc) + { + if (TryEvictFrom(comp, preferred, match, loc)) return true; + foreach (var b in comp.markersByFaction.Values) + if (b != preferred && TryEvictFrom(comp, b, match, loc)) return true; + return false; + } + + private static bool TryEvictFrom(MultiplayerGameComp comp, List bucket, Predicate match, LocationPings loc) + { + for (int i = 0; i < bucket.Count; i++) + { + if (match(bucket[i])) + { + SelectionDrawer.selectTimes.Remove(bucket[i]); + loc?.selectedMarkerIds.Remove(bucket[i].markerId); + DropLocalAppearanceFor(bucket[i].markerId); + bucket.RemoveAt(i); + comp.markersVersion++; + return true; + } + } + return false; + } + + private static void PruneEmptyFactionBuckets(MultiplayerGameComp comp) + { + List empties = null; + foreach (var kv in comp.markersByFaction) + if (kv.Value.Count == 0) + (empties ??= new List()).Add(kv.Key); + if (empties == null) return; + foreach (var key in empties) + comp.markersByFaction.Remove(key); + comp.markersVersion++; + } + + private static int MarkerCap => Mathf.Max(PingMarkerCap.Min, Multiplayer.game?.gameComp?.markerCapPerPlayer ?? PingMarkerCap.Default); + + private void RemoveMarkersWhere(MultiplayerGameComp comp, List markers, Predicate match) + { + for (int i = markers.Count - 1; i >= 0; i--) + { + if (match(markers[i])) + { + SelectionDrawer.selectTimes.Remove(markers[i]); + selectedMarkerIds.Remove(markers[i].markerId); + DropLocalAppearanceFor(markers[i].markerId); + markers.RemoveAt(i); + comp.markersVersion++; + } + } + } +} diff --git a/Source/Client/UI/LocationPings.Wheel.cs b/Source/Client/UI/LocationPings.Wheel.cs new file mode 100644 index 000000000..b9fbc13ab --- /dev/null +++ b/Source/Client/UI/LocationPings.Wheel.cs @@ -0,0 +1,383 @@ +using Multiplayer.Client.Util; +using Multiplayer.Common.Networking.Packet; +using UnityEngine; +using Verse; + +namespace Multiplayer.Client; + +public partial class LocationPings +{ + private const float WheelOuterR = 175f; + private const float WheelInnerR = 60f; + private const float WheelInnerDeadzone = WheelInnerR; + private const float CardRadius = 117f; + private const float IconOffsetY = -10f; + private const float NameOffsetY = 21f; + private const float IconBaseSize = 32f; + private const float NameCardWidth = 80f; + private const float NameCardHeight = 22f; + private const float NameCardStripeHeight = 3f; + + public const float WheelBackdropR = WheelOuterR + 26f; + private const float ChevronTabWidth = 56f; + private const float ChevronTabHeight = 20f; + private const float ChevronTabGapY = 4f; + + // Clockwise from the top. + private static readonly PingCategory[] WheelOptions = + { + PingCategory.Attack, + PingCategory.Help, + PingCategory.Loot, + PingCategory.Rally, + PingCategory.Defend, + }; + + // Pre-rotated; 35.5° half-angle leaves a 1° seam so the backdrop shows through. + internal static readonly Texture2D[] PingSectors = MakePingSectors(outerRadius: 127f, innerRadius: 44f); + internal static readonly Texture2D[] PingSectorArcs = MakePingSectors(outerRadius: 127f, innerRadius: 119f); + + private static Texture2D[] MakePingSectors(float outerRadius, float innerRadius) + { + int slots = WheelOptions.Length; + var texs = new Texture2D[slots]; + for (int i = 0; i < slots; i++) + texs[i] = MultiplayerStatic.MakeSectorTex(256, outerRadius, innerRadius, + halfAngleDeg: 35.5f, centerAngleDeg: i * (360f / slots)); + return texs; + } + + private PingCategory ComputeHoveredCategory() => + ComputeHoveredCategoryAt(wheelScreenOrigin, UI.MousePositionOnUIInverted); + + private static PingCategory ComputeHoveredCategoryAt(Vector2 center, Vector2 mouse) + { + var dx = mouse.x - center.x; + var dy = mouse.y - center.y; + var distSq = dx * dx + dy * dy; + if (distSq < WheelInnerDeadzone * WheelInnerDeadzone) + return PingCategory.Default; + + // GUI coords: x right, y down. 12 o'clock = -y. + var angle = Mathf.Atan2(dx, -dy) * Mathf.Rad2Deg; + if (angle < 0) angle += 360; + + var sectorSize = 360f / WheelOptions.Length; + var sectorIdx = Mathf.FloorToInt((angle + sectorSize / 2f) / sectorSize) % WheelOptions.Length; + return WheelOptions[sectorIdx]; + } + + private static Rect ComputeChevronTabRect(Vector2 center) + { + var x = center.x - ChevronTabWidth / 2f; + var y = center.y - WheelBackdropR - ChevronTabGapY - ChevronTabHeight; + return new Rect(x, y, ChevronTabWidth, ChevronTabHeight); + } + + public void DrawWheelOverlay() + { + if (MenuWindowOpen) return; + if (!wheelActive) return; + + DrawWheelCore(wheelScreenOrigin, mousePos: UI.MousePositionOnUIInverted, inDrawer: false); + } + + public void DrawWheelInDrawer(Vector2 center, Vector2 mousePos) + { + DrawWheelCore(center, mousePos, inDrawer: true); + } + + // Cursor mode lets Default-ring click fall through; drawer mode consumes it. + private bool TryHandleWheelMouseDown(Vector2 center, Vector2 mousePos, bool inDrawer) + { + var ev = Event.current; + if (ev.type != EventType.MouseDown || ev.button != 0) return false; + + // Chevron is cursor-mode only; drawer mode uses the deadzone to disarm. + if (!inDrawer && ComputeChevronTabRect(center).Contains(mousePos)) + { + ToggleDrawer(); + ev.Use(); + return true; + } + + var dx = mousePos.x - center.x; + var dy = mousePos.y - center.y; + var distSq = dx * dx + dy * dy; + var outerR2 = WheelOuterR * WheelOuterR; + var innerR2 = WheelInnerR * WheelInnerR; + if (distSq > outerR2) return false; + + if (distSq < innerR2) + { + if (!inDrawer) + CancelWheel(); + else if (armedCategory != null) + DisarmPlacement(); + ev.Use(); + return true; + } + + var clicked = ComputeHoveredCategoryAt(center, mousePos); + + if (!inDrawer) + { + // Default-category click in the ring falls through so the wheel stays up. + if (clicked == PingCategory.Default) return false; + + var asMarker = Multiplayer.settings.pingPlaceMode == PingPlaceMode.Marker; + FirePing(wheelTargetMapId, wheelTargetTile, wheelTargetMapLoc, clicked, asMarker); + CancelWheel(); + ev.Use(); + return true; + } + + // Drawer mode: any in-ring click consumes the event; real slices also arm. + if (clicked != PingCategory.Default) + ArmPlacement(clicked); + ev.Use(); + return true; + } + + private void DrawWheelCore(Vector2 center, Vector2 mousePos, bool inDrawer) + { + var ev = Event.current; + + if (inDrawer) + { + hoveredCategory = ComputeHoveredCategoryAt(center, mousePos); + } + + if (TryHandleWheelMouseDown(center, mousePos, inDrawer)) return; + + if (ev.type != EventType.Repaint) return; + + var drawerOpen = inDrawer; + + var sectorSize = 360f / WheelOptions.Length; + var inDeadzone = hoveredCategory == PingCategory.Default; + + // 256-px sector tex with 127-px native outer radius scales up to WheelOuterR on screen. + const float TexNativeOuterR = 127f; + var sectorRectSide = 256f * (WheelOuterR / TexNativeOuterR); + var sectorRect = new Rect(center.x - sectorRectSide / 2f, center.y - sectorRectSide / 2f, + sectorRectSide, sectorRectSide); + + var backdropDiam = WheelBackdropR * 2f; + var backdropRect = new Rect(center.x - backdropDiam / 2f, center.y - backdropDiam / 2f, + backdropDiam, backdropDiam); + using (MpStyle.Set(new Color(0f, 0f, 0f, 0.55f))) + GUI.DrawTexture(backdropRect, MultiplayerStatic.PingCircle); + using (MpStyle.Set(new Color(1f, 1f, 1f, 0.18f))) + GUI.DrawTexture(backdropRect, MultiplayerStatic.PingRing); + + // Armed slice keeps its tint so the cursor cue persists when off-wheel. + for (var i = 0; i < WheelOptions.Length; i++) + { + var hovered = WheelOptions[i] == hoveredCategory; + var armed = drawerOpen && armedCategory == WheelOptions[i]; + + Color fill, arc; + if (armed) + { + var t = WheelOptions[i].Tint(); + var amt = hovered ? 0.40f : 0.28f; + fill = new Color( + Mathf.Lerp(0.24f, t.r, amt), + Mathf.Lerp(0.24f, t.g, amt), + Mathf.Lerp(0.24f, t.b, amt), + 1f); + arc = new Color( + Mathf.Lerp(0.16f, t.r, 0.35f), + Mathf.Lerp(0.16f, t.g, 0.35f), + Mathf.Lerp(0.16f, t.b, 0.35f), + 1f); + } + else + { + fill = hovered + ? new Color(0.24f, 0.24f, 0.27f, 1f) + : new Color(0.14f, 0.14f, 0.16f, 1f); + arc = hovered + ? new Color(0.16f, 0.16f, 0.19f, 1f) + : new Color(0.08f, 0.08f, 0.10f, 1f); + } + + using (MpStyle.Set(fill)) + GUI.DrawTexture(sectorRect, PingSectors[i]); + using (MpStyle.Set(arc)) + GUI.DrawTexture(sectorRect, PingSectorArcs[i]); + } + + for (var i = 0; i < WheelOptions.Length; i++) + { + var cat = WheelOptions[i]; + var hovered = cat == hoveredCategory; + var tint = cat.Tint(); + var sectorAngleRad = (i * sectorSize) * Mathf.Deg2Rad; + var dir = new Vector2(Mathf.Sin(sectorAngleRad), -Mathf.Cos(sectorAngleRad)); + var anchor = center + dir * CardRadius; + + var iconCenterX = anchor.x; + var iconCenterY = anchor.y + IconOffsetY; + var iconTex = cat.Icon(); + if (iconTex != null) + { + var iconSize = IconBaseSize * cat.IconScale(); + var iconRect = new Rect(iconCenterX - iconSize / 2f, iconCenterY - iconSize / 2f, + iconSize, iconSize); + + using (MpStyle.Set(new Color(0f, 0f, 0f, 0.55f))) + GUI.DrawTexture(new Rect(iconRect.x + 1f, iconRect.y + 1.5f, + iconRect.width, iconRect.height), iconTex); + using (MpStyle.Set(Color.white)) + GUI.DrawTexture(iconRect, iconTex); + } + else + { + using (MpStyle.Set(GameFont.Medium).Set(TextAnchor.MiddleCenter)) + MpUI.LabelOutlined(new Rect(iconCenterX - 20f, iconCenterY - 14f, 40f, 28f), + cat.Glyph(), + Color.white, + new Color(0f, 0f, 0f, 0.95f)); + } + + // Dark ribbon name card with a category-color top stripe - the only color element per slice. + var nameRect = new Rect(anchor.x - NameCardWidth / 2f, + anchor.y + NameOffsetY - NameCardHeight / 2f, + NameCardWidth, NameCardHeight); + + Widgets.DrawBoxSolid(new Rect(nameRect.x + 1f, nameRect.y + 2f, + nameRect.width, nameRect.height), new Color(0f, 0f, 0f, 0.45f)); + Widgets.DrawBoxSolid(nameRect, hovered + ? new Color(0.22f, 0.22f, 0.25f, 0.97f) + : new Color(0.10f, 0.10f, 0.12f, 0.92f)); + Widgets.DrawBoxSolid(new Rect(nameRect.x, nameRect.y, nameRect.width, NameCardStripeHeight), + new Color(tint.r, tint.g, tint.b, hovered ? 1f : 0.78f)); + using (MpStyle.Set(hovered ? new Color(1f, 1f, 1f, 0.55f) : new Color(1f, 1f, 1f, 0.22f))) + Widgets.DrawBox(nameRect); + using (MpStyle.Set(GameFont.Tiny).Set(TextAnchor.MiddleCenter).Set(WordWrap.NoWrap)) + MpUI.LabelOutlined(nameRect, cat.DisplayName(), + hovered ? Color.white : new Color(1f, 1f, 1f, 0.92f), + new Color(0f, 0f, 0f, 0.95f)); + } + + // Center cancel disc - doubles as Disarm in drawer mode. + var cancelDiam = (WheelInnerR - 4f) * 2f; + var cancelRect = new Rect(center.x - cancelDiam / 2f, center.y - cancelDiam / 2f, + cancelDiam, cancelDiam); + var hot = inDeadzone || (drawerOpen && armedCategory != null); + var cancelFill = hot + ? new Color(0.85f, 0.30f, 0.30f, 1f) + : new Color(0.18f, 0.18f, 0.20f, 0.92f); + + using (MpStyle.Set(new Color(0f, 0f, 0f, 0.55f))) + GUI.DrawTexture(new Rect(cancelRect.x + 1f, cancelRect.y + 2f, + cancelRect.width, cancelRect.height), MultiplayerStatic.PingCircle); + using (MpStyle.Set(cancelFill)) + GUI.DrawTexture(cancelRect, MultiplayerStatic.PingCircle); + using (MpStyle.Set(new Color(1f, 1f, 1f, hot ? 0.85f : 0.45f))) + GUI.DrawTexture(cancelRect.ExpandedBy(2f), MultiplayerStatic.PingRing); + + string cancelLabel; + if (drawerOpen && armedCategory != null) + cancelLabel = inDeadzone + ? MpTranslate.Fallback("MpPingWheel_Disarm", "Disarm") + : MpTranslate.Fallback("MpPingWheel_ClickToDisarm", "X"); + else + cancelLabel = inDeadzone + ? MpTranslate.Fallback("MpPingWheel_Cancel", "Cancel") + : "X"; + var cancelLabelColor = hot ? Color.white : new Color(1f, 1f, 1f, 0.55f); + using (MpStyle.Set(GameFont.Small).Set(TextAnchor.MiddleCenter)) + MpUI.LabelOutlined(cancelRect, cancelLabel, + cancelLabelColor, + new Color(0f, 0f, 0f, 0.95f)); + + if (!inDrawer) + DrawChevronTab(center, mousePos); + } + + private void DrawChevronTab(Vector2 center, Vector2 mousePos) + { + var tabRect = ComputeChevronTabRect(center); + var hot = tabRect.Contains(mousePos); + + var atlas = hot && Input.GetMouseButton(0) ? Widgets.ButtonBGAtlasClick + : (hot ? Widgets.ButtonBGAtlasMouseover : Widgets.ButtonBGAtlas); + Widgets.DrawAtlas(tabRect, atlas); + + var chevSize = ChevronTabHeight - 6f; + var chevRect = new Rect(tabRect.center.x - chevSize / 2f, tabRect.center.y - chevSize / 2f, + chevSize, chevSize); + + using (MpStyle.Set(new Color(0f, 0f, 0f, 0.7f))) + GUI.DrawTexture(new Rect(chevRect.x + 1f, chevRect.y + 1f, chevRect.width, chevRect.height), + MultiplayerStatic.PingChevronUp); + using (MpStyle.Set(hot ? Color.white : new Color(0.95f, 0.95f, 0.95f, 1f))) + GUI.DrawTexture(chevRect, MultiplayerStatic.PingChevronUp); + } + + public void DrawArmedCursor() + { + if (armedCategory is not { } cat) return; + if (Event.current.type != EventType.Repaint) return; + + if (Find.DesignatorManager?.SelectedDesignator != null) return; + if ((Find.Targeter?.IsTargeting ?? false) + || (Find.WorldTargeter?.IsTargeting ?? false)) return; + + var mouse = UI.MousePositionOnUIInverted; + // Skip while over the wheel (armed slice shows the cue) or over any window. + var dx = mouse.x - wheelScreenOrigin.x; + var dy = mouse.y - wheelScreenOrigin.y; + var wheelDispSq = dx * dx + dy * dy; + var backdropR2 = WheelBackdropR * WheelBackdropR; + if (wheelDispSq <= backdropR2) return; + + if (Find.WindowStack?.GetWindowAt(mouse) != null) return; + + const float GhostSize = 32f; + const float GhostOffsetX = 18f; + const float GhostOffsetY = 18f; + + var ghostRect = new Rect(mouse.x + GhostOffsetX, mouse.y + GhostOffsetY, + GhostSize, GhostSize); + + using (MpStyle.Set(new Color(0f, 0f, 0f, 0.75f))) + GUI.DrawTexture(new Rect(ghostRect.x - 3f, ghostRect.y - 3f, ghostRect.width + 6f, ghostRect.height + 6f), + MultiplayerStatic.PingCircle); + var tint = cat.Tint(); + using (MpStyle.Set(new Color(tint.r, tint.g, tint.b, 0.85f))) + GUI.DrawTexture(ghostRect, MultiplayerStatic.PingCircle); + using (MpStyle.Set(new Color(1f, 1f, 1f, 0.9f))) + GUI.DrawTexture(ghostRect.ExpandedBy(1f), MultiplayerStatic.PingRing); + + var icon = cat.Icon(); + if (icon != null) + { + var iconSize = GhostSize * 0.66f * cat.IconScale(); + var iconRect = new Rect(ghostRect.center.x - iconSize / 2f, + ghostRect.center.y - iconSize / 2f, iconSize, iconSize); + using (MpStyle.Set(new Color(0f, 0f, 0f, 0.6f))) + GUI.DrawTexture(new Rect(iconRect.x + 1f, iconRect.y + 1.5f, + iconRect.width, iconRect.height), icon); + using (MpStyle.Set(Color.white)) + GUI.DrawTexture(iconRect, icon); + } + else + { + using (MpStyle.Set(GameFont.Small).Set(TextAnchor.MiddleCenter)) + MpUI.LabelOutlined(ghostRect, cat.Glyph(), Color.white, new Color(0f, 0f, 0f, 0.95f)); + } + + var modeLabel = ArmedAsMarker + ? MpTranslate.Fallback("MpPingArmed_Marker", "Marker") + : MpTranslate.Fallback("MpPingArmed_Ping", "Ping"); + var modeRect = new Rect(ghostRect.x - 12f, ghostRect.yMax + 2f, GhostSize + 24f, 14f); + Widgets.DrawBoxSolid(modeRect, new Color(0f, 0f, 0f, 0.7f)); + using (MpStyle.Set(GameFont.Tiny).Set(TextAnchor.MiddleCenter).Set(WordWrap.NoWrap)) + MpUI.LabelOutlined(modeRect, modeLabel, Color.white, new Color(0f, 0f, 0f, 0.95f)); + } + +} diff --git a/Source/Client/UI/LocationPings.cs b/Source/Client/UI/LocationPings.cs index 68179ff78..47ba6f1c8 100644 --- a/Source/Client/UI/LocationPings.cs +++ b/Source/Client/UI/LocationPings.cs @@ -1,5 +1,7 @@ +using System; using System.Collections.Generic; using System.Linq; +using System.Text; using Multiplayer.Client.Util; using Multiplayer.Common.Networking.Packet; using RimWorld; @@ -10,91 +12,499 @@ namespace Multiplayer.Client; -public class LocationPings +public partial class LocationPings { public List pings = new(); + // Bumped on every mutation of `pings`. Paired with markersVersion as PingMenuWindow's row-cache key. + public int pingsVersion; + // Null-safe so callers can hit this before MultiplayerGame exists. + public IReadOnlyList Markers => Multiplayer.game?.gameComp?.AllMarkers ?? Array.Empty(); + public bool alertHidden; private int pingJumpCycle; + public bool wheelActive; + public Vector2 wheelScreenOrigin; + private int wheelTargetMapId; + private PlanetTile wheelTargetTile; + private Vector3 wheelTargetMapLoc; + private float? pingKeyDownTime; + private PingCategory hoveredCategory; + + public static bool MenuWindowOpen => PingMenuWindow.Opened != null; + + // Wheel-slice click sets this; LMB on the map drops a ping/marker until disarmed. + public PingCategory? armedCategory; + public bool ArmedAsMarker => Multiplayer.settings.pingPlaceMode == PingPlaceMode.Marker; + + // Reset each launch so opening the drawer next session doesn't pre-arm a stale category. + public PingCategory? lastUsedCategory; + + public HashSet selectedMarkerIds = new(); + public HashSet selectedPingPlayerIds = new(); + // Bumped on every user-initiated selection mutation. Reactive removals (marker delete, ping + // expire) skip the bump - markersVersion / pingsVersion already invalidates downstream caches. + public int selectionVersion; + + // Reuse list - vanilla's gizmo grid is reference-cache-keyed. + internal List cachedGizmos; + internal GizmoCacheKey cachedGizmoKey = GizmoCacheKey.Invalid; + + // Reused buffer for the selectedObjects + gizmos merge in PingGizmoInjectPatch. Cleared and + // refilled each frame so no fresh List allocates while the player has a selection in view. + // Item refs (Things + cached Gizmos) stay stable between frames on no-op input, keeping + // vanilla's downstream gizmo-grid cache hot. + internal readonly List gizmoInjectionBuffer = new(); + + // Cached analysis backing PingSelectionUI.DrawInlineActionsRow. + internal int cachedInlineOwnedCount; + internal PingInfo cachedInlineOnlyOwnedSingle; + internal string cachedInlineForeignSampleUsername; + internal int cachedInlineForeignSampleFactionId; + internal bool cachedInlineForeignSpectatorPresent; + internal readonly List cachedInlineMarkerIds = new(); + internal List<(string label, Action onClick)> cachedInlineActions; + internal int cachedInlineMarkersV = -1; + internal int cachedInlineSelectionV = -1; + internal int cachedInlineFactionId = int.MinValue; + + // Cached planet-view marker selection backing MarkerInspectTab.CollectSelectedPlanetMarkers. + // Vanilla calls IsVisible / StillValid several times per frame; the cache keeps those calls O(1). + // hasResult disambiguates an empty cache (no markers selected - return null) from a fresh + // sentinel state. pingsVersion is intentionally excluded - ephemeral pings don't appear here. + internal readonly List cachedPlanetMarkers = new(); + internal bool cachedPlanetMarkersHasResult; + internal int cachedPlanetMarkersV = -1; + internal int cachedPlanetSelectionV = -1; + + // factionId == -1 sentinel = single-player / pre-handshake. markersVersion picks up local-only + // mutations (hide/alpha) that don't add or remove markers. selectionVersion picks up reselection + // when owned/foreign counts happen to match. + internal struct GizmoCacheKey + { + public int owned; + public int foreign; + public int renameTargetId; + public int factionId; + public int markersVersion; + public int selectionVersion; + + public static GizmoCacheKey Invalid => new() + { + owned = -1, foreign = -1, renameTargetId = 0, factionId = -1, + markersVersion = -1, selectionVersion = -1, + }; + + public bool Matches(int owned, int foreign, int rename, int faction, int markersV, int selectionV) + => this.owned == owned && this.foreign == foreign && renameTargetId == rename + && factionId == faction && markersVersion == markersV && selectionVersion == selectionV; + } + + public int SelectedCount => selectedMarkerIds.Count + selectedPingPlayerIds.Count; + public bool HasSelection => SelectedCount > 0; + public bool IsMarkerSelected(int markerId) => selectedMarkerIds.Contains(markerId); + public bool IsPingSelected(int playerId) => selectedPingPlayerIds.Contains(playerId); + + // Local-player view of PingInfo.CanBeModifiedBy. Used by the UI's delete/rename gate; the + // Receive handlers call CanBeModifiedBy directly with the sender's identity. + public static bool CanDeleteMarker(PingInfo info) + { + var sess = Multiplayer.session; + if (sess == null) return false; + var meName = sess.GetPlayerInfo(sess.playerId)?.username; + var multifaction = Multiplayer.game?.gameComp?.multifaction ?? false; + var meFactionId = Multiplayer.RealPlayerFaction?.loadID ?? -1; + return info.CanBeModifiedBy(sess.playerId, meName, meFactionId, multifaction); + } + + // Caps on-screen pin/ring size as zoom changes. Shared by render + hit-test. + public static float OnScreenPingSize => Math.Min(UI.CurUICellSize() * 4, 32f); + + public static PingInfo FindMarkerById(int markerId) + { + var comp = Multiplayer.game?.gameComp; + if (comp == null) return null; + foreach (var bucket in comp.markersByFaction.Values) + for (int i = 0; i < bucket.Count; i++) + if (bucket[i].markerId == markerId) + return bucket[i]; + return null; + } + + public void SelectInfo(PingInfo info, bool additive) + { + if (!additive) ClearSelection(); + if (info.isMarker) selectedMarkerIds.Add(info.markerId); + else selectedPingPlayerIds.Add(info.player); + selectionVersion++; + SelectionDrawer.Notify_Selected(info); + } + + public void ToggleSelection(PingInfo info) + { + if (info.isMarker) selectedMarkerIds.Remove(info.markerId); + else selectedPingPlayerIds.Remove(info.player); + selectionVersion++; + } + public void UpdatePing() { - var pingsEnabled = !TickPatch.Simulating && Multiplayer.settings.enablePings; + // Replay scrub transiently empties Find.Maps; let the replay's section snapshots restore state and skip our sweep. + if (Multiplayer.IsReplay) + { + if (wheelActive) CancelWheel(); + if (armedCategory != null) DisarmPlacement(playSound: false); + ClearSelection(); + return; + } - if (pingsEnabled) - if (MultiplayerStatic.PingKeyDef.JustPressed || KeyDown(Multiplayer.settings.sendPingButton)) + // Sweep markers whose Target turned null (map unloaded, area removed, etc). Runs on the + // arbiter too - scribed state, so drops here have to match the host's save. + if (Multiplayer.game?.gameComp is { } comp) + { + foreach (var bucket in comp.markersByFaction.Values) { - if (WorldRendererUtility.WorldSelected) + for (int i = bucket.Count - 1; i >= 0; i--) { - // Grab the tile under mouse - var tile = GenWorld.MouseTile(); - // If the tile is not valid, snap to expandable world objects (handles orbital locations) - if (!tile.Valid) - tile = GenWorld.MouseTile(true); - - // Make sure the tile is valid and that we didn't ping with the mouse outside of map bounds or in space - if (tile.Valid) - PingLocation(-1, tile, Vector3.zero); + var m = bucket[i]; + m.Update(); + if (m.Target == null) + { + SelectionDrawer.selectTimes.Remove(m); + selectedMarkerIds.Remove(m.markerId); + // Drop the per-client appearance override too - its markerId is dead now. + var s = Multiplayer.settings; + if (s != null && m.markerId != 0) + { + s.localMarkerAlpha?.Remove(m.markerId); + s.locallyHiddenMarkers?.Remove(m.markerId); + } + bucket.RemoveAt(i); + comp.markersVersion++; + } } - else if (Find.CurrentMap != null) - PingLocation(Find.CurrentMap.uniqueID, PlanetTile.Invalid, UI.MouseMapPosition()); } + PruneEmptyFactionBuckets(comp); + } + + if (Multiplayer.arbiterInstance) return; + + var pingsEnabled = !TickPatch.Simulating && Multiplayer.settings.enablePings; + + if (!pingsEnabled) + { + if (wheelActive) CancelWheel(); + } + else + { + HandleWheelEligibleInput(); + HandleLegacyMouse2(); + } + + if (armedCategory != null) + { + var designatorActive = Find.DesignatorManager?.SelectedDesignator != null; + var targeterActive = Find.Targeter?.IsTargeting ?? false; + var worldTargeterActive = Find.WorldTargeter?.IsTargeting ?? false; + if (designatorActive || targeterActive || worldTargeterActive) + DisarmPlacement(playSound: false); + } for (int i = pings.Count - 1; i >= 0; i--) { var ping = pings[i]; - if (ping.Update() || ping.PlayerInfo == null || ping.Target == null) + { + selectedPingPlayerIds.Remove(ping.player); + SelectionDrawer.selectTimes.Remove(ping); pings.RemoveAt(i); + pingsVersion++; + } } - if (pingsEnabled && KeyDown(Multiplayer.settings.jumpToPingButton)) + if (pingsEnabled && KeyTriggered(Multiplayer.settings.jumpToPingButton)) { pingJumpCycle++; if (Input.GetKey(KeyCode.LeftShift) || Input.GetKey(KeyCode.RightShift)) alertHidden = true; - else if (pings.Any()) - // ReSharper disable once PossibleInvalidOperationException - CameraJumper.TryJumpAndSelect(pings[GenMath.PositiveMod(pingJumpCycle, pings.Count)].Target.Value); + else if (pings.Count > 0 + && pings[GenMath.PositiveMod(pingJumpCycle, pings.Count)].Target is { } jumpTarget) + CameraJumper.TryJump(jumpTarget); } } - private static bool KeyDown(KeyCode? keyNullable) + private void HandleWheelEligibleInput() { - if (keyNullable is not { } key) return false; + if (MenuWindowOpen) + { + pingKeyDownTime = null; + wheelActive = false; + return; + } + + var sk = Multiplayer.settings.sendPingButton; + var skUsable = sk is { } skv && skv != KeyCode.Mouse2; + var skv2 = sk.GetValueOrDefault(); + + bool pressedNow = MultiplayerStatic.PingKeyDef.JustPressed + || (skUsable && Input.GetKeyDown(skv2)); + bool heldNow = MultiplayerStatic.PingKeyDef.IsDown + || (skUsable && Input.GetKey(skv2)); + + if (pressedNow && pingKeyDownTime == null) + { + if (TryCaptureTarget(out var mapId, out var tile, out var mapLoc)) + { + pingKeyDownTime = Time.time; + wheelTargetMapId = mapId; + wheelTargetTile = tile; + wheelTargetMapLoc = mapLoc; + wheelScreenOrigin = UI.MousePositionOnUIInverted; + hoveredCategory = PingCategory.Default; + } + } + + if (pingKeyDownTime is { } downTime) + { + var hold = Time.time - downTime; + + if (!wheelActive && heldNow + && Multiplayer.settings.enablePingWheel + && hold >= Multiplayer.settings.pingWheelHoldDelay) + { + wheelActive = true; + } + + if (wheelActive) + hoveredCategory = ComputeHoveredCategory(); + + if (!heldNow) + { + // Release in center = cancel; release on a slice = typed ping; quick tap (no wheel) = default. + PingCategory? toFire = wheelActive + ? (hoveredCategory == PingCategory.Default ? null : (PingCategory?)hoveredCategory) + : PingCategory.Default; + + if (toFire is { } cat) + { + var asMarker = Multiplayer.settings.pingPlaceMode == PingPlaceMode.Marker; + FirePing(wheelTargetMapId, wheelTargetTile, wheelTargetMapLoc, cat, asMarker); + } + + CancelWheel(); + } + } + } + + // Mouse2 hold conflicts with vanilla camera-drag, so it gets the no-wheel quick-tap path. + private void HandleLegacyMouse2() + { + if (Multiplayer.settings.sendPingButton != KeyCode.Mouse2) return; + if (MenuWindowOpen) return; + if (!MpInput.Mouse2UpWithoutDrag) return; + if (!TryCaptureTarget(out var mapId, out var tile, out var mapLoc)) return; + var asMarker = Multiplayer.settings.pingPlaceMode == PingPlaceMode.Marker; + FirePing(mapId, tile, mapLoc, PingCategory.Default, asMarker); + } + private void ToggleDrawer() + { + var existing = PingMenuWindow.Opened; + if (existing != null) + { + // Silent so PostClose's FloatMenu_Cancel isn't doubled. + DisarmPlacement(playSound: false); + existing.Close(); + return; + } + + if (wheelScreenOrigin == Vector2.zero) + wheelScreenOrigin = new Vector2(UI.screenWidth / 2f, UI.screenHeight / 2f); + + Find.WindowStack.Add(new PingMenuWindow()); + SoundDefOf.FloatMenu_Open.PlayOneShotOnCamera(); + } + + public void ArmPlacement(PingCategory category, bool playSound = true) + { + armedCategory = category; + if (playSound) + SoundDefOf.Click.PlayOneShotOnCamera(); + } + + public void DisarmPlacement(bool playSound = true) + { + if (armedCategory == null) return; + armedCategory = null; + if (playSound) + SoundDefOf.FloatMenu_Cancel.PlayOneShotOnCamera(); + } + + public bool FireArmedAtMap(int mapId, PlanetTile tile, Vector3 mapLoc) + { + if (armedCategory is not { } cat) return false; + FirePing(mapId, tile, mapLoc, cat, ArmedAsMarker); + return true; + } + + public void JumpToAndSelect(PingInfo info) + { + if (info.Target is { } target) + CameraJumper.TryJump(target); + + SelectInfo(info, additive: false); + } + + // SelectionDrawer.selectTimes uses ref-equality keys; sweep on session-end / load. + public static void DropStaleSelectTimes() + { + var times = SelectionDrawer.selectTimes; + if (times.Count == 0) return; + List stale = null; + foreach (var key in times.Keys) + if (key is PingInfo) + (stale ??= new List()).Add(key); + if (stale != null) + foreach (var k in stale) + times.Remove(k); + } + + public void ClearSelection() + { + if (selectedMarkerIds.Count > 0) + foreach (var m in Markers) + if (selectedMarkerIds.Contains(m.markerId)) + SelectionDrawer.selectTimes.Remove(m); + if (selectedPingPlayerIds.Count > 0) + foreach (var p in pings) + if (selectedPingPlayerIds.Contains(p.player)) + SelectionDrawer.selectTimes.Remove(p); + + selectedMarkerIds.Clear(); + selectedPingPlayerIds.Clear(); + selectionVersion++; + } + + private bool TryCaptureTarget(out int mapId, out PlanetTile tile, out Vector3 mapLoc) + { + mapId = -1; + tile = PlanetTile.Invalid; + mapLoc = Vector3.zero; + + if (WorldRendererUtility.WorldSelected) + { + var t = GenWorld.MouseTile(); + if (!t.Valid) t = GenWorld.MouseTile(true); + if (!t.Valid) return false; + tile = t; + return true; + } + + if (Find.CurrentMap != null) + { + mapId = Find.CurrentMap.uniqueID; + mapLoc = UI.MouseMapPosition(); + return true; + } + + return false; + } + + private void CancelWheel() + { + wheelActive = false; + pingKeyDownTime = null; + } + + // Strip control characters - keeps crafted packets from injecting weird text. + private static string SanitizeLabel(string raw) + { + if (string.IsNullOrEmpty(raw)) return ""; + var sb = new StringBuilder(raw.Length); + foreach (var c in raw) + if (!char.IsControl(c)) sb.Append(c); + return sb.ToString(); + } + + private void FirePing(int mapId, PlanetTile tile, Vector3 mapLoc, PingCategory category, bool asMarker) + { + if (Multiplayer.arbiterInstance) return; + if (Multiplayer.Client == null) return; + // Stamp from the placer; server relays unchanged so every receiver agrees on the value. + var tick = Find.TickManager?.TicksGame ?? 0; + Multiplayer.Client.Send(new ClientPingLocPacket( + mapId, tile.tileId, tile.layerId, + mapLoc.x, mapLoc.y, mapLoc.z, + (byte)category, asMarker, "", tick)); + if (category != PingCategory.Default) + lastUsedCategory = category; + category.Sound().PlayOneShotOnCamera(); + } + + // Mouse2 reports the *release* (UpWithoutDrag) because hold is reserved for camera-drag; + // every other key returns the press edge. + private static bool KeyTriggered(KeyCode? keyNullable) + { if (keyNullable == KeyCode.Mouse2) return MpInput.Mouse2UpWithoutDrag; + if (keyNullable is not { } key) return false; + return Input.GetKeyDown(key); } - private void PingLocation(int map, PlanetTile tile, Vector3 loc) + public void SendRenameMarker(int markerId, string label) { - Multiplayer.Client.Send(new ClientPingLocPacket(map, tile.tileId, tile.layerId, loc.x, loc.y, loc.z)); - SoundDefOf.TinyBell.PlayOneShotOnCamera(); + if (markerId == 0) return; + // Modal can outlive the connection - drop silently if so. + if (Multiplayer.Client == null) return; + var safe = SanitizeLabel(label); + if (safe.Length > PingCategoryWire.MaxLabelChars) + safe = safe.Substring(0, PingCategoryWire.MaxLabelChars); + Multiplayer.Client.Send(new ClientRenameMarkerPacket(markerId, safe)); } - public void ReceivePing(ServerPingLocPacket packet) + public void SendDeleteMarker(int markerId) { - if (!Multiplayer.settings.enablePings) return; + if (markerId == 0 || Multiplayer.Client == null) return; + Multiplayer.Client.Send(new ClientDeleteMarkerPacket(new[] { markerId })); + } - var data = packet.data; - var planetTile = new PlanetTile(data.planetTileId, data.planetTileLayer); - // Return early if both the map and planet tile are invalid - if (data.mapId == -1 && !planetTile.Valid) - return; + public void SendDeleteMarkers(int[] markerIds) + { + if (markerIds == null || markerIds.Length == 0 || Multiplayer.Client == null) return; + Multiplayer.Client.Send(new ClientDeleteMarkerPacket(markerIds)); + } + + public void SendClearMyMarkers() + { + if (Multiplayer.Client == null) return; + Multiplayer.Client.Send(new ClientClearMarkersPacket((byte)PingMarkerClearMode.Mine, -1, "")); + } + + public void SendClearMyMarkersOnMap(int mapId) + { + if (Multiplayer.Client == null) return; + Multiplayer.Client.Send(new ClientClearMarkersPacket((byte)PingMarkerClearMode.OnMap, mapId, "")); + } + + public void SendClearMarkersFromPlayer(string username) + { + if (string.IsNullOrEmpty(username) || Multiplayer.Client == null) return; + Multiplayer.Client.Send(new ClientClearMarkersPacket((byte)PingMarkerClearMode.FromPlayer, -1, username)); + } - pings.RemoveAll(p => p.player == packet.playerId); - pings.Add(new PingInfo { - player = packet.playerId, - mapId = data.mapId, - planetTile = planetTile, - mapLoc = new Vector3(data.x, data.y, data.z) - }); - alertHidden = false; - - if (packet.playerId != Multiplayer.session.playerId) - SoundDefOf.TinyBell.PlayOneShotOnCamera(); + // Host-only - server enforces the gate; client-side this is a UI button on PingHostSettingsDialog. + public void SendClearAllMarkers() + { + if (Multiplayer.Client == null) return; + Multiplayer.Client.Send(new ClientClearMarkersPacket((byte)PingMarkerClearMode.AllMarkers, -1, "")); + } + + public void SendClearAllPings() + { + if (Multiplayer.Client == null) return; + Multiplayer.Client.Send(new ClientClearMarkersPacket((byte)PingMarkerClearMode.AllPings, -1, "")); } } diff --git a/Source/Client/UI/MarkerInspectTab.cs b/Source/Client/UI/MarkerInspectTab.cs new file mode 100644 index 000000000..a8fa3eb82 --- /dev/null +++ b/Source/Client/UI/MarkerInspectTab.cs @@ -0,0 +1,183 @@ +using System.Collections.Generic; +using Multiplayer.Client.Util; +using RimWorld; +using UnityEngine; +using Verse; +using Verse.Sound; + +namespace Multiplayer.Client; + +// Surfaces marker actions inside vanilla's WorldInspectPane when a tile is selected alongside markers - +// the tile pane otherwise covers our pane (see PingSelectionUI.UpdatePingInspectPaneVisibility). +public class MarkerInspectTab : InspectTabBase +{ + // labelKey is re-translated by InspectPaneUtility, so we ship the raw key + register a runtime + // fallback (PingRuntimeTranslations); pre-translating here breaks under dev-mode pseudo-localization. + public MarkerInspectTab() + { + labelKey = "MpMarkerInspectTab_Label"; + size = new Vector2(580f, 280f); + } + + private Vector2 listScroll; + + // Returns null when no on-planet markers are selected (the tab stays hidden); otherwise the + // shared cached list. Read directly - do not mutate. Vanilla pumps this through IsVisible / + // StillValid every frame, so the cache must hit on unchanged state. + public static List CollectSelectedPlanetMarkers() + { + var loc = Multiplayer.session?.locationPings; + if (loc == null) return null; + + var markersV = Multiplayer.game?.gameComp?.markersVersion ?? 0; + var selectionV = loc.selectionVersion; + if (loc.cachedPlanetMarkersV == markersV && loc.cachedPlanetSelectionV == selectionV) + return loc.cachedPlanetMarkersHasResult ? loc.cachedPlanetMarkers : null; + + loc.cachedPlanetMarkers.Clear(); + if (loc.selectedMarkerIds.Count > 0) + { + foreach (var m in loc.Markers) + if (m.mapId == -1 + && loc.selectedMarkerIds.Contains(m.markerId) + && m.IsVisible()) + loc.cachedPlanetMarkers.Add(m); + } + loc.cachedPlanetMarkersHasResult = loc.cachedPlanetMarkers.Count > 0; + loc.cachedPlanetMarkersV = markersV; + loc.cachedPlanetSelectionV = selectionV; + return loc.cachedPlanetMarkersHasResult ? loc.cachedPlanetMarkers : null; + } + + public override bool IsVisible => CollectSelectedPlanetMarkers() != null; + + public override float PaneTopY + { + get + { + // Same anchor as WorldInspectPane. + const float PaneHeight = 165f; + const float PaneBottomGap = 35f; + return UI.screenHeight - PaneHeight - PaneBottomGap; + } + } + + public override bool StillValid => CollectSelectedPlanetMarkers() != null; + + // Match vanilla WITab: X collapses the tab, marker selection stays (Deselect clears it). + public override void CloseTab() + { + Find.World?.UI?.inspectPane?.CloseOpenTab(); + SoundDefOf.TabClose.PlayOneShotOnCamera(); + } + + public override void FillTab() + { + var loc = Multiplayer.session?.locationPings; + if (loc == null) return; + + var selected = CollectSelectedPlanetMarkers(); + if (selected == null) return; + + const float Pad = 8f; + var inner = new Rect(Pad, Pad, size.x - Pad * 2f, size.y - Pad * 2f); + + var headerRect = new Rect(inner.x, inner.y, inner.width, 22f); + using (MpStyle.Set(GameFont.Small).Set(TextAnchor.UpperLeft)) + Widgets.Label(headerRect, HeaderLabel(selected.Count)); + + // Bottom action row reserved first so the list shrinks to fill remaining space. Height + // covers two rows so DrawInlineActionsRow can wrap when 5+ buttons appear (own-marker + // case: Delete, Rename, Fade, Hide-for-me, Deselect). + const float ActionRowH = 56f; + const float Gap = 6f; + var actionRowRect = new Rect(inner.x, inner.yMax - ActionRowH, inner.width, ActionRowH); + PingSelectionUI.DrawInlineActionsRow(actionRowRect, selected, loc); + + var listRect = new Rect(inner.x, headerRect.yMax + Gap, + inner.width, actionRowRect.y - headerRect.yMax - Gap * 2f); + DrawMarkerList(listRect, selected, loc); + } + + private const float RowH = 26f; + + private void DrawMarkerList(Rect outRect, List markers, LocationPings loc) + { + var viewH = markers.Count * RowH + 4f; + var viewRect = new Rect(0f, 0f, outRect.width - 16f, viewH); + Widgets.BeginScrollView(outRect, ref listScroll, viewRect); + + var stride = RowH; + var firstVisible = Mathf.Max(0, (int)(listScroll.y / stride) - 1); + var lastVisible = Mathf.Min(markers.Count, firstVisible + (int)(outRect.height / stride) + 3); + for (var i = firstVisible; i < lastVisible; i++) + { + var rowRect = new Rect(0f, i * stride, viewRect.width, stride - 2f); + DrawMarkerRow(rowRect, markers[i], loc); + } + + Widgets.EndScrollView(); + } + + private static void DrawMarkerRow(Rect rect, PingInfo info, LocationPings loc) + { + var isSelected = loc.IsMarkerSelected(info.markerId); + Widgets.DrawHighlightIfMouseover(rect); + if (isSelected) Widgets.DrawHighlightSelected(rect); + + // 4px placer color stripe. + var stripe = info.BaseColor; + Widgets.DrawBoxSolid(new Rect(rect.x + 2f, rect.y + 3f, 4f, rect.height - 6f), + new Color(stripe.r, stripe.g, stripe.b, 0.95f)); + + // Category icon. + var iconRect = new Rect(rect.x + 12f, rect.y + 4f, 18f, 18f); + var iconTex = info.category.Icon(); + if (iconTex != null) + { + using (MpStyle.Set(info.category.Tint())) + GUI.DrawTexture(iconRect, iconTex); + } + + // Show "Category - Label" if the marker has a user label, otherwise just the category name. + var primary = string.IsNullOrEmpty(info.label) + ? info.category.DisplayName() + : $"{info.category.DisplayName()} - {info.label}"; + + var placer = info.placedByUsername ?? "?"; + var factionName = info.placedByFactionLoadId >= 0 + ? Find.FactionManager?.GetById(info.placedByFactionLoadId)?.Name + : null; + var secondary = string.IsNullOrEmpty(factionName) ? placer : $"{placer} · {factionName}"; + + // NoWrap - otherwise a long player or faction name wraps at the " · " separator and stacks vertically. + const float SecondaryW = PingInfo.LabelWidth; + var primaryRect = new Rect(iconRect.xMax + 6f, rect.y, rect.width - iconRect.xMax - 6f - SecondaryW - 6f, rect.height); + using (MpStyle.Set(GameFont.Small).Set(TextAnchor.MiddleLeft).Set(WordWrap.NoWrap)) + Widgets.Label(primaryRect, primary); + + var secondaryRect = new Rect(rect.xMax - SecondaryW - 4f, rect.y, SecondaryW, rect.height); + using (MpStyle.Set(GameFont.Tiny).Set(TextAnchor.MiddleRight).Set(WordWrap.NoWrap).Set(new Color(0.75f, 0.75f, 0.75f))) + Widgets.Label(secondaryRect, secondary); + + if (Widgets.ButtonInvisible(rect)) + { + var shift = Selector.ShiftIsHeld; + if (shift) + { + if (isSelected) loc.ToggleSelection(info); + else loc.SelectInfo(info, additive: true); + } + else + { + loc.SelectInfo(info, additive: false); + } + SoundDefOf.Click.PlayOneShotOnCamera(); + } + } + + private static string HeaderLabel(int count) + => MpTranslate.Fallback("MpMarkerInspectTab_Header", + count == 1 ? "1 marker on selected tile" : $"{count} markers on selected tile", + count); +} diff --git a/Source/Client/UI/PingCategoryExtensions.cs b/Source/Client/UI/PingCategoryExtensions.cs new file mode 100644 index 000000000..ae63fdf30 --- /dev/null +++ b/Source/Client/UI/PingCategoryExtensions.cs @@ -0,0 +1,95 @@ +using Multiplayer.Client.Util; +using Multiplayer.Common.Networking.Packet; +using RimWorld; +using UnityEngine; +using Verse; +using Verse.Sound; + +namespace Multiplayer.Client; + +public static class PingCategoryExtensions +{ + public static readonly PingCategory[] All = + { + PingCategory.Default, + PingCategory.Attack, + PingCategory.Defend, + PingCategory.Help, + PingCategory.Loot, + PingCategory.Rally, + }; + + public static Color Tint(this PingCategory c) => c switch + { + PingCategory.Attack => new Color(1f, 0.25f, 0.25f), + PingCategory.Defend => new Color(0.4f, 0.6f, 1f ), + PingCategory.Help => new Color(1f, 0.95f, 0.3f ), + PingCategory.Loot => new Color(0.4f, 1f, 0.4f ), + PingCategory.Rally => new Color(0.85f, 0.55f, 1f ), + _ => Color.white, + }; + + public static string Glyph(this PingCategory c) => c switch + { + PingCategory.Attack => "A", + PingCategory.Defend => "D", + PingCategory.Help => "+", + PingCategory.Loot => "L", + PingCategory.Rally => "R", + _ => "", + }; + + // Returns null for Default so the renderer falls back to Glyph(). + public static Texture2D Icon(this PingCategory c) => c switch + { + PingCategory.Attack => MultiplayerStatic.PingIconAttack, + PingCategory.Defend => MultiplayerStatic.PingIconDefend, + PingCategory.Help => MultiplayerStatic.PingIconHelp, + PingCategory.Loot => MultiplayerStatic.PingIconLoot, + PingCategory.Rally => MultiplayerStatic.PingIconRally, + _ => null, + }; + + // Normalizes visual size across vanilla icons with varying canvas padding. + public static float IconScale(this PingCategory c) => c switch + { + PingCategory.Attack => 1.20f, + PingCategory.Defend => 0.92f, + PingCategory.Help => 1.00f, + PingCategory.Loot => 1.06f, + PingCategory.Rally => 0.95f, + _ => 1.00f, + }; + + private static string EnglishFallback(PingCategory c) => c switch + { + PingCategory.Default => "Ping", + PingCategory.Attack => "Attack", + PingCategory.Defend => "Defend", + PingCategory.Help => "Help", + PingCategory.Loot => "Loot", + PingCategory.Rally => "Rally", + _ => c.ToString(), + }; + + // Dev-mode pseudo-localization mangles missing keys - must go through MpTranslate.Fallback. + public static string DisplayName(this PingCategory c) + => MpTranslate.Fallback("MpPingCategory_" + c, EnglishFallback(c)); + + // Vanilla SoundDefOf only (no XML). The ?? TinyBell fallback covers the case where a + // SoundDefOf field is null because RimWorld renamed or removed the underlying Def. + // Every chosen SoundDef must ship with on-camera subSounds - PlayOneShotOnCamera errors otherwise. + public static SoundDef Sound(this PingCategory c) + { + var picked = c switch + { + PingCategory.Attack => SoundDefOf.Quest_Failed, + PingCategory.Defend => SoundDefOf.DraftOn, + PingCategory.Help => SoundDefOf.TutorMessageAppear, + PingCategory.Loot => SoundDefOf.ExecuteTrade, + PingCategory.Rally => SoundDefOf.Quest_Accepted, + _ => SoundDefOf.TinyBell, + }; + return picked ?? SoundDefOf.TinyBell; + } +} diff --git a/Source/Client/UI/PingInfo.cs b/Source/Client/UI/PingInfo.cs index 3809575dc..043a0b8ee 100644 --- a/Source/Client/UI/PingInfo.cs +++ b/Source/Client/UI/PingInfo.cs @@ -1,27 +1,47 @@ using System; +using Multiplayer.API; using Multiplayer.Client.Util; +using Multiplayer.Common.Networking.Packet; using RimWorld.Planet; using UnityEngine; using Verse; namespace Multiplayer.Client; -public class PingInfo +public class PingInfo : IExposable, ISynchronizable { public int player; - public int mapId; // Map id or -1 for planet + public int mapId; // -1 = planet public PlanetTile planetTile; public Vector3 mapLoc; - public PlayerInfo PlayerInfo => Multiplayer.session.GetPlayerInfo(player); + public PingCategory category; + public string label = ""; + public bool isMarker; + + // ++nextMarkerId on every receiver: identical packet stream means every client picks the same id. + public int markerId; + + // Stable identity that survives save/load and the placer disconnecting (runtime `player` is reused on rejoin). + public string placedByUsername; + public int placedByFactionLoadId = -1; + public float placedByR = 1f, placedByG = 1f, placedByB = 1f; + + // Server-echoed placer TicksGame; survives save/load. + public int placedAtTick; + + public PlayerInfo PlayerInfo => Multiplayer.session?.GetPlayerInfo(player); public float y = 1f; private float v = -3f; + // Wall-clock - pings only. Markers short-circuit Update() before reading. private float lastTime = Time.time; private float bounceAt = Time.time + 2; public float timeAlive; - private float AlphaMult => 1f - Mathf.Clamp01(timeAlive - (PingDuration - 1f)); + public float placedAt; + + private float AlphaMult => (isMarker ? 1f : 1f - Mathf.Clamp01(timeAlive - (PingDuration - 1f))) * LocalAlphaMult(); public GlobalTargetInfo? Target { @@ -37,10 +57,110 @@ public GlobalTargetInfo? Target } } - const float PingDuration = 10f; + // Markers: durable RGB snapshot. Pings: live PlayerInfo.color so color edits show immediately. + public Color BaseColor + { + get + { + if (isMarker) + return new Color(placedByR, placedByG, placedByB); + + var pi = PlayerInfo; + return pi != null ? pi.color : new Color(placedByR, placedByG, placedByB); + } + } + + // Default pings use the placer's color. Typed pings use the pure category tint, otherwise placer + category tints muddy each other. + public Color PinColor + { + get + { + var b = BaseColor; + if (category == PingCategory.Default) return b; + var t = category.Tint(); + return new Color(t.r, t.g, t.b, b.a); + } + } + + internal const float PingDuration = 10f; + + internal const float LabelWidth = 200f; + + // Visibility only - filtered markers still exist on every client and still sync. + public bool IsVisible() + { + var s = Multiplayer.settings; + if (s == null) return true; + if (!string.IsNullOrEmpty(placedByUsername) + && s.hiddenPlayerNames != null + && s.hiddenPlayerNames.Contains(placedByUsername)) + return false; + if (s.hiddenFactionLoadIds != null + && s.hiddenFactionLoadIds.Contains(placedByFactionLoadId)) + return false; + if (!s.showSpectatorMarkers) + { + var spec = Multiplayer.WorldComp?.spectatorFaction; + if (spec != null && placedByFactionLoadId == spec.loadID) + return false; + } + return true; + } + + // Matches by runtime id OR durable username (survives leaves and save-load). + public bool IsPlacedBy(int playerId, string username) + { + if (player == playerId) return true; + return !string.IsNullOrEmpty(username) && placedByUsername == username; + } + + // Placer OR map-owner-faction (multifaction) OR host bypass. UI and Receive handlers share this. + public bool CanBeModifiedBy(int playerId, string username, int factionLoadId, bool multifaction, bool senderIsHost = false) + { + if (senderIsHost) return true; + if (IsPlacedBy(playerId, username)) return true; + if (!multifaction || mapId < 0) return false; + if (Find.Maps.GetById(mapId) is { } map) + return map.ParentFaction?.loadID == factionLoadId; + return false; + } + + public bool IsOwnedByLocalPlayer() + { + var sess = Multiplayer.session; + if (sess == null) return false; + return IsPlacedBy(sess.playerId, sess.GetPlayerInfo(sess.playerId)?.username); + } + + // Pings never have a markerId; implicitly returns 1f. + public float LocalAlphaMult() + { + if (!isMarker || markerId == 0) return 1f; + var s = Multiplayer.settings; + if (s == null || s.localMarkerAlpha == null) return 1f; + if (s.localMarkerAlpha.TryGetValue(markerId, out var a)) + return Mathf.Clamp(a, 0.05f, 1f); // 0.05 floor so collapsed-state never goes to nothing + return 1f; + } + + // Hidden markers stay clickable (as a dot) so owner can always unhide. + public bool IsLocallyHidden() + { + if (!isMarker || markerId == 0) return false; + var s = Multiplayer.settings; + if (s == null || s.locallyHiddenMarkers == null) return false; + return s.locallyHiddenMarkers.Contains(markerId); + } public bool Update() { + if (isMarker) + { + y = 0f; + v = 0f; + return false; + } + float delta = Mathf.Min(Time.time - lastTime, 0.05f); lastTime = Time.time; @@ -70,24 +190,164 @@ public bool Update() return timeAlive > PingDuration; } - public void DrawAt(Vector2 screenCenter, Color baseColor, float size) + public void DrawAt(Vector2 screenCenter, float size) { - var colorAlpha = baseColor; - colorAlpha.a = 0.5f * AlphaMult; + if (!IsVisible()) return; + + if (IsLocallyHidden() && isMarker) + { + DrawCollapsedDot(screenCenter, size); + if (PingSelectionUI.IsSelected(this)) + PingSelectionUI.DrawSelectionBrackets(this, screenCenter, size); + return; + } + + var baseColor = BaseColor; + var pinColor = PinColor; - using (MpStyle.Set(colorAlpha)) - GUI.DrawTexture( - new Rect(screenCenter - new Vector2(size / 2 - 1, size / 2), new(size, size)), - MultiplayerStatic.PingBase - ); + // Brackets before the pin so the icon sits inside the bracket frame. + if (PingSelectionUI.IsSelected(this)) + PingSelectionUI.DrawSelectionBrackets(this, screenCenter, size); - var color = baseColor; - color.a = AlphaMult; + var ringSize = size * 1.12f; + var ringRect = new Rect(screenCenter - new Vector2(ringSize / 2f - 1f, ringSize / 2f), + new Vector2(ringSize, ringSize)); + using (MpStyle.Set(new Color(0f, 0f, 0f, 0.45f * AlphaMult))) + GUI.DrawTexture(ringRect.ExpandedBy(1.5f), MultiplayerStatic.PingBase); + var groundRingColor = baseColor; + groundRingColor.a = 0.85f * AlphaMult; + using (MpStyle.Set(groundRingColor)) + GUI.DrawTexture(ringRect, MultiplayerStatic.PingBase); + + var pinRect = new Rect(screenCenter - new Vector2(size / 2, size + y * size), new(size, size)); + + var pinDrawColor = pinColor; + pinDrawColor.a = AlphaMult; + using (MpStyle.Set(pinDrawColor)) + GUI.DrawTexture(pinRect, MultiplayerStatic.PingPin); + + if (category != PingCategory.Default) + { + var iconTex = category.Icon(); + if (iconTex != null) + { + var iconSize = size * 0.42f * category.IconScale(); + var headCenterY = pinRect.y + size * 0.34f; + var iconRect = new Rect( + pinRect.center.x - iconSize / 2f, + headCenterY - iconSize / 2f, + iconSize, + iconSize); + + using (MpStyle.Set(new Color(0f, 0f, 0f, 0.6f * AlphaMult))) + GUI.DrawTexture(new Rect(iconRect.x + 1f, iconRect.y + 1.5f, + iconRect.width, iconRect.height), iconTex); + using (MpStyle.Set(new Color(1f, 1f, 1f, AlphaMult))) + GUI.DrawTexture(iconRect, iconTex); + } + else + { + var glyph = category.Glyph(); + if (glyph.Length > 0) + { + var glyphRect = new Rect(pinRect.x, pinRect.y + size * 0.18f, size, size * 0.32f); + using (MpStyle.Set(GameFont.Small).Set(TextAnchor.MiddleCenter)) + MpUI.LabelOutlined(glyphRect, glyph, + new Color(1f, 1f, 1f, AlphaMult), + new Color(0f, 0f, 0f, 0.95f * AlphaMult)); + } + } + } + + var labelY = screenCenter.y + size * 0.42f; + if (category != PingCategory.Default) + { + var nameRect = new Rect(screenCenter.x - LabelWidth / 2f, labelY, LabelWidth, 18f); + using (MpStyle.Set(GameFont.Small).Set(TextAnchor.MiddleCenter)) + MpUI.LabelOutlined(nameRect, category.DisplayName(), + new Color(1f, 1f, 1f, AlphaMult), + new Color(0f, 0f, 0f, 0.95f * AlphaMult)); + labelY += 18f; + } + if (!string.IsNullOrEmpty(label)) + { + var labelRect = new Rect(screenCenter.x - LabelWidth / 2f, labelY, LabelWidth, 16f); + using (MpStyle.Set(GameFont.Tiny).Set(TextAnchor.MiddleCenter)) + MpUI.LabelOutlined(labelRect, label, + new Color(1f, 1f, 1f, AlphaMult), + new Color(0f, 0f, 0f, 0.9f * AlphaMult)); + } + } + + // Small owner-tinted dot for locally-hidden markers. + private void DrawCollapsedDot(Vector2 screenCenter, float size) + { + var dotSize = size * 0.30f; + var rect = new Rect(screenCenter.x - dotSize / 2f, screenCenter.y - dotSize / 2f, dotSize, dotSize); + var color = BaseColor; + color.a = 0.55f; + using (MpStyle.Set(new Color(0f, 0f, 0f, 0.5f))) + GUI.DrawTexture(rect.ExpandedBy(1.5f), MultiplayerStatic.PingCircle); using (MpStyle.Set(color)) - GUI.DrawTexture( - new Rect(screenCenter - new Vector2(size / 2, size + y * size), new(size, size)), - MultiplayerStatic.PingPin - ); + GUI.DrawTexture(rect, MultiplayerStatic.PingCircle); + } + + public void Sync(SyncWorker sync) + { + sync.Bind(ref player); + sync.Bind(ref mapId); + + // PlanetTile.layerId is private - round-trip components manually. + int tileId = planetTile.tileId; + int layerId = planetTile.layerId; + sync.Bind(ref tileId); + sync.Bind(ref layerId); + if (!sync.isWriting) + planetTile = new PlanetTile(tileId, layerId); + + sync.Bind(ref mapLoc); + + byte cat = (byte)category; + sync.Bind(ref cat); + if (!sync.isWriting) + category = (PingCategory)cat; + + sync.Bind(ref label); + sync.Bind(ref isMarker); + sync.Bind(ref markerId); + sync.Bind(ref placedByUsername); + sync.Bind(ref placedByFactionLoadId); + sync.Bind(ref placedByR); + sync.Bind(ref placedByG); + sync.Bind(ref placedByB); + sync.Bind(ref placedAtTick); + } + + public void ExposeData() + { + Scribe_Values.Look(ref player, "player", -1); + Scribe_Values.Look(ref mapId, "mapId", -1); + + // PlanetTile is a readonly struct with private layerId - no Scribe overload, so manual. + int tileId = planetTile.tileId; + int layerId = planetTile.layerId; + Scribe_Values.Look(ref tileId, "planetTileId", -1); + Scribe_Values.Look(ref layerId, "planetTileLayer", -1); + if (Scribe.mode == LoadSaveMode.LoadingVars) + planetTile = new PlanetTile(tileId, layerId); + + Scribe_Values.Look(ref mapLoc, "mapLoc"); + Scribe_Values.Look(ref category, "category", PingCategory.Default); + Scribe_Values.Look(ref label, "label", ""); + Scribe_Values.Look(ref isMarker, "isMarker"); + Scribe_Values.Look(ref markerId, "markerId"); + + Scribe_Values.Look(ref placedByUsername, "placedByUsername"); + Scribe_Values.Look(ref placedByFactionLoadId, "placedByFactionLoadId", -1); + Scribe_Values.Look(ref placedByR, "placedByR", 1f); + Scribe_Values.Look(ref placedByG, "placedByG", 1f); + Scribe_Values.Look(ref placedByB, "placedByB", 1f); + Scribe_Values.Look(ref placedAtTick, "placedAtTick"); } } diff --git a/Source/Client/UI/PingSelectionUI.cs b/Source/Client/UI/PingSelectionUI.cs new file mode 100644 index 000000000..3140d93eb --- /dev/null +++ b/Source/Client/UI/PingSelectionUI.cs @@ -0,0 +1,616 @@ +using System.Collections.Generic; +using Multiplayer.Client.Util; +using RimWorld; +using RimWorld.Planet; +using UnityEngine; +using Verse; +using Verse.Sound; + +namespace Multiplayer.Client; + +// Marker/ping selection helpers: bracket overlay, inspect-pane lifecycle, gizmo factory. +public static class PingSelectionUI +{ + public static bool IsSelected(PingInfo info) + { + var loc = Multiplayer.session?.locationPings; + if (loc == null) return false; + return info.isMarker + ? loc.IsMarkerSelected(info.markerId) + : loc.IsPingSelected(info.player); + } + + public static void DrawSelectionBrackets(PingInfo info, Vector2 screenCenter, float size) + { + var rectSize = size * 1.35f; + var rect = new Rect(screenCenter.x - rectSize / 2f, screenCenter.y - rectSize / 2f, + rectSize, rectSize); + SelectionDrawerUtility.DrawSelectionOverlayOnGUI(info, rect, scale: 0.4f, selectedTextJump: 20f); + } + + public static void UpdatePingInspectPaneVisibility() + { + if (Multiplayer.arbiterInstance) return; + // OnGUI can fire before UpdatePing clears selection on replay entry. + if (Multiplayer.IsReplay) return; + var loc = Multiplayer.session?.locationPings; + if (loc == null) return; + + // No Event.Use() - same press still clears vanilla in a mixed selection. + if (loc.HasSelection && KeyBindingDefOf.Cancel.KeyDownEvent) + loc.ClearSelection(); + + // Vanilla's selection-blocks check is view-scoped - planet selection only blocks on planet view. + var onPlanet = WorldRendererUtility.WorldSelected; + var vanillaSelectorBlocks = onPlanet + ? Find.WorldSelector != null + && (Find.WorldSelector.NumSelectedObjects > 0 || Find.WorldSelector.SelectedTile.Valid) + : (Find.Selector?.NumSelected ?? 0) > 0; + var openOurPane = loc.HasSelection && !vanillaSelectorBlocks; + var open = PingInspectPane.Opened; + + if (openOurPane && open == null) + Find.WindowStack.Add(new PingInspectPane()); + else if (!openOurPane && open != null) + open.Close(doCloseSound: false); + } + + // No intersect-with sweep - selectedMarkerIds may also contain map-bound ids the planet view doesn't see. + public static List CollectSelectedOnPlanet(LocationPings loc) + { + var result = new List(); + + if (loc.selectedMarkerIds.Count > 0) + foreach (var m in loc.Markers) + if (m.mapId == -1 && loc.selectedMarkerIds.Contains(m.markerId) && m.IsVisible()) + result.Add(m); + + if (loc.selectedPingPlayerIds.Count > 0) + foreach (var p in loc.pings) + if (p.mapId == -1 && loc.selectedPingPlayerIds.Contains(p.player) && p.IsVisible()) + result.Add(p); + + return result; + } + + // Filter-hidden markers stay in the set but are skipped here so brackets/gizmos ignore them. + public static List CollectSelectedOnCurrentMap(LocationPings loc) + { + var result = new List(); + if (Find.CurrentMap == null) return result; + var mapId = Find.CurrentMap.uniqueID; + + if (loc.selectedMarkerIds.Count > 0) + { + var stillAlive = new HashSet(); + foreach (var m in loc.Markers) + if (m.mapId == mapId && loc.selectedMarkerIds.Contains(m.markerId)) + { + stillAlive.Add(m.markerId); + if (m.IsVisible()) + result.Add(m); + } + if (stillAlive.Count != loc.selectedMarkerIds.Count) + { + loc.selectedMarkerIds.IntersectWith(stillAlive); + loc.selectionVersion++; + } + } + + if (loc.selectedPingPlayerIds.Count > 0) + { + var stillAlive = new HashSet(); + foreach (var p in loc.pings) + if (p.mapId == mapId && loc.selectedPingPlayerIds.Contains(p.player)) + { + stillAlive.Add(p.player); + if (p.IsVisible()) + result.Add(p); + } + if (stillAlive.Count != loc.selectedPingPlayerIds.Count) + { + loc.selectedPingPlayerIds.IntersectWith(stillAlive); + loc.selectionVersion++; + } + } + + return result; + } + + // Cached so the vanilla gizmo-grid reference-cache hits. No hotKey - would double-fire. + public static List BuildGizmos(List selected, LocationPings loc) + { + // "Owned" = deletable by the local player (placer OR multifaction map-owner). + var ownedMarkerCount = 0; + var foreignMarkerCount = 0; + var foreignSampleUsername = (string)null; + var foreignSampleFactionId = -1; + var foreignSpectatorPresent = false; + foreach (var info in selected) + { + if (!info.isMarker) continue; + if (LocationPings.CanDeleteMarker(info)) + { + ownedMarkerCount++; + } + else + { + foreignMarkerCount++; + if (foreignSampleUsername == null && !string.IsNullOrEmpty(info.placedByUsername)) + { + foreignSampleUsername = info.placedByUsername; + foreignSampleFactionId = info.placedByFactionLoadId; + } + var spec = Multiplayer.WorldComp?.spectatorFaction; + if (spec != null && info.placedByFactionLoadId == spec.loadID) + foreignSpectatorPresent = true; + } + } + + var renameTargetId = 0; + if (ownedMarkerCount == 1 && foreignMarkerCount == 0) + { + var t = FindOnlyOwnedMarker(selected); + if (t != null) renameTargetId = t.markerId; + } + + // Faction switch invalidates cache - CanDeleteMarker reads RealPlayerFaction. + var factionId = Multiplayer.RealPlayerFaction?.loadID ?? -1; + var markersV = Multiplayer.game?.gameComp?.markersVersion ?? 0; + var selectionV = loc.selectionVersion; + + if (loc.cachedGizmos != null + && loc.cachedGizmoKey.Matches(ownedMarkerCount, foreignMarkerCount, renameTargetId, factionId, markersV, selectionV)) + return loc.cachedGizmos; + + var result = new List(); + if (ownedMarkerCount > 0) + { + var label = ownedMarkerCount == 1 + ? DeleteLabel() + : MultiDeleteLabel(ownedMarkerCount); + var desc = foreignMarkerCount > 0 + ? MpTranslate.Fallback("MpPingSel_DeleteForeignDesc", + $"Removes {ownedMarkerCount} of your markers. {foreignMarkerCount} marker(s) from other players are in this selection and cannot be deleted by you.", + ownedMarkerCount, foreignMarkerCount) + : MpTranslate.Fallback("MpPingSel_DeleteDesc", "Remove this marker from the map."); + result.Add(new Command_Action + { + defaultLabel = label, + defaultDesc = desc, + icon = TexButton.Delete, + action = () => DeleteOwnedFromCurrentSelection(loc), + }); + + // Rename only on a single-owned selection - renaming many markers at once doesn't make sense. + if (ownedMarkerCount == 1 && foreignMarkerCount == 0) + { + var theOne = FindOnlyOwnedMarker(selected); + if (theOne != null) + { + result.Add(new Command_Action + { + defaultLabel = RenameLabel(), + defaultDesc = MpTranslate.Fallback("MpPingSel_RenameDesc", "Change this marker's label."), + icon = TexButton.Rename, + action = () => Find.WindowStack.Add(new PingLabelWindow(theOne.markerId, theOne.label)), + }); + } + } + } + + // Show mute-actions for the first foreign placer only; multi-foreign almost always shares one placer. + if (foreignSampleUsername != null) + { + var settings = Multiplayer.settings; + var alreadyMutedPlayer = settings != null && settings.hiddenPlayerNames.Contains(foreignSampleUsername); + result.Add(new Command_Action + { + defaultLabel = alreadyMutedPlayer + ? UnmutePlayerLabel(foreignSampleUsername) + : MutePlayerLabel(foreignSampleUsername), + defaultDesc = MpTranslate.Fallback("MpPingSel_MutePlayerDesc", + $"Hide all current and future markers and pings placed by {foreignSampleUsername}, including the audible cue when one is dropped.", + foreignSampleUsername), + icon = MultiplayerStatic.PingMuteIcon, + action = () => ToggleMutePlayer(foreignSampleUsername), + }); + + if (foreignSampleFactionId >= 0) + { + var factionMan = Find.FactionManager; + var faction = factionMan?.GetById(foreignSampleFactionId); + var factionName = faction?.Name ?? "?"; + var alreadyMutedFaction = settings != null && settings.hiddenFactionLoadIds.Contains(foreignSampleFactionId); + result.Add(new Command_Action + { + defaultLabel = alreadyMutedFaction + ? UnmuteFactionLabel(factionName) + : MuteFactionLabel(factionName), + defaultDesc = MpTranslate.Fallback("MpPingSel_MuteFactionDesc", + $"Hide all markers and pings placed by anyone in {factionName}.", + factionName), + icon = MultiplayerStatic.PingMuteIcon, + action = () => ToggleMuteFaction(foreignSampleFactionId), + }); + } + } + if (foreignSpectatorPresent) + { + var settings = Multiplayer.settings; + var alreadyMuted = settings != null && !settings.showSpectatorMarkers; + result.Add(new Command_Action + { + defaultLabel = alreadyMuted ? UnmuteSpectatorsLabel() : MuteSpectatorsLabel(), + defaultDesc = MpTranslate.Fallback("MpPingSel_MuteSpectatorsDesc", + "Hide all markers and pings placed by spectators (joiners who haven't picked a faction yet)."), + icon = MultiplayerStatic.PingMuteIcon, + action = ToggleMuteSpectators, + }); + } + + // Local overrides apply to any selected marker (incl. foreign) - "see past this" is the use case. + var markerIds = new List(); + foreach (var info in selected) + if (info.isMarker && info.markerId != 0) markerIds.Add(info.markerId); + if (markerIds.Count > 0) + { + var anyHidden = false; + var anyDimmed = false; + foreach (var id in markerIds) + { + if (Multiplayer.settings?.locallyHiddenMarkers?.Contains(id) ?? false) anyHidden = true; + if (Multiplayer.settings?.localMarkerAlpha?.TryGetValue(id, out var a) ?? false) + if (a < 0.999f) anyDimmed = true; + } + + result.Add(new Command_Action + { + defaultLabel = TransparencyLabel(), + defaultDesc = MpTranslate.Fallback("MpPingSel_TransparencyDesc", + "Adjust how much this marker is faded for you, without changing what other players see."), + icon = MultiplayerStatic.PingTransparencyIcon, + action = () => + { + Find.WindowStack.Add(new MarkerAlphaWindow(markerIds)); + SoundDefOf.Click.PlayOneShotOnCamera(); + }, + }); + + result.Add(new Command_Action + { + defaultLabel = anyHidden ? UnhideLocallyLabel() : HideLocallyLabel(), + defaultDesc = MpTranslate.Fallback("MpPingSel_HideLocallyDesc", + "Collapse this marker to a small dot in your own view. Click the dot to bring it back. Other players are unaffected."), + icon = anyHidden ? MultiplayerStatic.PingShowForMeIcon : MultiplayerStatic.PingHideForMeIcon, + action = () => + { + ToggleHideLocally(markerIds, makeVisible: anyHidden); + SoundDefOf.Click.PlayOneShotOnCamera(); + }, + }); + + if (anyDimmed || anyHidden) + { + result.Add(new Command_Action + { + defaultLabel = ResetLocalAppearanceLabel(), + defaultDesc = MpTranslate.Fallback("MpPingSel_ResetLocalAppearanceDesc", + "Clear any transparency or local-hide settings for this marker."), + icon = MultiplayerStatic.PingResetViewIcon, + action = () => + { + ResetLocalAppearance(markerIds); + SoundDefOf.Click.PlayOneShotOnCamera(); + }, + }); + } + } + + // Escape hatch for the drag-select pulls-in-markers behavior. + result.Add(new Command_Action + { + defaultLabel = DeselectAllMarkersLabel(), + defaultDesc = MpTranslate.Fallback("MpPingSel_DeselectAllDesc", + "Drop every marker and ping from your current selection. Vanilla selection (pawns, items, buildings) is not affected."), + icon = MultiplayerStatic.PingDeselectIcon, + action = () => + { + loc.ClearSelection(); + SoundDefOf.Click.PlayOneShotOnCamera(); + }, + }); + + loc.cachedGizmos = result; + loc.cachedGizmoKey = new LocationPings.GizmoCacheKey + { + owned = ownedMarkerCount, + foreign = foreignMarkerCount, + renameTargetId = renameTargetId, + factionId = factionId, + markersVersion = markersV, + selectionVersion = selectionV, + }; + return result; + } + + // Planet-view has no gizmo grid - inline row mirrors what BuildGizmos shows on map-view. + // Both the analysis and the resulting actions list are cached on the same (markersV, + // selectionV, factionId) key; every settings toggle that affects mute/hide state bumps + // markersVersion via BumpMarkersVersion, so the closure captures stay in sync. + public static void DrawInlineActionsRow(Rect rowRect, List selected, LocationPings loc) + { + var markersV = Multiplayer.game?.gameComp?.markersVersion ?? 0; + var factionId = Multiplayer.RealPlayerFaction?.loadID ?? -1; + if (loc.cachedInlineMarkersV != markersV + || loc.cachedInlineSelectionV != loc.selectionVersion + || loc.cachedInlineFactionId != factionId) + { + loc.cachedInlineOwnedCount = 0; + loc.cachedInlineOnlyOwnedSingle = null; + loc.cachedInlineForeignSampleUsername = null; + loc.cachedInlineForeignSampleFactionId = -1; + loc.cachedInlineForeignSpectatorPresent = false; + loc.cachedInlineMarkerIds.Clear(); + var spec = Multiplayer.WorldComp?.spectatorFaction; + foreach (var info in selected) + { + if (!info.isMarker) continue; + if (info.markerId != 0) + loc.cachedInlineMarkerIds.Add(info.markerId); + if (LocationPings.CanDeleteMarker(info)) + { + loc.cachedInlineOwnedCount++; + // Set on first, null on every subsequent - consumer gates on ownedCount == 1. + loc.cachedInlineOnlyOwnedSingle = loc.cachedInlineOwnedCount == 1 ? info : null; + } + else + { + if (loc.cachedInlineForeignSampleUsername == null && !string.IsNullOrEmpty(info.placedByUsername)) + { + loc.cachedInlineForeignSampleUsername = info.placedByUsername; + loc.cachedInlineForeignSampleFactionId = info.placedByFactionLoadId; + } + if (spec != null && info.placedByFactionLoadId == spec.loadID) + loc.cachedInlineForeignSpectatorPresent = true; + } + } + + loc.cachedInlineActions = BuildInlineActions(loc); + + loc.cachedInlineMarkersV = markersV; + loc.cachedInlineSelectionV = loc.selectionVersion; + loc.cachedInlineFactionId = factionId; + } + + PackInlineActionButtons(rowRect, loc.cachedInlineActions); + } + + // Built once per cache invalidation. Lambdas capture loc.cachedInlineMarkerIds by reference; + // that List is .Clear()-and-refilled in place during rebuild, so a cache hit means the + // captured contents are still current. Foreign sample / faction id are read out of the + // analysis cache and captured by value. + private static List<(string label, System.Action onClick)> BuildInlineActions(LocationPings loc) + { + var ownedCount = loc.cachedInlineOwnedCount; + var onlyOwnedSingle = loc.cachedInlineOnlyOwnedSingle; + var foreignSampleUsername = loc.cachedInlineForeignSampleUsername; + var foreignSampleFactionId = loc.cachedInlineForeignSampleFactionId; + var foreignSpectatorPresent = loc.cachedInlineForeignSpectatorPresent; + var markerIds = loc.cachedInlineMarkerIds; + + // Measured-width row-wrap; fixed-width overflows once 4+ buttons appear. + var actions = new List<(string label, System.Action onClick)>(); + + if (ownedCount > 0) + { + actions.Add((ownedCount == 1 ? DeleteLabel() : MultiDeleteLabel(ownedCount), + () => DeleteOwnedFromCurrentSelection(loc))); + + if (ownedCount == 1 && onlyOwnedSingle != null) + { + var theOne = onlyOwnedSingle; + actions.Add((RenameLabel(), + () => Find.WindowStack.Add(new PingLabelWindow(theOne.markerId, theOne.label)))); + } + } + + if (foreignSampleUsername != null) + { + var muted = Multiplayer.settings?.hiddenPlayerNames.Contains(foreignSampleUsername) ?? false; + actions.Add((muted ? UnmutePlayerLabel(foreignSampleUsername) : MutePlayerLabel(foreignSampleUsername), + () => ToggleMutePlayer(foreignSampleUsername))); + + if (foreignSampleFactionId >= 0) + { + var factionName = Find.FactionManager?.GetById(foreignSampleFactionId)?.Name ?? "?"; + var mutedFaction = Multiplayer.settings?.hiddenFactionLoadIds.Contains(foreignSampleFactionId) ?? false; + actions.Add((mutedFaction ? UnmuteFactionLabel(factionName) : MuteFactionLabel(factionName), + () => ToggleMuteFaction(foreignSampleFactionId))); + } + } + + if (foreignSpectatorPresent) + { + var muted = Multiplayer.settings != null && !Multiplayer.settings.showSpectatorMarkers; + actions.Add((muted ? UnmuteSpectatorsLabel() : MuteSpectatorsLabel(), + ToggleMuteSpectators)); + } + + if (markerIds.Count > 0) + { + var anyHidden = false; + foreach (var id in markerIds) + if (Multiplayer.settings?.locallyHiddenMarkers?.Contains(id) ?? false) anyHidden = true; + + actions.Add((TransparencyLabel(), + () => Find.WindowStack.Add(new MarkerAlphaWindow(markerIds)))); + actions.Add((anyHidden ? UnhideLocallyLabel() : HideLocallyLabel(), + () => ToggleHideLocally(markerIds, makeVisible: anyHidden))); + } + + actions.Add((DeselectAllMarkersLabel(), + () => { loc.ClearSelection(); SoundDefOf.Click.PlayOneShotOnCamera(); })); + + return actions; + } + + // Measured-width pack with row-wrap; caller reserves vertical space (see ActionRowH in MarkerInspectTab.FillTab). + private static void PackInlineActionButtons(Rect rowRect, + List<(string label, System.Action onClick)> actions) + { + const float BtnH = 24f; + const float BtnPadX = 10f; + const float BtnGap = 6f; + const float RowGap = 4f; + + var x = rowRect.x; + var y = rowRect.y; + foreach (var (label, onClick) in actions) + { + var w = Text.CalcSize(label).x + BtnPadX * 2f; + // Always draw the first button on a row even if it's wider than rowRect - clipping + // there is better than an invisible action. + if (x > rowRect.x && x + w > rowRect.xMax) + { + x = rowRect.x; + y += BtnH + RowGap; + } + if (y + BtnH > rowRect.yMax + 1f) break; + if (Widgets.ButtonText(new Rect(x, y, w, BtnH), label)) + onClick(); + x += w + BtnGap; + } + } + + private static void ToggleHideLocally(List markerIds, bool makeVisible) + { + var s = Multiplayer.settings; + if (s == null) return; + s.locallyHiddenMarkers ??= new HashSet(); + foreach (var id in markerIds) + { + if (makeVisible) s.locallyHiddenMarkers.Remove(id); + else s.locallyHiddenMarkers.Add(id); + } + // markersVersion bump - local-hide doesn't mutate marker, but changes what counts as drawn. + if (Multiplayer.game?.gameComp != null) Multiplayer.game.gameComp.markersVersion++; + MultiplayerLoader.Multiplayer.instance?.WriteSettings(); + } + + private static void ResetLocalAppearance(List markerIds) + { + var s = Multiplayer.settings; + if (s == null) return; + s.locallyHiddenMarkers ??= new HashSet(); + s.localMarkerAlpha ??= new Dictionary(); + foreach (var id in markerIds) + { + s.locallyHiddenMarkers.Remove(id); + s.localMarkerAlpha.Remove(id); + } + if (Multiplayer.game?.gameComp != null) Multiplayer.game.gameComp.markersVersion++; + MultiplayerLoader.Multiplayer.instance?.WriteSettings(); + } + + private static void ToggleMutePlayer(string username) + { + var s = Multiplayer.settings; + if (s == null || string.IsNullOrEmpty(username)) return; + if (!s.hiddenPlayerNames.Add(username)) + s.hiddenPlayerNames.Remove(username); + BumpMarkersVersion(); + MultiplayerLoader.Multiplayer.instance?.WriteSettings(); + SoundDefOf.Click.PlayOneShotOnCamera(); + } + + private static void ToggleMuteFaction(int factionLoadId) + { + var s = Multiplayer.settings; + if (s == null) return; + if (!s.hiddenFactionLoadIds.Add(factionLoadId)) + s.hiddenFactionLoadIds.Remove(factionLoadId); + BumpMarkersVersion(); + MultiplayerLoader.Multiplayer.instance?.WriteSettings(); + SoundDefOf.Click.PlayOneShotOnCamera(); + } + + private static void ToggleMuteSpectators() + { + var s = Multiplayer.settings; + if (s == null) return; + s.showSpectatorMarkers = !s.showSpectatorMarkers; + BumpMarkersVersion(); + MultiplayerLoader.Multiplayer.instance?.WriteSettings(); + SoundDefOf.Click.PlayOneShotOnCamera(); + } + + // Mute toggles change what IsVisible() returns; downstream caches key on markersVersion. + private static void BumpMarkersVersion() + { + if (Multiplayer.game?.gameComp != null) + Multiplayer.game.gameComp.markersVersion++; + } + + private static PingInfo FindOnlyOwnedMarker(List selected) + { + PingInfo found = null; + foreach (var info in selected) + { + if (!info.isMarker) continue; + if (!LocationPings.CanDeleteMarker(info)) continue; + if (found != null) return null; + found = info; + } + return found; + } + + // Walks loc.selectedMarkerIds directly (no per-map filter) so planet-view delete works too - + // the inline action row in MarkerInspectTab routes through here when mapId == -1. + private static void DeleteOwnedFromCurrentSelection(LocationPings loc) + { + var ids = new List(); + foreach (var m in loc.Markers) + if (loc.selectedMarkerIds.Contains(m.markerId) && LocationPings.CanDeleteMarker(m)) + ids.Add(m.markerId); + if (ids.Count > 0) + loc.SendDeleteMarkers(ids.ToArray()); + SoundDefOf.Click.PlayOneShotOnCamera(); + loc.ClearSelection(); + } + + // Constrained to fit vanilla's 75 px gizmo cell at GameFont.Tiny - defaultDesc carries the full text. + private static string DeleteLabel() + => MpTranslate.Fallback("MpPingSel_Delete", "Delete"); + private static string RenameLabel() + => MpTranslate.Fallback("MpPingSel_Rename", "Rename"); + private static string MultiDeleteLabel(int deletableCount) + => MpTranslate.Fallback("MpPingSel_MultiDelete", $"Delete ({deletableCount})", deletableCount); + + private static string DeselectAllMarkersLabel() + => MpTranslate.Fallback("MpPingSel_DeselectAll", "Deselect"); + + private static string MutePlayerLabel(string username) + => MpTranslate.Fallback("MpPingSel_MutePlayer", $"Mute {username}", username); + private static string UnmutePlayerLabel(string username) + => MpTranslate.Fallback("MpPingSel_UnmutePlayer", $"Unmute {username}", username); + + private static string MuteFactionLabel(string factionName) + => MpTranslate.Fallback("MpPingSel_MuteFaction", $"Mute {factionName}", factionName); + private static string UnmuteFactionLabel(string factionName) + => MpTranslate.Fallback("MpPingSel_UnmuteFaction", $"Unmute {factionName}", factionName); + + private static string MuteSpectatorsLabel() + => MpTranslate.Fallback("MpPingSel_MuteSpectators", "Mute spectators"); + private static string UnmuteSpectatorsLabel() + => MpTranslate.Fallback("MpPingSel_UnmuteSpectators", "Unmute spectators"); + + private static string TransparencyLabel() + => MpTranslate.Fallback("MpPingSel_Transparency", "Fade"); + private static string HideLocallyLabel() + => MpTranslate.Fallback("MpPingSel_HideLocally", "Hide for me"); + private static string UnhideLocallyLabel() + => MpTranslate.Fallback("MpPingSel_UnhideLocally", "Show for me"); + private static string ResetLocalAppearanceLabel() + => MpTranslate.Fallback("MpPingSel_ResetLocalAppearance", "Reset view"); +} diff --git a/Source/Client/Util/MpTranslate.cs b/Source/Client/Util/MpTranslate.cs new file mode 100644 index 000000000..431b55329 --- /dev/null +++ b/Source/Client/Util/MpTranslate.cs @@ -0,0 +1,17 @@ +using Verse; + +namespace Multiplayer.Client.Util +{ + // English fallback for keys not yet shipped in rwmt/Multiplayer-Locale. + public static class MpTranslate + { + public static string Fallback(string key, string fallback) + => key.CanTranslate() ? key.Translate().ToString() : fallback; + + public static string Fallback(string key, string fallback, NamedArgument arg) + => key.CanTranslate() ? key.Translate(arg).ToString() : fallback; + + public static string Fallback(string key, string fallback, NamedArgument arg1, NamedArgument arg2) + => key.CanTranslate() ? key.Translate(arg1, arg2).ToString() : fallback; + } +} diff --git a/Source/Client/Util/MpUI.cs b/Source/Client/Util/MpUI.cs index b80ea128d..308af1d6e 100644 --- a/Source/Client/Util/MpUI.cs +++ b/Source/Client/Util/MpUI.cs @@ -35,6 +35,31 @@ public static void DrawRotatedLine(Vector2 center, float length, float width, fl GL.PopMatrix(); } + /// + /// Draws with a 1px outline by stamping the text 8 times around the + /// target rect (cardinal + diagonal offsets) before drawing the foreground pass. Cheap (8 extra + /// labels per call) and keeps the label readable over varied map backgrounds. + /// + public static void LabelOutlined(Rect rect, string label, Color textColor, Color outlineColor) + { + var prev = GUI.color; + + GUI.color = outlineColor; + Widgets.Label(new Rect(rect.x - 1f, rect.y, rect.width, rect.height), label); + Widgets.Label(new Rect(rect.x + 1f, rect.y, rect.width, rect.height), label); + Widgets.Label(new Rect(rect.x, rect.y - 1f, rect.width, rect.height), label); + Widgets.Label(new Rect(rect.x, rect.y + 1f, rect.width, rect.height), label); + Widgets.Label(new Rect(rect.x - 1f, rect.y - 1f, rect.width, rect.height), label); + Widgets.Label(new Rect(rect.x + 1f, rect.y - 1f, rect.width, rect.height), label); + Widgets.Label(new Rect(rect.x - 1f, rect.y + 1f, rect.width, rect.height), label); + Widgets.Label(new Rect(rect.x + 1f, rect.y + 1f, rect.width, rect.height), label); + + GUI.color = textColor; + Widgets.Label(rect, label); + + GUI.color = prev; + } + public static void Label(Rect rect, string label, GameFont? font = null, TextAnchor? anchor = null, Color? color = null) { var prevFont = Text.Font; diff --git a/Source/Client/Util/PingRuntimeTranslations.cs b/Source/Client/Util/PingRuntimeTranslations.cs new file mode 100644 index 000000000..f7317ec0b --- /dev/null +++ b/Source/Client/Util/PingRuntimeTranslations.cs @@ -0,0 +1,31 @@ +using Verse; + +namespace Multiplayer.Client.Util; + +// For keys passed through vanilla code that hardcodes `.Translate()` (e.g. InspectPaneUtility's +// `tab.labelKey.Translate()`). MpTranslate.Fallback can't help - vanilla re-translates the resolved +// English string and dev-mode pseudo-localization mangles the result. Inject the value into the +// active language so the normal Translate() path resolves cleanly until the keys land in the +// Languages submodule. +public static class PingRuntimeTranslations +{ + public static void Register() + { + // MarkerInspectTab.labelKey - vanilla's InspectPaneUtility.DoTabs calls .Translate() on it. + Add("MpMarkerInspectTab_Label", "Markers"); + } + + private static void Add(string key, string value) + { + var lang = LanguageDatabase.activeLanguage; + if (lang == null) return; + // Skip if Multiplayer-Locale already provides a translation - real localization always wins. + if (lang.keyedReplacements.TryGetValue(key, out var existing) && !existing.isPlaceholder) return; + lang.keyedReplacements[key] = new LoadedLanguage.KeyedReplacement + { + key = key, + value = value, + isPlaceholder = false, + }; + } +} diff --git a/Source/Client/Windows/BootstrapConfiguratorWindow.BootstrapFlow.cs b/Source/Client/Windows/BootstrapConfiguratorWindow.BootstrapFlow.cs index 06ce06083..12578ee82 100644 --- a/Source/Client/Windows/BootstrapConfiguratorWindow.BootstrapFlow.cs +++ b/Source/Client/Windows/BootstrapConfiguratorWindow.BootstrapFlow.cs @@ -250,7 +250,9 @@ private void StartHostedBootstrapSaveCreation() pauseOnLetter = settings.pauseOnLetter, pauseOnJoin = settings.pauseOnJoin, pauseOnDesync = settings.pauseOnDesync, - timeControl = settings.timeControl + timeControl = settings.timeControl, + // Carry the cap into the hosted session so the scribed value matches settings.toml. + markerCapPerPlayer = settings.markerCapPerPlayer, }; if (!HostWindow.HostProgrammatically(hostSettings)) diff --git a/Source/Client/Windows/MarkerAlphaWindow.cs b/Source/Client/Windows/MarkerAlphaWindow.cs new file mode 100644 index 000000000..e08e81cf6 --- /dev/null +++ b/Source/Client/Windows/MarkerAlphaWindow.cs @@ -0,0 +1,158 @@ +using System.Collections.Generic; +using Multiplayer.Client.Util; +using RimWorld; +using UnityEngine; +using Verse; +using Verse.Sound; + +namespace Multiplayer.Client; + +// Local-only alpha override per markerId, persisted to MpSettings. Styled after vanilla +// Dialog_Slider: centered Medium title, min/max labels under the slider, Cancel/Apply pair. +public class MarkerAlphaWindow : Window +{ + private const float MinAlpha = 0.05f; + private const float SwatchSize = 56f; + + private readonly List markerIds; + private readonly float initialAlpha; + private float alpha; + + public override Vector2 InitialSize => new(360f, 220f); + public override float Margin => 10f; + + public MarkerAlphaWindow(List markerIds) + { + this.markerIds = markerIds; + + // Mixed values: seed from first marker; user can re-drag. + alpha = 1f; + var s = Multiplayer.settings; + if (s?.localMarkerAlpha != null && markerIds != null && markerIds.Count > 0 + && s.localMarkerAlpha.TryGetValue(markerIds[0], out var a)) + alpha = Mathf.Clamp(a, MinAlpha, 1f); + initialAlpha = alpha; + + forcePause = true; + closeOnAccept = true; + closeOnCancel = true; + closeOnClickedOutside = true; + absorbInputAroundWindow = true; + doCloseX = true; + focusWhenOpened = true; + soundAppear = SoundDefOf.InfoCard_Open; + soundClose = SoundDefOf.InfoCard_Close; + } + + public override void DoWindowContents(Rect inRect) + { + const float TitleH = 28f; + const float SwatchGap = 10f; + const float SliderH = 28f; + const float MinMaxH = 14f; + const float ButtonH = 30f; + const float ButtonGap = 10f; + + var titleRect = new Rect(inRect.x, inRect.y, inRect.width, TitleH); + using (MpStyle.Set(GameFont.Medium).Set(TextAnchor.UpperCenter)) + Widgets.Label(titleRect, MpTranslate.Fallback("MpMarkerAlpha_Title", "Marker transparency")); + + var swatchRect = new Rect( + inRect.center.x - SwatchSize / 2f, + titleRect.yMax + 4f, + SwatchSize, SwatchSize); + DrawAlphaSwatch(swatchRect, alpha); + + var sliderY = swatchRect.yMax + SwatchGap; + var sliderRect = new Rect(inRect.x + 6f, sliderY, inRect.width - 12f, SliderH); + var pctLabel = MpTranslate.Fallback("MpMarkerAlpha_Percent", + $"{Mathf.RoundToInt(alpha * 100f)}%", Mathf.RoundToInt(alpha * 100f)); + var newAlpha = Widgets.HorizontalSlider(sliderRect, alpha, MinAlpha, 1f, + middleAlignment: true, label: pctLabel, roundTo: 0.01f); + if (!Mathf.Approximately(newAlpha, alpha)) + { + alpha = newAlpha; + ApplyToSelection(); + } + + var minMaxRect = new Rect(inRect.x + 6f, sliderRect.yMax + 2f, inRect.width - 12f, MinMaxH); + using (MpStyle.Set(GameFont.Tiny).Set(TextAnchor.UpperLeft).Set(ColoredText.SubtleGrayColor)) + Widgets.Label(minMaxRect, + MpTranslate.Fallback("MpMarkerAlpha_MinHint", $"{Mathf.RoundToInt(MinAlpha * 100f)}% (min)", + Mathf.RoundToInt(MinAlpha * 100f))); + using (MpStyle.Set(GameFont.Tiny).Set(TextAnchor.UpperRight).Set(ColoredText.SubtleGrayColor)) + Widgets.Label(minMaxRect, MpTranslate.Fallback("MpMarkerAlpha_MaxHint", "100% (full)")); + TooltipHandler.TipRegion(sliderRect, MpTranslate.Fallback("MpMarkerAlpha_MinTip", + "Markers stay clickable at the minimum so you can always bring them back.")); + + var btnY = inRect.yMax - ButtonH; + var btnW = (inRect.width - ButtonGap) / 2f; + var cancelRect = new Rect(inRect.x, btnY, btnW, ButtonH); + var applyRect = new Rect(inRect.x + btnW + ButtonGap, btnY, btnW, ButtonH); + + if (Widgets.ButtonText(cancelRect, MpTranslate.Fallback("MpMarkerAlpha_Cancel", "Cancel"))) + { + alpha = initialAlpha; + ApplyToSelection(); + Close(); + } + if (Widgets.ButtonText(applyRect, MpTranslate.Fallback("MpMarkerAlpha_Apply", "Apply"))) + { + SoundDefOf.Click.PlayOneShotOnCamera(); + Close(); + } + } + + private static void DrawAlphaSwatch(Rect rect, float alpha) + { + // Checkerboard backdrop so the alpha is visible against any UI shade. + var checkColorA = new Color(0.30f, 0.30f, 0.30f); + var checkColorB = new Color(0.20f, 0.20f, 0.20f); + const int Checks = 4; + var cw = rect.width / Checks; + var ch = rect.height / Checks; + for (var iy = 0; iy < Checks; iy++) + for (var ix = 0; ix < Checks; ix++) + { + var c = ((ix + iy) & 1) == 0 ? checkColorA : checkColorB; + Widgets.DrawBoxSolid(new Rect(rect.x + ix * cw, rect.y + iy * ch, cw, ch), c); + } + + using (MpStyle.Set(new Color(0.65f, 0.85f, 1f, alpha))) + GUI.DrawTexture(rect.ContractedBy(6f), MultiplayerStatic.PingCircle); + + Widgets.DrawBox(rect); + } + + private void ApplyToSelection() + { + var s = Multiplayer.settings; + if (s == null || markerIds == null) return; + s.localMarkerAlpha ??= new Dictionary(); + foreach (var id in markerIds) + { + if (alpha >= 0.999f) s.localMarkerAlpha.Remove(id); + else s.localMarkerAlpha[id] = alpha; + } + if (Multiplayer.game?.gameComp != null) Multiplayer.game.gameComp.markersVersion++; + } + + public override void OnAcceptKeyPressed() + { + SoundDefOf.Click.PlayOneShotOnCamera(); + Close(); + } + + public override void OnCancelKeyPressed() + { + alpha = initialAlpha; + ApplyToSelection(); + Close(); + } + + public override void PostClose() + { + base.PostClose(); + MultiplayerLoader.Multiplayer.instance?.WriteSettings(); + } +} diff --git a/Source/Client/Windows/PingFiltersDialog.cs b/Source/Client/Windows/PingFiltersDialog.cs new file mode 100644 index 000000000..1ddb652c5 --- /dev/null +++ b/Source/Client/Windows/PingFiltersDialog.cs @@ -0,0 +1,422 @@ +using System.Collections.Generic; +using Multiplayer.Client.Util; +using RimWorld; +using UnityEngine; +using Verse; +using Verse.Sound; + +namespace Multiplayer.Client; + +// Per-client visibility filter. Pure render gate; settings persist via MpSettings. +public class PingFiltersDialog : Window +{ + public static PingFiltersDialog Opened => Find.WindowStack?.WindowOfType(); + + public override Vector2 InitialSize => new(420f, 460f); + + private Vector2 listScroll; + + // Screen-space anchor from trigger button; flips above if clipped. + private Rect? requestedAnchor; + + private List cachedFactionsWithMarkers; + private int cachedFactionsMarkersV = -1; + + private List cachedOtherPlayers; + private int cachedOtherPlayersMarkersV = -1; + private int cachedOtherPlayersPlayerCount = -1; + + public PingFiltersDialog(Rect? anchor = null) + { + requestedAnchor = anchor; + draggable = true; + resizeable = false; + doCloseX = true; + closeOnClickedOutside = false; + closeOnAccept = false; + closeOnCancel = true; + absorbInputAroundWindow = false; + preventCameraMotion = false; + focusWhenOpened = true; + onlyOneOfTypeAllowed = true; + soundClose = SoundDefOf.FloatMenu_Cancel; + layer = WindowLayer.GameUI; + } + + public override void SetInitialSizeAndPosition() + { + var size = InitialSize; + var screen = new Vector2(UI.screenWidth, UI.screenHeight); + const float ScreenMargin = 6f; + + // Priority: toolbar-anchor (fresh click) > prior drag-position > center. + Vector2 desired; + if (requestedAnchor is { } trigger) + { + const float Gap = 4f; + var belowY = trigger.yMax + Gap; + if (belowY + size.y > screen.y - ScreenMargin) + desired = new Vector2(trigger.x, trigger.y - size.y - Gap); + else + desired = new Vector2(trigger.x, belowY); + requestedAnchor = null; + } + else + { + var saved = Multiplayer.settings.pingFiltersDialogRect; + // Re-validate: InitialSize could have changed. + if (saved.width > 0f && saved.height > 0f + && saved.x >= -size.x + ScreenMargin && saved.x <= screen.x - ScreenMargin + && saved.y >= -size.y + ScreenMargin && saved.y <= screen.y - ScreenMargin) + desired = new Vector2(saved.x, saved.y); + else + desired = new Vector2((screen.x - size.x) / 2f, (screen.y - size.y) / 2f); + } + var x = Mathf.Clamp(desired.x, ScreenMargin, screen.x - size.x - ScreenMargin); + var y = Mathf.Clamp(desired.y, ScreenMargin, screen.y - size.y - ScreenMargin); + windowRect = new Rect(x, y, size.x, size.y); + } + + public override void PostClose() + { + base.PostClose(); + Multiplayer.settings.pingFiltersDialogRect = windowRect; + MultiplayerLoader.Multiplayer.instance?.WriteSettings(); + } + + public override void DoWindowContents(Rect inRect) + { + var settings = Multiplayer.settings; + if (settings == null) return; + + var titleRect = new Rect(inRect.x, inRect.y, inRect.width - 30f, 28f); + using (MpStyle.Set(GameFont.Medium).Set(TextAnchor.MiddleLeft)) + Widgets.Label(titleRect, MpTranslate.Fallback("MpPingFilters_Title", "Marker visibility")); + + var y = titleRect.yMax + 8f; + + var specRect = new Rect(inRect.x, y, inRect.width, 24f); + var showSpec = settings.showSpectatorMarkers; + Widgets.CheckboxLabeled(specRect, MpTranslate.Fallback("MpPingFilters_ShowSpectators","Show spectator markers"), ref showSpec); + if (showSpec != settings.showSpectatorMarkers) + { + settings.showSpectatorMarkers = showSpec; + MultiplayerLoader.Multiplayer.instance?.WriteSettings(); + } + y = specRect.yMax + 10f; + + Widgets.DrawLineHorizontal(inRect.x, y, inRect.width); + y += 6f; + + const float ResetH = 28f; + const float ResetGap = 8f; + var listOutRect = new Rect(inRect.x, y, inRect.width, inRect.yMax - y - ResetH - ResetGap); + + var factions = ListFactionsWithMarkers(); + var players = ListOtherPlayers(); + var viewH = SectionHeaderH + + (factions.Count == 0 ? EmptyRowH : factions.Count * RowH) + + SectionGap + + SectionHeaderH + + (players.Count == 0 ? EmptyRowH : players.Count * RowH) + + 4f; + var viewRect = new Rect(0f, 0f, listOutRect.width - 16f, viewH); + + Widgets.BeginScrollView(listOutRect, ref listScroll, viewRect); + + var yy = 0f; + DrawSectionHeader(new Rect(0f, yy, viewRect.width, SectionHeaderH), MpTranslate.Fallback("MpPingFilters_FactionsHeader","By faction")); + yy += SectionHeaderH; + + if (factions.Count == 0) + { + DrawEmptyRow(new Rect(0f, yy, viewRect.width, EmptyRowH), MpTranslate.Fallback("MpPingFilters_NoFactions", "No factions with markers yet")); + yy += EmptyRowH; + } + else + { + foreach (var f in factions) + { + DrawFactionRow(new Rect(0f, yy, viewRect.width, RowH), f, settings); + yy += RowH; + } + } + yy += SectionGap; + + DrawSectionHeader(new Rect(0f, yy, viewRect.width, SectionHeaderH), MpTranslate.Fallback("MpPingFilters_PlayersHeader", "By player")); + yy += SectionHeaderH; + + if (players.Count == 0) + { + DrawEmptyRow(new Rect(0f, yy, viewRect.width, EmptyRowH), MpTranslate.Fallback("MpPingFilters_NoOtherPlayers","You're the only player")); + yy += EmptyRowH; + } + else + { + foreach (var p in players) + { + DrawPlayerRow(new Rect(0f, yy, viewRect.width, RowH), p, settings); + yy += RowH; + } + } + + Widgets.EndScrollView(); + + var resetRect = new Rect(inRect.x, listOutRect.yMax + ResetGap, inRect.width, ResetH); + if (Widgets.ButtonText(resetRect, MpTranslate.Fallback("MpPingFilters_ResetAll", "Reset all"))) + { + settings.hiddenFactionLoadIds.Clear(); + settings.hiddenPlayerNames.Clear(); + settings.showSpectatorMarkers = true; + MultiplayerLoader.Multiplayer.instance?.WriteSettings(); + SoundDefOf.Click.PlayOneShotOnCamera(); + } + } + + private const float RowH = 28f; + private const float EmptyRowH = 24f; + private const float SectionGap = 8f; + private const float SectionHeaderH = 22f; + + // Vanilla Faction.Color falls back to def.colorSpectrum when Faction.color isn't explicitly + // set, which gives every MP-created player faction the same stripe. Use a per-loadID palette + // so factions in the visibility panel are easy to tell apart; user-chosen colors via + // Dialog_ChooseFactionColor still take priority. + private static readonly Color[] FactionPalette = + { + new(0.40f, 0.78f, 1.00f), // sky blue + new(1.00f, 0.62f, 0.30f), // orange + new(0.55f, 0.95f, 0.55f), // green + new(1.00f, 0.55f, 0.85f), // pink + new(0.95f, 0.92f, 0.45f), // yellow + new(0.75f, 0.55f, 1.00f), // violet + new(0.92f, 0.45f, 0.45f), // red + new(0.45f, 0.92f, 0.85f), // teal + }; + + private static Color FactionStripeColor(Faction f) + { + if (f.color.HasValue) return f.color.Value; + var len = FactionPalette.Length; + return FactionPalette[((f.loadID % len) + len) % len]; + } + + private static void DrawSectionHeader(Rect rect, string label) + { + using (MpStyle.Set(GameFont.Tiny).Set(TextAnchor.MiddleLeft).Set(new Color(0.7f, 0.7f, 0.7f))) + Widgets.Label(rect, label); + } + + private static void DrawEmptyRow(Rect rect, string label) + { + using (MpStyle.Set(GameFont.Tiny).Set(TextAnchor.MiddleLeft).Set(new Color(0.55f, 0.55f, 0.55f))) + Widgets.Label(rect.ContractedBy(8f, 0f), label); + } + + private static void DrawFactionRow(Rect rect, Faction f, MpSettings s) + { + Widgets.DrawHighlightIfMouseover(rect); + + var stripe = FactionStripeColor(f); + Widgets.DrawBoxSolid(new Rect(rect.x + 2f, rect.y + 4f, 4f, rect.height - 8f), + new Color(stripe.r, stripe.g, stripe.b, 0.95f)); + + // Reserve 32 on the right (28 checkbox + 4 gap) so long names don't touch the checkbox. + var labelRect = new Rect(rect.x + 12f, rect.y, rect.width - 12f - 32f, rect.height); + using (MpStyle.Set(GameFont.Small).Set(TextAnchor.MiddleLeft)) + Widgets.Label(labelRect, f.Name); + + // Checkbox = show (inverse of hidden set). + var show = !s.hiddenFactionLoadIds.Contains(f.loadID); + var prev = show; + var checkRect = new Rect(rect.xMax - 28f, rect.y + (rect.height - 24f) / 2f, 24f, 24f); + Widgets.Checkbox(checkRect.position, ref show, 24f); + + var labelClickRect = new Rect(rect.x, rect.y, rect.width - 32f, rect.height); + if (Widgets.ButtonInvisible(labelClickRect)) + { + show = !show; + SoundDefOf.Click.PlayOneShotOnCamera(); + } + + if (prev != show) + { + if (show) s.hiddenFactionLoadIds.Remove(f.loadID); + else s.hiddenFactionLoadIds.Add(f.loadID); + MultiplayerLoader.Multiplayer.instance?.WriteSettings(); + } + } + + private static void DrawPlayerRow(Rect rect, PlayerRowItem p, MpSettings s) + { + Widgets.DrawHighlightIfMouseover(rect); + + if (p.isSelf) + Widgets.DrawBoxSolid(rect, new Color(0.30f, 0.55f, 0.90f, 0.18f)); + + var stripe = p.color; + Widgets.DrawBoxSolid(new Rect(rect.x + 2f, rect.y + 4f, 4f, rect.height - 8f), + new Color(stripe.r, stripe.g, stripe.b, 0.95f)); + + // No self-mute (nonsensical). + var canShowMute = !p.isSelf; + // FromPlayer-clear: host can target anyone; non-host only themselves. + var canShowClear = !string.IsNullOrEmpty(p.username) && (Multiplayer.LocalServer != null || p.isSelf); + + // Clear button sits at xMax-56; reserving 60 covers it (and the checkbox column to its right). + var labelRightReserve = canShowClear ? 60f : (canShowMute ? 32f : 0f); + var labelRect = new Rect(rect.x + 12f, rect.y, rect.width - 12f - labelRightReserve, rect.height); + var labelColor = p.isSelf ? new Color(stripe.r, stripe.g, stripe.b, 1f) : GUI.color; + using (MpStyle.Set(GameFont.Small).Set(TextAnchor.MiddleLeft).Set(labelColor)) + Widgets.Label(labelRect, p.label); + + var checkRect = new Rect(rect.xMax - 28f, rect.y + (rect.height - 24f) / 2f, 24f, 24f); + var clearRect = new Rect(rect.xMax - 56f, rect.y + (rect.height - 24f) / 2f, 22f, 22f); + + if (string.IsNullOrEmpty(p.username)) + { + // Empty username = disabled controls only. + var phantom = true; + using (MpStyle.Set(new Color(GUI.color.r, GUI.color.g, GUI.color.b, 0.4f))) + { + Widgets.Checkbox(checkRect.position, ref phantom, 24f); + GUI.DrawTexture(clearRect, TexButton.Delete); + } + return; + } + + if (canShowClear) + { + TooltipHandler.TipRegion(clearRect, MpTranslate.Fallback("MpPingFilters_ClearPlayerMarkers", + $"Clear every marker placed by {p.username}.", + new NamedArgument(p.username, "USERNAME"))); + if (Widgets.ButtonImage(clearRect, TexButton.Delete)) + { + Multiplayer.session?.locationPings?.SendClearMarkersFromPlayer(p.username); + SoundDefOf.Click.PlayOneShotOnCamera(); + // Stop the click falling through to GUI.DragWindow. + Event.current.Use(); + } + } + + if (!canShowMute) return; + + var show = !s.hiddenPlayerNames.Contains(p.username); + var prev2 = show; + Widgets.Checkbox(checkRect.position, ref show, 24f); + + // Exclude clear-button hit area. + var labelClickRect = new Rect(rect.x, rect.y, rect.width - 60f, rect.height); + if (Widgets.ButtonInvisible(labelClickRect)) + { + show = !show; + SoundDefOf.Click.PlayOneShotOnCamera(); + } + + if (prev2 != show) + { + if (show) s.hiddenPlayerNames.Remove(p.username); + else s.hiddenPlayerNames.Add(p.username); + MultiplayerLoader.Multiplayer.instance?.WriteSettings(); + } + } + + // Spectator handled by master toggle. + private List ListFactionsWithMarkers() + { + // Multiplayer.GameComp / WorldComp throw on null - must use the safe-nav form because + // the dialog can outlive a packet-disconnect that nulls Multiplayer.game. + var comp = Multiplayer.game?.gameComp; + var factionMan = Find.FactionManager; + if (comp == null || factionMan == null) + return cachedFactionsWithMarkers ??= new List(); + + if (cachedFactionsWithMarkers != null && cachedFactionsMarkersV == comp.markersVersion) + return cachedFactionsWithMarkers; + + cachedFactionsWithMarkers ??= new List(); + cachedFactionsWithMarkers.Clear(); + var spectator = Multiplayer.game?.worldComp?.spectatorFaction; + foreach (var entry in comp.markersByFaction) + { + if (entry.Value == null || entry.Value.Count == 0) continue; + var f = factionMan.GetById(entry.Key); + if (f == null) continue; + if (spectator != null && f.loadID == spectator.loadID) continue; + cachedFactionsWithMarkers.Add(f); + } + cachedFactionsMarkersV = comp.markersVersion; + return cachedFactionsWithMarkers; + } + + private readonly struct PlayerRowItem + { + public readonly string username; + public readonly string label; + public readonly Color color; + public readonly bool isSelf; + public PlayerRowItem(string username, string label, Color color, bool isSelf = false) + { this.username = username; this.label = label; this.color = color; this.isSelf = isSelf; } + } + + // Self first, then connected players, then offline placers from markersByFaction. No arbiter. + private List ListOtherPlayers() + { + if (Multiplayer.session == null) + return cachedOtherPlayers ??= new List(); + + var comp = Multiplayer.game?.gameComp; + var markersV = comp?.markersVersion ?? 0; + var playerCount = Multiplayer.session.players?.Count ?? 0; + + if (cachedOtherPlayers != null + && cachedOtherPlayersMarkersV == markersV + && cachedOtherPlayersPlayerCount == playerCount) + return cachedOtherPlayers; + + cachedOtherPlayers ??= new List(); + cachedOtherPlayers.Clear(); + + var meId = Multiplayer.session.playerId; + var mePi = Multiplayer.session.GetPlayerInfo(meId); + var meName = mePi?.username; + var seenUsernames = new HashSet(); + + if (mePi != null) + cachedOtherPlayers.Add(new PlayerRowItem(meName ?? "", (meName ?? "?") + " " + MpTranslate.Fallback("MpPingFilters_YouSuffix", "(you)"), + mePi.color, isSelf: true)); + if (!string.IsNullOrEmpty(meName)) seenUsernames.Add(meName!); + + var others = new List(); + foreach (var p in Multiplayer.session.players) + { + if (p.id == meId) continue; + if (p.IsArbiter) continue; + var name = p.username ?? ""; + others.Add(new PlayerRowItem(name, p.username ?? "?", p.color)); + if (!string.IsNullOrEmpty(name)) seenUsernames.Add(name); + } + + if (comp != null) + { + foreach (var m in comp.AllMarkers) + { + var name = m.placedByUsername; + if (string.IsNullOrEmpty(name)) continue; + if (!seenUsernames.Add(name)) continue; + var color = new Color(m.placedByR, m.placedByG, m.placedByB); + others.Add(new PlayerRowItem(name, name + " " + MpTranslate.Fallback("MpPingFilters_OfflineSuffix", "(offline)"), color)); + } + } + + others.Sort((a, b) => string.CompareOrdinal(a.username, b.username)); + cachedOtherPlayers.AddRange(others); + cachedOtherPlayersMarkersV = markersV; + cachedOtherPlayersPlayerCount = playerCount; + return cachedOtherPlayers; + } + + public static string OpenTooltipLabel() + => MpTranslate.Fallback("MpPingFilters_OpenTooltip", + "Choose which players' and factions' markers to show"); +} diff --git a/Source/Client/Windows/PingHostSettingsDialog.cs b/Source/Client/Windows/PingHostSettingsDialog.cs new file mode 100644 index 000000000..ac83ee1d5 --- /dev/null +++ b/Source/Client/Windows/PingHostSettingsDialog.cs @@ -0,0 +1,153 @@ +using Multiplayer.Client.Comp; +using Multiplayer.Client.Util; +using Multiplayer.Common.Networking.Packet; +using RimWorld; +using UnityEngine; +using Verse; +using Verse.Sound; + +namespace Multiplayer.Client; + +// Host-only settings popup, opened from a header button on PingMenuWindow. Owns the per-player +// marker cap (synced game-comp field) and the two host-only "clear everything" actions. +public class PingHostSettingsDialog : Window +{ + public static PingHostSettingsDialog Opened => Find.WindowStack?.WindowOfType(); + + public override Vector2 InitialSize => new(360f, 260f); + + private Rect? requestedAnchor; + private string markerCapBuffer; + private int lastMarkerCapBufferedFor = -1; + + public PingHostSettingsDialog(Rect? anchor = null) + { + requestedAnchor = anchor; + draggable = true; + resizeable = false; + doCloseX = true; + closeOnClickedOutside = false; + closeOnAccept = false; + closeOnCancel = true; + absorbInputAroundWindow = false; + preventCameraMotion = false; + focusWhenOpened = true; + onlyOneOfTypeAllowed = true; + soundClose = SoundDefOf.FloatMenu_Cancel; + layer = WindowLayer.GameUI; + } + + public override void SetInitialSizeAndPosition() + { + var size = InitialSize; + var screen = new Vector2(UI.screenWidth, UI.screenHeight); + const float ScreenMargin = 6f; + + Vector2 desired; + if (requestedAnchor is { } trigger) + { + const float Gap = 4f; + var belowY = trigger.yMax + Gap; + if (belowY + size.y > screen.y - ScreenMargin) + desired = new Vector2(trigger.x, trigger.y - size.y - Gap); + else + desired = new Vector2(trigger.x, belowY); + requestedAnchor = null; + } + else + { + var saved = Multiplayer.settings.pingHostSettingsDialogRect; + if (saved.width > 0f && saved.height > 0f + && saved.x >= -size.x + ScreenMargin && saved.x <= screen.x - ScreenMargin + && saved.y >= -size.y + ScreenMargin && saved.y <= screen.y - ScreenMargin) + desired = new Vector2(saved.x, saved.y); + else + desired = new Vector2((screen.x - size.x) / 2f, (screen.y - size.y) / 2f); + } + var x = Mathf.Clamp(desired.x, ScreenMargin, screen.x - size.x - ScreenMargin); + var y = Mathf.Clamp(desired.y, ScreenMargin, screen.y - size.y - ScreenMargin); + windowRect = new Rect(x, y, size.x, size.y); + } + + public override void PostClose() + { + base.PostClose(); + Multiplayer.settings.pingHostSettingsDialogRect = windowRect; + MultiplayerLoader.Multiplayer.instance?.WriteSettings(); + } + + public override void DoWindowContents(Rect inRect) + { + var titleRect = new Rect(inRect.x, inRect.y, inRect.width - 30f, 28f); + using (MpStyle.Set(GameFont.Medium).Set(TextAnchor.MiddleLeft)) + Widgets.Label(titleRect, MpTranslate.Fallback("MpPingHostSettings_Title", "Host settings")); + + var y = titleRect.yMax + 8f; + + var comp = Multiplayer.game?.gameComp; + var loc = Multiplayer.session?.locationPings; + + if (comp != null) + { + const float RowH = 28f; + const float LabelW = 180f; + const float FieldW = 70f; + + if (lastMarkerCapBufferedFor != comp.markerCapPerPlayer) + { + markerCapBuffer = comp.markerCapPerPlayer.ToString(); + lastMarkerCapBufferedFor = comp.markerCapPerPlayer; + } + var capRect = new Rect(inRect.x, y, LabelW + FieldW + 8f, RowH); + var capLabel = MpTranslate.Fallback("MpPingMenuWindow_MarkerCapPerPlayer", "Marker limit per player"); + var prevCap = comp.markerCapPerPlayer; + var editCap = prevCap; + MpUI.TextFieldNumericLabeled(capRect, $"{capLabel}: ", ref editCap, ref markerCapBuffer, LabelW, PingMarkerCap.Min, PingMarkerCap.Max); + TooltipHandler.TipRegion(capRect, MpTranslate.Fallback("MpPingMenuWindow_MarkerCapPerPlayer_Tip", + $"Maximum markers any single player can own. Range {PingMarkerCap.Min}-{PingMarkerCap.Max}. Excess markers are evicted oldest-first.", + PingMarkerCap.Min, PingMarkerCap.Max)); + if (editCap != prevCap) + { + comp.SetMarkerCapPerPlayer(editCap); + lastMarkerCapBufferedFor = editCap; + } + y = capRect.yMax + 12f; + } + + Widgets.DrawLineHorizontal(inRect.x, y, inRect.width); + y += 6f; + + var sectionHeaderRect = new Rect(inRect.x, y, inRect.width, 18f); + using (MpStyle.Set(GameFont.Tiny).Set(TextAnchor.MiddleLeft).Set(new Color(0.65f, 0.65f, 0.65f))) + Widgets.Label(sectionHeaderRect, MpTranslate.Fallback("MpPingHostSettings_ClearAllHeader", "Clear everything for every player")); + y = sectionHeaderRect.yMax + 4f; + + const float ButtonH = 28f; + var clearMarkersRect = new Rect(inRect.x, y, inRect.width, ButtonH); + if (Widgets.ButtonText(clearMarkersRect, MpTranslate.Fallback("MpPingHostSettings_ClearAllMarkers", "Clear all markers"))) + { + Find.WindowStack.Add(Dialog_MessageBox.CreateConfirmation( + MpTranslate.Fallback("MpPingHostSettings_ClearAllMarkers_Confirm", + "Remove every marker placed by every player? This cannot be undone."), + () => + { + loc?.SendClearAllMarkers(); + SoundDefOf.Click.PlayOneShotOnCamera(); + }, + destructive: true)); + } + y = clearMarkersRect.yMax + 4f; + + var clearPingsRect = new Rect(inRect.x, y, inRect.width, ButtonH); + if (Widgets.ButtonText(clearPingsRect, MpTranslate.Fallback("MpPingHostSettings_ClearAllPings", "Clear all pings"))) + { + loc?.SendClearAllPings(); + SoundDefOf.Click.PlayOneShotOnCamera(); + } + y = clearPingsRect.yMax; + } + + public static string OpenTooltipLabel() + => MpTranslate.Fallback("MpPingHostSettings_OpenTooltip", + "Host-only: set per-player marker limits and wipe all markers or pings."); +} diff --git a/Source/Client/Windows/PingInspectPane.cs b/Source/Client/Windows/PingInspectPane.cs new file mode 100644 index 000000000..64b822b22 --- /dev/null +++ b/Source/Client/Windows/PingInspectPane.cs @@ -0,0 +1,252 @@ +using System; +using System.Collections.Generic; +using System.Text; +using Multiplayer.Client.Util; +using Multiplayer.Common.Networking.Packet; +using RimWorld; +using RimWorld.Planet; +using UnityEngine; +using Verse; + +namespace Multiplayer.Client; + +// Bottom-left pane mirroring MainTabWindow_Inspect; IInspectPane lets PaneWidthFor offset the gizmo grid. +public class PingInspectPane : Window, IInspectPane +{ + public static PingInspectPane Opened => Find.WindowStack?.WindowOfType(); + + // Matches MainTabWindow_Inspect.PaneTopY's hardcoded offset. + private const float PaneBottomGap = 35f; + + public override Vector2 InitialSize => new(InspectPaneUtility.PaneWidthFor(this), InspectPaneUtility.PaneHeight); + public override float Margin => 0f; + + public PingInspectPane() + { + layer = WindowLayer.GameUI; + preventCameraMotion = false; + closeOnAccept = false; + closeOnCancel = false; + closeOnClickedOutside = false; + doCloseX = false; + doCloseButton = false; + forcePause = false; + drawShadow = true; + focusWhenOpened = false; + soundAppear = null; + soundClose = null; + draggable = false; + absorbInputAroundWindow = false; + } + + public override void SetInitialSizeAndPosition() + { + var paneWidth = InspectPaneUtility.PaneWidthFor(this); + var y = Mathf.Max(0f, UI.screenHeight - InspectPaneUtility.PaneHeight - PaneBottomGap); + windowRect = new Rect(0f, y, paneWidth, InspectPaneUtility.PaneHeight); + } + + private readonly List cachedSelected = new(); + private string cachedPaneLabel; + private int cachedMarkersV = -1; + private int cachedPingsV = -1; + private int cachedSelectionV = -1; + private bool cachedOnPlanet; + private int cachedMapId = int.MinValue; + + public override void DoWindowContents(Rect inRect) + { + // Mirror InspectPaneOnGUI: keeps RecentHeight non-zero for CameraDriver. + RecentHeight = InspectPaneUtility.PaneHeight; + + var loc = Multiplayer.session?.locationPings; + if (loc == null || !loc.HasSelection) return; + + // Planet: no gizmo grid - draw action buttons inline. Map: gizmos own them. + var onPlanet = WorldRendererUtility.WorldSelected; + var currentMapId = onPlanet ? -1 : Find.CurrentMap?.uniqueID ?? -1; + var markersV = Multiplayer.game?.gameComp?.markersVersion ?? 0; + var pingsV = loc.pingsVersion; + var selectionV = loc.selectionVersion; + + if (cachedMarkersV != markersV || cachedPingsV != pingsV || cachedSelectionV != selectionV + || cachedOnPlanet != onPlanet || cachedMapId != currentMapId) + { + var fresh = onPlanet + ? PingSelectionUI.CollectSelectedOnPlanet(loc) + : PingSelectionUI.CollectSelectedOnCurrentMap(loc); + cachedSelected.Clear(); + cachedSelected.AddRange(fresh); + cachedPaneLabel = cachedSelected.Count > 0 ? PaneLabel(cachedSelected) : null; + cachedMarkersV = markersV; + cachedPingsV = pingsV; + cachedSelectionV = selectionV; + cachedOnPlanet = onPlanet; + cachedMapId = currentMapId; + } + + var selected = cachedSelected; + if (selected.Count == 0) return; + + // Recompute body text every frame so the "X minutes ago" portion stays accurate while the + // pane is open. Caching the formatted string would freeze the relative time at selection. + var bodyText = BodyText(selected); + + var rect = inRect.ContractedBy(InspectPaneUtility.PaneInnerMargin); + rect.yMin -= 4f; + rect.yMax += 6f; + Widgets.BeginGroup(rect); + try + { + var titleXOffset = 0f; + if (selected.Count == 1) + { + var c = selected[0].BaseColor; + Widgets.DrawBoxSolid(new Rect(0f, 4f, 4f, 26f), + new Color(c.r, c.g, c.b, 1f)); + titleXOffset = 10f; + } + + var labelRect = new Rect(titleXOffset, 0f, rect.width - titleXOffset, 30f); + using (MpStyle.Set(GameFont.Medium).Set(TextAnchor.UpperLeft)) + Widgets.Label(labelRect, cachedPaneLabel); + + // 52 = two 24-tall rows + 4-tall row gap; matches MarkerInspectTab.ActionRowH so the + // inline action row can wrap when 5+ buttons appear (e.g. own-marker selection). + const float ButtonRowH = 52f; + const float ButtonRowGap = 4f; + var bodyHeight = rect.height - 28f - (onPlanet ? ButtonRowH + ButtonRowGap : 0f); + var bodyRect = new Rect(0f, 28f, rect.width, bodyHeight); + using (MpStyle.Set(GameFont.Small).Set(TextAnchor.UpperLeft)) + Widgets.Label(bodyRect, bodyText); + + if (onPlanet) + { + var buttonRowRect = new Rect(0f, bodyRect.yMax + ButtonRowGap, rect.width, ButtonRowH); + PingSelectionUI.DrawInlineActionsRow(buttonRowRect, selected, loc); + } + } + finally + { + Widgets.EndGroup(); + } + } + + // IInspectPane stubs - registration enables PaneWidthFor's WindowOfType lookup. + public Type OpenTabType { get; set; } + public float RecentHeight { get; set; } + public Vector2 RequestedTabSize => new(InspectPaneUtility.PaneWidthFor(this), InspectPaneUtility.PaneHeight); + public float PaneTopY => UI.screenHeight - InspectPaneUtility.PaneHeight - PaneBottomGap; + public bool AnythingSelected => Multiplayer.session?.locationPings?.HasSelection ?? false; + public bool ShouldShowSelectNextInCellButton => false; + public bool ShouldShowPaneContents => AnythingSelected; + public IEnumerable CurTabs => null; + + public void DoInspectPaneButtons(Rect rect, ref float lineEndWidth) { } + public string GetLabel(Rect rect) => ""; + public void DoPaneContents(Rect rect) { } + public void SelectNextInCell() { } + public void CloseOpenTab() => OpenTabType = null; + public void Reset() => OpenTabType = null; + + private static string PaneLabel(List selected) + { + if (selected.Count == 1) + { + var info = selected[0]; + var noun = info.isMarker ? MarkerNoun() : PingNoun(); + var name = info.category == PingCategory.Default + ? noun.CapitalizeFirst() + : $"{info.category.DisplayName()} {noun}"; + if (!string.IsNullOrEmpty(info.label)) + name += $" - {info.label}"; + return name; + } + return MultiCountLabel(selected.Count); + } + + private static string BodyText(List selected) + { + if (selected.Count == 1) + { + var info = selected[0]; + var sb = new StringBuilder(); + var name = string.IsNullOrEmpty(info.placedByUsername) ? "?" : info.placedByUsername; + sb.AppendLine(MpTranslate.Fallback("MpPingSel_Attribution", $"Placed by {name}", name)); + + var factionName = info.placedByFactionLoadId >= 0 + ? Find.FactionManager?.GetById(info.placedByFactionLoadId)?.Name + : null; + if (!string.IsNullOrEmpty(factionName)) + sb.AppendLine(MpTranslate.Fallback("MpPingSel_Faction", $"Faction: {factionName}", factionName)); + + // Only markers carry a meaningful tick stamp - pings fade in seconds anyway. + if (info.isMarker && info.placedAtTick > 0) + sb.AppendLine(MpTranslate.Fallback("MpPingSel_PlacedAtTick", $"Placed: {FormatPlacedAt(info.placedAtTick)}", + FormatPlacedAt(info.placedAtTick))); + + if (!info.isMarker) + sb.Append(MpTranslate.Fallback("MpPingSel_Fading", "Ping will fade automatically")); + return sb.ToString().TrimEnd(); + } + + var counts = new Dictionary(); + var foreignMarkerCount = 0; + foreach (var info in selected) + { + counts.TryGetValue(info.category, out var c); + counts[info.category] = c + 1; + if (info.isMarker && !LocationPings.CanDeleteMarker(info)) + foreignMarkerCount++; + } + var lines = new List(); + foreach (var cat in PingCategoryExtensions.All) + if (counts.TryGetValue(cat, out var n) && n > 0) + lines.Add($"{n} × {cat.DisplayName()}"); + if (foreignMarkerCount > 0) + lines.Add(ForeignSelectionLabel(foreignMarkerCount)); + return string.Join("\n", lines); + } + + private static string MarkerNoun() + => MpTranslate.Fallback("MpPingSel_MarkerNoun", "marker"); + private static string PingNoun() + => MpTranslate.Fallback("MpPingSel_PingNoun", "ping"); + + private static string MultiCountLabel(int count) + => MpTranslate.Fallback("MpPingSel_MultiCount", $"{count} selected", count); + + private static string ForeignSelectionLabel(int count) + => MpTranslate.Fallback("MpPingSel_ForeignInSelection", + count == 1 + ? "1 marker from another player (you cannot delete it)" + : $"{count} markers from other players (you cannot delete them)", + count); + + // Renders a marker's placedAtTick as "N hours/days ago" for recent placements, falling back + // to an absolute game-date string for older ones. Uses 2500 ticks/hour and 60000 ticks/day + // (vanilla TicksPerHour / TicksPerDay). + private static string FormatPlacedAt(int placedAtTick) + { + var now = Find.TickManager?.TicksGame ?? 0; + var delta = now - placedAtTick; + // Negative delta = clock skew from a joiner whose game tick is behind; show absolute date. + if (delta < 0) return GenDate.DateFullStringAt(placedAtTick, Vector2.zero); + if (delta < GenDate.TicksPerHour) + { + var mins = Mathf.Max(1, delta / (GenDate.TicksPerHour / 60)); + return MpTranslate.Fallback("MpPingSel_MinutesAgo", $"{mins}m ago", mins); + } + if (delta < GenDate.TicksPerDay) + { + var hours = delta / GenDate.TicksPerHour; + return MpTranslate.Fallback("MpPingSel_HoursAgo", $"{hours}h ago", hours); + } + if (delta < GenDate.TicksPerDay * 7) + { + var days = delta / GenDate.TicksPerDay; + return MpTranslate.Fallback("MpPingSel_DaysAgo", $"{days}d ago", days); + } + return GenDate.DateFullStringAt(placedAtTick, Vector2.zero); + } +} diff --git a/Source/Client/Windows/PingLabelWindow.cs b/Source/Client/Windows/PingLabelWindow.cs new file mode 100644 index 000000000..cafa5c315 --- /dev/null +++ b/Source/Client/Windows/PingLabelWindow.cs @@ -0,0 +1,101 @@ +using Multiplayer.Client.Util; +using Multiplayer.Common.Networking.Packet; +using RimWorld; +using UnityEngine; +using Verse; + +namespace Multiplayer.Client; + +// Modal rename - Confirm sends ClientRenameMarkerPacket; UI updates on the server relay. +public class PingLabelWindow : Window +{ + private readonly int markerId; + private string buffer; + private bool focused; + + public override Vector2 InitialSize => new(360f, 175f); + + public PingLabelWindow(int markerId, string currentLabel) + { + this.markerId = markerId; + buffer = currentLabel ?? ""; + if (buffer.Length > PingCategoryWire.MaxLabelChars) + buffer = buffer.Substring(0, PingCategoryWire.MaxLabelChars); + + forcePause = false; + closeOnAccept = false; + closeOnCancel = true; + absorbInputAroundWindow = true; + doCloseX = true; + focusWhenOpened = true; + } + + public override void DoWindowContents(Rect inRect) + { + const float Pad = 6f; + const float TitleH = 28f; + const float FieldH = 28f; + const float ButtonH = 32f; + + using (MpStyle.Set(GameFont.Medium).Set(TextAnchor.MiddleLeft)) + Widgets.Label(new Rect(inRect.x, inRect.y, inRect.width - 30f, TitleH), MpTranslate.Fallback("MpPingLabel_Title", "Rename marker")); + + var fieldRect = new Rect(inRect.x, inRect.y + TitleH + Pad, inRect.width, FieldH); + const string fieldName = "MpPingLabelField"; + GUI.SetNextControlName(fieldName); + var next = Widgets.TextField(fieldRect, buffer, PingCategoryWire.MaxLabelChars); + if (next != buffer) buffer = next; + + if (!focused) + { + UI.FocusControl(fieldName, this); + focused = true; + } + + var btnY = inRect.yMax - ButtonH; + var btnW = (inRect.width - Pad) / 2f; + if (Widgets.ButtonText(new Rect(inRect.x, btnY, btnW, ButtonH), MpTranslate.Fallback("MpPingLabel_Cancel", "Cancel"))) + { + Event.current.Use(); + Close(); + return; + } + + var enterPressed = Event.current.type == EventType.KeyDown + && (Event.current.keyCode == KeyCode.Return || Event.current.keyCode == KeyCode.KeypadEnter); + if (Widgets.ButtonText(new Rect(inRect.x + btnW + Pad, btnY, btnW, ButtonH), MpTranslate.Fallback("MpPingLabel_Confirm", "OK")) || enterPressed) + { + if (enterPressed) Event.current.Use(); + Confirm(); + } + } + + private void Confirm() + { + if (string.IsNullOrWhiteSpace(buffer)) + { + Messages.Message(MpTranslate.Fallback("MpPingLabel_EmptyReject", + "Marker label cannot be empty."), + MessageTypeDefOf.RejectInput, historical: false); + return; + } + + // Re-validate ownership - a faction switch via FactionSidebar can land while the modal + // is open, and a stale send would silently no-op on every receiver. + var loc = Multiplayer.session?.locationPings; + if (loc == null) { Close(); return; } + var marker = LocationPings.FindMarkerById(markerId); + if (marker == null || !LocationPings.CanDeleteMarker(marker)) + { + Messages.Message(MpTranslate.Fallback("MpPingLabel_NoLongerOwned", + "You can no longer rename this marker."), + MessageTypeDefOf.RejectInput, historical: false); + Close(); + return; + } + + loc.SendRenameMarker(markerId, buffer); + Close(); + } + +} diff --git a/Source/Client/Windows/PingMenuWindow.cs b/Source/Client/Windows/PingMenuWindow.cs new file mode 100644 index 000000000..08d37c010 --- /dev/null +++ b/Source/Client/Windows/PingMenuWindow.cs @@ -0,0 +1,553 @@ +using System.Collections.Generic; +using Multiplayer.Client.Comp; +using Multiplayer.Client.Util; +using Multiplayer.Common.Networking.Packet; +using RimWorld; +using UnityEngine; +using Verse; +using Verse.Sound; + +namespace Multiplayer.Client; + +// Draggable drawer: wheel on the left, mode toggle + clear buttons + placed-items list on the right. +public class PingMenuWindow : Window +{ + public static PingMenuWindow Opened => Find.WindowStack?.WindowOfType(); + + public override Vector2 InitialSize => new(820f, 540f); + + private Vector2 listScroll; + + // Cache keys are (markersVersion, pingsVersion); filter toggles don't invalidate because + // BuildRows / MyMarker* don't call IsVisible (filters apply downstream in render gates). + private List cachedRows; + private int cachedRowsMarkersVersion = -1; + private int cachedRowsPingsVersion = -1; + private int cachedMyMarkerCount; + private int cachedMyMarkerCountVersion = -1; + private int cachedMyMarkersOnMap; + private int cachedMyMarkersOnMapVersion = -1; + private int cachedMyMarkersOnMapMapId = -1; + + private const float WheelSectionW = 440f; + private const float SectionGap = 14f; + + public PingMenuWindow() + { + draggable = true; + resizeable = false; + closeOnClickedOutside = false; + closeOnAccept = false; + closeOnCancel = true; + absorbInputAroundWindow = false; + preventCameraMotion = false; + focusWhenOpened = true; + onlyOneOfTypeAllowed = true; + soundClose = SoundDefOf.FloatMenu_Cancel; + doCloseX = true; + layer = WindowLayer.GameUI; + } + + public override void PostOpen() + { + base.PostOpen(); + if (!Multiplayer.settings.rememberLastCategory) return; + var loc = Multiplayer.session?.locationPings; + if (loc == null) return; + if (loc.armedCategory != null) return; + if (loc.lastUsedCategory is { } cat) + loc.ArmPlacement(cat, playSound: false); + } + + private const float WindowInnerMargin = 18f; + private const float HeaderBlockH = 56f; + + public override void SetInitialSizeAndPosition() + { + var size = InitialSize; + var screen = new Vector2(UI.screenWidth, UI.screenHeight); + const float ScreenMargin = 6f; + + var saved = Multiplayer.settings.pingMenuWindowRect; + // Validate against current size - a future InitialSize change must not let a stale rect + // bypass the clamp below. + if (saved.width > 0f && saved.height > 0f + && saved.x >= -size.x + ScreenMargin && saved.x <= screen.x - ScreenMargin + && saved.y >= -size.y + ScreenMargin && saved.y <= screen.y - ScreenMargin) + { + windowRect = new Rect(saved.x, saved.y, size.x, size.y); + return; + } + + var loc = Multiplayer.session?.locationPings; + var cursorWheelCenter = loc?.wheelScreenOrigin ?? new Vector2(screen.x / 2f, screen.y / 2f); + + var wheelLocalCenterX = WindowInnerMargin + WheelSectionW / 2f; + var bodyHeight = size.y - 2 * WindowInnerMargin - HeaderBlockH; + var wheelLocalCenterY = WindowInnerMargin + HeaderBlockH + bodyHeight / 2f; + + var desiredX = cursorWheelCenter.x - wheelLocalCenterX; + var desiredY = cursorWheelCenter.y - wheelLocalCenterY; + + var x = Mathf.Clamp(desiredX, ScreenMargin, screen.x - size.x - ScreenMargin); + var y = Mathf.Clamp(desiredY, ScreenMargin, screen.y - size.y - ScreenMargin); + + windowRect = new Rect(x, y, size.x, size.y); + } + + public override void PostClose() + { + base.PostClose(); + Multiplayer.session?.locationPings?.DisarmPlacement(playSound: false); + Multiplayer.settings.pingMenuWindowRect = windowRect; + MultiplayerLoader.Multiplayer.instance?.WriteSettings(); + } + + public override void DoWindowContents(Rect inRect) + { + const float CloseXReserve = 30f; + const float HeaderBtnGap = 6f; + const float FiltersBtnW = 90f; + const float HostBtnW = 120f; // wide enough for "Host Settings" + + var isHost = Multiplayer.LocalServer != null; + var hostBtnReserve = isHost ? HostBtnW + HeaderBtnGap : 0f; + + var titleRect = new Rect(inRect.x, inRect.y, + inRect.width - CloseXReserve - FiltersBtnW - HeaderBtnGap - hostBtnReserve, 28f); + using (MpStyle.Set(GameFont.Medium).Set(TextAnchor.MiddleLeft)) + Widgets.Label(titleRect, MpTranslate.Fallback("MpPingMenuWindow_Header", "Ping & marker menu")); + + var filtersBtnRect = new Rect(titleRect.xMax + HeaderBtnGap, inRect.y + 2f, + FiltersBtnW, 24f); + if (Widgets.ButtonText(filtersBtnRect, MpTranslate.Fallback("MpPingFilters_OpenBtn", "Filters"))) + { + // To screen-space for dialog anchor. + var anchor = new Rect( + windowRect.x + filtersBtnRect.x, + windowRect.y + filtersBtnRect.y, + filtersBtnRect.width, + filtersBtnRect.height); + if (PingFiltersDialog.Opened == null) + Find.WindowStack.Add(new PingFiltersDialog(anchor)); + else + PingFiltersDialog.Opened.Close(); + SoundDefOf.Click.PlayOneShotOnCamera(); + // Stop the click falling through to GUI.DragWindow. + Event.current.Use(); + } + TooltipHandler.TipRegion(filtersBtnRect, PingFiltersDialog.OpenTooltipLabel()); + + if (isHost) + { + var hostBtnRect = new Rect(filtersBtnRect.xMax + HeaderBtnGap, inRect.y + 2f, + HostBtnW, 24f); + if (Widgets.ButtonText(hostBtnRect, MpTranslate.Fallback("MpPingHostSettings_OpenBtn", "Host Settings"))) + { + var anchor = new Rect( + windowRect.x + hostBtnRect.x, + windowRect.y + hostBtnRect.y, + hostBtnRect.width, + hostBtnRect.height); + if (PingHostSettingsDialog.Opened == null) + Find.WindowStack.Add(new PingHostSettingsDialog(anchor)); + else + PingHostSettingsDialog.Opened.Close(); + SoundDefOf.Click.PlayOneShotOnCamera(); + Event.current.Use(); + } + TooltipHandler.TipRegion(hostBtnRect, PingHostSettingsDialog.OpenTooltipLabel()); + } + + var subtitleRect = new Rect(inRect.x, titleRect.yMax + 2f, + inRect.width - CloseXReserve, 18f); + using (MpStyle.Set(GameFont.Tiny).Set(TextAnchor.MiddleLeft).Set(new Color(0.7f, 0.7f, 0.7f))) + Widgets.Label(subtitleRect, SubtitleLabel()); + + var bodyTop = subtitleRect.yMax + 8f; + var body = new Rect(inRect.x, bodyTop, inRect.width, inRect.yMax - bodyTop); + + var wheelSection = new Rect(body.x, body.y, WheelSectionW, body.height); + var contentSection = new Rect(wheelSection.xMax + SectionGap, body.y, + body.width - WheelSectionW - SectionGap, body.height); + + var dividerX = wheelSection.xMax + SectionGap / 2f; + Widgets.DrawLineVertical(dividerX, body.y + 4f, body.height - 8f); + + DrawWheelSection(wheelSection); + DrawContentSection(contentSection); + } + + private void DrawWheelSection(Rect section) + { + var loc = Multiplayer.session?.locationPings; + if (loc == null) return; + + var wheelCenterLocal = new Vector2(section.center.x, section.center.y); + + // Sync screen-space center so DrawArmedCursor and PingMapClickPatch hit-test correctly. + loc.wheelScreenOrigin = new Vector2( + windowRect.x + WindowInnerMargin + wheelCenterLocal.x, + windowRect.y + WindowInnerMargin + wheelCenterLocal.y); + + loc.DrawWheelInDrawer(wheelCenterLocal, Event.current.mousePosition); + } + + private void DrawContentSection(Rect section) + { + var y = section.y; + + var headerRect = new Rect(section.x, y, section.width, 16f); + using (MpStyle.Set(GameFont.Tiny).Set(TextAnchor.MiddleLeft).Set(new Color(0.65f, 0.65f, 0.65f))) + Widgets.Label(headerRect, MpTranslate.Fallback("MpPingMenuWindow_PlacementType", "Placement type")); + y = headerRect.yMax + 2f; + + var modeRow = new Rect(section.x, y, section.width, 32f); + DrawModeRow(modeRow); + y = modeRow.yMax + 3f; + + var modeDescRect = new Rect(section.x, y, section.width, 14f); + using (MpStyle.Set(GameFont.Tiny).Set(TextAnchor.MiddleLeft).Set(WordWrap.NoWrap).Set(new Color(0.78f, 0.78f, 0.78f))) + Widgets.Label(modeDescRect, MpTranslate.Fallback("MpPingMode_" + Multiplayer.settings.pingPlaceMode + "_Description", + Multiplayer.settings.pingPlaceMode switch + { + PingPlaceMode.Ping => "Briefly flash a spot to call attention. Fades on its own.", + PingPlaceMode.Marker => "Drop a pin to mark a spot. Stays until you remove it.", + _ => "", + })); + y = modeDescRect.yMax + 10f; + + DrawActionStack(section, ref y); + y += 10f; + + Widgets.DrawLineHorizontal(section.x, y, section.width); + y += 6f; + + var cap = Multiplayer.game?.gameComp?.markerCapPerPlayer ?? PingMarkerCap.Default; + var listHeaderRect = new Rect(section.x, y, section.width, 20f); + var markerCount = MyMarkerCount(); + using (MpStyle.Set(GameFont.Small).Set(TextAnchor.MiddleLeft)) + Widgets.Label(listHeaderRect, MpTranslate.Fallback("MpPingMenuWindow_ListHeaderWithCount", + $"Your pings & markers ({markerCount}/{cap})", + markerCount, cap)); + y = listHeaderRect.yMax + 4f; + + var listRect = new Rect(section.x, y, section.width, section.yMax - y); + DrawList(listRect); + } + + private static void DrawModeRow(Rect row) + { + var mode = Multiplayer.settings.pingPlaceMode; + var leftRect = new Rect(row.x, row.y, row.width / 2f - 3f, row.height); + var rightRect = new Rect(row.x + row.width / 2f + 3f, row.y, row.width / 2f - 3f, row.height); + + if (DrawModeTabButton(leftRect, ModeLabel(PingPlaceMode.Ping), PingPlaceMode.Ping, mode == PingPlaceMode.Ping) + && mode != PingPlaceMode.Ping) + { + Multiplayer.settings.pingPlaceMode = PingPlaceMode.Ping; + MultiplayerLoader.Multiplayer.instance?.WriteSettings(); + SoundDefOf.Checkbox_TurnedOn.PlayOneShotOnCamera(); + } + if (DrawModeTabButton(rightRect, ModeLabel(PingPlaceMode.Marker), PingPlaceMode.Marker, mode == PingPlaceMode.Marker) + && mode != PingPlaceMode.Marker) + { + Multiplayer.settings.pingPlaceMode = PingPlaceMode.Marker; + MultiplayerLoader.Multiplayer.instance?.WriteSettings(); + SoundDefOf.Checkbox_TurnedOn.PlayOneShotOnCamera(); + } + } + + private void DrawActionStack(Rect section, ref float y) + { + var myCount = MyMarkerCount(); + var hasMap = Find.CurrentMap != null; + var onMapCount = hasMap ? MyMarkersOnCurrentMap() : 0; + + var loc = Multiplayer.session?.locationPings; + const float ButtonH = 28f; + var clearMineRect = new Rect(section.x, y, section.width, ButtonH); + if (Widgets.ButtonText(clearMineRect, MpTranslate.Fallback("MpPingDrawer_ClearAllMine", + myCount > 0 ? $"Clear all my markers ({myCount})" : "Clear all my markers", + myCount), active: myCount > 0)) + { + loc?.SendClearMyMarkers(); + SoundDefOf.Click.PlayOneShotOnCamera(); + } + y = clearMineRect.yMax + 4f; + + var clearOnMapRect = new Rect(section.x, y, section.width, ButtonH); + if (Widgets.ButtonText(clearOnMapRect, MpTranslate.Fallback("MpPingDrawer_ClearMineOnThisMap", + onMapCount > 0 ? $"Clear my markers on this map ({onMapCount})" : "Clear my markers on this map", + onMapCount), active: hasMap && onMapCount > 0)) + { + loc?.SendClearMyMarkersOnMap(Find.CurrentMap.uniqueID); + SoundDefOf.Click.PlayOneShotOnCamera(); + } + y = clearOnMapRect.yMax; + } + + private void DrawList(Rect outRect) + { + var rows = BuildRows(); + const float rowHeight = 36f; + const float rowGap = 2f; + var viewRectHeight = rows.Count * (rowHeight + rowGap) + 4f; + var viewRect = new Rect(0f, 0f, outRect.width - 16f, viewRectHeight); + + Widgets.BeginScrollView(outRect, ref listScroll, viewRect); + + if (rows.Count == 0) + { + using (MpStyle.Set(GameFont.Small).Set(TextAnchor.MiddleCenter).Set(new Color(0.7f, 0.7f, 0.7f))) + Widgets.Label(new Rect(0f, 8f, viewRect.width, 32f), MpTranslate.Fallback("MpPingMenuWindow_Empty", "(none yet)")); + } + else + { + // Clip to visible viewport with a 1-row buffer so fast scrolling doesn't show pop-in. + var stride = rowHeight + rowGap; + var firstVisible = Mathf.Max(0, (int)(listScroll.y / stride) - 1); + var lastVisible = Mathf.Min(rows.Count, firstVisible + (int)(outRect.height / stride) + 3); + for (var i = firstVisible; i < lastVisible; i++) + { + var rowRect = new Rect(0f, i * stride, viewRect.width, rowHeight); + DrawListRow(rowRect, rows[i]); + } + } + + Widgets.EndScrollView(); + } + + private void DrawListRow(Rect rect, PingInfo info) + { + var isSelected = info.isMarker + ? Multiplayer.session.locationPings.IsMarkerSelected(info.markerId) + : Multiplayer.session.locationPings.IsPingSelected(info.player); + + Widgets.DrawHighlightIfMouseover(rect); + if (isSelected) + Widgets.DrawHighlightSelected(rect); + + var stripe = info.BaseColor; + Widgets.DrawBoxSolid(new Rect(rect.x, rect.y, 3f, rect.height), new Color(stripe.r, stripe.g, stripe.b, 0.95f)); + + var iconRect = new Rect(rect.x + 8f, rect.y + 4f, 28f, 28f); + var iconTex = info.category.Icon(); + if (iconTex != null) + GUI.DrawTexture(iconRect, iconTex); + + // 154 = Rename(76) + gap(6) + Delete(64) + 8 margin. Pings auto-fade, so no action row. + var actionsReserved = info.isMarker ? 154f : 0f; + var bodyRect = new Rect(iconRect.xMax + 6f, rect.y + 2f, rect.width - iconRect.xMax - 6f - actionsReserved, rect.height - 4f); + var primary = string.IsNullOrEmpty(info.label) + ? info.category.DisplayName() + : $"{info.category.DisplayName()} - {info.label}"; + var secondary = $"{TargetDescription(info)} · {info.placedByUsername ?? "?"}{(info.isMarker ? "" : " " + RemainingTimeLabel(info))}"; + + using (MpStyle.Set(GameFont.Small).Set(TextAnchor.UpperLeft) + .Set(info.isMarker ? Color.white : new Color(1f, 1f, 1f, 0.85f))) + Widgets.Label(new Rect(bodyRect.x, bodyRect.y, bodyRect.width, 18f), primary); + using (MpStyle.Set(GameFont.Tiny).Set(TextAnchor.UpperLeft).Set(new Color(0.75f, 0.75f, 0.75f))) + Widgets.Label(new Rect(bodyRect.x, bodyRect.y + 17f, bodyRect.width, 14f), secondary); + + var bodyClickReserved = info.isMarker ? 148f : 0f; + var bodyClickRect = new Rect(rect.x, rect.y, rect.width - bodyClickReserved, rect.height); + if (Widgets.ButtonInvisible(bodyClickRect)) + { + Multiplayer.session.locationPings.JumpToAndSelect(info); + SoundDefOf.Click.PlayOneShotOnCamera(); + } + + if (info.isMarker) + { + var canDelete = LocationPings.CanDeleteMarker(info); + var renameRect = new Rect(rect.xMax - 148f, rect.y + 4f, 76f, rect.height - 8f); + var deleteRect = new Rect(rect.xMax - 68f, rect.y + 4f, 64f, rect.height - 8f); + if (Widgets.ButtonText(renameRect, RenameLabel(), active: canDelete)) + { + Find.WindowStack.Add(new PingLabelWindow(info.markerId, info.label)); + SoundDefOf.Click.PlayOneShotOnCamera(); + } + if (Widgets.ButtonText(deleteRect, DeleteLabel(), active: canDelete)) + { + Multiplayer.session?.locationPings?.SendDeleteMarker(info.markerId); + SoundDefOf.Click.PlayOneShotOnCamera(); + } + } + } + + // Atlas alone doesn't read as a tab - add accent bar. + private static bool DrawModeTabButton(Rect rect, string label, PingPlaceMode mode, bool selected) + { + var atlas = selected ? Widgets.ButtonBGAtlasClick + : (Mouse.IsOver(rect) ? Widgets.ButtonBGAtlasMouseover : Widgets.ButtonBGAtlas); + Widgets.DrawAtlas(rect, atlas); + + if (selected) + { + var accent = ModeAccentColor(mode); + var bar = new Rect(rect.x + 4f, rect.yMax - 4f, rect.width - 8f, 3f); + Widgets.DrawBoxSolid(bar, accent); + } + + using (MpStyle.Set(GameFont.Small).Set(TextAnchor.MiddleCenter) + .Set(selected ? Color.white : new Color(0.72f, 0.72f, 0.72f))) + Widgets.Label(rect, label); + MouseoverSounds.DoRegion(rect); + return Widgets.ButtonInvisible(rect, false); + } + + private static Color ModeAccentColor(PingPlaceMode m) => m switch + { + PingPlaceMode.Marker => new Color(0.40f, 0.70f, 1.00f), + _ => new Color(1.00f, 0.85f, 0.40f), + }; + + // Local player only. + private List BuildRows() + { + var loc = Multiplayer.session?.locationPings; + var comp = Multiplayer.game?.gameComp; + var markersV = comp?.markersVersion ?? 0; + var pingsV = loc?.pingsVersion ?? 0; + + if (cachedRows != null + && cachedRowsMarkersVersion == markersV + && cachedRowsPingsVersion == pingsV) + { +#if DEBUG + AssertRowsCacheStillValid(loc, comp); +#endif + return cachedRows; + } + + var rows = ComputeRowsUncached(loc, comp); + cachedRows = rows; + cachedRowsMarkersVersion = markersV; + cachedRowsPingsVersion = pingsV; + return rows; + } + + private static List ComputeRowsUncached(LocationPings loc, MultiplayerGameComp comp) + { + var rows = new List(); + if (loc == null) return rows; + + if (comp != null) + { + var mine = new List(); + foreach (var m in comp.AllMarkers) + if (m.IsOwnedByLocalPlayer()) mine.Add(m); + mine.Sort((a, b) => b.markerId.CompareTo(a.markerId)); + rows.AddRange(mine); + } + + for (var i = loc.pings.Count - 1; i >= 0; i--) + if (loc.pings[i].IsOwnedByLocalPlayer()) + rows.Add(loc.pings[i]); + + return rows; + } + + private int MyMarkerCount() + { + var comp = Multiplayer.game?.gameComp; + if (comp == null) return 0; + if (cachedMyMarkerCountVersion == comp.markersVersion) return cachedMyMarkerCount; + + var n = 0; + foreach (var m in comp.AllMarkers) + if (m.IsOwnedByLocalPlayer()) n++; + + cachedMyMarkerCount = n; + cachedMyMarkerCountVersion = comp.markersVersion; + return n; + } + + private int MyMarkersOnCurrentMap() + { + var comp = Multiplayer.game?.gameComp; + if (comp == null || Find.CurrentMap == null) return 0; + var id = Find.CurrentMap.uniqueID; + if (cachedMyMarkersOnMapVersion == comp.markersVersion + && cachedMyMarkersOnMapMapId == id) + return cachedMyMarkersOnMap; + + var n = 0; + foreach (var m in comp.AllMarkers) + if (m.mapId == id && m.IsOwnedByLocalPlayer()) n++; + + cachedMyMarkersOnMap = n; + cachedMyMarkersOnMapVersion = comp.markersVersion; + cachedMyMarkersOnMapMapId = id; + return n; + } + +#if DEBUG + private int debugAssertFrame; + private void AssertRowsCacheStillValid(LocationPings loc, MultiplayerGameComp comp) + { + // Every 64th frame, assert no mutation site forgot to bump versions. + if ((debugAssertFrame++ & 63) != 0) return; + var fresh = ComputeRowsUncached(loc, comp); + var stale = fresh.Count != cachedRows.Count; + if (!stale) + for (var i = 0; i < fresh.Count; i++) + if (!ReferenceEquals(fresh[i], cachedRows[i])) { stale = true; break; } + if (stale) + Log.ErrorOnce( + $"[MP] PingMenuWindow row cache stale: cached={cachedRows.Count} fresh={fresh.Count}. " + + "A mutation path missed markersVersion++ or pingsVersion++.", 0x6D7A8B); + } +#endif + + private static string TargetDescription(PingInfo info) + { + if (info.mapId == -1) + return MpTranslate.Fallback("MpPingMenuWindow_TargetPlanet", "Planet"); + var map = Find.Maps.GetById(info.mapId); + return map?.Parent?.LabelCap + ?? MpTranslate.Fallback("MpPingMenuWindow_TargetMap", "Map"); + } + + private static string RemainingTimeLabel(PingInfo p) + { + var remaining = Mathf.Max(0f, PingInfo.PingDuration - p.timeAlive); + return $"{remaining:0.0}s"; + } + + private static string DeleteLabel() + => MpTranslate.Fallback("MpPingMenuWindow_Delete", "Delete"); + private static string RenameLabel() + => MpTranslate.Fallback("MpPingSel_Rename", "Rename"); + + private static string SubtitleLabel() + { + var loc = Multiplayer.session?.locationPings; + var modeWord = ModeWordLower(Multiplayer.settings.pingPlaceMode); + + if (loc?.armedCategory is { } cat) + { + var catName = cat.DisplayName(); + return MpTranslate.Fallback("MpPingMenuWindow_Subtitle_Armed", + $"Click on the map to place a {catName} {modeWord}.", + catName, modeWord); + } + return MpTranslate.Fallback("MpPingMenuWindow_Subtitle_Idle", + $"Select a category from the wheel to place a {modeWord}.", + modeWord); + } + + private static string ModeWordLower(PingPlaceMode m) + => MpTranslate.Fallback("MpPingMode_" + m + "_LowerWord", + m == PingPlaceMode.Marker ? "marker" : "ping"); + + private static string ModeLabel(PingPlaceMode m) + => MpTranslate.Fallback("MpPingMode_" + m, + m switch + { + PingPlaceMode.Ping => "Ping", + PingPlaceMode.Marker => "Marker", + _ => m.ToString(), + }); +} diff --git a/Source/Client/Windows/SaveFileReader.cs b/Source/Client/Windows/SaveFileReader.cs index 69fbab6e7..daa8dfbbf 100644 --- a/Source/Client/Windows/SaveFileReader.cs +++ b/Source/Client/Windows/SaveFileReader.cs @@ -7,6 +7,7 @@ using System.Threading.Tasks; using System.Xml; using Multiplayer.Common; +using Multiplayer.Common.Networking.Packet; using RimWorld; using UnityEngine; using Verse; @@ -101,6 +102,7 @@ public class SaveFile(string displayName, bool replay, FileInfo file) public int protocol; public bool asyncTime; public bool multifaction; + public int markerCapPerPlayer = PingMarkerCap.Default; public bool HasRwVersion => rwVersion != null; @@ -157,6 +159,7 @@ public static SaveFile ReadSpSave(FileInfo file) saveFile.modNames = replay.info.modNames.ToArray(); saveFile.asyncTime = replay.info.asyncTime; saveFile.multifaction = replay.info.multifaction; + saveFile.markerCapPerPlayer = replay.info.markerCapPerPlayer; } else { diff --git a/Source/Common/MultiplayerServer.cs b/Source/Common/MultiplayerServer.cs index 27dc3553e..6d5247830 100644 --- a/Source/Common/MultiplayerServer.cs +++ b/Source/Common/MultiplayerServer.cs @@ -224,6 +224,97 @@ public void SendToPlaying(T packet, bool reliable = true, ServerPlayer? exclu player.conn.Send(serialized, reliable); } + // Per-loading-player buffer for marker mutations; drained on ChangeState(ServerPlaying). + // Closes the snapshot-to-state-change race. Tiered eviction (see MidJoinPacketTier). + private readonly Dictionary> midJoinMarkerBuffer = new(); + + // Per-player soft cap. At the cap, a Replaceable entry is evicted ahead of any Critical one. + private const int MidJoinMarkerBufferCapPerPlayer = 1024; + // Guarded by `midJoinMarkerBuffer`'s monitor. + private readonly HashSet midJoinBufferOverflowLogged = new(); + + // Critical = losing this leaves the joiner desynced (ghost marker after a missed delete or clear). + // Replaceable = at most a cosmetic divergence; dropped first when the cap is hit. + public enum MidJoinPacketTier + { + Replaceable, + Critical, + } + + public readonly record struct BufferedMidJoinPacket(SerializedPacket packet, MidJoinPacketTier tier); + + // Called only from packet handlers on the server tick thread, so Players is not concurrently mutated. + public void SendToPlayingAndBufferForLoading(T packet, MidJoinPacketTier tier, ServerPlayer? excluding = null) where T : IPacket + { + var serialized = packet.Serialize(); + foreach (ServerPlayer player in PlayingPlayers) + if (player != excluding) + player.conn.Send(serialized, reliable: true); + + // Same SerializedPacket bytes - no per-destination re-serialization. + var entry = new BufferedMidJoinPacket(serialized, tier); + lock (midJoinMarkerBuffer) + { + foreach (ServerPlayer player in playerManager.Players) + { + if (player.conn.State != ConnectionStateEnum.ServerLoading) continue; + if (!midJoinMarkerBuffer.TryGetValue(player.id, out var list)) + midJoinMarkerBuffer[player.id] = list = new List(MidJoinMarkerBufferCapPerPlayer); + if (list.Count >= MidJoinMarkerBufferCapPerPlayer) + EvictForCap(list, player.id); + list.Add(entry); + } + } + } + + // Evicts the oldest Replaceable entry; falls back to the head if the buffer is all Critical. + private void EvictForCap(List list, int playerId) + { + var evictIdx = -1; + for (var i = 0; i < list.Count; i++) + { + if (list[i].tier == MidJoinPacketTier.Replaceable) + { + evictIdx = i; + break; + } + } + var evictingCritical = evictIdx < 0; + if (evictingCritical) evictIdx = 0; + list.RemoveAt(evictIdx); + + if (midJoinBufferOverflowLogged.Add(playerId)) + { + var note = evictingCritical + ? "evicting a critical entry (delete/clear) - joiner may inherit a ghost marker" + : "evicting oldest replaceable entry"; + ServerLog.Log($"Mid-join marker buffer for player {playerId} hit cap " + + $"({MidJoinMarkerBufferCapPerPlayer}); {note}."); + } + } + + public void DrainMidJoinMarkerBuffer(ServerPlayer player) + { + List? buffered; + lock (midJoinMarkerBuffer) + { + if (!midJoinMarkerBuffer.TryGetValue(player.id, out buffered)) return; + midJoinMarkerBuffer.Remove(player.id); + midJoinBufferOverflowLogged.Remove(player.id); + } + foreach (var entry in buffered) + player.conn.Send(entry.packet, reliable: true); + } + + public void ClearMidJoinMarkerBuffer(int playerId) + { + lock (midJoinMarkerBuffer) + { + midJoinMarkerBuffer.Remove(playerId); + midJoinBufferOverflowLogged.Remove(playerId); + } + } + public void SendToIngame(T packet, bool reliable = true, ServerPlayer? excluding = null) where T : IPacket { var serialized = packet.Serialize(); diff --git a/Source/Common/Networking/Packet/ClearMarkersPacket.cs b/Source/Common/Networking/Packet/ClearMarkersPacket.cs new file mode 100644 index 000000000..9249b9452 --- /dev/null +++ b/Source/Common/Networking/Packet/ClearMarkersPacket.cs @@ -0,0 +1,57 @@ +using System; + +namespace Multiplayer.Common.Networking.Packet; + +public enum PingMarkerClearMode : byte +{ + /// Sender's markers, all maps. + Mine = 0, + /// Sender's markers on one map. + OnMap = 1, + /// Every marker placed by a named user, all maps. Any player can do this to deal with griefers. + FromPlayer = 2, + /// Host-only: every marker, every player. + AllMarkers = 3, + /// Host-only: every ephemeral ping currently in flight. + AllPings = 4, +} + +public static class PingMarkerClearWire +{ + public static readonly int Count = Enum.GetValues(typeof(PingMarkerClearMode)).Length; + public static bool IsValid(byte raw) => raw < Count; +} + +// playerId / username stamped by the server, not trusted from the client (PlayerInfo may evict mid-relay). +[PacketDefinition(Packets.Server_ClearMarkers)] +public record struct ServerClearMarkersPacket(int playerId, string username, bool senderIsHost, ClientClearMarkersPacket data) : IPacket +{ + public int playerId = playerId; + public string username = username; + public bool senderIsHost = senderIsHost; + public ClientClearMarkersPacket data = data; + + public void Bind(PacketBuffer buf) + { + buf.Bind(ref playerId); + buf.Bind(ref username, maxLength: MultiplayerServer.MaxUsernameLength); + buf.Bind(ref senderIsHost); + buf.Bind(ref data); + } +} + +[PacketDefinition(Packets.Client_ClearMarkers)] +public record struct ClientClearMarkersPacket(byte mode, int mapId, string targetUsername) : IPacket +{ + public byte mode = mode; + public int mapId = mapId; + // FromPlayer only; empty otherwise. + public string targetUsername = targetUsername; + + public void Bind(PacketBuffer buf) + { + buf.Bind(ref mode); + buf.Bind(ref mapId); + buf.Bind(ref targetUsername, maxLength: MultiplayerServer.MaxUsernameLength); + } +} diff --git a/Source/Common/Networking/Packet/DeleteMarkerPacket.cs b/Source/Common/Networking/Packet/DeleteMarkerPacket.cs new file mode 100644 index 000000000..ad9d96555 --- /dev/null +++ b/Source/Common/Networking/Packet/DeleteMarkerPacket.cs @@ -0,0 +1,38 @@ +using System; + +namespace Multiplayer.Common.Networking.Packet; + +// playerId / factionId / username stamped by the server, not trusted from the client. +[PacketDefinition(Packets.Server_DeleteMarker)] +public record struct ServerDeleteMarkerPacket(int playerId, int factionId, string username, bool senderIsHost, ClientDeleteMarkerPacket data) : IPacket +{ + public int playerId = playerId; + public int factionId = factionId; + public string username = username; + public bool senderIsHost = senderIsHost; + public ClientDeleteMarkerPacket data = data; + + public void Bind(PacketBuffer buf) + { + buf.Bind(ref playerId); + buf.Bind(ref factionId); + buf.Bind(ref username, maxLength: MultiplayerServer.MaxUsernameLength); + buf.Bind(ref senderIsHost); + buf.Bind(ref data); + } +} + +[PacketDefinition(Packets.Client_DeleteMarker)] +public record struct ClientDeleteMarkerPacket(int[] markerIds) : IPacket +{ + public const int MaxBatchSize = 256; + + public int[] markerIds = markerIds; + + public void Bind(PacketBuffer buf) + { + markerIds ??= Array.Empty(); + // Cap reader allocation against malformed input. + buf.Bind(ref markerIds, BinderOf.Int(), maxLength: MaxBatchSize); + } +} diff --git a/Source/Common/Networking/Packet/PingCategory.cs b/Source/Common/Networking/Packet/PingCategory.cs new file mode 100644 index 000000000..6896b4125 --- /dev/null +++ b/Source/Common/Networking/Packet/PingCategory.cs @@ -0,0 +1,37 @@ +using System; + +namespace Multiplayer.Common.Networking.Packet; + +public enum PingCategory : byte +{ + Default = 0, + Attack = 1, + Defend = 2, + Help = 3, + Loot = 4, + Rally = 5, +} + +public static class PingCategoryWire +{ + public static readonly int Count = Enum.GetValues(typeof(PingCategory)).Length; + public const int MaxLabelChars = 64; + public const int MaxLabelBytes = MaxLabelChars * 4; + + public static bool IsValid(byte raw) => raw < Count; +} + +// Marker cap; wire / scribe / UI must agree on the receiver FIFO eviction value. +public static class PingMarkerCap +{ + public const int Default = 50; + public const int Min = 1; + public const int Max = 200; + + public static int Clamp(int value) + { + if (value < Min) return Min; + if (value > Max) return Max; + return value; + } +} diff --git a/Source/Common/Networking/Packet/PingLocationPacket.cs b/Source/Common/Networking/Packet/PingLocationPacket.cs index 5e7f5f00e..2c1ca0c6e 100644 --- a/Source/Common/Networking/Packet/PingLocationPacket.cs +++ b/Source/Common/Networking/Packet/PingLocationPacket.cs @@ -1,25 +1,50 @@ -namespace Multiplayer.Common.Networking.Packet; +namespace Multiplayer.Common.Networking.Packet; +// playerId / factionId / username / color stamped by the server, not trusted from the client. [PacketDefinition(Packets.Server_PingLocation)] -public record struct ServerPingLocPacket(int playerId, ClientPingLocPacket data) : IPacket +public record struct ServerPingLocPacket(int playerId, int factionId, string username, byte r, byte g, byte b, ClientPingLocPacket data) : IPacket { public int playerId = playerId; + public int factionId = factionId; + public string username = username; + public byte r = r; + public byte g = g; + public byte b = b; public ClientPingLocPacket data = data; public void Bind(PacketBuffer buf) { buf.Bind(ref playerId); + buf.Bind(ref factionId); + buf.Bind(ref username, maxLength: MultiplayerServer.MaxUsernameLength); + buf.Bind(ref r); + buf.Bind(ref g); + buf.Bind(ref b); buf.Bind(ref data); } } [PacketDefinition(Packets.Client_PingLocation)] -public record struct ClientPingLocPacket(int mapId, int planetTileId, int planetTileLayer, float x, float y, float z) : IPacket +public record struct ClientPingLocPacket( + int mapId, + int planetTileId, + int planetTileLayer, + float x, float y, float z, + byte category, + bool isMarker, + string label, + int placedAtTick +) : IPacket { public int mapId = mapId; public int planetTileId = planetTileId; public int planetTileLayer = planetTileLayer; public float x = x, y = y, z = z; + public byte category = category; + public bool isMarker = isMarker; + public string label = label; + // Stamped by sender; relayed verbatim so receivers agree on "placed at". + public int placedAtTick = placedAtTick; public void Bind(PacketBuffer buf) { @@ -29,5 +54,9 @@ public void Bind(PacketBuffer buf) buf.Bind(ref x); buf.Bind(ref y); buf.Bind(ref z); + buf.Bind(ref category); + buf.Bind(ref isMarker); + buf.Bind(ref label, maxLength: PingCategoryWire.MaxLabelBytes); + buf.Bind(ref placedAtTick); } } diff --git a/Source/Common/Networking/Packet/RenameMarkerPacket.cs b/Source/Common/Networking/Packet/RenameMarkerPacket.cs new file mode 100644 index 000000000..f3030138c --- /dev/null +++ b/Source/Common/Networking/Packet/RenameMarkerPacket.cs @@ -0,0 +1,34 @@ +namespace Multiplayer.Common.Networking.Packet; + +// playerId / factionId / username stamped by the server. Per-marker ownership enforced on the receiver. +[PacketDefinition(Packets.Server_RenameMarker)] +public record struct ServerRenameMarkerPacket(int playerId, int factionId, string username, bool senderIsHost, ClientRenameMarkerPacket data) : IPacket +{ + public int playerId = playerId; + public int factionId = factionId; + public string username = username; + public bool senderIsHost = senderIsHost; + public ClientRenameMarkerPacket data = data; + + public void Bind(PacketBuffer buf) + { + buf.Bind(ref playerId); + buf.Bind(ref factionId); + buf.Bind(ref username, maxLength: MultiplayerServer.MaxUsernameLength); + buf.Bind(ref senderIsHost); + buf.Bind(ref data); + } +} + +[PacketDefinition(Packets.Client_RenameMarker)] +public record struct ClientRenameMarkerPacket(int markerId, string label) : IPacket +{ + public int markerId = markerId; + public string label = label; + + public void Bind(PacketBuffer buf) + { + buf.Bind(ref markerId); + buf.Bind(ref label, maxLength: PingCategoryWire.MaxLabelBytes); + } +} diff --git a/Source/Common/Networking/Packets.cs b/Source/Common/Networking/Packets.cs index 13186cd32..03a5a6995 100644 --- a/Source/Common/Networking/Packets.cs +++ b/Source/Common/Networking/Packets.cs @@ -29,6 +29,9 @@ public enum Packets : byte Client_Debug, Client_Selected, Client_PingLocation, + Client_ClearMarkers, + Client_DeleteMarker, + Client_RenameMarker, Client_Traces, Client_Autosaving, Client_RequestRejoin, @@ -61,6 +64,9 @@ public enum Packets : byte Server_Debug, Server_Selected, Server_PingLocation, + Server_ClearMarkers, + Server_DeleteMarker, + Server_RenameMarker, Server_Traces, Server_SetFaction, diff --git a/Source/Common/Networking/State/ServerLoadingState.cs b/Source/Common/Networking/State/ServerLoadingState.cs index 76638809b..8aa9e7763 100644 --- a/Source/Common/Networking/State/ServerLoadingState.cs +++ b/Source/Common/Networking/State/ServerLoadingState.cs @@ -35,6 +35,10 @@ protected override async Task RunState() SendWorldData(); Player.SendPlayerList(); + + // Drain before ChangeState so subsequent broadcasts reach this player via the live path. + Server.DrainMidJoinMarkerBuffer(Player); + connection.ChangeState(ConnectionStateEnum.ServerPlaying); } diff --git a/Source/Common/Networking/State/ServerPlayingState.cs b/Source/Common/Networking/State/ServerPlayingState.cs index 39b752158..1886d0846 100644 --- a/Source/Common/Networking/State/ServerPlayingState.cs +++ b/Source/Common/Networking/State/ServerPlayingState.cs @@ -2,262 +2,325 @@ using System.Linq; using Multiplayer.Common.Networking.Packet; -namespace Multiplayer.Common +namespace Multiplayer.Common; + +public class ServerPlayingState(ConnectionBase conn) : MpConnectionState(conn) { - public class ServerPlayingState(ConnectionBase conn) : MpConnectionState(conn) + [PacketHandler(Packets.Client_WorldReady)] + public void HandleWorldReady(ByteReader data) { - [PacketHandler(Packets.Client_WorldReady)] - public void HandleWorldReady(ByteReader data) - { - Player.UpdateStatus(PlayerStatus.Playing); - } + Player.UpdateStatus(PlayerStatus.Playing); + } - [PacketHandler(Packets.Client_RequestRejoin)] - public void HandleRejoin(ByteReader data) - { - connection.ChangeState(ConnectionStateEnum.ServerLoading); - Player.ResetTimeVotes(); - } + [PacketHandler(Packets.Client_RequestRejoin)] + public void HandleRejoin(ByteReader data) + { + connection.ChangeState(ConnectionStateEnum.ServerLoading); + Player.ResetTimeVotes(); + } + + [TypedPacketHandler] + public void HandleDesynced(ClientDesyncedPacket packet) => + Server.playerManager.OnDesync(Player, packet.tick, packet.diffAt); - [TypedPacketHandler] - public void HandleDesynced(ClientDesyncedPacket packet) => - Server.playerManager.OnDesync(Player, packet.tick, packet.diffAt); + [TypedPacketHandler] + public void HandleTraces(ClientTracesPacket packet) + { + if (!Player.IsHost) return; + Server.GetPlayer(packet.playerId)?.SendPacket(ServerTracesPacket.Transfer(packet.rawTraces, packet.rawJittedMethods)); + } - [TypedPacketHandler] - public void HandleTraces(ClientTracesPacket packet) + [TypedPacketHandler] + public void HandleClientCommand(ClientCommandPacket packet) + { + int? mapToResync = null; + + if (packet.type == CommandType.PlayerCount) { - if (!Player.IsHost) return; - Server.GetPlayer(packet.playerId)?.SendPacket(ServerTracesPacket.Transfer(packet.rawTraces, packet.rawJittedMethods)); + ByteReader reader = new ByteReader(packet.data); + var prevMapId = reader.ReadInt32(); + var newMapId = reader.ReadInt32(); + if (Player.currentMapId != prevMapId) + ServerLog.Error($"Inconsistent player {Player.Username} map. Last known map: {Player.currentMapId}, " + + $"however received command with transition: {prevMapId} -> {newMapId}"); + Player.currentMapId = newMapId; + Player.hasReportedCurrentMap = true; + + if (Server.CanUseStandaloneMapStreaming(newMapId)) + mapToResync = newMapId; } - [TypedPacketHandler] - public void HandleClientCommand(ClientCommandPacket packet) - { - int? mapToResync = null; + // todo check if map id is valid for the player - if (packet.type == CommandType.PlayerCount) - { - ByteReader reader = new ByteReader(packet.data); - var prevMapId = reader.ReadInt32(); - var newMapId = reader.ReadInt32(); - if (Player.currentMapId != prevMapId) - ServerLog.Error($"Inconsistent player {Player.Username} map. Last known map: {Player.currentMapId}, " + - $"however received command with transition: {prevMapId} -> {newMapId}"); - Player.currentMapId = newMapId; - Player.hasReportedCurrentMap = true; + Server.commands.Send(packet.type, Player.FactionId, packet.mapId, packet.data, Player); - if (Server.CanUseStandaloneMapStreaming(newMapId)) - mapToResync = newMapId; - } + if (mapToResync is int currentMapId) + Server.SendMapResponse(Player, currentMapId); + } - // todo check if map id is valid for the player + public const int MaxChatMsgLength = 128; - Server.commands.Send(packet.type, Player.FactionId, packet.mapId, packet.data, Player); + [TypedPacketHandler] + public void HandleChat(ClientChatPacket packet) + { + string msg = packet.msg; + msg = msg.Trim(); - if (mapToResync is int currentMapId) - Server.SendMapResponse(Player, currentMapId); - } + if (msg.Length == 0) return; - public const int MaxChatMsgLength = 128; + if (msg.Length > MaxChatMsgLength) + msg = msg[..MaxChatMsgLength]; - [TypedPacketHandler] - public void HandleChat(ClientChatPacket packet) + if (msg[0] == '/') + { + var cmd = msg[1..]; + Server.HandleChatCmd(Player, cmd); + } + else { - string msg = packet.msg; - msg = msg.Trim(); - - if (msg.Length == 0) return; - - if (msg.Length > MaxChatMsgLength) - msg = msg[..MaxChatMsgLength]; - - if (msg[0] == '/') - { - var cmd = msg[1..]; - Server.HandleChatCmd(Player, cmd); - } - else - { - Server.SendChat($"{connection.username}: {msg}"); - } + Server.SendChat($"{connection.username}: {msg}"); } + } - [PacketHandler(Packets.Client_WorldDataUpload, allowFragmented: true)] - public void HandleWorldDataUpload(ByteReader data) + [PacketHandler(Packets.Client_WorldDataUpload, allowFragmented: true)] + public void HandleWorldDataUpload(ByteReader data) + { + // On standalone, accept from any playing client; otherwise only host/arbiter + if (!Server.IsStandaloneServer && (Server.ArbiterPlaying ? !Player.IsArbiter : !Player.IsHost)) + return; + + ServerLog.Detail($"Got world upload {data.Left}"); + + Server.worldData.mapData = new Dictionary(); + + int maps = data.ReadInt32(); + for (int i = 0; i < maps; i++) { - // On standalone, accept from any playing client; otherwise only host/arbiter - if (!Server.IsStandaloneServer && (Server.ArbiterPlaying ? !Player.IsArbiter : !Player.IsHost)) - return; + int mapId = data.ReadInt32(); + Server.worldData.mapData[mapId] = data.ReadPrefixedBytes(); + } - ServerLog.Detail($"Got world upload {data.Left}"); + Server.worldData.savedGame = data.ReadPrefixedBytes(); + Server.worldData.sessionData = data.ReadPrefixedBytes(); - Server.worldData.mapData = new Dictionary(); + if (Server.worldData.CreatingJoinPoint) + Server.worldData.EndJoinPointCreation(); + } - int maps = data.ReadInt32(); - for (int i = 0; i < maps; i++) - { - int mapId = data.ReadInt32(); - Server.worldData.mapData[mapId] = data.ReadPrefixedBytes(); - } + [TypedPacketHandler] + public void HandleStandaloneWorldSnapshot(ClientStandaloneWorldSnapshotPacket packet) + { + if (!Server.IsStandaloneServer) + return; - Server.worldData.savedGame = data.ReadPrefixedBytes(); - Server.worldData.sessionData = data.ReadPrefixedBytes(); + if (!Player.IsPlaying) + return; - if (Server.worldData.CreatingJoinPoint) - Server.worldData.EndJoinPointCreation(); - } + var accepted = Server.worldData.TryAcceptStandaloneWorldSnapshot(Player, packet.tick, + packet.worldData, packet.sessionData, packet.sha256Hash); - [TypedPacketHandler] - public void HandleStandaloneWorldSnapshot(ClientStandaloneWorldSnapshotPacket packet) + if (accepted) { - if (!Server.IsStandaloneServer) - return; - - if (!Player.IsPlaying) - return; - - var accepted = Server.worldData.TryAcceptStandaloneWorldSnapshot(Player, packet.tick, - packet.worldData, packet.sessionData, packet.sha256Hash); - - if (accepted) - { - ServerLog.Detail( - $"Accepted standalone world snapshot tick={packet.tick} from {Player.Username}"); - } - else - { - ServerLog.Detail( - $"Rejected standalone world snapshot tick={packet.tick} from {Player.Username}"); - } + ServerLog.Detail( + $"Accepted standalone world snapshot tick={packet.tick} from {Player.Username}"); } - - [TypedPacketHandler] - public void HandleStandaloneMapSnapshot(ClientStandaloneMapSnapshotPacket packet) + else { - if (!Server.IsStandaloneServer) - return; - - if (!Player.IsPlaying) - return; - - var accepted = Server.worldData.TryAcceptStandaloneMapSnapshot(Player, packet.mapId, packet.tick, - packet.mapData, packet.sha256Hash); - - if (accepted) - { - ServerLog.Detail( - $"Accepted standalone map snapshot map={packet.mapId} tick={packet.tick} from {Player.Username}"); - } - else - { - ServerLog.Detail( - $"Rejected standalone map snapshot map={packet.mapId} tick={packet.tick} from {Player.Username}"); - } + ServerLog.Detail( + $"Rejected standalone world snapshot tick={packet.tick} from {Player.Username}"); } + } - [TypedPacketHandler] - public void HandleCursor(ClientCursorPacket clientPacket) - { - if (Player.lastCursorTick == Server.NetTimer) return; // policy - Player.lastCursorTick = Server.NetTimer; + [TypedPacketHandler] + public void HandleStandaloneMapSnapshot(ClientStandaloneMapSnapshotPacket packet) + { + if (!Server.IsStandaloneServer) + return; + + if (!Player.IsPlaying) + return; + + var accepted = Server.worldData.TryAcceptStandaloneMapSnapshot(Player, packet.mapId, packet.tick, + packet.mapData, packet.sha256Hash); - var serverPacket = new ServerCursorPacket(Player.id, clientPacket); - Server.SendToIngame(serverPacket, reliable: false, excluding: Player); + if (accepted) + { + ServerLog.Detail( + $"Accepted standalone map snapshot map={packet.mapId} tick={packet.tick} from {Player.Username}"); + } + else + { + ServerLog.Detail( + $"Rejected standalone map snapshot map={packet.mapId} tick={packet.tick} from {Player.Username}"); } + } - [TypedPacketHandler] - public void HandleSelected(ClientSelectedPacket packet) => - Server.SendToPlaying(new ServerSelectedPacket(Player.id, packet), excluding: Player); + [TypedPacketHandler] + public void HandleCursor(ClientCursorPacket clientPacket) + { + if (Player.lastCursorTick == Server.NetTimer) return; // policy + Player.lastCursorTick = Server.NetTimer; - [TypedPacketHandler] - public void HandlePing(ClientPingLocPacket packet) => - Server.SendToPlaying(new ServerPingLocPacket(Player.id, packet)); + var serverPacket = new ServerCursorPacket(Player.id, clientPacket); + Server.SendToIngame(serverPacket, reliable: false, excluding: Player); + } - [TypedPacketHandler] - public void HandleClientKeepAlive(ClientKeepAlivePacket packet) - { - Player.ticksBehind = packet.ticksBehind; - Player.ticksBehindReceivedAt = Server.gameTimer; - Player.simulating = packet.simulating; - Player.keepAliveAt = Server.NetTimer; + [TypedPacketHandler] + public void HandleSelected(ClientSelectedPacket packet) => + Server.SendToPlaying(new ServerSelectedPacket(Player.id, packet), excluding: Player); - if (Player.IsHost) - Server.workTicks = packet.workTicks; + [TypedPacketHandler] + public void HandlePing(ClientPingLocPacket packet) + { + if (Player.lastPingTick == Server.NetTimer) return; + if (!PingCategoryWire.IsValid(packet.category)) return; + if (packet.label != null && packet.label.Length > PingCategoryWire.MaxLabelChars) return; + Player.lastPingTick = Server.NetTimer; + + // Buffered for mid-handshake joiners. Replaceable: dropping this just loses the create on the joiner, no ghost marker. + Server.SendToPlayingAndBufferForLoading(new ServerPingLocPacket( + Player.id, Player.FactionId, + Player.Username ?? "", + Player.color.r, Player.color.g, Player.color.b, + packet), MultiplayerServer.MidJoinPacketTier.Replaceable); + } - var idMatched = Player.keepAliveId == packet.id; - connection.OnKeepAliveArrived(idMatched); - if (idMatched) Player.keepAliveId++; - } + [TypedPacketHandler] + public void HandleClearMarkers(ClientClearMarkersPacket packet) + { + if (Player.lastMarkerClearTick == Server.NetTimer) return; + if (!PingMarkerClearWire.IsValid(packet.mode)) return; + var mode = (PingMarkerClearMode)packet.mode; + // FromPlayer with empty target would silently no-op on every receiver. + if (mode == PingMarkerClearMode.FromPlayer && string.IsNullOrEmpty(packet.targetUsername)) + return; + // FromPlayer: host or self only. + if (mode == PingMarkerClearMode.FromPlayer + && !Player.IsHost && packet.targetUsername != Player.Username) + return; + // AllMarkers / AllPings are host-only. + if ((mode == PingMarkerClearMode.AllMarkers || mode == PingMarkerClearMode.AllPings) + && !Player.IsHost) + return; + Player.lastMarkerClearTick = Server.NetTimer; + + // Critical: losing a clear leaves the joiner with markers everyone else wiped. + Server.SendToPlayingAndBufferForLoading(new ServerClearMarkersPacket(Player.id, Player.Username ?? "", Player.IsHost, packet), + MultiplayerServer.MidJoinPacketTier.Critical); + } - [TypedPacketHandler] - public void HandleDesyncCheck(ClientSyncInfoPacket packet) - { - var arbiter = Server.ArbiterPlaying; - if (arbiter ? !Player.IsArbiter : !Player.IsHost) return; // policy + [TypedPacketHandler] + public void HandleDeleteMarker(ClientDeleteMarkerPacket packet) + { + if (Player.lastMarkerDeleteTick == Server.NetTimer) return; + if (packet.markerIds == null || packet.markerIds.Length == 0 + || packet.markerIds.Length > ClientDeleteMarkerPacket.MaxBatchSize) return; + Player.lastMarkerDeleteTick = Server.NetTimer; + + // Critical: a missed delete is the ghost-marker scenario the buffer exists to prevent. + Server.SendToPlayingAndBufferForLoading(new ServerDeleteMarkerPacket(Player.id, Player.FactionId, Player.Username ?? "", Player.IsHost, packet), + MultiplayerServer.MidJoinPacketTier.Critical); + } - // Keep at most 10 sync infos - Server.worldData.syncInfos.Add(packet.rawSyncOpinion); - if (Server.worldData.syncInfos.Count > 10) - Server.worldData.syncInfos.RemoveAt(0); + [TypedPacketHandler] + public void HandleRenameMarker(ClientRenameMarkerPacket packet) + { + if (Player.lastMarkerRenameTick == Server.NetTimer) return; + // markerId == 0 is the never-assigned sentinel. + if (packet.markerId == 0) return; + if (packet.label != null && packet.label.Length > PingCategoryWire.MaxLabelChars) return; + Player.lastMarkerRenameTick = Server.NetTimer; + + // Per-marker ownership enforced on the receiver via PingInfo.CanBeModifiedBy. + // Replaceable: a missed rename = stale label, not a ghost marker. + Server.SendToPlayingAndBufferForLoading(new ServerRenameMarkerPacket(Player.id, Player.FactionId, Player.Username ?? "", Player.IsHost, packet), + MultiplayerServer.MidJoinPacketTier.Replaceable); + } - foreach (var p in Server.PlayingPlayers.Where(p => !p.IsArbiter && (arbiter || !p.IsHost))) - p.conn.SendFragmented(new ServerSyncInfoPacket { rawSyncOpinion = packet.rawSyncOpinion }.Serialize()); - } + [TypedPacketHandler] + public void HandleClientKeepAlive(ClientKeepAlivePacket packet) + { + Player.ticksBehind = packet.ticksBehind; + Player.ticksBehindReceivedAt = Server.gameTimer; + Player.simulating = packet.simulating; + Player.keepAliveAt = Server.NetTimer; - [TypedPacketHandler] - public void HandleFreeze(ClientFreezePacket packet) - { - Player.frozen = packet.freeze; + if (Player.IsHost) + Server.workTicks = packet.workTicks; - if (!packet.freeze) - Player.unfrozenAt = Server.NetTimer; - } + var idMatched = Player.keepAliveId == packet.id; + connection.OnKeepAliveArrived(idMatched); + if (idMatched) Player.keepAliveId++; + } - [TypedPacketHandler] - public void HandleAutosaving(ClientAutosavingPacket packet) - { - var forceJoinPoint = packet.reason == JoinPointRequestReason.Save; + [TypedPacketHandler] + public void HandleDesyncCheck(ClientSyncInfoPacket packet) + { + var arbiter = Server.ArbiterPlaying; + if (arbiter ? !Player.IsArbiter : !Player.IsHost) return; // policy - ServerLog.Detail( - $"Received Client_Autosaving from {Player.Username}, standalone={Server.IsStandaloneServer}, " + - $"isHost={Player.IsHost}, reason={packet.reason}, force={forceJoinPoint}"); - - // On standalone, any playing client can trigger a join point (always, regardless of settings) - // On hosted, only the host can trigger and only if the Autosave flag is set - if (Server.IsStandaloneServer || - (Player.IsHost && Server.settings.autoJoinPoint.HasFlag(AutoJoinPointFlags.Autosave))) - Server.worldData.TryStartJoinPointCreation(forceJoinPoint, sourcePlayer: Player); - } + // Keep at most 10 sync infos + Server.worldData.syncInfos.Add(packet.rawSyncOpinion); + if (Server.worldData.syncInfos.Count > 10) + Server.worldData.syncInfos.RemoveAt(0); - [TypedPacketHandler] - public void HandleDebug(ClientDebugPacket _) - { - if (!Server.commands.CanUseDevMode(Player)) - return; + foreach (var p in Server.PlayingPlayers.Where(p => !p.IsArbiter && (arbiter || !p.IsHost))) + p.conn.SendFragmented(new ServerSyncInfoPacket { rawSyncOpinion = packet.rawSyncOpinion }.Serialize()); + } + + [TypedPacketHandler] + public void HandleFreeze(ClientFreezePacket packet) + { + Player.frozen = packet.freeze; - Server.worldData.mapCmds.Clear(); - Server.gameTimer = Server.startingTimer; + if (!packet.freeze) + Player.unfrozenAt = Server.NetTimer; + } - Server.SendToPlaying(new ServerDebugPacket()); - } + [TypedPacketHandler] + public void HandleAutosaving(ClientAutosavingPacket packet) + { + var forceJoinPoint = packet.reason == JoinPointRequestReason.Save; - [TypedPacketHandler] - public void HandleSetFaction(ClientSetFactionPacket packet) - { - // todo restrict handling + ServerLog.Detail( + $"Received Client_Autosaving from {Player.Username}, standalone={Server.IsStandaloneServer}, " + + $"isHost={Player.IsHost}, reason={packet.reason}, force={forceJoinPoint}"); - int playerId = packet.playerId; - int factionId = packet.factionId; + // On standalone, any playing client can trigger a join point (always, regardless of settings) + // On hosted, only the host can trigger and only if the Autosave flag is set + if (Server.IsStandaloneServer || + (Player.IsHost && Server.settings.autoJoinPoint.HasFlag(AutoJoinPointFlags.Autosave))) + Server.worldData.TryStartJoinPointCreation(forceJoinPoint, sourcePlayer: Player); + } - var player = Server.GetPlayer(playerId); - if (player == null) return; - if (player.FactionId == factionId) return; + [TypedPacketHandler] + public void HandleDebug(ClientDebugPacket _) + { + if (!Server.commands.CanUseDevMode(Player)) + return; - player.FactionId = factionId; - Server.SendToPlaying(new ServerSetFactionPacket(playerId, factionId)); - } + Server.worldData.mapCmds.Clear(); + Server.gameTimer = Server.startingTimer; + + Server.SendToPlaying(new ServerDebugPacket()); + } - [TypedPacketHandler] - public void HandleFrameTime(ClientFrameTimePacket packet) => Player.frameTime = packet.frameTime; + [TypedPacketHandler] + public void HandleSetFaction(ClientSetFactionPacket packet) + { + // todo restrict handling + + int playerId = packet.playerId; + int factionId = packet.factionId; + + var player = Server.GetPlayer(playerId); + if (player == null) return; + if (player.FactionId == factionId) return; + + player.FactionId = factionId; + Server.SendToPlaying(new ServerSetFactionPacket(playerId, factionId)); } + + [TypedPacketHandler] + public void HandleFrameTime(ClientFrameTimePacket packet) => Player.frameTime = packet.frameTime; } diff --git a/Source/Common/PlayerManager.cs b/Source/Common/PlayerManager.cs index a56eb2d8c..108f5681f 100644 --- a/Source/Common/PlayerManager.cs +++ b/Source/Common/PlayerManager.cs @@ -70,6 +70,7 @@ public void SetDisconnected(ConnectionBase conn, MpDisconnectReason reason) ServerPlayer player = conn.serverPlayer; Players.Remove(player); + server.ClearMidJoinMarkerBuffer(player.id); if (player.IsHost && server.worldData.CreatingJoinPoint) { diff --git a/Source/Common/ReplayInfo.cs b/Source/Common/ReplayInfo.cs index 117563497..891109e24 100644 --- a/Source/Common/ReplayInfo.cs +++ b/Source/Common/ReplayInfo.cs @@ -3,6 +3,7 @@ using System.Xml; using System.Xml.Schema; using System.Xml.Serialization; +using Multiplayer.Common.Networking.Packet; namespace Multiplayer.Common; @@ -22,6 +23,7 @@ public class ReplayInfo public XmlBool asyncTime; public bool multifaction; + public int markerCapPerPlayer = PingMarkerCap.Default; public static byte[] Write(ReplayInfo info) { @@ -40,7 +42,10 @@ public static byte[] Write(ReplayInfo info) public static ReplayInfo Read(byte[] xml) { - return (ReplayInfo)GetSerializer().Deserialize(new MemoryStream(xml))!; + var info = (ReplayInfo)GetSerializer().Deserialize(new MemoryStream(xml))!; + // Defend against hand-edited or corrupt headers; saves themselves clamp on LoadingVars. + info.markerCapPerPlayer = PingMarkerCap.Clamp(info.markerCapPerPlayer); + return info; } private static XmlSerializer GetSerializer() diff --git a/Source/Common/ServerPlayer.cs b/Source/Common/ServerPlayer.cs index 51471d217..592163e90 100644 --- a/Source/Common/ServerPlayer.cs +++ b/Source/Common/ServerPlayer.cs @@ -23,6 +23,10 @@ public class ServerPlayer : IChatSource public string steamPersonaName = ""; public int lastCursorTick = -1; + public int lastPingTick = -1; + public int lastMarkerClearTick = -1; + public int lastMarkerDeleteTick = -1; + public int lastMarkerRenameTick = -1; public int keepAliveId; public int keepAliveAt; diff --git a/Source/Common/ServerSettings.cs b/Source/Common/ServerSettings.cs index 66b27aa3d..eb9d76795 100644 --- a/Source/Common/ServerSettings.cs +++ b/Source/Common/ServerSettings.cs @@ -20,6 +20,7 @@ public class ServerSettings public bool arbiter; public bool asyncTime; public bool multifaction; + public int markerCapPerPlayer = PingMarkerCap.Default; public bool debugMode; public bool desyncTraces = true; public bool syncConfigs = true; @@ -62,6 +63,9 @@ public void ExposeData() ScribeLike.Look(ref lan, "lan", true); ScribeLike.Look(ref asyncTime, "asyncTime"); ScribeLike.Look(ref multifaction, "multifaction"); + ScribeLike.Look(ref markerCapPerPlayer, "markerCapPerPlayer", PingMarkerCap.Default); + // Clamp hand-edited settings.toml values. + markerCapPerPlayer = PingMarkerCap.Clamp(markerCapPerPlayer); ScribeLike.Look(ref debugMode, "debugMode"); ScribeLike.Look(ref desyncTraces, "desyncTraces", true); ScribeLike.Look(ref syncConfigs, "syncConfigs", true); @@ -90,6 +94,9 @@ public void ExposeData() buf.Bind(ref settings.arbiter); buf.Bind(ref settings.asyncTime); buf.Bind(ref settings.multifaction); + buf.Bind(ref settings.markerCapPerPlayer); + // Defend against hand-crafted out-of-range values on the wire. + settings.markerCapPerPlayer = PingMarkerCap.Clamp(settings.markerCapPerPlayer); buf.Bind(ref settings.debugMode); buf.Bind(ref settings.desyncTraces); buf.Bind(ref settings.syncConfigs); diff --git a/Source/Common/Version.cs b/Source/Common/Version.cs index 68009be35..fe091a7d7 100644 --- a/Source/Common/Version.cs +++ b/Source/Common/Version.cs @@ -6,7 +6,9 @@ namespace Multiplayer.Common public static class MpVersion { public const string SimpleVersion = "0.11.5"; - public const int Protocol = 55; + + // Wire-compatibility protocol version; intentionally distinct from Packets.Max. + public const int Protocol = 63; public static readonly string? GitHash = Assembly.GetExecutingAssembly() .GetCustomAttributes() diff --git a/Source/Tests/PacketTest.cs b/Source/Tests/PacketTest.cs index c98994699..318c79222 100644 --- a/Source/Tests/PacketTest.cs +++ b/Source/Tests/PacketTest.cs @@ -55,12 +55,51 @@ private static IEnumerable RoundtripPackets() data = [] }; - yield return new ClientPingLocPacket(0, 0, 0, 0f, 0f, 0f); - - yield return new ClientPingLocPacket(1, 42, 3, 10.5f, -2.25f, 99.9f); - - yield return new ServerPingLocPacket(7, - new ClientPingLocPacket(5, 123, 1, 1.23f, 4.56f, 7.89f)); + yield return new ClientPingLocPacket(0, 0, 0, 0f, 0f, 0f, (byte)PingCategory.Default, false, "", 0); + + yield return new ClientPingLocPacket(1, 42, 3, 10.5f, -2.25f, 99.9f, (byte)PingCategory.Attack, false, "rush this", 60000); + + yield return new ClientPingLocPacket(9, 7, 0, -1.5f, 0f, 2.5f, (byte)PingCategory.Defend, true, "hold this corner", 123456); + + yield return new ServerPingLocPacket(7, 10, "Alice", 255, 0, 0, + new ClientPingLocPacket(5, 123, 1, 1.23f, 4.56f, 7.89f, (byte)PingCategory.Rally, false, "iron deposit", 250000)); + + yield return new ServerPingLocPacket(11, -1, "Bob", 0, 200, 100, + new ClientPingLocPacket(2, 0, 0, 50.5f, 1f, 80f, (byte)PingCategory.Loot, true, "stockpile here", 1_000_000)); + + // Empty username: server stamps "" if Player.Username is null mid-shutdown. + yield return new ServerPingLocPacket(3, -1, "", 128, 128, 128, + new ClientPingLocPacket(0, 0, 0, 0f, 0f, 0f, (byte)PingCategory.Default, false, "", 0)); + + yield return new ClientClearMarkersPacket((byte)PingMarkerClearMode.Mine, -1, ""); + yield return new ClientClearMarkersPacket((byte)PingMarkerClearMode.OnMap, 42, ""); + yield return new ClientClearMarkersPacket((byte)PingMarkerClearMode.FromPlayer, -1, "Charlie"); + + yield return new ServerClearMarkersPacket(3, "Alice", false, new ClientClearMarkersPacket((byte)PingMarkerClearMode.Mine, -1, "")); + yield return new ServerClearMarkersPacket(8, "Bob", false, new ClientClearMarkersPacket((byte)PingMarkerClearMode.OnMap, 99, "")); + // senderIsHost = true: server only relays FromPlayer from host or self. + yield return new ServerClearMarkersPacket(2, "Alice", true, new ClientClearMarkersPacket((byte)PingMarkerClearMode.FromPlayer, -1, "Charlie")); + // Empty-username defensive case. + yield return new ServerClearMarkersPacket(0, "", false, new ClientClearMarkersPacket((byte)PingMarkerClearMode.Mine, -1, "")); + + yield return new ClientDeleteMarkerPacket(new[] { 0 }); + yield return new ClientDeleteMarkerPacket(new[] { 1 }); + yield return new ClientDeleteMarkerPacket(new[] { int.MaxValue }); + yield return new ClientDeleteMarkerPacket(new[] { 1, 2, 3, 4, 5 }); + // At-cap batch boundary for MaxBatchSize. + yield return new ClientDeleteMarkerPacket(Enumerable.Range(1, ClientDeleteMarkerPacket.MaxBatchSize).ToArray()); + yield return new ServerDeleteMarkerPacket(5, 7, "Alice", false, new ClientDeleteMarkerPacket(new[] { 42 })); + // senderIsHost = true: host-bypass branch (placer-agnostic delete). + yield return new ServerDeleteMarkerPacket(8, 11, "Bob", true, new ClientDeleteMarkerPacket(new[] { 10, 20, 30 })); + // factionId == -1 is the spectator/none sentinel. + yield return new ServerDeleteMarkerPacket(0, -1, "", false, new ClientDeleteMarkerPacket(new[] { 1 })); + + yield return new ClientRenameMarkerPacket(1, ""); + yield return new ClientRenameMarkerPacket(int.MaxValue, "renamed marker"); + yield return new ClientRenameMarkerPacket(42, new string('x', PingCategoryWire.MaxLabelChars)); + yield return new ServerRenameMarkerPacket(3, 7, "Alice", false, new ClientRenameMarkerPacket(99, "battle spot")); + // Mirrors the delete bypass branch. + yield return new ServerRenameMarkerPacket(0, -1, "", true, new ClientRenameMarkerPacket(1, "")); yield return ServerPlayerListPacket.List([ new ServerPlayerListPacket.PlayerInfo diff --git a/Source/Tests/packet-serializations/ClientClearMarkersPacket.verified.txt b/Source/Tests/packet-serializations/ClientClearMarkersPacket.verified.txt new file mode 100644 index 000000000..6c52c7feb --- /dev/null +++ b/Source/Tests/packet-serializations/ClientClearMarkersPacket.verified.txt @@ -0,0 +1,3 @@ +00-FF-FF-FF-FF-00-00-00-00 +01-2A-00-00-00-00-00-00-00 +02-FF-FF-FF-FF-07-00-00-00-43-68-61-72-6C-69-65 diff --git a/Source/Tests/packet-serializations/ClientDeleteMarkerPacket.verified.txt b/Source/Tests/packet-serializations/ClientDeleteMarkerPacket.verified.txt new file mode 100644 index 000000000..cb147f815 --- /dev/null +++ b/Source/Tests/packet-serializations/ClientDeleteMarkerPacket.verified.txt @@ -0,0 +1,5 @@ +01-00-00-00-00-00-00-00 +01-00-00-00-01-00-00-00 +01-00-00-00-FF-FF-FF-7F +05-00-00-00-01-00-00-00-02-00-00-00-03-00-00-00-04-00-00-00-05-00-00-00 +00-01-00-00-01-00-00-00-02-00-00-00-03-00-00-00-04-00-00-00-05-00-00-00-06-00-00-00-07-00-00-00-08-00-00-00-09-00-00-00-0A-00-00-00-0B-00-00-00-0C-00-00-00-0D-00-00-00-0E-00-00-00-0F-00-00-00-10-00-00-00-11-00-00-00-12-00-00-00-13-00-00-00-14-00-00-00-15-00-00-00-16-00-00-00-17-00-00-00-18-00-00-00-19-00-00-00-1A-00-00-00-1B-00-00-00-1C-00-00-00-1D-00-00-00-1E-00-00-00-1F-00-00-00-20-00-00-00-21-00-00-00-22-00-00-00-23-00-00-00-24-00-00-00-25-00-00-00-26-00-00-00-27-00-00-00-28-00-00-00-29-00-00-00-2A-00-00-00-2B-00-00-00-2C-00-00-00-2D-00-00-00-2E-00-00-00-2F-00-00-00-30-00-00-00-31-00-00-00-32-00-00-00-33-00-00-00-34-00-00-00-35-00-00-00-36-00-00-00-37-00-00-00-38-00-00-00-39-00-00-00-3A-00-00-00-3B-00-00-00-3C-00-00-00-3D-00-00-00-3E-00-00-00-3F-00-00-00-40-00-00-00-41-00-00-00-42-00-00-00-43-00-00-00-44-00-00-00-45-00-00-00-46-00-00-00-47-00-00-00-48-00-00-00-49-00-00-00-4A-00-00-00-4B-00-00-00-4C-00-00-00-4D-00-00-00-4E-00-00-00-4F-00-00-00-50-00-00-00-51-00-00-00-52-00-00-00-53-00-00-00-54-00-00-00-55-00-00-00-56-00-00-00-57-00-00-00-58-00-00-00-59-00-00-00-5A-00-00-00-5B-00-00-00-5C-00-00-00-5D-00-00-00-5E-00-00-00-5F-00-00-00-60-00-00-00-61-00-00-00-62-00-00-00-63-00-00-00-64-00-00-00-65-00-00-00-66-00-00-00-67-00-00-00-68-00-00-00-69-00-00-00-6A-00-00-00-6B-00-00-00-6C-00-00-00-6D-00-00-00-6E-00-00-00-6F-00-00-00-70-00-00-00-71-00-00-00-72-00-00-00-73-00-00-00-74-00-00-00-75-00-00-00-76-00-00-00-77-00-00-00-78-00-00-00-79-00-00-00-7A-00-00-00-7B-00-00-00-7C-00-00-00-7D-00-00-00-7E-00-00-00-7F-00-00-00-80-00-00-00-81-00-00-00-82-00-00-00-83-00-00-00-84-00-00-00-85-00-00-00-86-00-00-00-87-00-00-00-88-00-00-00-89-00-00-00-8A-00-00-00-8B-00-00-00-8C-00-00-00-8D-00-00-00-8E-00-00-00-8F-00-00-00-90-00-00-00-91-00-00-00-92-00-00-00-93-00-00-00-94-00-00-00-95-00-00-00-96-00-00-00-97-00-00-00-98-00-00-00-99-00-00-00-9A-00-00-00-9B-00-00-00-9C-00-00-00-9D-00-00-00-9E-00-00-00-9F-00-00-00-A0-00-00-00-A1-00-00-00-A2-00-00-00-A3-00-00-00-A4-00-00-00-A5-00-00-00-A6-00-00-00-A7-00-00-00-A8-00-00-00-A9-00-00-00-AA-00-00-00-AB-00-00-00-AC-00-00-00-AD-00-00-00-AE-00-00-00-AF-00-00-00-B0-00-00-00-B1-00-00-00-B2-00-00-00-B3-00-00-00-B4-00-00-00-B5-00-00-00-B6-00-00-00-B7-00-00-00-B8-00-00-00-B9-00-00-00-BA-00-00-00-BB-00-00-00-BC-00-00-00-BD-00-00-00-BE-00-00-00-BF-00-00-00-C0-00-00-00-C1-00-00-00-C2-00-00-00-C3-00-00-00-C4-00-00-00-C5-00-00-00-C6-00-00-00-C7-00-00-00-C8-00-00-00-C9-00-00-00-CA-00-00-00-CB-00-00-00-CC-00-00-00-CD-00-00-00-CE-00-00-00-CF-00-00-00-D0-00-00-00-D1-00-00-00-D2-00-00-00-D3-00-00-00-D4-00-00-00-D5-00-00-00-D6-00-00-00-D7-00-00-00-D8-00-00-00-D9-00-00-00-DA-00-00-00-DB-00-00-00-DC-00-00-00-DD-00-00-00-DE-00-00-00-DF-00-00-00-E0-00-00-00-E1-00-00-00-E2-00-00-00-E3-00-00-00-E4-00-00-00-E5-00-00-00-E6-00-00-00-E7-00-00-00-E8-00-00-00-E9-00-00-00-EA-00-00-00-EB-00-00-00-EC-00-00-00-ED-00-00-00-EE-00-00-00-EF-00-00-00-F0-00-00-00-F1-00-00-00-F2-00-00-00-F3-00-00-00-F4-00-00-00-F5-00-00-00-F6-00-00-00-F7-00-00-00-F8-00-00-00-F9-00-00-00-FA-00-00-00-FB-00-00-00-FC-00-00-00-FD-00-00-00-FE-00-00-00-FF-00-00-00-00-01-00-00 (1028 bytes) diff --git a/Source/Tests/packet-serializations/ClientPingLocPacket.verified.txt b/Source/Tests/packet-serializations/ClientPingLocPacket.verified.txt index c18ffba9d..2e7c4deb1 100644 --- a/Source/Tests/packet-serializations/ClientPingLocPacket.verified.txt +++ b/Source/Tests/packet-serializations/ClientPingLocPacket.verified.txt @@ -1,2 +1,3 @@ -00-00-00-00-00-00-00-00-00-00-00-00-00-00-00-00-00-00-00-00-00-00-00-00 -01-00-00-00-2A-00-00-00-03-00-00-00-00-00-28-41-00-00-10-C0-CD-CC-C7-42 +00-00-00-00-00-00-00-00-00-00-00-00-00-00-00-00-00-00-00-00-00-00-00-00-00-00-00-00-00-00-00-00-00-00 (34 bytes) +01-00-00-00-2A-00-00-00-03-00-00-00-00-00-28-41-00-00-10-C0-CD-CC-C7-42-01-00-09-00-00-00-72-75-73-68-20-74-68-69-73-60-EA-00-00 (43 bytes) +09-00-00-00-07-00-00-00-00-00-00-00-00-00-C0-BF-00-00-00-00-00-00-20-40-02-01-10-00-00-00-68-6F-6C-64-20-74-68-69-73-20-63-6F-72-6E-65-72-40-E2-01-00 (50 bytes) diff --git a/Source/Tests/packet-serializations/ClientRenameMarkerPacket.verified.txt b/Source/Tests/packet-serializations/ClientRenameMarkerPacket.verified.txt new file mode 100644 index 000000000..97b1eee52 --- /dev/null +++ b/Source/Tests/packet-serializations/ClientRenameMarkerPacket.verified.txt @@ -0,0 +1,3 @@ +01-00-00-00-00-00-00-00 +FF-FF-FF-7F-0E-00-00-00-72-65-6E-61-6D-65-64-20-6D-61-72-6B-65-72 +2A-00-00-00-40-00-00-00-78-78-78-78-78-78-78-78-78-78-78-78-78-78-78-78-78-78-78-78-78-78-78-78-78-78-78-78-78-78-78-78-78-78-78-78-78-78-78-78-78-78-78-78-78-78-78-78-78-78-78-78-78-78-78-78-78-78-78-78-78-78-78-78 (72 bytes) diff --git a/Source/Tests/packet-serializations/ServerClearMarkersPacket.verified.txt b/Source/Tests/packet-serializations/ServerClearMarkersPacket.verified.txt new file mode 100644 index 000000000..f06304f4a --- /dev/null +++ b/Source/Tests/packet-serializations/ServerClearMarkersPacket.verified.txt @@ -0,0 +1,4 @@ +03-00-00-00-05-00-00-00-41-6C-69-63-65-00-00-FF-FF-FF-FF-00-00-00-00 +08-00-00-00-03-00-00-00-42-6F-62-00-01-63-00-00-00-00-00-00-00 +02-00-00-00-05-00-00-00-41-6C-69-63-65-01-02-FF-FF-FF-FF-07-00-00-00-43-68-61-72-6C-69-65 +00-00-00-00-00-00-00-00-00-00-FF-FF-FF-FF-00-00-00-00 diff --git a/Source/Tests/packet-serializations/ServerDeleteMarkerPacket.verified.txt b/Source/Tests/packet-serializations/ServerDeleteMarkerPacket.verified.txt new file mode 100644 index 000000000..794ffda21 --- /dev/null +++ b/Source/Tests/packet-serializations/ServerDeleteMarkerPacket.verified.txt @@ -0,0 +1,3 @@ +05-00-00-00-07-00-00-00-05-00-00-00-41-6C-69-63-65-00-01-00-00-00-2A-00-00-00 +08-00-00-00-0B-00-00-00-03-00-00-00-42-6F-62-01-03-00-00-00-0A-00-00-00-14-00-00-00-1E-00-00-00 +00-00-00-00-FF-FF-FF-FF-00-00-00-00-00-01-00-00-00-01-00-00-00 diff --git a/Source/Tests/packet-serializations/ServerPingLocPacket.verified.txt b/Source/Tests/packet-serializations/ServerPingLocPacket.verified.txt index 58c43ef31..13c5d0e7a 100644 --- a/Source/Tests/packet-serializations/ServerPingLocPacket.verified.txt +++ b/Source/Tests/packet-serializations/ServerPingLocPacket.verified.txt @@ -1 +1,3 @@ -07-00-00-00-05-00-00-00-7B-00-00-00-01-00-00-00-A4-70-9D-3F-85-EB-91-40-E1-7A-FC-40 +07-00-00-00-0A-00-00-00-05-00-00-00-41-6C-69-63-65-FF-00-00-05-00-00-00-7B-00-00-00-01-00-00-00-A4-70-9D-3F-85-EB-91-40-E1-7A-FC-40-05-00-0C-00-00-00-69-72-6F-6E-20-64-65-70-6F-73-69-74-90-D0-03-00 (66 bytes) +0B-00-00-00-FF-FF-FF-FF-03-00-00-00-42-6F-62-00-C8-64-02-00-00-00-00-00-00-00-00-00-00-00-00-00-4A-42-00-00-80-3F-00-00-A0-42-04-01-0E-00-00-00-73-74-6F-63-6B-70-69-6C-65-20-68-65-72-65-40-42-0F-00 (66 bytes) +03-00-00-00-FF-FF-FF-FF-00-00-00-00-80-80-80-00-00-00-00-00-00-00-00-00-00-00-00-00-00-00-00-00-00-00-00-00-00-00-00-00-00-00-00-00-00-00-00-00-00 (49 bytes) diff --git a/Source/Tests/packet-serializations/ServerRenameMarkerPacket.verified.txt b/Source/Tests/packet-serializations/ServerRenameMarkerPacket.verified.txt new file mode 100644 index 000000000..0851489f8 --- /dev/null +++ b/Source/Tests/packet-serializations/ServerRenameMarkerPacket.verified.txt @@ -0,0 +1,2 @@ +03-00-00-00-07-00-00-00-05-00-00-00-41-6C-69-63-65-00-63-00-00-00-0B-00-00-00-62-61-74-74-6C-65-20-73-70-6F-74 (37 bytes) +00-00-00-00-FF-FF-FF-FF-00-00-00-00-01-01-00-00-00-00-00-00-00