From 7f3c427346426fca61bf9ad06c62d193b0ad3cbb Mon Sep 17 00:00:00 2001 From: LumiLunaLuma <285561842+LumiLunaLuma@users.noreply.github.com> Date: Tue, 19 May 2026 21:11:29 +0200 Subject: [PATCH 1/2] Ping categories, persistent markers, selection UI, and per-player/faction visibility filters Adds typed pings and persistent on-map markers. Players hold to open a radial wheel, pick a category, and release to send a ping or place a marker. Six categories: Default, Attack, Defend, Help, Loot, Rally. Markers can be renamed or deleted by their owner or by the host. Each client can adjust marker opacity locally. A per-client filter hides markers by faction or by player, with a master toggle for spectator markers. The host has a settings dialog with a per-player marker cap and clear-all controls. Wire protocol bumps from 55 to 63. PingLocationPacket now carries a PingCategory byte and a UTF-8 label of up to 64 chars. New paired client/server packets: ClearMarkers, DeleteMarker, RenameMarker. Markers are scribed on MultiplayerGameComp, so they're included in every save and in replay sections. ConvertToSp drops MP-only state (including markers) on the way out; the pre-conversion replay preserves it for re-hosting. Per-client preferences (wheel toggle, hold delay, place mode, last-used category memory, window rects, hidden factions and players, spectator-marker toggle, and per-marker opacity and visibility overrides) live in MpSettings under an mp* XML prefix. New keybind MpTogglePingMenu with no default key, to avoid colliding with mod-added bindings. --- Defs/KeyBindings.xml | 6 + .../Client/Comp/Game/MultiplayerGameComp.cs | 89 ++- Source/Client/Debug/DebugActions.cs | 3 +- Source/Client/Desyncs/SaveableDesyncInfo.cs | 37 +- Source/Client/Desyncs/SyncCoordinator.cs | 50 +- Source/Client/EarlyInit.cs | 4 + Source/Client/Multiplayer.cs | 20 + Source/Client/MultiplayerGame.cs | 10 + Source/Client/MultiplayerStatic.cs | 316 +++++++++ Source/Client/Networking/HostUtil.cs | 2 + .../Networking/State/ClientPlayingState.cs | 9 + Source/Client/Patches/Pings.cs | 406 ++++++++++++ Source/Client/Saving/ConvertToSp.cs | 8 +- Source/Client/Saving/Replay.cs | 13 +- Source/Client/Settings/MpSettings.cs | 43 +- Source/Client/Settings/MpSettingsUI.cs | 26 + Source/Client/UI/AlertPing.cs | 124 ++-- Source/Client/UI/DrawPingMap.cs | 31 +- Source/Client/UI/DrawPingPlanet.cs | 155 ++++- Source/Client/UI/IngameUI.cs | 26 +- Source/Client/UI/LocationPings.Receive.cs | 284 ++++++++ Source/Client/UI/LocationPings.Wheel.cs | 383 +++++++++++ Source/Client/UI/LocationPings.cs | 498 ++++++++++++-- Source/Client/UI/MarkerInspectTab.cs | 183 ++++++ Source/Client/UI/PingCategoryExtensions.cs | 95 +++ Source/Client/UI/PingInfo.cs | 298 ++++++++- Source/Client/UI/PingSelectionUI.cs | 616 ++++++++++++++++++ Source/Client/Util/MpTranslate.cs | 17 + Source/Client/Util/MpUI.cs | 25 + Source/Client/Util/PingRuntimeTranslations.cs | 31 + ...otstrapConfiguratorWindow.BootstrapFlow.cs | 4 +- Source/Client/Windows/MarkerAlphaWindow.cs | 158 +++++ Source/Client/Windows/PingFiltersDialog.cs | 422 ++++++++++++ .../Client/Windows/PingHostSettingsDialog.cs | 153 +++++ Source/Client/Windows/PingInspectPane.cs | 252 +++++++ Source/Client/Windows/PingLabelWindow.cs | 101 +++ Source/Client/Windows/PingMenuWindow.cs | 553 ++++++++++++++++ Source/Client/Windows/SaveFileReader.cs | 3 + Source/Common/MultiplayerServer.cs | 91 +++ .../Networking/Packet/ClearMarkersPacket.cs | 57 ++ .../Networking/Packet/DeleteMarkerPacket.cs | 38 ++ .../Common/Networking/Packet/PingCategory.cs | 37 ++ .../Networking/Packet/PingLocationPacket.cs | 35 +- .../Networking/Packet/RenameMarkerPacket.cs | 34 + Source/Common/Networking/Packets.cs | 6 + .../Networking/State/ServerLoadingState.cs | 4 + .../Networking/State/ServerPlayingState.cs | 473 ++++++++------ Source/Common/PlayerManager.cs | 1 + Source/Common/ReplayInfo.cs | 7 +- Source/Common/ServerPlayer.cs | 4 + Source/Common/ServerSettings.cs | 7 + Source/Common/Version.cs | 4 +- Source/Tests/PacketTest.cs | 51 +- .../ClientClearMarkersPacket.verified.txt | 3 + .../ClientDeleteMarkerPacket.verified.txt | 5 + .../ClientPingLocPacket.verified.txt | 5 +- .../ClientRenameMarkerPacket.verified.txt | 3 + .../ServerClearMarkersPacket.verified.txt | 4 + .../ServerDeleteMarkerPacket.verified.txt | 3 + .../ServerPingLocPacket.verified.txt | 4 +- .../ServerRenameMarkerPacket.verified.txt | 2 + 61 files changed, 5939 insertions(+), 393 deletions(-) create mode 100644 Source/Client/Patches/Pings.cs create mode 100644 Source/Client/UI/LocationPings.Receive.cs create mode 100644 Source/Client/UI/LocationPings.Wheel.cs create mode 100644 Source/Client/UI/MarkerInspectTab.cs create mode 100644 Source/Client/UI/PingCategoryExtensions.cs create mode 100644 Source/Client/UI/PingSelectionUI.cs create mode 100644 Source/Client/Util/MpTranslate.cs create mode 100644 Source/Client/Util/PingRuntimeTranslations.cs create mode 100644 Source/Client/Windows/MarkerAlphaWindow.cs create mode 100644 Source/Client/Windows/PingFiltersDialog.cs create mode 100644 Source/Client/Windows/PingHostSettingsDialog.cs create mode 100644 Source/Client/Windows/PingInspectPane.cs create mode 100644 Source/Client/Windows/PingLabelWindow.cs create mode 100644 Source/Client/Windows/PingMenuWindow.cs create mode 100644 Source/Common/Networking/Packet/ClearMarkersPacket.cs create mode 100644 Source/Common/Networking/Packet/DeleteMarkerPacket.cs create mode 100644 Source/Common/Networking/Packet/PingCategory.cs create mode 100644 Source/Common/Networking/Packet/RenameMarkerPacket.cs create mode 100644 Source/Tests/packet-serializations/ClientClearMarkersPacket.verified.txt create mode 100644 Source/Tests/packet-serializations/ClientDeleteMarkerPacket.verified.txt create mode 100644 Source/Tests/packet-serializations/ClientRenameMarkerPacket.verified.txt create mode 100644 Source/Tests/packet-serializations/ServerClearMarkersPacket.verified.txt create mode 100644 Source/Tests/packet-serializations/ServerDeleteMarkerPacket.verified.txt create mode 100644 Source/Tests/packet-serializations/ServerRenameMarkerPacket.verified.txt 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 From f0de06b851ff0c0e026bd11e4a0cff7531ff6fb8 Mon Sep 17 00:00:00 2001 From: LumiLunaLuma <285561842+LumiLunaLuma@users.noreply.github.com> Date: Wed, 20 May 2026 21:09:12 +0200 Subject: [PATCH 2/2] XML ping categories, paged radial wheel and review feedback cleanup Moved ping categories into XML and added pagination to the radial wheel. Categories now live in Defs/MultiplayerPingDefs.xml as MultiplayerPingDef defs with label, description, order, tint, icon, and sound. The six vanilla categories reacreated in XML format. The wheel pages when more than six categories are present. Back and More chevron slices sit at the upper-left and upper-right of the wheel, with dots above the wheel for page indication. Wire protocol bumps from 55 to 56. PingLocationPacket now carries a ushort def short-hash instead of a byte enum. Markers scribe by defName, so save/load survives categories being added or removed. Namespaces in every new file this PR introduced are reverted to block-scoped. Drops the in-code English-fallback helper and the runtime keyed-replacement injection. The desync-snapshot diagnostic in SyncCoordinator removed. --- Defs/MultiplayerPingDefs.xml | 83 ++ Source/Client/Debug/DebugActions.cs | 3 +- Source/Client/Desyncs/SaveableDesyncInfo.cs | 19 +- Source/Client/Desyncs/SyncCoordinator.cs | 50 +- Source/Client/EarlyInit.cs | 4 - Source/Client/MultiplayerStatic.cs | 66 +- Source/Client/Settings/MpSettingsUI.cs | 12 +- Source/Client/UI/AlertPing.cs | 137 +-- Source/Client/UI/DrawPingPlanet.cs | 3 +- Source/Client/UI/LocationPings.Receive.cs | 449 ++++---- Source/Client/UI/LocationPings.Wheel.cs | 916 ++++++++++------ Source/Client/UI/LocationPings.cs | 55 +- Source/Client/UI/MarkerInspectTab.cs | 292 +++--- Source/Client/UI/MultiplayerPingDef.cs | 147 +++ Source/Client/UI/PingCategoryExtensions.cs | 108 +- Source/Client/UI/PingInfo.cs | 59 +- Source/Client/UI/PingSelectionUI.cs | 986 +++++++++--------- Source/Client/Util/MpTranslate.cs | 17 - Source/Client/Util/PingRuntimeTranslations.cs | 31 - Source/Client/Windows/MarkerAlphaWindow.cs | 256 +++-- Source/Client/Windows/PingFiltersDialog.cs | 666 ++++++------ .../Client/Windows/PingHostSettingsDialog.cs | 239 +++-- Source/Client/Windows/PingInspectPane.cs | 432 ++++---- Source/Client/Windows/PingLabelWindow.cs | 151 ++- Source/Client/Windows/PingMenuWindow.cs | 890 ++++++++-------- .../Networking/Packet/ClearMarkersPacket.cs | 93 +- .../Networking/Packet/DeleteMarkerPacket.cs | 57 +- .../Common/Networking/Packet/PingCategory.cs | 37 - .../Networking/Packet/PingCategoryWire.cs | 34 + .../Networking/Packet/PingLocationPacket.cs | 113 +- .../Networking/Packet/RenameMarkerPacket.cs | 55 +- .../Networking/State/ServerPlayingState.cs | 528 +++++----- Source/Common/Version.cs | 2 +- Source/Tests/PacketTest.cs | 15 +- .../ClientPingLocPacket.verified.txt | 6 +- .../ServerPingLocPacket.verified.txt | 6 +- 36 files changed, 3689 insertions(+), 3328 deletions(-) create mode 100644 Defs/MultiplayerPingDefs.xml create mode 100644 Source/Client/UI/MultiplayerPingDef.cs delete mode 100644 Source/Client/Util/MpTranslate.cs delete mode 100644 Source/Client/Util/PingRuntimeTranslations.cs delete mode 100644 Source/Common/Networking/Packet/PingCategory.cs create mode 100644 Source/Common/Networking/Packet/PingCategoryWire.cs diff --git a/Defs/MultiplayerPingDefs.xml b/Defs/MultiplayerPingDefs.xml new file mode 100644 index 000000000..1386057f5 --- /dev/null +++ b/Defs/MultiplayerPingDefs.xml @@ -0,0 +1,83 @@ + + + + + + + + MpPing_Default + + Plain attention ping. No category selected. + true + 0 + + + + MpPing_Attack + + Call an attack here. + 100 + (1, 0.25, 0.25) + UI/Commands/AttackMelee + 1.20 + A + Quest_Failed + + + + MpPing_Defend + + Hold this position. + 200 + (0.4, 0.6, 1) + UI/Designators/HomeAreaOn + 0.92 + D + DraftOn + + + + MpPing_Help + + Request assistance. + 300 + (1, 0.95, 0.3) + UI/Commands/AsMedical + 1.00 + + + TutorMessageAppear + + + + MpPing_Loot + + Items or resources to grab. + 400 + (0.4, 1, 0.4) + UI/Buttons/TradeMode + 1.06 + L + ExecuteTrade + + + + MpPing_Rally + + Meet up here. + 500 + (0.85, 0.55, 1) + UI/Commands/GatherSpotActive + 0.95 + R + Quest_Accepted + + + diff --git a/Source/Client/Debug/DebugActions.cs b/Source/Client/Debug/DebugActions.cs index b98e4f02a..46d05147b 100644 --- a/Source/Client/Debug/DebugActions.cs +++ b/Source/Client/Debug/DebugActions.cs @@ -231,8 +231,7 @@ 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.SnapshotFreshness(IsFresh: false, ElapsedMs: 0, SnapshotTick: -1, DesyncTick: -1, FallbackReason: "debug action - no live snapshot")) + new SaveableDesyncInfo(Multiplayer.game.sync, new ClientSyncOpinion(0), new ClientSyncOpinion(0), 0, true) )); } diff --git a/Source/Client/Desyncs/SaveableDesyncInfo.cs b/Source/Client/Desyncs/SaveableDesyncInfo.cs index 148d90111..646d1a075 100644 --- a/Source/Client/Desyncs/SaveableDesyncInfo.cs +++ b/Source/Client/Desyncs/SaveableDesyncInfo.cs @@ -22,13 +22,11 @@ public class SaveableDesyncInfo( ClientSyncOpinion local, ClientSyncOpinion remote, int diffAt, - bool diffAtFound, - SaveableDesyncInfo.SnapshotFreshness snapshotFreshness) + bool diffAtFound) { 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); @@ -117,11 +115,6 @@ private string GetDesyncDetails() 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}") @@ -142,13 +135,6 @@ private string GetDesyncDetails() .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}") @@ -222,7 +208,4 @@ 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 3f7943c73..0714065d5 100644 --- a/Source/Client/Desyncs/SyncCoordinator.cs +++ b/Source/Client/Desyncs/SyncCoordinator.cs @@ -1,6 +1,5 @@ using System; using System.Collections.Generic; -using System.Diagnostics; using System.Linq; using Multiplayer.Client.Desyncs; using Multiplayer.Client.Util; @@ -129,60 +128,13 @@ 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, snapshotInfo) + new SaveableDesyncInfo(this, local, remote, diffAt, found) )); } - // 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 af0795ea9..35d89bb7e 100644 --- a/Source/Client/EarlyInit.cs +++ b/Source/Client/EarlyInit.cs @@ -59,10 +59,6 @@ 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/MultiplayerStatic.cs b/Source/Client/MultiplayerStatic.cs index 92e08e1ab..789508b58 100644 --- a/Source/Client/MultiplayerStatic.cs +++ b/Source/Client/MultiplayerStatic.cs @@ -38,16 +38,16 @@ public static class MultiplayerStatic 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); + // Wheel sector textures are generated per slot count by LocationPings.Wheel.cs + // (see SectorTexCache / SectorArcCache there); only the chevrons live here. + // Up = drawer-toggle tab. Left / Right = wheel page-nav slots (higher native res because + // they render larger than the toggle tab). + public static readonly Texture2D PingChevronUp = MakeChevronTex(64, ChevronDir.Up); + public static readonly Texture2D PingChevronLeft = MakeChevronTex(96, ChevronDir.Left); + public static readonly Texture2D PingChevronRight = MakeChevronTex(96, ChevronDir.Right); - // 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); + // Category icons used to live here as readonly Texture2D fields; they now resolve lazily + // from MultiplayerPingDef.iconPath so mods can declare new categories in XML. // Gizmo action icons reuse vanilla UI/ atlases (visibility toggles, reset arrows). public static readonly Texture2D PingHideForMeIcon = ContentFinder.Get("UI/Designators/PlanHide"); @@ -143,26 +143,54 @@ internal static Texture2D MakeSectorTex(int size, float outerRadius, float inner // 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) + private enum ChevronDir { Up, Right, Down, Left } + + // Two-stroke chevron (^ / > / v / <) with antialiased edges. Apex sits 0.78 along the + // pointing axis, arm-ends at 0.22 along that axis and ±0.36 across it - same proportions + // for every orientation, so rotated chevrons stay visually consistent with the original + // up-pointing one. + private static Texture2D MakeChevronTex(int size, ChevronDir dir) { 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; + var apexOff = size * 0.78f; + var armAlong = size * 0.22f; + var armAcross = size * 0.36f; + + float apexX, apexY, arm1X, arm1Y, arm2X, arm2Y; + switch (dir) + { + case ChevronDir.Right: + apexX = apexOff; apexY = center; + arm1X = armAlong; arm1Y = center + armAcross; + arm2X = armAlong; arm2Y = center - armAcross; + break; + case ChevronDir.Down: + apexX = center; apexY = armAlong; + arm1X = center + armAcross; arm1Y = apexOff; + arm2X = center - armAcross; arm2Y = apexOff; + break; + case ChevronDir.Left: + apexX = armAlong; apexY = center; + arm1X = apexOff; arm1Y = center + armAcross; + arm2X = apexOff; arm2Y = center - armAcross; + break; + default: // Up + apexX = center; apexY = apexOff; + arm1X = center + armAcross; arm1Y = armAlong; + arm2X = center - armAcross; arm2Y = armAlong; + break; + } 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 d1 = DistToSegment(px, py, apexX, apexY, arm1X, arm1Y); + var d2 = DistToSegment(px, py, apexX, apexY, arm2X, arm2Y); + var d = Mathf.Min(d1, d2); var alpha = Mathf.Clamp01(strokeHalf - d + 0.5f); pixels[py * size + px] = new Color32(255, 255, 255, (byte)(alpha * 255f)); diff --git a/Source/Client/Settings/MpSettingsUI.cs b/Source/Client/Settings/MpSettingsUI.cs index a2e1fe88c..e10e2e84e 100644 --- a/Source/Client/Settings/MpSettingsUI.cs +++ b/Source/Client/Settings/MpSettingsUI.cs @@ -238,21 +238,19 @@ 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"); + => "MpEnablePingWheel".Translate(); 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."); + => "MpEnablePingWheelDesc".Translate(); private static string MpPingPlaceModeLabel() - => MpTranslate.Fallback("MpPingPlaceModeSetting", "Default ping place-mode"); + => "MpPingPlaceModeSetting".Translate(); private static string MpPingPlaceModeValue(PingPlaceMode mode) => mode == PingPlaceMode.Marker - ? MpTranslate.Fallback("MpPingMode_Marker", "Marker") - : MpTranslate.Fallback("MpPingMode_Ping", "Ping"); + ? "MpPingMode_Marker".Translate() + : "MpPingMode_Ping".Translate(); const string UsernameField = "UsernameField"; diff --git a/Source/Client/UI/AlertPing.cs b/Source/Client/UI/AlertPing.cs index c194e7796..ae6b1e5ba 100644 --- a/Source/Client/UI/AlertPing.cs +++ b/Source/Client/UI/AlertPing.cs @@ -5,92 +5,93 @@ using UnityEngine; using Verse; -namespace Multiplayer.Client; - -public class AlertPing : Alert +namespace Multiplayer.Client { - // A freshly placed marker counts as alert-worthy for the same window a ping stays visible. - private const float FreshMarkerWindow = PingInfo.PingDuration; - - public AlertPing() + public class AlertPing : Alert { - defaultPriority = AlertPriority.Critical; - } + // A freshly placed marker counts as alert-worthy for the same window a ping stays visible. + private const float FreshMarkerWindow = PingInfo.PingDuration; - public override Color BGColor - { - get + public AlertPing() { - float num = Pulser.PulseBrightness(0.5f, Pulser.PulseBrightness(0.5f, 0.6f)); - return new Color(num, num, num) * Color.red; + defaultPriority = AlertPriority.Critical; } - } - public override string GetLabel() - { - return "MpAlertPing".Translate(); - } + public override Color BGColor + { + get + { + float num = Pulser.PulseBrightness(0.5f, Pulser.PulseBrightness(0.5f, 0.6f)); + return new Color(num, num, num) * Color.red; + } + } - public override TaggedString GetExplanation() - { - if (Multiplayer.Client == null) - return ""; + public override string GetLabel() + { + return "MpAlertPing".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()}"; - } + public override TaggedString GetExplanation() + { + if (Multiplayer.Client == null) + return ""; - private List culpritList = new(); + // 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 Culprits - { - get - { - culpritList.Clear(); + private List culpritList = new(); - if (Multiplayer.Client != null && !Multiplayer.session.locationPings.alertHidden) + private List Culprits + { + get { - 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) + culpritList.Clear(); + + if (Multiplayer.Client != null && !Multiplayer.session.locationPings.alertHidden) { - if (!IsFreshMarker(marker)) continue; - if (!marker.IsVisible()) continue; - if (marker.Target.HasValue) - culpritList.Add(marker.Target.Value); + 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); + } } - } - return culpritList; + return culpritList; + } } - } - // 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; + // 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); - } + public override AlertReport GetReport() + { + return AlertReport.CulpritsAre(Culprits); + } - public override void OnClick() - { - if (Input.GetKey(KeyCode.LeftShift) || Input.GetKey(KeyCode.RightShift)) - Multiplayer.session.locationPings.alertHidden = true; - else - base.OnClick(); + public override void OnClick() + { + if (Input.GetKey(KeyCode.LeftShift) || Input.GetKey(KeyCode.RightShift)) + Multiplayer.session.locationPings.alertHidden = true; + else + base.OnClick(); + } } } diff --git a/Source/Client/UI/DrawPingPlanet.cs b/Source/Client/UI/DrawPingPlanet.cs index e948c77dd..e04bded1f 100644 --- a/Source/Client/UI/DrawPingPlanet.cs +++ b/Source/Client/UI/DrawPingPlanet.cs @@ -138,8 +138,7 @@ private static void DrawClusterOnPlanet(PlanetTile tile, List group) 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), + "MpPingCluster_Label".Translate(group.Count), new Color(1f, 1f, 1f, 1f), new Color(0f, 0f, 0f, 0.95f)); } diff --git a/Source/Client/UI/LocationPings.Receive.cs b/Source/Client/UI/LocationPings.Receive.cs index 718d809e2..19f890df1 100644 --- a/Source/Client/UI/LocationPings.Receive.cs +++ b/Source/Client/UI/LocationPings.Receive.cs @@ -8,276 +8,277 @@ using Verse; using Verse.Sound; -namespace Multiplayer.Client; - -public partial class LocationPings +namespace Multiplayer.Client { - // Re-validate every wire field - defends against crafted packets, keeps arbiter in lock-step. - public void ReceivePing(ServerPingLocPacket packet) + public partial class LocationPings { - 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) + // Re-validate every wire field - defends against crafted packets, keeps arbiter in lock-step. + public void ReceivePing(ServerPingLocPacket packet) { - if (data.planetTileLayer < 0) return; - var layers = Find.WorldGrid?.PlanetLayers; - if (layers == null || !layers.ContainsKey(data.planetTileLayer)) + 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 category = PingCategoryExtensions.ResolveFromWire(data.category); + 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, - }; + 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) + 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 ?? SoundDefOf.TinyBell).PlayOneShotOnCamera(); + } + + // Mutates scribed state - runs on every receiver INCLUDING the arbiter (no early return). + public void ReceiveDeleteMarker(ServerDeleteMarkerPacket packet) { - // 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; - } + var ids = packet.data.markerIds; + if (ids == null || ids.Length == 0) return; - // 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; - 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--) + foreach (var bucket in comp.markersByFaction.Values) { - var m = bucket[i]; - if (idSet.Contains(m.markerId) - && m.CanBeModifiedBy(packet.playerId, packet.username, packet.factionId, comp.multifaction, packet.senderIsHost)) + for (int i = bucket.Count - 1; i >= 0; i--) { - SelectionDrawer.selectTimes.Remove(m); - DropLocalAppearanceFor(m.markerId); - bucket.RemoveAt(i); - comp.markersVersion++; + 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); } - 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); - } + // 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; + 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); + 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++) + foreach (var bucket in comp.markersByFaction.Values) { - var m = bucket[i]; - if (m.markerId == markerId - && m.CanBeModifiedBy(packet.playerId, packet.username, packet.factionId, comp.multifaction, packet.senderIsHost)) + for (int i = 0; i < bucket.Count; i++) { - m.label = label; - comp.markersVersion++; - return; + 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; + 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; + 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; + // 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); } - 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() + // 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) { - 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 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; + } } - var loc = Multiplayer.session?.locationPings; - while (Count() >= cap) + // SortedDictionary enumeration order is the same on every client - deterministic fallback. + private static bool EvictOneOldest(MultiplayerGameComp comp, List preferred, Predicate match, LocationPings loc) { - if (!EvictOneOldest(comp, targetBucket, MatchesPlacer, loc)) break; + 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; } - } - // 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++) + private static bool TryEvictFrom(MultiplayerGameComp comp, List bucket, Predicate match, LocationPings loc) { - if (match(bucket[i])) + for (int i = 0; i < bucket.Count; i++) { - SelectionDrawer.selectTimes.Remove(bucket[i]); - loc?.selectedMarkerIds.Remove(bucket[i].markerId); - DropLocalAppearanceFor(bucket[i].markerId); - bucket.RemoveAt(i); - comp.markersVersion++; - return true; + 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; } - 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 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 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--) + private void RemoveMarkersWhere(MultiplayerGameComp comp, List markers, Predicate match) { - if (match(markers[i])) + for (int i = markers.Count - 1; i >= 0; i--) { - SelectionDrawer.selectTimes.Remove(markers[i]); - selectedMarkerIds.Remove(markers[i].markerId); - DropLocalAppearanceFor(markers[i].markerId); - markers.RemoveAt(i); - comp.markersVersion++; + 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 index b9fbc13ab..c0b374943 100644 --- a/Source/Client/UI/LocationPings.Wheel.cs +++ b/Source/Client/UI/LocationPings.Wheel.cs @@ -1,383 +1,651 @@ +using System.Collections.Generic; using Multiplayer.Client.Util; -using Multiplayer.Common.Networking.Packet; using UnityEngine; using Verse; -namespace Multiplayer.Client; - -public partial class LocationPings +namespace Multiplayer.Client { - 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) + public partial class LocationPings { - 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 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; + + // Visible-slice cap. Anything beyond this gets paged through Back / More nav slices at the + // upper-left and upper-right of the wheel. See BuildPage for the layout rules. + public const int WheelMaxSlots = 6; + + // What a single wheel slot is showing - category Defs share the array with the Prev/Next + // nav buttons so hit-testing and rendering can iterate uniformly. + internal enum SlotKind { Empty, Category, PrevPage, NextPage } + internal readonly struct WheelSlot + { + public readonly SlotKind kind; + public readonly MultiplayerPingDef def; + public WheelSlot(SlotKind k) { kind = k; def = null; } + public WheelSlot(MultiplayerPingDef d) { kind = SlotKind.Category; def = d; } + public static readonly WheelSlot Empty = new(SlotKind.Empty); + public static readonly WheelSlot Prev = new(SlotKind.PrevPage); + public static readonly WheelSlot Next = new(SlotKind.NextPage); + } - private PingCategory ComputeHoveredCategory() => - ComputeHoveredCategoryAt(wheelScreenOrigin, UI.MousePositionOnUIInverted); + // Pre-rotated sector textures, keyed by slot count. Generated lazily for 2..6 slots so the + // static init cost is bounded; the procedural ramp gives a 1° seam (the 35.5° half-angle). + private static readonly Dictionary SectorTexCache = new(); + private static readonly Dictionary SectorArcCache = new(); - 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 Texture2D[] GetSectors(int slots) + { + if (SectorTexCache.TryGetValue(slots, out var arr)) return arr; + return SectorTexCache[slots] = MakeSectorSet(slots, outerRadius: 127f, innerRadius: 44f); + } - 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); - } + private static Texture2D[] GetSectorArcs(int slots) + { + if (SectorArcCache.TryGetValue(slots, out var arr)) return arr; + return SectorArcCache[slots] = MakeSectorSet(slots, outerRadius: 127f, innerRadius: 119f); + } - public void DrawWheelOverlay() - { - if (MenuWindowOpen) return; - if (!wheelActive) return; + private static Texture2D[] MakeSectorSet(int slots, float outerRadius, float innerRadius) + { + // Half-angle is one slice minus the 1° seam, so two adjacent slices leave a hairline of + // backdrop visible between them no matter how many slots the wheel has. + var halfAngle = 360f / slots / 2f - 0.5f; + var texs = new Texture2D[slots]; + for (int i = 0; i < slots; i++) + texs[i] = MultiplayerStatic.MakeSectorTex(256, outerRadius, innerRadius, + halfAngleDeg: halfAngle, centerAngleDeg: i * (360f / slots)); + return texs; + } - DrawWheelCore(wheelScreenOrigin, mousePos: UI.MousePositionOnUIInverted, inDrawer: false); - } + // Slot positions in clock-face terms: 0=top, 1=upper-right, 2=lower-right, 3=bottom, + // 4=lower-left, 5=upper-left. Nav lives at slots 5 (Back, '<' chevron, upper-left) and 1 + // (More, '>' chevron, upper-right) so spatial position matches chevron direction matches + // semantic meaning. + private const int BackSlot = 5; + private const int MoreSlot = 1; + + // Builds the slot layout for the given page. Returned list is always exactly slotCount + // entries; empty slots are rendered as dim slices with no label. slotCount can be 1..6. + // + // Layout rules (page index = wheelPage): + // total <= WheelMaxSlots -> single page, slotCount = total, no nav, cats fill sequentially + // total > WheelMaxSlots: + // page 0: [cat, More, cat, cat, cat, cat] (5 cats + More at upper-right) + // middle: [cat, More, cat, cat, cat, Back] (4 cats + both nav at the upper sides) + // last page: [cat, cat, cat, cat, cat, Back] (up to 5 cats + Back at upper-left) + internal static List BuildPage(List cats, int page, out int slotCount, out int totalPages) + { + var list = new List(WheelMaxSlots); + var total = cats.Count; - public void DrawWheelInDrawer(Vector2 center, Vector2 mousePos) - { - DrawWheelCore(center, mousePos, inDrawer: true); - } + if (total == 0) + { + slotCount = 1; + totalPages = 1; + list.Add(WheelSlot.Empty); + return list; + } - // 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; + if (total <= WheelMaxSlots) + { + for (int i = 0; i < total; i++) + list.Add(new WheelSlot(cats[i])); + slotCount = total; + totalPages = 1; + return list; + } - // Chevron is cursor-mode only; drawer mode uses the deadzone to disarm. - if (!inDrawer && ComputeChevronTabRect(center).Contains(mousePos)) - { - ToggleDrawer(); - ev.Use(); - return true; - } + // Multi-page layout. CountPages / FirstCatIndexForPage produce the same per-page cat + // counts as the prior bookend-nav layout (5 / 4 / ... / up-to-5) - only the slot + // positions changed, not the capacities, so the page math is reused as-is. + totalPages = CountPages(total); + slotCount = WheelMaxSlots; + var clamped = Mathf.Clamp(page, 0, totalPages - 1); + + var isFirst = clamped == 0; + var startIdx = FirstCatIndexForPage(clamped); + var remaining = total - startIdx; + // First page is never last in multi-page mode (total > WheelMaxSlots guarantees a + // spillover). After the first page, "last" is whichever page can hold the remaining + // cats without needing a More slot - i.e. remaining fits in the 5 cat-slots of a no-More + // page. + var isLast = !isFirst && remaining <= WheelMaxSlots - 1; + + for (int s = 0; s < WheelMaxSlots; s++) + list.Add(WheelSlot.Empty); + + if (!isFirst) list[BackSlot] = WheelSlot.Prev; + if (!isLast) list[MoreSlot] = WheelSlot.Next; + + // Fill non-nav slots in clock order. Skipping the nav positions interleaves the cat + // sequence (e.g. on page 0 cats land at slots 0, 2, 3, 4, 5 - the More slot at 1 is + // jumped over). Users locate cats by icon/colour, not position, so the discontinuity + // is acceptable in exchange for stable nav positions. + var ci = startIdx; + for (int s = 0; s < WheelMaxSlots && ci < total; s++) + { + if (s == MoreSlot && !isLast) continue; + if (s == BackSlot && !isFirst) continue; + list[s] = new WheelSlot(cats[ci++]); + } - 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; + return list; + } - if (distSq < innerR2) + // Counts pages by walking through the per-page capacities documented in BuildPage. Faster + // than a closed-form expression and keeps the two paths trivially in sync. + private static int CountPages(int total) { - if (!inDrawer) - CancelWheel(); - else if (armedCategory != null) - DisarmPlacement(); - ev.Use(); - return true; + if (total <= WheelMaxSlots) return 1; + var pages = 0; + var consumed = 0; + while (consumed < total) + { + var isFirst = pages == 0; + var firstSlot = isFirst ? 0 : 1; + var lastPageCapacity = WheelMaxSlots - firstSlot; + var remaining = total - consumed; + var willBeLast = remaining <= lastPageCapacity; + var endSlot = willBeLast ? WheelMaxSlots : WheelMaxSlots - 1; + consumed += endSlot - firstSlot; + pages++; + } + return pages; } - var clicked = ComputeHoveredCategoryAt(center, mousePos); - - if (!inDrawer) + private static int FirstCatIndexForPage(int page) { - // 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; + // Page p > 0 starts after page 0's 5 cats + (p-1) middle pages × 4 cats each. + if (page <= 0) return 0; + return 5 + (page - 1) * 4; } - // 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) + // Per-frame view: pulls categories from DefDatabase (excluding the Default) and builds the + // active page. Cached only within the call - the def list is tiny so the rebuild is cheap. + private List BuildCurrentPage(out int slotCount, out int totalPages) { - hoveredCategory = ComputeHoveredCategoryAt(center, mousePos); + var cats = MultiplayerPingDef.Sorted(includeDefault: false); + return BuildPage(cats, wheelPage, out slotCount, out totalPages); } - if (TryHandleWheelMouseDown(center, mousePos, inDrawer)) return; + // Hover -> def. For nav slots returns null (the click handler still consumes them). + // For Empty / deadzone returns null too. The DrawWheelCore renderer treats null as "no + // category will fire on release". + private MultiplayerPingDef ComputeHoveredCategory() + { + var slots = BuildCurrentPage(out var slotCount, out _); + var idx = HoveredSlotIndex(wheelScreenOrigin, UI.MousePositionOnUIInverted, slotCount); + if (idx < 0) return null; + var slot = slots[idx]; + return slot.kind == SlotKind.Category ? slot.def : null; + } - if (ev.type != EventType.Repaint) return; + // Pure hit-test: returns the slot index under the cursor for a wheel of `slotCount` + // slices, or -1 if the cursor sits in the centre deadzone. Doesn't touch DefDatabase so + // DrawWheelCore can call it without rebuilding the page each time. + private static int HoveredSlotIndex(Vector2 center, Vector2 mouse, int slotCount) + { + var dx = mouse.x - center.x; + var dy = mouse.y - center.y; + var distSq = dx * dx + dy * dy; + if (distSq < WheelInnerDeadzone * WheelInnerDeadzone) return -1; + // 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 / slotCount; + return Mathf.FloorToInt((angle + sectorSize / 2f) / sectorSize) % slotCount; + } - var drawerOpen = inDrawer; + 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); + } - var sectorSize = 360f / WheelOptions.Length; - var inDeadzone = hoveredCategory == PingCategory.Default; + public void DrawWheelOverlay() + { + if (MenuWindowOpen) return; + if (!wheelActive) return; - // 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); + DrawWheelCore(wheelScreenOrigin, mousePos: UI.MousePositionOnUIInverted, inDrawer: false); + } - 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); + public void DrawWheelInDrawer(Vector2 center, Vector2 mousePos) + { + DrawWheelCore(center, mousePos, inDrawer: true); + } - // Armed slice keeps its tint so the cursor cue persists when off-wheel. - for (var i = 0; i < WheelOptions.Length; i++) + // Cursor mode lets Default-ring click fall through; drawer mode consumes it. Page-nav slots + // never fire a ping in either mode - they swap wheelPage and re-render. + private bool TryHandleWheelMouseDown(Vector2 center, Vector2 mousePos, bool inDrawer) { - var hovered = WheelOptions[i] == hoveredCategory; - var armed = drawerOpen && armedCategory == WheelOptions[i]; + var ev = Event.current; + if (ev.type != EventType.MouseDown || ev.button != 0) return false; - Color fill, arc; - if (armed) + // Chevron is cursor-mode only; drawer mode uses the deadzone to disarm. + if (!inDrawer && ComputeChevronTabRect(center).Contains(mousePos)) { - 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); + ToggleDrawer(); + ev.Use(); + return true; } - else + + 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) { - 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); + if (!inDrawer) + CancelWheel(); + else if (armedCategory != null) + DisarmPlacement(); + ev.Use(); + return true; } - using (MpStyle.Set(fill)) - GUI.DrawTexture(sectorRect, PingSectors[i]); - using (MpStyle.Set(arc)) - GUI.DrawTexture(sectorRect, PingSectorArcs[i]); + var slots = BuildCurrentPage(out var slotCount, out var totalPages); + + // 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 / slotCount; + var idx = Mathf.FloorToInt((angle + sectorSize / 2f) / sectorSize) % slotCount; + var slot = slots[idx]; + + switch (slot.kind) + { + case SlotKind.PrevPage: + wheelPage = Mathf.Max(0, wheelPage - 1); + ev.Use(); + return true; + case SlotKind.NextPage: + wheelPage = Mathf.Min(totalPages - 1, wheelPage + 1); + ev.Use(); + return true; + case SlotKind.Empty: + // Click on an empty slot in the ring still consumes the event in drawer mode so + // it doesn't leak through to the window drag, but does nothing else. + if (inDrawer) ev.Use(); + return inDrawer; + case SlotKind.Category: + var clicked = slot.def; + if (!inDrawer) + { + var asMarker = Multiplayer.settings.pingPlaceMode == PingPlaceMode.Marker; + FirePing(wheelTargetMapId, wheelTargetTile, wheelTargetMapLoc, clicked, asMarker); + CancelWheel(); + ev.Use(); + return true; + } + ArmPlacement(clicked); + ev.Use(); + return true; + } + return false; } - for (var i = 0; i < WheelOptions.Length; i++) + private void DrawWheelCore(Vector2 center, Vector2 mousePos, bool inDrawer) { - 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 ev = Event.current; + + // Build the page slots once per invocation; the hover-compute, deadzone test, and the + // repaint render below all share this result. Previously each of those rebuilt the page + // independently, which meant 2-3 sorted-list allocations per Repaint while the wheel + // was open. + var slots = BuildCurrentPage(out var slotCount, out var totalPages); + var hoveredSlotIdx = HoveredSlotIndex(center, mousePos, slotCount); + var hoveredDef = hoveredSlotIdx >= 0 && slots[hoveredSlotIdx].kind == SlotKind.Category + ? slots[hoveredSlotIdx].def + : null; + + // Cursor mode keeps `hoveredCategory` updated from HandleWheelEligibleInput (key-release + // there reads this field). Drawer mode has no such pump, so we update it here. + if (inDrawer) + hoveredCategory = hoveredDef; + + if (TryHandleWheelMouseDown(center, mousePos, inDrawer)) return; + + if (ev.type != EventType.Repaint) return; + + var drawerOpen = inDrawer; + + var sectorSize = 360f / slotCount; + var inDeadzone = hoveredDef == null && !PointerIsOverNavSlot(slots, slotCount, center, mousePos); + + // 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); + + var sectors = GetSectors(slotCount); + var arcs = GetSectorArcs(slotCount); + + // Armed slice keeps its tint so the cursor cue persists when off-wheel. + for (var i = 0; i < slotCount; i++) { - 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); + var slot = slots[i]; + var hovered = i == hoveredSlotIdx && slot.kind != SlotKind.Empty; + var armed = drawerOpen && slot.kind == SlotKind.Category && armedCategory == slot.def; + var navHot = (slot.kind == SlotKind.PrevPage || slot.kind == SlotKind.NextPage) && hovered; + + Color fill, arc; + if (armed) + { + var t = slot.def.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 if (slot.kind == SlotKind.Empty) + { + // Empty slots stay flat so they read as "nothing here" rather than a clickable + // category - dimmer than the unhovered cat fill. + fill = new Color(0.10f, 0.10f, 0.11f, 1f); + arc = new Color(0.06f, 0.06f, 0.07f, 1f); + } + else if (slot.kind == SlotKind.PrevPage || slot.kind == SlotKind.NextPage) + { + // Nav slots render in neutral steel grey - colour belongs to categories so nav + // reads as wheel chrome rather than a 14th category. The chevron texture carries + // the direction. + fill = navHot + ? new Color(0.28f, 0.28f, 0.32f, 1f) + : new Color(0.13f, 0.13f, 0.15f, 1f); + arc = new Color(0.05f, 0.05f, 0.06f, 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, sectors[i]); + using (MpStyle.Set(arc)) + GUI.DrawTexture(sectorRect, arcs[i]); } - else + + for (var i = 0; i < slotCount; i++) { - using (MpStyle.Set(GameFont.Medium).Set(TextAnchor.MiddleCenter)) - MpUI.LabelOutlined(new Rect(iconCenterX - 20f, iconCenterY - 14f, 40f, 28f), - cat.Glyph(), - Color.white, + var slot = slots[i]; + var hovered = i == hoveredSlotIdx && slot.kind != SlotKind.Empty; + 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; + + switch (slot.kind) + { + case SlotKind.Empty: + continue; + case SlotKind.PrevPage: + DrawNavSlot(anchor, hovered, isNext: false); + continue; + case SlotKind.NextPage: + DrawNavSlot(anchor, hovered, isNext: true); + continue; + } + + var cat = slot.def; + var tint = cat.tint; + var iconTex = cat.IconTexture; + 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 if (!string.IsNullOrEmpty(cat.glyph)) + { + 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)); } - // 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), + // 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 + ? "MpPingWheel_Disarm".Translate() + : "MpPingWheel_ClickToDisarm".Translate(); + else + cancelLabel = inDeadzone + ? "MpPingWheel_Cancel".Translate() + : "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); + + // Page indicator on multi-page wheels - small dots above the chevron tab so the user + // sees how many pages exist and where they are. + if (totalPages > 1) + DrawPageIndicator(center, totalPages, wheelPage, inDrawer); } - // 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 static bool PointerIsOverNavSlot(List slots, int slotCount, Vector2 center, Vector2 mouse) + { + var dx = mouse.x - center.x; + var dy = mouse.y - center.y; + var distSq = dx * dx + dy * dy; + if (distSq < WheelInnerR * WheelInnerR) return false; + if (distSq > WheelOuterR * WheelOuterR) return false; + var angle = Mathf.Atan2(dx, -dy) * Mathf.Rad2Deg; + if (angle < 0) angle += 360; + var sectorSize = 360f / slotCount; + var idx = Mathf.FloorToInt((angle + sectorSize / 2f) / sectorSize) % slotCount; + var k = slots[idx].kind; + return k == SlotKind.PrevPage || k == SlotKind.NextPage; + } - 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); - } + // Bold chevron texture at the slot centre - no label, no offset. The page-dot indicator + // above the wheel (DrawPageIndicator) communicates position; the chevron only has to say + // "this is page nav, in that direction". + private static void DrawNavSlot(Vector2 anchor, bool hovered, bool isNext) + { + const float ChevSize = 26f; + var chevRect = new Rect(anchor.x - ChevSize / 2f, anchor.y - ChevSize / 2f, + ChevSize, ChevSize); + var tex = isNext ? MultiplayerStatic.PingChevronRight : MultiplayerStatic.PingChevronLeft; + + using (MpStyle.Set(new Color(0f, 0f, 0f, 0.55f))) + GUI.DrawTexture(new Rect(chevRect.x + 1f, chevRect.y + 1f, + chevRect.width, chevRect.height), tex); + using (MpStyle.Set(hovered ? Color.white : new Color(0.90f, 0.90f, 0.92f, 1f))) + GUI.DrawTexture(chevRect, tex); + } - 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) + // Tiny dots above the wheel that highlight the active page; rendered below the chevron tab + // in cursor mode and just below the title in drawer mode (same logical position relative to + // the wheel centre). + private static void DrawPageIndicator(Vector2 center, int totalPages, int activePage, bool inDrawer) { - 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); + const float Dot = 5f; + const float DotGap = 5f; + var width = totalPages * Dot + (totalPages - 1) * DotGap; + var x = center.x - width / 2f; + // Sits just above the backdrop ring (or below the chevron tab if there is one). + var y = center.y - WheelBackdropR - (inDrawer ? 10f : ChevronTabGapY + ChevronTabHeight + 8f); + for (int i = 0; i < totalPages; i++) + { + var r = new Rect(x + i * (Dot + DotGap), y, Dot, Dot); + Widgets.DrawBoxSolid(r, i == activePage + ? new Color(1f, 1f, 1f, 0.95f) + : new Color(1f, 1f, 1f, 0.35f)); + } } - else + + private void DrawChevronTab(Vector2 center, Vector2 mousePos) { - using (MpStyle.Set(GameFont.Small).Set(TextAnchor.MiddleCenter)) - MpUI.LabelOutlined(ghostRect, cat.Glyph(), Color.white, new Color(0f, 0f, 0f, 0.95f)); + 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); } - 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)); - } + public void DrawArmedCursor() + { + if (armedCategory == null) 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); + + var cat = armedCategory; + + 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.IconTexture; + 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 if (!string.IsNullOrEmpty(cat.glyph)) + { + 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 + ? "MpPingArmed_Marker".Translate() + : "MpPingArmed_Ping".Translate(); + 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 47ba6f1c8..daab4f66e 100644 --- a/Source/Client/UI/LocationPings.cs +++ b/Source/Client/UI/LocationPings.cs @@ -29,16 +29,19 @@ public partial class LocationPings private PlanetTile wheelTargetTile; private Vector3 wheelTargetMapLoc; private float? pingKeyDownTime; - private PingCategory hoveredCategory; + // Null = mouse is in the deadzone / not pointing at any slice. The Default-flagged def is + // never set here - that case is represented as null. + internal MultiplayerPingDef 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 MultiplayerPingDef 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; + // Stored as defName so a mod removal between sessions doesn't crash the open. + public string lastUsedCategoryDefName; public HashSet selectedMarkerIds = new(); public HashSet selectedPingPlayerIds = new(); @@ -261,7 +264,10 @@ private void HandleWheelEligibleInput() wheelTargetTile = tile; wheelTargetMapLoc = mapLoc; wheelScreenOrigin = UI.MousePositionOnUIInverted; - hoveredCategory = PingCategory.Default; + hoveredCategory = null; + // Cursor-mode wheel always opens on page 0 so it stays glanceable for the common + // first-N categories. Drawer mode keeps its own page (preserved across opens). + ResetWheelPage(); } } @@ -282,14 +288,16 @@ private void HandleWheelEligibleInput() 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; + MultiplayerPingDef toFire; + if (wheelActive) + toFire = hoveredCategory; // null if in the deadzone + else + toFire = MultiplayerPingDef.Default; - if (toFire is { } cat) + if (toFire != null) { var asMarker = Multiplayer.settings.pingPlaceMode == PingPlaceMode.Marker; - FirePing(wheelTargetMapId, wheelTargetTile, wheelTargetMapLoc, cat, asMarker); + FirePing(wheelTargetMapId, wheelTargetTile, wheelTargetMapLoc, toFire, asMarker); } CancelWheel(); @@ -305,7 +313,9 @@ private void HandleLegacyMouse2() 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); + var def = MultiplayerPingDef.Default; + if (def == null) return; + FirePing(mapId, tile, mapLoc, def, asMarker); } private void ToggleDrawer() @@ -326,8 +336,9 @@ private void ToggleDrawer() SoundDefOf.FloatMenu_Open.PlayOneShotOnCamera(); } - public void ArmPlacement(PingCategory category, bool playSound = true) + public void ArmPlacement(MultiplayerPingDef category, bool playSound = true) { + if (category == null || category.isDefault) return; armedCategory = category; if (playSound) SoundDefOf.Click.PlayOneShotOnCamera(); @@ -343,8 +354,8 @@ public void DisarmPlacement(bool playSound = true) public bool FireArmedAtMap(int mapId, PlanetTile tile, Vector3 mapLoc) { - if (armedCategory is not { } cat) return false; - FirePing(mapId, tile, mapLoc, cat, ArmedAsMarker); + if (armedCategory == null) return false; + FirePing(mapId, tile, mapLoc, armedCategory, ArmedAsMarker); return true; } @@ -415,8 +426,15 @@ private void CancelWheel() { wheelActive = false; pingKeyDownTime = null; + ResetWheelPage(); } + // Pageable wheel state. Reset on every open so the user lands on familiar slots; mutated + // by the Next/Previous nav slices in DrawWheelCore. Drawer mode does NOT reset on open - + // see PingMenuWindow.PostOpen for that policy decision. + internal int wheelPage; + internal void ResetWheelPage() => wheelPage = 0; + // Strip control characters - keeps crafted packets from injecting weird text. private static string SanitizeLabel(string raw) { @@ -427,19 +445,20 @@ private static string SanitizeLabel(string raw) return sb.ToString(); } - private void FirePing(int mapId, PlanetTile tile, Vector3 mapLoc, PingCategory category, bool asMarker) + private void FirePing(int mapId, PlanetTile tile, Vector3 mapLoc, MultiplayerPingDef category, bool asMarker) { if (Multiplayer.arbiterInstance) return; if (Multiplayer.Client == null) return; + if (category == 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(); + PingCategoryExtensions.ToWire(category), asMarker, "", tick)); + if (!category.isDefault) + lastUsedCategoryDefName = category.defName; + category.Sound.PlayOneShotOnCamera(); } // Mouse2 reports the *release* (UpWithoutDrag) because hold is reserved for camera-drag; diff --git a/Source/Client/UI/MarkerInspectTab.cs b/Source/Client/UI/MarkerInspectTab.cs index a8fa3eb82..2a9c8d91a 100644 --- a/Source/Client/UI/MarkerInspectTab.cs +++ b/Source/Client/UI/MarkerInspectTab.cs @@ -5,179 +5,179 @@ 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 +namespace Multiplayer.Client { - // 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() + // 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 { - 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) + public MarkerInspectTab() { - foreach (var m in loc.Markers) - if (m.mapId == -1 - && loc.selectedMarkerIds.Contains(m.markerId) - && m.IsVisible()) - loc.cachedPlanetMarkers.Add(m); + labelKey = "MpMarkerInspectTab_Label"; + size = new Vector2(580f, 280f); } - loc.cachedPlanetMarkersHasResult = loc.cachedPlanetMarkers.Count > 0; - loc.cachedPlanetMarkersV = markersV; - loc.cachedPlanetSelectionV = selectionV; - return loc.cachedPlanetMarkersHasResult ? loc.cachedPlanetMarkers : null; - } - public override bool IsVisible => CollectSelectedPlanetMarkers() != null; + private Vector2 listScroll; - public override float PaneTopY - { - get + // 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() { - // Same anchor as WorldInspectPane. - const float PaneHeight = 165f; - const float PaneBottomGap = 35f; - return UI.screenHeight - PaneHeight - PaneBottomGap; - } - } + var loc = Multiplayer.session?.locationPings; + if (loc == null) return null; - public override bool StillValid => CollectSelectedPlanetMarkers() != 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; - // 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); - } + 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; + } - private const float RowH = 26f; + public override bool IsVisible => CollectSelectedPlanetMarkers() != null; - 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++) + public override float PaneTopY { - var rowRect = new Rect(0f, i * stride, viewRect.width, stride - 2f); - DrawMarkerRow(rowRect, markers[i], loc); + get + { + // Same anchor as WorldInspectPane. + const float PaneHeight = 165f; + const float PaneBottomGap = 35f; + return UI.screenHeight - PaneHeight - PaneBottomGap; + } } - Widgets.EndScrollView(); - } + public override bool StillValid => CollectSelectedPlanetMarkers() != null; - 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) + // Match vanilla WITab: X collapses the tab, marker selection stays (Deselect clears it). + public override void CloseTab() { - using (MpStyle.Set(info.category.Tint())) - GUI.DrawTexture(iconRect, iconTex); + Find.World?.UI?.inspectPane?.CloseOpenTab(); + SoundDefOf.TabClose.PlayOneShotOnCamera(); } - // 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}"; + 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); + } - var placer = info.placedByUsername ?? "?"; - var factionName = info.placedByFactionLoadId >= 0 - ? Find.FactionManager?.GetById(info.placedByFactionLoadId)?.Name - : null; - var secondary = string.IsNullOrEmpty(factionName) ? placer : $"{placer} · {factionName}"; + private const float RowH = 26f; - // 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); + 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); + } - 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); + Widgets.EndScrollView(); + } - if (Widgets.ButtonInvisible(rect)) + private static void DrawMarkerRow(Rect rect, PingInfo info, LocationPings loc) { - var shift = Selector.ShiftIsHeld; - if (shift) + 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. Untyped (Default or unresolved def) gets no icon - the placer-colour + // stripe to the left already carries the row's identity. + var iconRect = new Rect(rect.x + 12f, rect.y + 4f, 18f, 18f); + var cat = info.category; + var iconTex = cat?.IconTexture; + if (iconTex != null) { - if (isSelected) loc.ToggleSelection(info); - else loc.SelectInfo(info, additive: true); + using (MpStyle.Set(cat.tint)) + GUI.DrawTexture(iconRect, iconTex); } - else + + // Show "Category - Label" if the marker has a user label, otherwise just the category name. + var catName = cat?.DisplayName() ?? ""; + var primary = string.IsNullOrEmpty(info.label) + ? catName + : $"{catName} - {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)) { - loc.SelectInfo(info, additive: false); + 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(); } - 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); + private static string HeaderLabel(int count) + => "MpMarkerInspectTab_Header".Translate(count); + } } diff --git a/Source/Client/UI/MultiplayerPingDef.cs b/Source/Client/UI/MultiplayerPingDef.cs new file mode 100644 index 000000000..5d8bef365 --- /dev/null +++ b/Source/Client/UI/MultiplayerPingDef.cs @@ -0,0 +1,147 @@ +using System.Collections.Generic; +using RimWorld; +using UnityEngine; +using Verse; +using Verse.Sound; + +namespace Multiplayer.Client +{ + // Player-placeable ping/marker category. Defined in XML so any mod can drop in additional + // categories without touching the C#; the wheel and selection UI pick these up automatically. + // + // One def in the database is flagged true: that's the "untyped" centre + // option (no slice, lives in the wheel's cancel disc). Vanilla ships MpPing_Default for that + // slot - mods should add new categories rather than replace it. + public class MultiplayerPingDef : Def + { + // Marks the singleton "no category picked" entry that lives in the centre of the wheel. + // Exactly one def in the database should have this set. If two defs flag themselves as + // default, ResolveDefault picks the first one it encounters; if none do, it falls back to + // the lowest-ordered def so the wheel still draws something. + public bool isDefault; + + // Sort key - lower comes first on the wheel and in lists. Use multiples of 100 in vanilla to + // leave room for mods to slot themselves between defaults without renumbering everything. + // Ties broken by defName (ordinal) for cross-client determinism. + public int order = 1000; + + // Slice / pin tint. Default-category gets ignored at render time (uses placer colour instead). + public Color tint = Color.white; + + // Wheel-slice icon. Resolved against ContentFinder lazily on first access so the static + // ctor doesn't run before content is mounted. + public string iconPath; + + // Normalises visual size across vanilla atlases with varying canvas padding. + public float iconScale = 1f; + + // One-character fallback drawn in place of the icon when iconPath is missing/unresolvable. + public string glyph = ""; + + // Vanilla SoundDef defName, e.g. "Quest_Failed". Falls back to TinyBell when null/missing. + public string soundDefName; + + // Lazily resolved; ContentFinder requires the asset bundles to be live and Texture2D fields + // can't be set from the def loader directly. Null-safe - callers check IconTexture != null. + private Texture2D resolvedIcon; + private bool iconResolved; + + public Texture2D IconTexture + { + get + { + if (iconResolved) return resolvedIcon; + iconResolved = true; + if (string.IsNullOrEmpty(iconPath)) return resolvedIcon = null; + // reportFailure: false so the wheel falls back to glyph silently when the path's bad. + resolvedIcon = ContentFinder.Get(iconPath, false); + return resolvedIcon; + } + } + + private SoundDef resolvedSound; + private bool soundResolved; + + public SoundDef Sound + { + get + { + if (soundResolved) return resolvedSound; + soundResolved = true; + if (!string.IsNullOrEmpty(soundDefName)) + resolvedSound = SoundDef.Named(soundDefName); + // SoundDef.Named throws on missing - guarded above. TinyBell is the universal fallback + // (defined in vanilla, always present, has on-camera subSounds). + return resolvedSound ?? SoundDefOf.TinyBell; + } + } + + public override void PostLoad() + { + base.PostLoad(); + // Defs without a label fall back to the defName for display, which reads as + // "MpPing_Attack" in the UI. Catch that here so vanilla labels aren't required to look up + // a fallback at every site. + if (string.IsNullOrEmpty(label)) + label = defName; + } + + // Cached on first access. Changing the active mod list requires a full process restart in + // RimWorld, which resets statics, so no explicit invalidation is needed. + private static MultiplayerPingDef cachedDefault; + + public static MultiplayerPingDef Default + { + get + { + if (cachedDefault != null) return cachedDefault; + return cachedDefault = ResolveDefault(); + } + } + + private static MultiplayerPingDef ResolveDefault() + { + MultiplayerPingDef fallback = null; + foreach (var def in DefDatabase.AllDefsListForReading) + { + if (def.isDefault) return def; + if (fallback == null || def.order < fallback.order) fallback = def; + } + // If no def is flagged isDefault, take the lowest-ordered as a softer fallback so the + // wheel still draws something. DefDatabase is empty only in dev/test contexts. + return fallback; + } + + public override IEnumerable ConfigErrors() + { + foreach (var e in base.ConfigErrors()) yield return e; + if (iconScale <= 0f) yield return $"{defName}: iconScale must be > 0"; + // Vanilla iconScale runs 0.92..1.20. Anything above 4 is almost certainly a missing + // decimal point (e.g. "1.20" mistyped as "120"); warn rather than crash at draw time. + if (iconScale > 4f) + yield return $"{defName}: iconScale {iconScale} looks like a typo (vanilla range is 0.9 to 1.2)"; + if (!string.IsNullOrEmpty(glyph) && glyph.Length > 4) + yield return $"{defName}: glyph is intended to be a single character (got '{glyph}')"; + } + + // Sorted by (order, defName) for cross-client determinism. Default lives in the centre disc + // and is filtered out of every selection UI - callers should pass `includeDefault: false`. + public static List Sorted(bool includeDefault) + { + var list = new List(); + foreach (var def in DefDatabase.AllDefsListForReading) + { + if (!includeDefault && def.isDefault) continue; + list.Add(def); + } + list.Sort(Compare); + return list; + } + + public static int Compare(MultiplayerPingDef a, MultiplayerPingDef b) + { + if (a.order != b.order) return a.order.CompareTo(b.order); + return string.CompareOrdinal(a.defName, b.defName); + } + } +} diff --git a/Source/Client/UI/PingCategoryExtensions.cs b/Source/Client/UI/PingCategoryExtensions.cs index ae63fdf30..3f16bed08 100644 --- a/Source/Client/UI/PingCategoryExtensions.cs +++ b/Source/Client/UI/PingCategoryExtensions.cs @@ -1,95 +1,29 @@ -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 +namespace Multiplayer.Client { - 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) + // Wire-side helpers around MultiplayerPingDef. The visual properties (tint / icon / glyph / + // sound) live on the def itself now - this class is just the ushort short-hash codec plus a + // couple of null-safe accessors. + public static class PingCategoryExtensions { - var picked = c switch + // Receivers map an unknown / zero hash to Default so a sender that joins with extra mods + // doesn't desync the wheel UI on a stripped-down client. A missing Default def still + // returns null and the renderer treats null as "untyped". + public static MultiplayerPingDef ResolveFromWire(ushort hash) { - 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; + if (hash == PingCategoryWire.UnknownHash) return MultiplayerPingDef.Default; + var def = DefDatabase.GetByShortHash(hash); + return def ?? MultiplayerPingDef.Default; + } + + // Senders pass the def's short-hash; resolved-to-null defs (caller didn't have one in the + // DefDatabase, e.g. mid-startup) emit UnknownHash so the receiver picks Default. + public static ushort ToWire(MultiplayerPingDef def) + => def?.shortHash ?? PingCategoryWire.UnknownHash; + + public static string DisplayName(this MultiplayerPingDef def) + => def?.LabelCap ?? ""; } } diff --git a/Source/Client/UI/PingInfo.cs b/Source/Client/UI/PingInfo.cs index 043a0b8ee..46ac6df9d 100644 --- a/Source/Client/UI/PingInfo.cs +++ b/Source/Client/UI/PingInfo.cs @@ -15,7 +15,10 @@ public class PingInfo : IExposable, ISynchronizable public PlanetTile planetTile; public Vector3 mapLoc; - public PingCategory category; + // Resolved from the wire / scribe. A null category is treated as "untyped" by every + // downstream check, so a category whose def was removed (mod uninstall) still renders as a + // plain ping rather than crashing. + public MultiplayerPingDef category; public string label = ""; public bool isMarker; @@ -76,12 +79,17 @@ public Color PinColor get { var b = BaseColor; - if (category == PingCategory.Default) return b; - var t = category.Tint(); + if (IsUntyped) return b; + var t = category.tint; return new Color(t.r, t.g, t.b, b.a); } } + // "Untyped" = the special Default-flagged def OR the def couldn't be resolved at all. Every + // category-conditional render path routes through this so a stale category from a save (def + // removed) degrades silently to a vanilla ping. + public bool IsUntyped => category == null || category.isDefault; + internal const float PingDuration = 10f; internal const float LabelWidth = 200f; @@ -226,12 +234,12 @@ public void DrawAt(Vector2 screenCenter, float size) using (MpStyle.Set(pinDrawColor)) GUI.DrawTexture(pinRect, MultiplayerStatic.PingPin); - if (category != PingCategory.Default) + if (!IsUntyped) { - var iconTex = category.Icon(); + var iconTex = category.IconTexture; if (iconTex != null) { - var iconSize = size * 0.42f * category.IconScale(); + var iconSize = size * 0.42f * category.iconScale; var headCenterY = pinRect.y + size * 0.34f; var iconRect = new Rect( pinRect.center.x - iconSize / 2f, @@ -245,22 +253,18 @@ public void DrawAt(Vector2 screenCenter, float size) using (MpStyle.Set(new Color(1f, 1f, 1f, AlphaMult))) GUI.DrawTexture(iconRect, iconTex); } - else + else if (!string.IsNullOrEmpty(category.glyph)) { - 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 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, category.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) + if (!IsUntyped) { var nameRect = new Rect(screenCenter.x - LabelWidth / 2f, labelY, LabelWidth, 18f); using (MpStyle.Set(GameFont.Small).Set(TextAnchor.MiddleCenter)) @@ -308,10 +312,13 @@ public void Sync(SyncWorker sync) sync.Bind(ref mapLoc); - byte cat = (byte)category; - sync.Bind(ref cat); + // Wire the def via its short-hash (vanilla's standard def-serialization size). An unknown + // hash on the receiver resolves to Default so a session with a half-installed mod set + // still re-hydrates ping rows without throwing. + ushort catHash = PingCategoryExtensions.ToWire(category); + sync.Bind(ref catHash); if (!sync.isWriting) - category = (PingCategory)cat; + category = PingCategoryExtensions.ResolveFromWire(catHash); sync.Bind(ref label); sync.Bind(ref isMarker); @@ -338,7 +345,17 @@ public void ExposeData() planetTile = new PlanetTile(tileId, layerId); Scribe_Values.Look(ref mapLoc, "mapLoc"); - Scribe_Values.Look(ref category, "category", PingCategory.Default); + + // Save as defName (string) so a missing-mod load doesn't drop the marker - we just fall + // back to Default at resolve time, which still renders. Scribe_Defs.Look would crash on + // unresolved. + string categoryDefName = Scribe.mode == LoadSaveMode.Saving ? category?.defName : null; + Scribe_Values.Look(ref categoryDefName, "categoryDefName"); + if (Scribe.mode == LoadSaveMode.LoadingVars) + category = string.IsNullOrEmpty(categoryDefName) + ? MultiplayerPingDef.Default + : (DefDatabase.GetNamedSilentFail(categoryDefName) ?? MultiplayerPingDef.Default); + Scribe_Values.Look(ref label, "label", ""); Scribe_Values.Look(ref isMarker, "isMarker"); Scribe_Values.Look(ref markerId, "markerId"); diff --git a/Source/Client/UI/PingSelectionUI.cs b/Source/Client/UI/PingSelectionUI.cs index 3140d93eb..a4eb6bbab 100644 --- a/Source/Client/UI/PingSelectionUI.cs +++ b/Source/Client/UI/PingSelectionUI.cs @@ -6,611 +6,601 @@ using Verse; using Verse.Sound; -namespace Multiplayer.Client; - -// Marker/ping selection helpers: bracket overlay, inspect-pane lifecycle, gizmo factory. -public static class PingSelectionUI +namespace Multiplayer.Client { - 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) + // Marker/ping selection helpers: bracket overlay, inspect-pane lifecycle, gizmo factory. + public static class PingSelectionUI { - 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 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 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(); + 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); + } - // 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); - } + 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(); - // 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(); + // 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); + } - 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); + // 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.selectedPingPlayerIds.Count > 0) - foreach (var p in loc.pings) - if (p.mapId == -1 && loc.selectedPingPlayerIds.Contains(p.player) && p.IsVisible()) - result.Add(p); + 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); - return result; - } + 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); - // 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; + return result; + } - if (loc.selectedMarkerIds.Count > 0) + // Filter-hidden markers stay in the set but are skipped here so brackets/gizmos ignore them. + public static List CollectSelectedOnCurrentMap(LocationPings loc) { - var stillAlive = new HashSet(); - foreach (var m in loc.Markers) - if (m.mapId == mapId && loc.selectedMarkerIds.Contains(m.markerId)) + 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) { - stillAlive.Add(m.markerId); - if (m.IsVisible()) - result.Add(m); + loc.selectedMarkerIds.IntersectWith(stillAlive); + loc.selectionVersion++; } - 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)) + 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) { - stillAlive.Add(p.player); - if (p.IsVisible()) - result.Add(p); + loc.selectedPingPlayerIds.IntersectWith(stillAlive); + loc.selectionVersion++; } - if (stillAlive.Count != loc.selectedPingPlayerIds.Count) - { - loc.selectedPingPlayerIds.IntersectWith(stillAlive); - loc.selectionVersion++; } - } - return result; - } + 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) + // Cached so the vanilla gizmo-grid reference-cache hits. No hotKey - would double-fire. + public static List BuildGizmos(List selected, LocationPings loc) { - if (!info.isMarker) continue; - if (LocationPings.CanDeleteMarker(info)) - { - ownedMarkerCount++; - } - else + // "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) { - foreignMarkerCount++; - if (foreignSampleUsername == null && !string.IsNullOrEmpty(info.placedByUsername)) + if (!info.isMarker) continue; + if (LocationPings.CanDeleteMarker(info)) { - foreignSampleUsername = info.placedByUsername; - foreignSampleFactionId = info.placedByFactionLoadId; + 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 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; - } + 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; + // 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; + 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 + var result = new List(); + if (ownedMarkerCount > 0) { - defaultLabel = label, - defaultDesc = desc, - icon = TexButton.Delete, - action = () => DeleteOwnedFromCurrentSelection(loc), - }); + var label = ownedMarkerCount == 1 + ? DeleteLabel() + : MultiDeleteLabel(ownedMarkerCount); + var desc = foreignMarkerCount > 0 + ? "MpPingSel_DeleteForeignDesc".Translate(ownedMarkerCount, foreignMarkerCount).ToString() + : "MpPingSel_DeleteDesc".Translate().ToString(); + 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) + // Rename only on a single-owned selection - renaming many markers at once doesn't make sense. + if (ownedMarkerCount == 1 && foreignMarkerCount == 0) { - result.Add(new Command_Action + var theOne = FindOnlyOwnedMarker(selected); + if (theOne != null) { - 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)), - }); + result.Add(new Command_Action + { + defaultLabel = RenameLabel(), + defaultDesc = "MpPingSel_RenameDesc".Translate(), + 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) + // Show mute-actions for the first foreign placer only; multi-foreign almost always shares one placer. + if (foreignSampleUsername != null) { - var factionMan = Find.FactionManager; - var faction = factionMan?.GetById(foreignSampleFactionId); - var factionName = faction?.Name ?? "?"; - var alreadyMutedFaction = settings != null && settings.hiddenFactionLoadIds.Contains(foreignSampleFactionId); + var settings = Multiplayer.settings; + var alreadyMutedPlayer = settings != null && settings.hiddenPlayerNames.Contains(foreignSampleUsername); 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), + defaultLabel = alreadyMutedPlayer + ? UnmutePlayerLabel(foreignSampleUsername) + : MutePlayerLabel(foreignSampleUsername), + defaultDesc = "MpPingSel_MutePlayerDesc".Translate(foreignSampleUsername), icon = MultiplayerStatic.PingMuteIcon, - action = () => ToggleMuteFaction(foreignSampleFactionId), + action = () => ToggleMutePlayer(foreignSampleUsername), }); - } - } - 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 (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 = "MpPingSel_MuteFactionDesc".Translate(factionName), + icon = MultiplayerStatic.PingMuteIcon, + action = () => ToggleMuteFaction(foreignSampleFactionId), + }); + } + } + if (foreignSpectatorPresent) { - 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; + var settings = Multiplayer.settings; + var alreadyMuted = settings != null && !settings.showSpectatorMarkers; + result.Add(new Command_Action + { + defaultLabel = alreadyMuted ? UnmuteSpectatorsLabel() : MuteSpectatorsLabel(), + defaultDesc = "MpPingSel_MuteSpectatorsDesc".Translate(), + icon = MultiplayerStatic.PingMuteIcon, + action = ToggleMuteSpectators, + }); } - result.Add(new Command_Action + // 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) { - 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 = () => + var anyHidden = false; + var anyDimmed = false; + foreach (var id in markerIds) { - Find.WindowStack.Add(new MarkerAlphaWindow(markerIds)); - SoundDefOf.Click.PlayOneShotOnCamera(); - }, - }); + 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 = 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 = () => + result.Add(new Command_Action { - ToggleHideLocally(markerIds, makeVisible: anyHidden); - SoundDefOf.Click.PlayOneShotOnCamera(); - }, - }); + defaultLabel = TransparencyLabel(), + defaultDesc = "MpPingSel_TransparencyDesc".Translate(), + icon = MultiplayerStatic.PingTransparencyIcon, + action = () => + { + Find.WindowStack.Add(new MarkerAlphaWindow(markerIds)); + 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, + defaultLabel = anyHidden ? UnhideLocallyLabel() : HideLocallyLabel(), + defaultDesc = "MpPingSel_HideLocallyDesc".Translate(), + icon = anyHidden ? MultiplayerStatic.PingShowForMeIcon : MultiplayerStatic.PingHideForMeIcon, action = () => { - ResetLocalAppearance(markerIds); + ToggleHideLocally(markerIds, makeVisible: anyHidden); SoundDefOf.Click.PlayOneShotOnCamera(); }, }); + + if (anyDimmed || anyHidden) + { + result.Add(new Command_Action + { + defaultLabel = ResetLocalAppearanceLabel(), + defaultDesc = "MpPingSel_ResetLocalAppearanceDesc".Translate(), + 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 = () => + // Escape hatch for the drag-select pulls-in-markers behavior. + result.Add(new Command_Action { - loc.ClearSelection(); - SoundDefOf.Click.PlayOneShotOnCamera(); - }, - }); + defaultLabel = DeselectAllMarkersLabel(), + defaultDesc = "MpPingSel_DeselectAllDesc".Translate(), + 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; - } + 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) + // 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) { - 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) + var markersV = Multiplayer.game?.gameComp?.markersVersion ?? 0; + var factionId = Multiplayer.RealPlayerFaction?.loadID ?? -1; + if (loc.cachedInlineMarkersV != markersV + || loc.cachedInlineSelectionV != loc.selectionVersion + || loc.cachedInlineFactionId != factionId) { - 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 + 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 (loc.cachedInlineForeignSampleUsername == null && !string.IsNullOrEmpty(info.placedByUsername)) + if (!info.isMarker) continue; + if (info.markerId != 0) + loc.cachedInlineMarkerIds.Add(info.markerId); + if (LocationPings.CanDeleteMarker(info)) { - loc.cachedInlineForeignSampleUsername = info.placedByUsername; - loc.cachedInlineForeignSampleFactionId = info.placedByFactionLoadId; + 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; } - if (spec != null && info.placedByFactionLoadId == spec.loadID) - loc.cachedInlineForeignSpectatorPresent = true; } - } - loc.cachedInlineActions = BuildInlineActions(loc); + loc.cachedInlineActions = BuildInlineActions(loc); - loc.cachedInlineMarkersV = markersV; - loc.cachedInlineSelectionV = loc.selectionVersion; - loc.cachedInlineFactionId = factionId; + loc.cachedInlineMarkersV = markersV; + loc.cachedInlineSelectionV = loc.selectionVersion; + loc.cachedInlineFactionId = factionId; + } + + PackInlineActionButtons(rowRect, loc.cachedInlineActions); } - 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; - // 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)>(); - // 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 > 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 (ownedCount == 1 && onlyOwnedSingle != null) + if (foreignSampleUsername != null) { - var theOne = onlyOwnedSingle; - actions.Add((RenameLabel(), - () => Find.WindowStack.Add(new PingLabelWindow(theOne.markerId, theOne.label)))); + 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 (foreignSampleUsername != null) - { - var muted = Multiplayer.settings?.hiddenPlayerNames.Contains(foreignSampleUsername) ?? false; - actions.Add((muted ? UnmutePlayerLabel(foreignSampleUsername) : MutePlayerLabel(foreignSampleUsername), - () => ToggleMutePlayer(foreignSampleUsername))); + if (foreignSpectatorPresent) + { + var muted = Multiplayer.settings != null && !Multiplayer.settings.showSpectatorMarkers; + actions.Add((muted ? UnmuteSpectatorsLabel() : MuteSpectatorsLabel(), + ToggleMuteSpectators)); + } - if (foreignSampleFactionId >= 0) + if (markerIds.Count > 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))); + 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; } - if (foreignSpectatorPresent) + // 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) { - var muted = Multiplayer.settings != null && !Multiplayer.settings.showSpectatorMarkers; - actions.Add((muted ? UnmuteSpectatorsLabel() : MuteSpectatorsLabel(), - ToggleMuteSpectators)); + 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; + } } - if (markerIds.Count > 0) + private static void ToggleHideLocally(List markerIds, bool makeVisible) { - var anyHidden = false; + var s = Multiplayer.settings; + if (s == null) return; + s.locallyHiddenMarkers ??= new HashSet(); 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))); + { + 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(); } - 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) + private static void ResetLocalAppearance(List markerIds) { - 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) + var s = Multiplayer.settings; + if (s == null) return; + s.locallyHiddenMarkers ??= new HashSet(); + s.localMarkerAlpha ??= new Dictionary(); + foreach (var id in markerIds) { - x = rowRect.x; - y += BtnH + RowGap; + s.locallyHiddenMarkers.Remove(id); + s.localMarkerAlpha.Remove(id); } - if (y + BtnH > rowRect.yMax + 1f) break; - if (Widgets.ButtonText(new Rect(x, y, w, BtnH), label)) - onClick(); - x += w + BtnGap; + if (Multiplayer.game?.gameComp != null) Multiplayer.game.gameComp.markersVersion++; + MultiplayerLoader.Multiplayer.instance?.WriteSettings(); } - } - 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) + private static void ToggleMutePlayer(string username) { - if (makeVisible) s.locallyHiddenMarkers.Remove(id); - else s.locallyHiddenMarkers.Add(id); + 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(); } - // 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) + private static void ToggleMuteFaction(int factionLoadId) { - s.locallyHiddenMarkers.Remove(id); - s.localMarkerAlpha.Remove(id); + 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(); } - 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(); + } - 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++; + } - // 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; + } - private static PingInfo FindOnlyOwnedMarker(List selected) - { - PingInfo found = null; - foreach (var info in selected) + // 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) { - if (!info.isMarker) continue; - if (!LocationPings.CanDeleteMarker(info)) continue; - if (found != null) return null; - found = info; + 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(); } - 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() + => "MpPingSel_Delete".Translate(); + private static string RenameLabel() + => "MpPingSel_Rename".Translate(); + private static string MultiDeleteLabel(int deletableCount) + => "MpPingSel_MultiDelete".Translate(deletableCount); + + private static string DeselectAllMarkersLabel() + => "MpPingSel_DeselectAll".Translate(); + + private static string MutePlayerLabel(string username) + => "MpPingSel_MutePlayer".Translate(username); + private static string UnmutePlayerLabel(string username) + => "MpPingSel_UnmutePlayer".Translate(username); + + private static string MuteFactionLabel(string factionName) + => "MpPingSel_MuteFaction".Translate(factionName); + private static string UnmuteFactionLabel(string factionName) + => "MpPingSel_UnmuteFaction".Translate(factionName); + + private static string MuteSpectatorsLabel() + => "MpPingSel_MuteSpectators".Translate(); + private static string UnmuteSpectatorsLabel() + => "MpPingSel_UnmuteSpectators".Translate(); + + private static string TransparencyLabel() + => "MpPingSel_Transparency".Translate(); + private static string HideLocallyLabel() + => "MpPingSel_HideLocally".Translate(); + private static string UnhideLocallyLabel() + => "MpPingSel_UnhideLocally".Translate(); + private static string ResetLocalAppearanceLabel() + => "MpPingSel_ResetLocalAppearance".Translate(); } - - // 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 deleted file mode 100644 index 431b55329..000000000 --- a/Source/Client/Util/MpTranslate.cs +++ /dev/null @@ -1,17 +0,0 @@ -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/PingRuntimeTranslations.cs b/Source/Client/Util/PingRuntimeTranslations.cs deleted file mode 100644 index f7317ec0b..000000000 --- a/Source/Client/Util/PingRuntimeTranslations.cs +++ /dev/null @@ -1,31 +0,0 @@ -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/MarkerAlphaWindow.cs b/Source/Client/Windows/MarkerAlphaWindow.cs index e08e81cf6..2d949b470 100644 --- a/Source/Client/Windows/MarkerAlphaWindow.cs +++ b/Source/Client/Windows/MarkerAlphaWindow.cs @@ -5,154 +5,152 @@ 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 +namespace Multiplayer.Client { - private const float MinAlpha = 0.05f; - private const float SwatchSize = 56f; + // 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; + private readonly List markerIds; + private readonly float initialAlpha; + private float alpha; - public override Vector2 InitialSize => new(360f, 220f); - public override float Margin => 10f; + 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 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)) + public override void DoWindowContents(Rect inRect) { - alpha = newAlpha; - ApplyToSelection(); + 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, "MpMarkerAlpha_Title".Translate()); + + 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 = "MpMarkerAlpha_Percent".Translate(Mathf.RoundToInt(alpha * 100f)).ToString(); + 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, + "MpMarkerAlpha_MinHint".Translate(Mathf.RoundToInt(MinAlpha * 100f))); + using (MpStyle.Set(GameFont.Tiny).Set(TextAnchor.UpperRight).Set(ColoredText.SubtleGrayColor)) + Widgets.Label(minMaxRect, "MpMarkerAlpha_MaxHint".Translate()); + TooltipHandler.TipRegion(sliderRect, "MpMarkerAlpha_MinTip".Translate()); + + 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, "MpMarkerAlpha_Cancel".Translate())) + { + alpha = initialAlpha; + ApplyToSelection(); + Close(); + } + if (Widgets.ButtonText(applyRect, "MpMarkerAlpha_Apply".Translate())) + { + SoundDefOf.Click.PlayOneShotOnCamera(); + Close(); + } } - 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"))) + private static void DrawAlphaSwatch(Rect rect, float alpha) { - alpha = initialAlpha; - ApplyToSelection(); - Close(); + // 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++; } - if (Widgets.ButtonText(applyRect, MpTranslate.Fallback("MpMarkerAlpha_Apply", "Apply"))) + + public override void OnAcceptKeyPressed() { 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++) + public override void OnCancelKeyPressed() { - var c = ((ix + iy) & 1) == 0 ? checkColorA : checkColorB; - Widgets.DrawBoxSolid(new Rect(rect.x + ix * cw, rect.y + iy * ch, cw, ch), c); + alpha = initialAlpha; + ApplyToSelection(); + Close(); } - 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) + public override void PostClose() { - if (alpha >= 0.999f) s.localMarkerAlpha.Remove(id); - else s.localMarkerAlpha[id] = alpha; + base.PostClose(); + MultiplayerLoader.Multiplayer.instance?.WriteSettings(); } - 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 index 1ddb652c5..c9e71c782 100644 --- a/Source/Client/Windows/PingFiltersDialog.cs +++ b/Source/Client/Windows/PingFiltersDialog.cs @@ -5,418 +5,416 @@ using Verse; using Verse.Sound; -namespace Multiplayer.Client; - -// Per-client visibility filter. Pure render gate; settings persist via MpSettings. -public class PingFiltersDialog : Window +namespace Multiplayer.Client { - public static PingFiltersDialog Opened => Find.WindowStack?.WindowOfType(); + // 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); + public override Vector2 InitialSize => new(420f, 460f); - private Vector2 listScroll; + private Vector2 listScroll; - // Screen-space anchor from trigger button; flips above if clipped. - private Rect? requestedAnchor; + // Screen-space anchor from trigger button; flips above if clipped. + private Rect? requestedAnchor; - private List cachedFactionsWithMarkers; - private int cachedFactionsMarkersV = -1; + private List cachedFactionsWithMarkers; + private int cachedFactionsMarkersV = -1; - private List cachedOtherPlayers; - private int cachedOtherPlayersMarkersV = -1; - private int cachedOtherPlayersPlayerCount = -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; + 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; + } - // Priority: toolbar-anchor (fresh click) > prior drag-position > center. - Vector2 desired; - if (requestedAnchor is { } trigger) + public override void SetInitialSizeAndPosition() { - 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); + 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 - desired = new Vector2(trigger.x, belowY); - requestedAnchor = null; + { + 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); } - else + + public override void PostClose() { - 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); + base.PostClose(); + Multiplayer.settings.pingFiltersDialogRect = windowRect; + MultiplayerLoader.Multiplayer.instance?.WriteSettings(); } - 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; - 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, "MpPingFilters_Title".Translate()); - 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 y = titleRect.yMax + 8f; + var specRect = new Rect(inRect.x, y, inRect.width, 24f); + var showSpec = settings.showSpectatorMarkers; + Widgets.CheckboxLabeled(specRect, "MpPingFilters_ShowSpectators".Translate(), ref showSpec); + if (showSpec != settings.showSpectatorMarkers) + { + settings.showSpectatorMarkers = showSpec; + MultiplayerLoader.Multiplayer.instance?.WriteSettings(); + } + y = specRect.yMax + 10f; - 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; - 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); - 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); - 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); - Widgets.BeginScrollView(listOutRect, ref listScroll, viewRect); + var yy = 0f; + DrawSectionHeader(new Rect(0f, yy, viewRect.width, SectionHeaderH), "MpPingFilters_FactionsHeader".Translate()); + yy += SectionHeaderH; - 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), "MpPingFilters_NoFactions".Translate()); + yy += EmptyRowH; + } + else + { + foreach (var f in factions) + { + DrawFactionRow(new Rect(0f, yy, viewRect.width, RowH), f, settings); + yy += RowH; + } + } + yy += SectionGap; - 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) + DrawSectionHeader(new Rect(0f, yy, viewRect.width, SectionHeaderH), "MpPingFilters_PlayersHeader".Translate()); + yy += SectionHeaderH; + + if (players.Count == 0) { - DrawFactionRow(new Rect(0f, yy, viewRect.width, RowH), f, settings); - yy += RowH; + DrawEmptyRow(new Rect(0f, yy, viewRect.width, EmptyRowH), "MpPingFilters_NoOtherPlayers".Translate()); + yy += EmptyRowH; + } + else + { + foreach (var p in players) + { + DrawPlayerRow(new Rect(0f, yy, viewRect.width, RowH), p, settings); + yy += RowH; + } } - } - yy += SectionGap; - DrawSectionHeader(new Rect(0f, yy, viewRect.width, SectionHeaderH), MpTranslate.Fallback("MpPingFilters_PlayersHeader", "By player")); - yy += SectionHeaderH; + Widgets.EndScrollView(); - 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) + var resetRect = new Rect(inRect.x, listOutRect.yMax + ResetGap, inRect.width, ResetH); + if (Widgets.ButtonText(resetRect, "MpPingFilters_ResetAll".Translate())) { - DrawPlayerRow(new Rect(0f, yy, viewRect.width, RowH), p, settings); - yy += RowH; + settings.hiddenFactionLoadIds.Clear(); + settings.hiddenPlayerNames.Clear(); + settings.showSpectatorMarkers = true; + MultiplayerLoader.Multiplayer.instance?.WriteSettings(); + SoundDefOf.Click.PlayOneShotOnCamera(); } } - Widgets.EndScrollView(); + private const float RowH = 28f; + private const float EmptyRowH = 24f; + private const float SectionGap = 8f; + private const float SectionHeaderH = 22f; - var resetRect = new Rect(inRect.x, listOutRect.yMax + ResetGap, inRect.width, ResetH); - if (Widgets.ButtonText(resetRect, MpTranslate.Fallback("MpPingFilters_ResetAll", "Reset all"))) + // 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 = { - settings.hiddenFactionLoadIds.Clear(); - settings.hiddenPlayerNames.Clear(); - settings.showSpectatorMarkers = true; - MultiplayerLoader.Multiplayer.instance?.WriteSettings(); - SoundDefOf.Click.PlayOneShotOnCamera(); + 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 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 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 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); - 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)); - 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); - // 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); - // 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(); + } - 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(); + } } - if (prev != show) + private static void DrawPlayerRow(Rect rect, PlayerRowItem p, MpSettings s) { - 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); + Widgets.DrawHighlightIfMouseover(rect); - if (p.isSelf) - Widgets.DrawBoxSolid(rect, new Color(0.30f, 0.55f, 0.90f, 0.18f)); + 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)); + 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); + // 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); + // 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); + 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))) + if (string.IsNullOrEmpty(p.username)) { - Widgets.Checkbox(checkRect.position, ref phantom, 24f); - GUI.DrawTexture(clearRect, TexButton.Delete); + // 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; } - 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)) + if (canShowClear) { - Multiplayer.session?.locationPings?.SendClearMarkersFromPlayer(p.username); - SoundDefOf.Click.PlayOneShotOnCamera(); - // Stop the click falling through to GUI.DragWindow. - Event.current.Use(); + TooltipHandler.TipRegion(clearRect, "MpPingFilters_ClearPlayerMarkers".Translate(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; + if (!canShowMute) return; - var show = !s.hiddenPlayerNames.Contains(p.username); - var prev2 = show; - Widgets.Checkbox(checkRect.position, ref show, 24f); + 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(); - } + // 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(); + 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) + // 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; + } - cachedFactionsWithMarkers ??= new List(); - cachedFactionsWithMarkers.Clear(); - var spectator = Multiplayer.game?.worldComp?.spectatorFaction; - foreach (var entry in comp.markersByFaction) + private readonly struct PlayerRowItem { - 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); + 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; } } - 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(); + // 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; + 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; + if (cachedOtherPlayers != null + && cachedOtherPlayersMarkersV == markersV + && cachedOtherPlayersPlayerCount == playerCount) + return cachedOtherPlayers; - cachedOtherPlayers ??= new List(); - cachedOtherPlayers.Clear(); + cachedOtherPlayers ??= new List(); + cachedOtherPlayers.Clear(); - var meId = Multiplayer.session.playerId; - var mePi = Multiplayer.session.GetPlayerInfo(meId); - var meName = mePi?.username; - var seenUsernames = new HashSet(); + 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!); + if (mePi != null) + cachedOtherPlayers.Add(new PlayerRowItem(meName ?? "", (meName ?? "?") + " " + "MpPingFilters_YouSuffix".Translate(), + 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); - } + 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) + if (comp != null) { - 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)); + 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 + " " + "MpPingFilters_OfflineSuffix".Translate(), color)); + } } + + others.Sort((a, b) => string.CompareOrdinal(a.username, b.username)); + cachedOtherPlayers.AddRange(others); + cachedOtherPlayersMarkersV = markersV; + cachedOtherPlayersPlayerCount = playerCount; + return cachedOtherPlayers; } - others.Sort((a, b) => string.CompareOrdinal(a.username, b.username)); - cachedOtherPlayers.AddRange(others); - cachedOtherPlayersMarkersV = markersV; - cachedOtherPlayersPlayerCount = playerCount; - return cachedOtherPlayers; + public static string OpenTooltipLabel() + => "MpPingFilters_OpenTooltip".Translate(); } - - 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 index ac83ee1d5..3a7070a66 100644 --- a/Source/Client/Windows/PingHostSettingsDialog.cs +++ b/Source/Client/Windows/PingHostSettingsDialog.cs @@ -6,148 +6,145 @@ 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 +namespace Multiplayer.Client { - public static PingHostSettingsDialog Opened => Find.WindowStack?.WindowOfType(); - - public override Vector2 InitialSize => new(360f, 260f); + // 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(); - private Rect? requestedAnchor; - private string markerCapBuffer; - private int lastMarkerCapBufferedFor = -1; + public override Vector2 InitialSize => new(360f, 260f); - 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; - } + private Rect? requestedAnchor; + private string markerCapBuffer; + private int lastMarkerCapBufferedFor = -1; - public override void SetInitialSizeAndPosition() - { - var size = InitialSize; - var screen = new Vector2(UI.screenWidth, UI.screenHeight); - const float ScreenMargin = 6f; + 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; + } - Vector2 desired; - if (requestedAnchor is { } trigger) + public override void SetInitialSizeAndPosition() { - 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); + 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 - desired = new Vector2(trigger.x, belowY); - requestedAnchor = null; + { + 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); } - else + + public override void PostClose() { - 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); + base.PostClose(); + Multiplayer.settings.pingHostSettingsDialogRect = windowRect; + MultiplayerLoader.Multiplayer.instance?.WriteSettings(); } - 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, "MpPingHostSettings_Title".Translate()); - 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 y = titleRect.yMax + 8f; + var comp = Multiplayer.game?.gameComp; + var loc = Multiplayer.session?.locationPings; - 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 (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 = "MpPingMenuWindow_MarkerCapPerPlayer".Translate(); + var prevCap = comp.markerCapPerPlayer; + var editCap = prevCap; + MpUI.TextFieldNumericLabeled(capRect, $"{capLabel}: ", ref editCap, ref markerCapBuffer, LabelW, PingMarkerCap.Min, PingMarkerCap.Max); + TooltipHandler.TipRegion(capRect, "MpPingMenuWindow_MarkerCapPerPlayer_Tip".Translate(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; - if (lastMarkerCapBufferedFor != comp.markerCapPerPlayer) + 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, "MpPingHostSettings_ClearAllHeader".Translate()); + y = sectionHeaderRect.yMax + 4f; + + const float ButtonH = 28f; + var clearMarkersRect = new Rect(inRect.x, y, inRect.width, ButtonH); + if (Widgets.ButtonText(clearMarkersRect, "MpPingHostSettings_ClearAllMarkers".Translate())) { - markerCapBuffer = comp.markerCapPerPlayer.ToString(); - lastMarkerCapBufferedFor = comp.markerCapPerPlayer; + Find.WindowStack.Add(Dialog_MessageBox.CreateConfirmation( + "MpPingHostSettings_ClearAllMarkers_Confirm".Translate(), + () => + { + loc?.SendClearAllMarkers(); + SoundDefOf.Click.PlayOneShotOnCamera(); + }, + destructive: true)); } - 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) + y = clearMarkersRect.yMax + 4f; + + var clearPingsRect = new Rect(inRect.x, y, inRect.width, ButtonH); + if (Widgets.ButtonText(clearPingsRect, "MpPingHostSettings_ClearAllPings".Translate())) { - comp.SetMarkerCapPerPlayer(editCap); - lastMarkerCapBufferedFor = editCap; + loc?.SendClearAllPings(); + SoundDefOf.Click.PlayOneShotOnCamera(); } - y = capRect.yMax + 12f; + y = clearPingsRect.yMax; } - 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() + => "MpPingHostSettings_OpenTooltip".Translate(); } - - 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 index 64b822b22..8a4dddd49 100644 --- a/Source/Client/Windows/PingInspectPane.cs +++ b/Source/Client/Windows/PingInspectPane.cs @@ -8,245 +8,257 @@ 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 +namespace Multiplayer.Client { - 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() + // Bottom-left pane mirroring MainTabWindow_Inspect; IInspectPane lets PaneWidthFor offset the gizmo grid. + public class PingInspectPane : Window, IInspectPane { - 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 static PingInspectPane Opened => Find.WindowStack?.WindowOfType(); - 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); - } + // Matches MainTabWindow_Inspect.PaneTopY's hardcoded offset. + private const float PaneBottomGap = 35f; - 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 Vector2 InitialSize => new(InspectPaneUtility.PaneWidthFor(this), InspectPaneUtility.PaneHeight); + public override float Margin => 0f; - public override void DoWindowContents(Rect inRect) - { - // Mirror InspectPaneOnGUI: keeps RecentHeight non-zero for CameraDriver. - RecentHeight = InspectPaneUtility.PaneHeight; + 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; + } - var loc = Multiplayer.session?.locationPings; - if (loc == null || !loc.HasSelection) return; + 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); + } - // 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; + 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; - if (cachedMarkersV != markersV || cachedPingsV != pingsV || cachedSelectionV != selectionV - || cachedOnPlanet != onPlanet || cachedMapId != currentMapId) + public override void DoWindowContents(Rect inRect) { - 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; - } + // Mirror InspectPaneOnGUI: keeps RecentHeight non-zero for CameraDriver. + RecentHeight = InspectPaneUtility.PaneHeight; - var selected = cachedSelected; - if (selected.Count == 0) return; + var loc = Multiplayer.session?.locationPings; + if (loc == null || !loc.HasSelection) 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); + // 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; - var rect = inRect.ContractedBy(InspectPaneUtility.PaneInnerMargin); - rect.yMin -= 4f; - rect.yMax += 6f; - Widgets.BeginGroup(rect); - try - { - var titleXOffset = 0f; - if (selected.Count == 1) + if (cachedMarkersV != markersV || cachedPingsV != pingsV || cachedSelectionV != selectionV + || cachedOnPlanet != onPlanet || cachedMapId != currentMapId) { - 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 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 labelRect = new Rect(titleXOffset, 0f, rect.width - titleXOffset, 30f); - using (MpStyle.Set(GameFont.Medium).Set(TextAnchor.UpperLeft)) - Widgets.Label(labelRect, cachedPaneLabel); + var selected = cachedSelected; + if (selected.Count == 0) return; - // 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); + // 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); - if (onPlanet) + var rect = inRect.ContractedBy(InspectPaneUtility.PaneInnerMargin); + rect.yMin -= 4f; + rect.yMax += 6f; + Widgets.BeginGroup(rect); + try { - var buttonRowRect = new Rect(0f, bodyRect.yMax + ButtonRowGap, rect.width, ButtonRowH); - PingSelectionUI.DrawInlineActionsRow(buttonRowRect, selected, loc); + 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(); } } - 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); - } + // 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; - 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(); - } + 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; - var counts = new Dictionary(); - var foreignMarkerCount = 0; - foreach (var info in selected) + private static string PaneLabel(List selected) { - counts.TryGetValue(info.category, out var c); - counts[info.category] = c + 1; - if (info.isMarker && !LocationPings.CanDeleteMarker(info)) - foreignMarkerCount++; + if (selected.Count == 1) + { + var info = selected[0]; + var noun = info.isMarker ? MarkerNoun() : PingNoun(); + // Untyped covers "Default category" and "category whose def vanished after a mod + // uninstall" - either way the bare noun is the honest answer. + var name = info.IsUntyped + ? noun.CapitalizeFirst() + : $"{info.category.DisplayName()} {noun}"; + if (!string.IsNullOrEmpty(info.label)) + name += $" - {info.label}"; + return name; + } + return MultiCountLabel(selected.Count); } - 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) + private static string BodyText(List selected) { - var hours = delta / GenDate.TicksPerHour; - return MpTranslate.Fallback("MpPingSel_HoursAgo", $"{hours}h ago", hours); + if (selected.Count == 1) + { + var info = selected[0]; + var sb = new StringBuilder(); + var name = string.IsNullOrEmpty(info.placedByUsername) ? "?" : info.placedByUsername; + sb.AppendLine("MpPingSel_Attribution".Translate(name)); + + var factionName = info.placedByFactionLoadId >= 0 + ? Find.FactionManager?.GetById(info.placedByFactionLoadId)?.Name + : null; + if (!string.IsNullOrEmpty(factionName)) + sb.AppendLine("MpPingSel_Faction".Translate(factionName)); + + // Only markers carry a meaningful tick stamp - pings fade in seconds anyway. + if (info.isMarker && info.placedAtTick > 0) + sb.AppendLine("MpPingSel_PlacedAtTick".Translate(FormatPlacedAt(info.placedAtTick))); + + if (!info.isMarker) + sb.Append("MpPingSel_Fading".Translate()); + return sb.ToString().TrimEnd(); + } + + // Tally by category. A null key (untyped / unknown-def) gets its own bucket so the + // count doesn't silently merge with Default. + var counts = new Dictionary(); + var untypedCount = 0; + var foreignMarkerCount = 0; + foreach (var info in selected) + { + if (info.IsUntyped) untypedCount++; + else + { + counts.TryGetValue(info.category, out var c); + counts[info.category] = c + 1; + } + if (info.isMarker && !LocationPings.CanDeleteMarker(info)) + foreignMarkerCount++; + } + var lines = new List(); + // Iterate sorted Defs so the breakdown order is deterministic and matches the wheel. + foreach (var cat in MultiplayerPingDef.Sorted(includeDefault: false)) + if (counts.TryGetValue(cat, out var n) && n > 0) + lines.Add($"{n} × {cat.DisplayName()}"); + if (untypedCount > 0) + { + var defLabel = MultiplayerPingDef.Default?.DisplayName() + ?? "MpPingSel_UntypedFallback".Translate().ToString(); + lines.Add($"{untypedCount} × {defLabel}"); + } + if (foreignMarkerCount > 0) + lines.Add(ForeignSelectionLabel(foreignMarkerCount)); + return string.Join("\n", lines); } - if (delta < GenDate.TicksPerDay * 7) + + private static string MarkerNoun() + => "MpPingSel_MarkerNoun".Translate(); + private static string PingNoun() + => "MpPingSel_PingNoun".Translate(); + + private static string MultiCountLabel(int count) + => "MpPingSel_MultiCount".Translate(count); + + private static string ForeignSelectionLabel(int count) + => "MpPingSel_ForeignInSelection".Translate(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 days = delta / GenDate.TicksPerDay; - return MpTranslate.Fallback("MpPingSel_DaysAgo", $"{days}d ago", days); + 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 "MpPingSel_MinutesAgo".Translate(mins); + } + if (delta < GenDate.TicksPerDay) + { + var hours = delta / GenDate.TicksPerHour; + return "MpPingSel_HoursAgo".Translate(hours); + } + if (delta < GenDate.TicksPerDay * 7) + { + var days = delta / GenDate.TicksPerDay; + return "MpPingSel_DaysAgo".Translate(days); + } + return GenDate.DateFullStringAt(placedAtTick, Vector2.zero); } - return GenDate.DateFullStringAt(placedAtTick, Vector2.zero); } } diff --git a/Source/Client/Windows/PingLabelWindow.cs b/Source/Client/Windows/PingLabelWindow.cs index cafa5c315..858d0ba7d 100644 --- a/Source/Client/Windows/PingLabelWindow.cs +++ b/Source/Client/Windows/PingLabelWindow.cs @@ -4,98 +4,97 @@ using UnityEngine; using Verse; -namespace Multiplayer.Client; - -// Modal rename - Confirm sends ClientRenameMarkerPacket; UI updates on the server relay. -public class PingLabelWindow : Window +namespace Multiplayer.Client { - private readonly int markerId; - private string buffer; - private bool focused; + // 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 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); + 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; - } + 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; + 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")); + using (MpStyle.Set(GameFont.Medium).Set(TextAnchor.MiddleLeft)) + Widgets.Label(new Rect(inRect.x, inRect.y, inRect.width - 30f, TitleH), "MpPingLabel_Title".Translate()); - 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; + 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; - } + 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 btnY = inRect.yMax - ButtonH; + var btnW = (inRect.width - Pad) / 2f; + if (Widgets.ButtonText(new Rect(inRect.x, btnY, btnW, ButtonH), "MpPingLabel_Cancel".Translate())) + { + 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(); + 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), "MpPingLabel_Confirm".Translate()) || enterPressed) + { + if (enterPressed) Event.current.Use(); + Confirm(); + } } - } - private void Confirm() - { - if (string.IsNullOrWhiteSpace(buffer)) + private void Confirm() { - Messages.Message(MpTranslate.Fallback("MpPingLabel_EmptyReject", - "Marker label cannot be empty."), - MessageTypeDefOf.RejectInput, historical: false); - return; - } + if (string.IsNullOrWhiteSpace(buffer)) + { + Messages.Message("MpPingLabel_EmptyReject".Translate(), + 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); + // 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("MpPingLabel_NoLongerOwned".Translate(), + MessageTypeDefOf.RejectInput, historical: false); + Close(); + return; + } + + loc.SendRenameMarker(markerId, buffer); Close(); - return; } - loc.SendRenameMarker(markerId, buffer); - Close(); } - } diff --git a/Source/Client/Windows/PingMenuWindow.cs b/Source/Client/Windows/PingMenuWindow.cs index 08d37c010..702e91f32 100644 --- a/Source/Client/Windows/PingMenuWindow.cs +++ b/Source/Client/Windows/PingMenuWindow.cs @@ -7,547 +7,529 @@ 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 +namespace Multiplayer.Client { - public static PingMenuWindow Opened => Find.WindowStack?.WindowOfType(); - - public override Vector2 InitialSize => new(820f, 540f); + // 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(); - private Vector2 listScroll; + public override Vector2 InitialSize => new(820f, 540f); - // 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 Vector2 listScroll; - private const float WheelSectionW = 440f; - private const float SectionGap = 14f; + // 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; - 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; - } + private const float WheelSectionW = 440f; + private const float SectionGap = 14f; - 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 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 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) + public override void PostOpen() { - windowRect = new Rect(saved.x, saved.y, size.x, size.y); - return; + base.PostOpen(); + if (!Multiplayer.settings.rememberLastCategory) return; + var loc = Multiplayer.session?.locationPings; + if (loc == null) return; + if (loc.armedCategory != null) return; + if (string.IsNullOrEmpty(loc.lastUsedCategoryDefName)) return; + // Mod could have been removed since the last open; silent-fail leaves the wheel idle. + var cat = DefDatabase.GetNamedSilentFail(loc.lastUsedCategoryDefName); + if (cat != null && !cat.isDefault) + loc.ArmPlacement(cat, playSound: false); } - var loc = Multiplayer.session?.locationPings; - var cursorWheelCenter = loc?.wheelScreenOrigin ?? new Vector2(screen.x / 2f, screen.y / 2f); + private const float WindowInnerMargin = 18f; + private const float HeaderBlockH = 56f; - var wheelLocalCenterX = WindowInnerMargin + WheelSectionW / 2f; - var bodyHeight = size.y - 2 * WindowInnerMargin - HeaderBlockH; - var wheelLocalCenterY = WindowInnerMargin + HeaderBlockH + bodyHeight / 2f; + 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 desiredX = cursorWheelCenter.x - wheelLocalCenterX; - var desiredY = cursorWheelCenter.y - wheelLocalCenterY; + var loc = Multiplayer.session?.locationPings; + var cursorWheelCenter = loc?.wheelScreenOrigin ?? new Vector2(screen.x / 2f, screen.y / 2f); - var x = Mathf.Clamp(desiredX, ScreenMargin, screen.x - size.x - ScreenMargin); - var y = Mathf.Clamp(desiredY, ScreenMargin, screen.y - size.y - ScreenMargin); + var wheelLocalCenterX = WindowInnerMargin + WheelSectionW / 2f; + var bodyHeight = size.y - 2 * WindowInnerMargin - HeaderBlockH; + var wheelLocalCenterY = WindowInnerMargin + HeaderBlockH + bodyHeight / 2f; - windowRect = new Rect(x, y, size.x, size.y); - } + var desiredX = cursorWheelCenter.x - wheelLocalCenterX; + var desiredY = cursorWheelCenter.y - wheelLocalCenterY; - public override void PostClose() - { - base.PostClose(); - Multiplayer.session?.locationPings?.DisarmPlacement(playSound: false); - Multiplayer.settings.pingMenuWindowRect = windowRect; - MultiplayerLoader.Multiplayer.instance?.WriteSettings(); - } + var x = Mathf.Clamp(desiredX, ScreenMargin, screen.x - size.x - ScreenMargin); + var y = Mathf.Clamp(desiredY, ScreenMargin, screen.y - size.y - ScreenMargin); - 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"))) + windowRect = new Rect(x, y, size.x, size.y); + } + + public override void PostClose() { - // 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(); + base.PostClose(); + Multiplayer.session?.locationPings?.DisarmPlacement(playSound: false); + Multiplayer.settings.pingMenuWindowRect = windowRect; + MultiplayerLoader.Multiplayer.instance?.WriteSettings(); } - TooltipHandler.TipRegion(filtersBtnRect, PingFiltersDialog.OpenTooltipLabel()); - if (isHost) + public override void DoWindowContents(Rect inRect) { - var hostBtnRect = new Rect(filtersBtnRect.xMax + HeaderBtnGap, inRect.y + 2f, - HostBtnW, 24f); - if (Widgets.ButtonText(hostBtnRect, MpTranslate.Fallback("MpPingHostSettings_OpenBtn", "Host Settings"))) + 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, "MpPingMenuWindow_Header".Translate()); + + var filtersBtnRect = new Rect(titleRect.xMax + HeaderBtnGap, inRect.y + 2f, + FiltersBtnW, 24f); + if (Widgets.ButtonText(filtersBtnRect, "MpPingFilters_OpenBtn".Translate())) { + // To screen-space for dialog anchor. 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)); + windowRect.x + filtersBtnRect.x, + windowRect.y + filtersBtnRect.y, + filtersBtnRect.width, + filtersBtnRect.height); + if (PingFiltersDialog.Opened == null) + Find.WindowStack.Add(new PingFiltersDialog(anchor)); else - PingHostSettingsDialog.Opened.Close(); + PingFiltersDialog.Opened.Close(); SoundDefOf.Click.PlayOneShotOnCamera(); + // Stop the click falling through to GUI.DragWindow. Event.current.Use(); } - TooltipHandler.TipRegion(hostBtnRect, PingHostSettingsDialog.OpenTooltipLabel()); - } + TooltipHandler.TipRegion(filtersBtnRect, PingFiltersDialog.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()); + if (isHost) + { + var hostBtnRect = new Rect(filtersBtnRect.xMax + HeaderBtnGap, inRect.y + 2f, + HostBtnW, 24f); + if (Widgets.ButtonText(hostBtnRect, "MpPingHostSettings_OpenBtn".Translate())) + { + 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 bodyTop = subtitleRect.yMax + 8f; - var body = new Rect(inRect.x, bodyTop, inRect.width, inRect.yMax - bodyTop); + 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 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 bodyTop = subtitleRect.yMax + 8f; + var body = new Rect(inRect.x, bodyTop, inRect.width, inRect.yMax - bodyTop); - var dividerX = wheelSection.xMax + SectionGap / 2f; - Widgets.DrawLineVertical(dividerX, body.y + 4f, body.height - 8f); + 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); - DrawWheelSection(wheelSection); - DrawContentSection(contentSection); - } + var dividerX = wheelSection.xMax + SectionGap / 2f; + Widgets.DrawLineVertical(dividerX, body.y + 4f, body.height - 8f); - private void DrawWheelSection(Rect section) - { - var loc = Multiplayer.session?.locationPings; - if (loc == null) return; + DrawWheelSection(wheelSection); + DrawContentSection(contentSection); + } - var wheelCenterLocal = new Vector2(section.center.x, section.center.y); + private void DrawWheelSection(Rect section) + { + var loc = Multiplayer.session?.locationPings; + if (loc == null) return; - // 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); + var wheelCenterLocal = new Vector2(section.center.x, section.center.y); - loc.DrawWheelInDrawer(wheelCenterLocal, Event.current.mousePosition); - } + // 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); - private void DrawContentSection(Rect section) - { - var y = section.y; + loc.DrawWheelInDrawer(wheelCenterLocal, Event.current.mousePosition); + } - 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; + private void DrawContentSection(Rect section) + { + var y = section.y; - var modeRow = new Rect(section.x, y, section.width, 32f); - DrawModeRow(modeRow); - y = modeRow.yMax + 3f; + 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, "MpPingMenuWindow_PlacementType".Translate()); + y = headerRect.yMax + 2f; - 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); - } + var modeRow = new Rect(section.x, y, section.width, 32f); + DrawModeRow(modeRow); + y = modeRow.yMax + 3f; - 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); + 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, ("MpPingMode_" + Multiplayer.settings.pingPlaceMode + "_Description").Translate()); + y = modeDescRect.yMax + 10f; - 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(); + 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, "MpPingMenuWindow_ListHeaderWithCount".Translate(markerCount, cap)); + y = listHeaderRect.yMax + 4f; + + var listRect = new Rect(section.x, y, section.width, section.yMax - y); + DrawList(listRect); } - } - 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)) + private static void DrawModeRow(Rect row) { - loc?.SendClearMyMarkers(); - SoundDefOf.Click.PlayOneShotOnCamera(); + 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(); + } } - 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)) + private void DrawActionStack(Rect section, ref float y) { - loc?.SendClearMyMarkersOnMap(Find.CurrentMap.uniqueID); - SoundDefOf.Click.PlayOneShotOnCamera(); + 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, "MpPingDrawer_ClearAllMine".Translate(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, "MpPingDrawer_ClearMineOnThisMap".Translate(onMapCount), active: hasMap && onMapCount > 0)) + { + loc?.SendClearMyMarkersOnMap(Find.CurrentMap.uniqueID); + SoundDefOf.Click.PlayOneShotOnCamera(); + } + y = clearOnMapRect.yMax; } - 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); + 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); + 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++) + if (rows.Count == 0) { - var rowRect = new Rect(0f, i * stride, viewRect.width, rowHeight); - DrawListRow(rowRect, rows[i]); + 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), "MpPingMenuWindow_Empty".Translate()); + } + 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(); + Widgets.EndScrollView(); } - if (info.isMarker) + private void DrawListRow(Rect rect, PingInfo info) { - 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)) + 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?.IconTexture; + 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 catName = info.category?.DisplayName() ?? ""; + var primary = string.IsNullOrEmpty(info.label) + ? catName + : $"{catName} - {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)) { - Find.WindowStack.Add(new PingLabelWindow(info.markerId, info.label)); + Multiplayer.session.locationPings.JumpToAndSelect(info); SoundDefOf.Click.PlayOneShotOnCamera(); } - if (Widgets.ButtonText(deleteRect, DeleteLabel(), active: canDelete)) + + if (info.isMarker) { - Multiplayer.session?.locationPings?.SendDeleteMarker(info.markerId); - SoundDefOf.Click.PlayOneShotOnCamera(); + 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) + // Atlas alone doesn't read as a tab - add accent bar. + private static bool DrawModeTabButton(Rect rect, string label, PingPlaceMode mode, bool selected) { - var accent = ModeAccentColor(mode); - var bar = new Rect(rect.x + 4f, rect.yMax - 4f, rect.width - 8f, 3f); - Widgets.DrawBoxSolid(bar, accent); - } + var atlas = selected ? Widgets.ButtonBGAtlasClick + : (Mouse.IsOver(rect) ? Widgets.ButtonBGAtlasMouseover : Widgets.ButtonBGAtlas); + Widgets.DrawAtlas(rect, atlas); - 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), - }; + if (selected) + { + var accent = ModeAccentColor(mode); + var bar = new Rect(rect.x + 4f, rect.yMax - 4f, rect.width - 8f, 3f); + Widgets.DrawBoxSolid(bar, accent); + } - // 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; + 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); } - 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; + 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), + }; - if (comp != null) + // Local player only. + private List BuildRows() { - 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); + 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; } - for (var i = loc.pings.Count - 1; i >= 0; i--) - if (loc.pings[i].IsOwnedByLocalPlayer()) - rows.Add(loc.pings[i]); + private static List ComputeRowsUncached(LocationPings loc, MultiplayerGameComp comp) + { + var rows = new List(); + if (loc == null) return rows; - 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); + } - private int MyMarkerCount() - { - var comp = Multiplayer.game?.gameComp; - if (comp == null) return 0; - if (cachedMyMarkerCountVersion == comp.markersVersion) return cachedMyMarkerCount; + for (var i = loc.pings.Count - 1; i >= 0; i--) + if (loc.pings[i].IsOwnedByLocalPlayer()) + rows.Add(loc.pings[i]); - var n = 0; - foreach (var m in comp.AllMarkers) - if (m.IsOwnedByLocalPlayer()) n++; + return rows; + } - cachedMyMarkerCount = n; - cachedMyMarkerCountVersion = comp.markersVersion; - return n; - } + private int MyMarkerCount() + { + var comp = Multiplayer.game?.gameComp; + if (comp == null) return 0; + if (cachedMyMarkerCountVersion == comp.markersVersion) return cachedMyMarkerCount; - 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; - } + var n = 0; + foreach (var m in comp.AllMarkers) + if (m.IsOwnedByLocalPlayer()) 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 + cachedMyMarkerCount = n; + cachedMyMarkerCountVersion = comp.markersVersion; + return n; + } - 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 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++; - private static string RemainingTimeLabel(PingInfo p) - { - var remaining = Mathf.Max(0f, PingInfo.PingDuration - p.timeAlive); - return $"{remaining:0.0}s"; - } + cachedMyMarkersOnMap = n; + cachedMyMarkersOnMapVersion = comp.markersVersion; + cachedMyMarkersOnMapMapId = id; + return n; + } - private static string DeleteLabel() - => MpTranslate.Fallback("MpPingMenuWindow_Delete", "Delete"); - private static string RenameLabel() - => MpTranslate.Fallback("MpPingSel_Rename", "Rename"); + #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 SubtitleLabel() - { - var loc = Multiplayer.session?.locationPings; - var modeWord = ModeWordLower(Multiplayer.settings.pingPlaceMode); + private static string TargetDescription(PingInfo info) + { + if (info.mapId == -1) + return "MpPingMenuWindow_TargetPlanet".Translate(); + var map = Find.Maps.GetById(info.mapId); + return map?.Parent?.LabelCap + ?? "MpPingMenuWindow_TargetMap".Translate(); + } - if (loc?.armedCategory is { } cat) + private static string RemainingTimeLabel(PingInfo p) { - var catName = cat.DisplayName(); - return MpTranslate.Fallback("MpPingMenuWindow_Subtitle_Armed", - $"Click on the map to place a {catName} {modeWord}.", - catName, modeWord); + var remaining = Mathf.Max(0f, PingInfo.PingDuration - p.timeAlive); + return $"{remaining:0.0}s"; } - 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 DeleteLabel() + => "MpPingMenuWindow_Delete".Translate(); + private static string RenameLabel() + => "MpPingSel_Rename".Translate(); + + private static string SubtitleLabel() + { + var loc = Multiplayer.session?.locationPings; + var modeWord = ModeWordLower(Multiplayer.settings.pingPlaceMode); - private static string ModeLabel(PingPlaceMode m) - => MpTranslate.Fallback("MpPingMode_" + m, - m switch + if (loc?.armedCategory is { } cat) { - PingPlaceMode.Ping => "Ping", - PingPlaceMode.Marker => "Marker", - _ => m.ToString(), - }); + var catName = cat.DisplayName(); + return "MpPingMenuWindow_Subtitle_Armed".Translate(catName, modeWord); + } + return "MpPingMenuWindow_Subtitle_Idle".Translate(modeWord); + } + + private static string ModeWordLower(PingPlaceMode m) + => ("MpPingMode_" + m + "_LowerWord").Translate(); + + private static string ModeLabel(PingPlaceMode m) + => ("MpPingMode_" + m).Translate(); + } } diff --git a/Source/Common/Networking/Packet/ClearMarkersPacket.cs b/Source/Common/Networking/Packet/ClearMarkersPacket.cs index 9249b9452..06abda1c2 100644 --- a/Source/Common/Networking/Packet/ClearMarkersPacket.cs +++ b/Source/Common/Networking/Packet/ClearMarkersPacket.cs @@ -1,57 +1,58 @@ 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 +namespace Multiplayer.Common.Networking.Packet { - 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 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 void Bind(PacketBuffer buf) + public static class PingMarkerClearWire { - buf.Bind(ref playerId); - buf.Bind(ref username, maxLength: MultiplayerServer.MaxUsernameLength); - buf.Bind(ref senderIsHost); - buf.Bind(ref data); + public static readonly int Count = Enum.GetValues(typeof(PingMarkerClearMode)).Length; + public static bool IsValid(byte raw) => raw < Count; } -} -[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; + // 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) + 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 { - buf.Bind(ref mode); - buf.Bind(ref mapId); - buf.Bind(ref targetUsername, maxLength: MultiplayerServer.MaxUsernameLength); + 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 index ad9d96555..c0a0ce0a4 100644 --- a/Source/Common/Networking/Packet/DeleteMarkerPacket.cs +++ b/Source/Common/Networking/Packet/DeleteMarkerPacket.cs @@ -1,38 +1,39 @@ 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 +namespace Multiplayer.Common.Networking.Packet { - 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) + // 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 { - buf.Bind(ref playerId); - buf.Bind(ref factionId); - buf.Bind(ref username, maxLength: MultiplayerServer.MaxUsernameLength); - buf.Bind(ref senderIsHost); - buf.Bind(ref data); + 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; + [PacketDefinition(Packets.Client_DeleteMarker)] + public record struct ClientDeleteMarkerPacket(int[] markerIds) : IPacket + { + public const int MaxBatchSize = 256; - public int[] markerIds = markerIds; + 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); + 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 deleted file mode 100644 index 6896b4125..000000000 --- a/Source/Common/Networking/Packet/PingCategory.cs +++ /dev/null @@ -1,37 +0,0 @@ -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/PingCategoryWire.cs b/Source/Common/Networking/Packet/PingCategoryWire.cs new file mode 100644 index 000000000..f1cf1014a --- /dev/null +++ b/Source/Common/Networking/Packet/PingCategoryWire.cs @@ -0,0 +1,34 @@ +namespace Multiplayer.Common.Networking.Packet +{ + // Wire-side constants for ping categories. The category identifier itself is a ushort short-hash + // resolved client-side against DefDatabase; this file just holds the shared + // label-length limits and the "unknown" sentinel. + // + // Common/ is RimWorld-blind, so we can't reference MultiplayerPingDef here - clients map the + // ushort back to a Def themselves. + public static class PingCategoryWire + { + public const int MaxLabelChars = 64; + public const int MaxLabelBytes = MaxLabelChars * 4; + + // 0 means "unknown / fall back to Default" on the receiver. Real defs always hash to non-zero + // via Verse.GenText.StableStringHash + the short-hash routine, so a packet with category == 0 + // is either a legacy probe or a sender that couldn't resolve its own def. + public const ushort UnknownHash = 0; + } + + // 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 2c1ca0c6e..7c1333826 100644 --- a/Source/Common/Networking/Packet/PingLocationPacket.cs +++ b/Source/Common/Networking/Packet/PingLocationPacket.cs @@ -1,62 +1,65 @@ -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, int factionId, string username, byte r, byte g, byte b, ClientPingLocPacket data) : IPacket +namespace Multiplayer.Common.Networking.Packet { - 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) + // playerId / factionId / username / color stamped by the server, not trusted from the client. + [PacketDefinition(Packets.Server_PingLocation)] + public record struct ServerPingLocPacket(int playerId, int factionId, string username, byte r, byte g, byte b, ClientPingLocPacket data) : IPacket { - 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); - } -} + 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; -[PacketDefinition(Packets.Client_PingLocation)] -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) + { + 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); + } + } - public void Bind(PacketBuffer buf) + [PacketDefinition(Packets.Client_PingLocation)] + public record struct ClientPingLocPacket( + int mapId, + int planetTileId, + int planetTileLayer, + float x, float y, float z, + ushort category, + bool isMarker, + string label, + int placedAtTick + ) : IPacket { - buf.Bind(ref mapId); - buf.Bind(ref planetTileId); - buf.Bind(ref planetTileLayer); - 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); + public int mapId = mapId; + public int planetTileId = planetTileId; + public int planetTileLayer = planetTileLayer; + public float x = x, y = y, z = z; + // Short-hash of the placer's MultiplayerPingDef. Receivers without that def map back to + // Default (PingCategoryExtensions.ResolveFromWire); zero is the explicit "unknown" sentinel. + public ushort 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) + { + buf.Bind(ref mapId); + buf.Bind(ref planetTileId); + buf.Bind(ref planetTileLayer); + 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 index f3030138c..b2e7d75a1 100644 --- a/Source/Common/Networking/Packet/RenameMarkerPacket.cs +++ b/Source/Common/Networking/Packet/RenameMarkerPacket.cs @@ -1,34 +1,35 @@ -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 +namespace Multiplayer.Common.Networking.Packet { - 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) + // 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 { - buf.Bind(ref playerId); - buf.Bind(ref factionId); - buf.Bind(ref username, maxLength: MultiplayerServer.MaxUsernameLength); - buf.Bind(ref senderIsHost); - buf.Bind(ref data); - } -} + public int playerId = playerId; + public int factionId = factionId; + public string username = username; + public bool senderIsHost = senderIsHost; + public ClientRenameMarkerPacket data = 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 playerId); + buf.Bind(ref factionId); + buf.Bind(ref username, maxLength: MultiplayerServer.MaxUsernameLength); + buf.Bind(ref senderIsHost); + buf.Bind(ref data); + } + } - public void Bind(PacketBuffer buf) + [PacketDefinition(Packets.Client_RenameMarker)] + public record struct ClientRenameMarkerPacket(int markerId, string label) : IPacket { - buf.Bind(ref markerId); - buf.Bind(ref label, maxLength: PingCategoryWire.MaxLabelBytes); + 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/State/ServerPlayingState.cs b/Source/Common/Networking/State/ServerPlayingState.cs index 1886d0846..b5941cd9e 100644 --- a/Source/Common/Networking/State/ServerPlayingState.cs +++ b/Source/Common/Networking/State/ServerPlayingState.cs @@ -2,325 +2,327 @@ using System.Linq; using Multiplayer.Common.Networking.Packet; -namespace Multiplayer.Common; - -public class ServerPlayingState(ConnectionBase conn) : MpConnectionState(conn) +namespace Multiplayer.Common { - [PacketHandler(Packets.Client_WorldReady)] - public void HandleWorldReady(ByteReader data) - { - Player.UpdateStatus(PlayerStatus.Playing); - } - - [PacketHandler(Packets.Client_RequestRejoin)] - public void HandleRejoin(ByteReader data) + public class ServerPlayingState(ConnectionBase conn) : MpConnectionState(conn) { - connection.ChangeState(ConnectionStateEnum.ServerLoading); - Player.ResetTimeVotes(); - } - - [TypedPacketHandler] - public void HandleDesynced(ClientDesyncedPacket packet) => - Server.playerManager.OnDesync(Player, packet.tick, packet.diffAt); + [PacketHandler(Packets.Client_WorldReady)] + public void HandleWorldReady(ByteReader data) + { + Player.UpdateStatus(PlayerStatus.Playing); + } - [TypedPacketHandler] - public void HandleTraces(ClientTracesPacket packet) - { - if (!Player.IsHost) return; - Server.GetPlayer(packet.playerId)?.SendPacket(ServerTracesPacket.Transfer(packet.rawTraces, packet.rawJittedMethods)); - } + [PacketHandler(Packets.Client_RequestRejoin)] + public void HandleRejoin(ByteReader data) + { + connection.ChangeState(ConnectionStateEnum.ServerLoading); + Player.ResetTimeVotes(); + } - [TypedPacketHandler] - public void HandleClientCommand(ClientCommandPacket packet) - { - int? mapToResync = null; + [TypedPacketHandler] + public void HandleDesynced(ClientDesyncedPacket packet) => + Server.playerManager.OnDesync(Player, packet.tick, packet.diffAt); - if (packet.type == CommandType.PlayerCount) + [TypedPacketHandler] + public void HandleTraces(ClientTracesPacket packet) { - 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; + if (!Player.IsHost) return; + Server.GetPlayer(packet.playerId)?.SendPacket(ServerTracesPacket.Transfer(packet.rawTraces, packet.rawJittedMethods)); } - // todo check if map id is valid for the player + [TypedPacketHandler] + public void HandleClientCommand(ClientCommandPacket packet) + { + int? mapToResync = null; - Server.commands.Send(packet.type, Player.FactionId, packet.mapId, packet.data, 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; - if (mapToResync is int currentMapId) - Server.SendMapResponse(Player, currentMapId); - } + if (Server.CanUseStandaloneMapStreaming(newMapId)) + mapToResync = newMapId; + } - public const int MaxChatMsgLength = 128; + // todo check if map id is valid for the player - [TypedPacketHandler] - public void HandleChat(ClientChatPacket packet) - { - string msg = packet.msg; - msg = msg.Trim(); + Server.commands.Send(packet.type, Player.FactionId, packet.mapId, packet.data, Player); - if (msg.Length == 0) return; + if (mapToResync is int currentMapId) + Server.SendMapResponse(Player, currentMapId); + } - if (msg.Length > MaxChatMsgLength) - msg = msg[..MaxChatMsgLength]; + public const int MaxChatMsgLength = 128; - if (msg[0] == '/') - { - var cmd = msg[1..]; - Server.HandleChatCmd(Player, cmd); - } - else + [TypedPacketHandler] + public void HandleChat(ClientChatPacket packet) { - Server.SendChat($"{connection.username}: {msg}"); + 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}"); + } } - } - - [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++) + [PacketHandler(Packets.Client_WorldDataUpload, allowFragmented: true)] + public void HandleWorldDataUpload(ByteReader data) { - int mapId = data.ReadInt32(); - Server.worldData.mapData[mapId] = data.ReadPrefixedBytes(); - } + // On standalone, accept from any playing client; otherwise only host/arbiter + if (!Server.IsStandaloneServer && (Server.ArbiterPlaying ? !Player.IsArbiter : !Player.IsHost)) + return; - Server.worldData.savedGame = data.ReadPrefixedBytes(); - Server.worldData.sessionData = data.ReadPrefixedBytes(); + ServerLog.Detail($"Got world upload {data.Left}"); - if (Server.worldData.CreatingJoinPoint) - Server.worldData.EndJoinPointCreation(); - } + Server.worldData.mapData = new Dictionary(); - [TypedPacketHandler] - public void HandleStandaloneWorldSnapshot(ClientStandaloneWorldSnapshotPacket packet) - { - if (!Server.IsStandaloneServer) - return; + int maps = data.ReadInt32(); + for (int i = 0; i < maps; i++) + { + int mapId = data.ReadInt32(); + Server.worldData.mapData[mapId] = data.ReadPrefixedBytes(); + } - if (!Player.IsPlaying) - return; + Server.worldData.savedGame = data.ReadPrefixedBytes(); + Server.worldData.sessionData = data.ReadPrefixedBytes(); - var accepted = Server.worldData.TryAcceptStandaloneWorldSnapshot(Player, packet.tick, - packet.worldData, packet.sessionData, packet.sha256Hash); + if (Server.worldData.CreatingJoinPoint) + Server.worldData.EndJoinPointCreation(); + } - if (accepted) + [TypedPacketHandler] + public void HandleStandaloneWorldSnapshot(ClientStandaloneWorldSnapshotPacket packet) { - ServerLog.Detail( - $"Accepted standalone world snapshot tick={packet.tick} from {Player.Username}"); + 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}"); + } } - else + + [TypedPacketHandler] + public void HandleStandaloneMapSnapshot(ClientStandaloneMapSnapshotPacket packet) { - ServerLog.Detail( - $"Rejected standalone world snapshot tick={packet.tick} from {Player.Username}"); + 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}"); + } } - } - [TypedPacketHandler] - public void HandleStandaloneMapSnapshot(ClientStandaloneMapSnapshotPacket packet) - { - if (!Server.IsStandaloneServer) - return; + [TypedPacketHandler] + public void HandleCursor(ClientCursorPacket clientPacket) + { + if (Player.lastCursorTick == Server.NetTimer) return; // policy + Player.lastCursorTick = Server.NetTimer; - if (!Player.IsPlaying) - return; + var serverPacket = new ServerCursorPacket(Player.id, clientPacket); + Server.SendToIngame(serverPacket, reliable: false, excluding: Player); + } - var accepted = Server.worldData.TryAcceptStandaloneMapSnapshot(Player, packet.mapId, packet.tick, - packet.mapData, packet.sha256Hash); + [TypedPacketHandler] + public void HandleSelected(ClientSelectedPacket packet) => + Server.SendToPlaying(new ServerSelectedPacket(Player.id, packet), excluding: Player); - if (accepted) + [TypedPacketHandler] + public void HandlePing(ClientPingLocPacket packet) { - ServerLog.Detail( - $"Accepted standalone map snapshot map={packet.mapId} tick={packet.tick} from {Player.Username}"); + if (Player.lastPingTick == Server.NetTimer) return; + // Common/ is RimWorld-blind so we can't validate the def-hash here - clients map an + // unknown hash back to Default at receive time (see PingCategoryExtensions.ResolveFromWire). + 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); } - else + + [TypedPacketHandler] + public void HandleClearMarkers(ClientClearMarkersPacket packet) { - ServerLog.Detail( - $"Rejected standalone map snapshot map={packet.mapId} tick={packet.tick} from {Player.Username}"); + 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 HandleCursor(ClientCursorPacket clientPacket) - { - if (Player.lastCursorTick == Server.NetTimer) return; // policy - Player.lastCursorTick = Server.NetTimer; - - var serverPacket = new ServerCursorPacket(Player.id, clientPacket); - Server.SendToIngame(serverPacket, reliable: false, excluding: Player); - } - - [TypedPacketHandler] - public void HandleSelected(ClientSelectedPacket packet) => - Server.SendToPlaying(new ServerSelectedPacket(Player.id, packet), excluding: Player); - - [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); - } - - [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 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); - } + [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); + } - [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); - } + [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); + } - [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 HandleClientKeepAlive(ClientKeepAlivePacket packet) + { + Player.ticksBehind = packet.ticksBehind; + Player.ticksBehindReceivedAt = Server.gameTimer; + Player.simulating = packet.simulating; + Player.keepAliveAt = Server.NetTimer; - if (Player.IsHost) - Server.workTicks = packet.workTicks; + if (Player.IsHost) + Server.workTicks = packet.workTicks; - var idMatched = Player.keepAliveId == packet.id; - connection.OnKeepAliveArrived(idMatched); - if (idMatched) Player.keepAliveId++; - } + var idMatched = Player.keepAliveId == packet.id; + connection.OnKeepAliveArrived(idMatched); + if (idMatched) Player.keepAliveId++; + } - [TypedPacketHandler] - public void HandleDesyncCheck(ClientSyncInfoPacket packet) - { - var arbiter = Server.ArbiterPlaying; - if (arbiter ? !Player.IsArbiter : !Player.IsHost) return; // policy + [TypedPacketHandler] + public void HandleDesyncCheck(ClientSyncInfoPacket packet) + { + var arbiter = Server.ArbiterPlaying; + if (arbiter ? !Player.IsArbiter : !Player.IsHost) return; // policy - // Keep at most 10 sync infos - Server.worldData.syncInfos.Add(packet.rawSyncOpinion); - if (Server.worldData.syncInfos.Count > 10) - Server.worldData.syncInfos.RemoveAt(0); + // Keep at most 10 sync infos + Server.worldData.syncInfos.Add(packet.rawSyncOpinion); + if (Server.worldData.syncInfos.Count > 10) + Server.worldData.syncInfos.RemoveAt(0); - foreach (var p in Server.PlayingPlayers.Where(p => !p.IsArbiter && (arbiter || !p.IsHost))) - p.conn.SendFragmented(new ServerSyncInfoPacket { rawSyncOpinion = packet.rawSyncOpinion }.Serialize()); - } + 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; + [TypedPacketHandler] + public void HandleFreeze(ClientFreezePacket packet) + { + Player.frozen = packet.freeze; - if (!packet.freeze) - Player.unfrozenAt = Server.NetTimer; - } + if (!packet.freeze) + Player.unfrozenAt = Server.NetTimer; + } - [TypedPacketHandler] - public void HandleAutosaving(ClientAutosavingPacket packet) - { - var forceJoinPoint = packet.reason == JoinPointRequestReason.Save; + [TypedPacketHandler] + public void HandleAutosaving(ClientAutosavingPacket packet) + { + var forceJoinPoint = packet.reason == JoinPointRequestReason.Save; - ServerLog.Detail( - $"Received Client_Autosaving from {Player.Username}, standalone={Server.IsStandaloneServer}, " + - $"isHost={Player.IsHost}, reason={packet.reason}, force={forceJoinPoint}"); + 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); + } - // 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); - } + [TypedPacketHandler] + public void HandleDebug(ClientDebugPacket _) + { + if (!Server.commands.CanUseDevMode(Player)) + return; - [TypedPacketHandler] - public void HandleDebug(ClientDebugPacket _) - { - if (!Server.commands.CanUseDevMode(Player)) - return; + Server.worldData.mapCmds.Clear(); + Server.gameTimer = Server.startingTimer; - Server.worldData.mapCmds.Clear(); - Server.gameTimer = Server.startingTimer; + Server.SendToPlaying(new ServerDebugPacket()); + } - Server.SendToPlaying(new ServerDebugPacket()); - } + [TypedPacketHandler] + public void HandleSetFaction(ClientSetFactionPacket packet) + { + // todo restrict handling - [TypedPacketHandler] - public void HandleSetFaction(ClientSetFactionPacket packet) - { - // todo restrict handling + int playerId = packet.playerId; + int factionId = packet.factionId; - int playerId = packet.playerId; - int factionId = packet.factionId; + var player = Server.GetPlayer(playerId); + if (player == null) return; + if (player.FactionId == factionId) return; - var player = Server.GetPlayer(playerId); - if (player == null) return; - if (player.FactionId == factionId) return; + player.FactionId = factionId; + Server.SendToPlaying(new ServerSetFactionPacket(playerId, factionId)); + } - player.FactionId = factionId; - Server.SendToPlaying(new ServerSetFactionPacket(playerId, factionId)); + [TypedPacketHandler] + public void HandleFrameTime(ClientFrameTimePacket packet) => Player.frameTime = packet.frameTime; } - - [TypedPacketHandler] - public void HandleFrameTime(ClientFrameTimePacket packet) => Player.frameTime = packet.frameTime; } diff --git a/Source/Common/Version.cs b/Source/Common/Version.cs index fe091a7d7..bdb307bc7 100644 --- a/Source/Common/Version.cs +++ b/Source/Common/Version.cs @@ -8,7 +8,7 @@ public static class MpVersion public const string SimpleVersion = "0.11.5"; // Wire-compatibility protocol version; intentionally distinct from Packets.Max. - public const int Protocol = 63; + public const int Protocol = 56; public static readonly string? GitHash = Assembly.GetExecutingAssembly() .GetCustomAttributes() diff --git a/Source/Tests/PacketTest.cs b/Source/Tests/PacketTest.cs index 318c79222..79e8c1727 100644 --- a/Source/Tests/PacketTest.cs +++ b/Source/Tests/PacketTest.cs @@ -55,21 +55,24 @@ private static IEnumerable RoundtripPackets() data = [] }; - yield return new ClientPingLocPacket(0, 0, 0, 0f, 0f, 0f, (byte)PingCategory.Default, false, "", 0); + // Category is a ushort short-hash now; the test only cares about wire roundtrip so any + // representative values cover the bit-pattern. UnknownHash (0) is the "fall back to Default" + // sentinel, large values cover the upper half of the ushort range. + yield return new ClientPingLocPacket(0, 0, 0, 0f, 0f, 0f, (ushort)PingCategoryWire.UnknownHash, 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(1, 42, 3, 10.5f, -2.25f, 99.9f, (ushort)0x1234, 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 ClientPingLocPacket(9, 7, 0, -1.5f, 0f, 2.5f, (ushort)0xABCD, 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)); + new ClientPingLocPacket(5, 123, 1, 1.23f, 4.56f, 7.89f, (ushort)0x5678, 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)); + new ClientPingLocPacket(2, 0, 0, 50.5f, 1f, 80f, (ushort)ushort.MaxValue, 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)); + new ClientPingLocPacket(0, 0, 0, 0f, 0f, 0f, (ushort)PingCategoryWire.UnknownHash, false, "", 0)); yield return new ClientClearMarkersPacket((byte)PingMarkerClearMode.Mine, -1, ""); yield return new ClientClearMarkersPacket((byte)PingMarkerClearMode.OnMap, 42, ""); diff --git a/Source/Tests/packet-serializations/ClientPingLocPacket.verified.txt b/Source/Tests/packet-serializations/ClientPingLocPacket.verified.txt index 2e7c4deb1..a46e3e7e7 100644 --- a/Source/Tests/packet-serializations/ClientPingLocPacket.verified.txt +++ b/Source/Tests/packet-serializations/ClientPingLocPacket.verified.txt @@ -1,3 +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-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) +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-00 (35 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-34-12-00-09-00-00-00-72-75-73-68-20-74-68-69-73-60-EA-00-00 (44 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-CD-AB-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 (51 bytes) diff --git a/Source/Tests/packet-serializations/ServerPingLocPacket.verified.txt b/Source/Tests/packet-serializations/ServerPingLocPacket.verified.txt index 13c5d0e7a..83ea1a176 100644 --- a/Source/Tests/packet-serializations/ServerPingLocPacket.verified.txt +++ b/Source/Tests/packet-serializations/ServerPingLocPacket.verified.txt @@ -1,3 +1,3 @@ -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) +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-78-56-00-0C-00-00-00-69-72-6F-6E-20-64-65-70-6F-73-69-74-90-D0-03-00 (67 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-FF-FF-01-0E-00-00-00-73-74-6F-63-6B-70-69-6C-65-20-68-65-72-65-40-42-0F-00 (67 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-00 (50 bytes)