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/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/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/Desyncs/SaveableDesyncInfo.cs b/Source/Client/Desyncs/SaveableDesyncInfo.cs index 200c3dfee..646d1a075 100644 --- a/Source/Client/Desyncs/SaveableDesyncInfo.cs +++ b/Source/Client/Desyncs/SaveableDesyncInfo.cs @@ -60,9 +60,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 +72,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 +109,12 @@ 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"; + desyncInfo .AppendLine("###Tick Data###") .AppendLine($"Arbiter Connected And Playing|||{Multiplayer.session.ArbiterPlaying}") @@ -123,6 +132,9 @@ 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###CPU Info###") .AppendLine($"Processor Name|||{SystemInfo.processorType}") .AppendLine($"Processor Speed (MHz)|||{SystemInfo.processorFrequency}") 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..789508b58 100644 --- a/Source/Client/MultiplayerStatic.cs +++ b/Source/Client/MultiplayerStatic.cs @@ -28,9 +28,353 @@ 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); + + // 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); + + // 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"); + 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 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 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 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)); + } + } + + 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..e10e2e84e 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,20 @@ private static bool DrawColorRow(MpSettings settings, int pos, ref ColorRGBClien return false; } + private static string MpPingWheelLabel() + => "MpEnablePingWheel".Translate(); + + private static string MpPingWheelDesc() + => "MpEnablePingWheelDesc".Translate(); + + private static string MpPingPlaceModeLabel() + => "MpPingPlaceModeSetting".Translate(); + + private static string MpPingPlaceModeValue(PingPlaceMode mode) + => mode == PingPlaceMode.Marker + ? "MpPingMode_Marker".Translate() + : "MpPingMode_Ping".Translate(); + 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..ae6b1e5ba 100644 --- a/Source/Client/UI/AlertPing.cs +++ b/Source/Client/UI/AlertPing.cs @@ -1,6 +1,5 @@ -using System.Collections.Generic; +using System.Collections.Generic; using System.Linq; -using HarmonyLib; using RimWorld; using RimWorld.Planet; using UnityEngine; @@ -8,9 +7,11 @@ 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 AlertPing() { defaultPriority = AlertPriority.Critical; @@ -35,7 +36,13 @@ public override TaggedString GetExplanation() if (Multiplayer.Client == null) return ""; - var players = Multiplayer.session.locationPings.pings.Select(p => p.PlayerInfo?.username).AllNotNull().JoinStringsAtMost(); + // 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()}"; } @@ -48,17 +55,32 @@ private List Culprits culpritList.Clear(); if (Multiplayer.Client != null && !Multiplayer.session.locationPings.alertHidden) - foreach (var ping in Multiplayer.session.locationPings.pings) + { + 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; } } + // 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); @@ -72,13 +94,4 @@ public override void OnClick() base.OnClick(); } } - - [HarmonyPatch(typeof(Alert), nameof(Alert.Notify_Started))] - static class PreventAlertPingBounce - { - static bool Prefix(Alert __instance) - { - return __instance is not AlertPing; - } - } } 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..e04bded1f 100644 --- a/Source/Client/UI/DrawPingPlanet.cs +++ b/Source/Client/UI/DrawPingPlanet.cs @@ -1,48 +1,146 @@ -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, + "MpPingCluster_Label".Translate(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..19f890df1 --- /dev/null +++ b/Source/Client/UI/LocationPings.Receive.cs @@ -0,0 +1,285 @@ +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 = 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, + }; + + 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) + { + 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..c0b374943 --- /dev/null +++ b/Source/Client/UI/LocationPings.Wheel.cs @@ -0,0 +1,651 @@ +using System.Collections.Generic; +using Multiplayer.Client.Util; +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; + + // 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); + } + + // 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 Texture2D[] GetSectors(int slots) + { + if (SectorTexCache.TryGetValue(slots, out var arr)) return arr; + return SectorTexCache[slots] = MakeSectorSet(slots, outerRadius: 127f, innerRadius: 44f); + } + + private static Texture2D[] GetSectorArcs(int slots) + { + if (SectorArcCache.TryGetValue(slots, out var arr)) return arr; + return SectorArcCache[slots] = MakeSectorSet(slots, outerRadius: 127f, innerRadius: 119f); + } + + 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; + } + + // 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; + + if (total == 0) + { + slotCount = 1; + totalPages = 1; + list.Add(WheelSlot.Empty); + return list; + } + + if (total <= WheelMaxSlots) + { + for (int i = 0; i < total; i++) + list.Add(new WheelSlot(cats[i])); + slotCount = total; + totalPages = 1; + return list; + } + + // 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++]); + } + + return list; + } + + // 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 (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; + } + + private static int FirstCatIndexForPage(int page) + { + // 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; + } + + // 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) + { + var cats = MultiplayerPingDef.Sorted(includeDefault: false); + return BuildPage(cats, wheelPage, out slotCount, out totalPages); + } + + // 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; + } + + // 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; + } + + 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. 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 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 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; + } + + private void DrawWheelCore(Vector2 center, Vector2 mousePos, bool inDrawer) + { + 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 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]); + } + + for (var i = 0; i < slotCount; i++) + { + 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)); + } + + // 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); + } + + 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; + } + + // 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); + } + + // 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) + { + 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)); + } + } + + 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 == 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 68179ff78..daab4f66e 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,518 @@ 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; + // 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 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. + // 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(); + // 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 = 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(); + } + } + + 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. + MultiplayerPingDef toFire; + if (wheelActive) + toFire = hoveredCategory; // null if in the deadzone + else + toFire = MultiplayerPingDef.Default; + + if (toFire != null) + { + var asMarker = Multiplayer.settings.pingPlaceMode == PingPlaceMode.Marker; + FirePing(wheelTargetMapId, wheelTargetTile, wheelTargetMapLoc, toFire, 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; + var def = MultiplayerPingDef.Default; + if (def == null) return; + FirePing(mapId, tile, mapLoc, def, 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(MultiplayerPingDef category, bool playSound = true) + { + if (category == null || category.isDefault) return; + 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 == null) return false; + FirePing(mapId, tile, mapLoc, armedCategory, 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; + 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) + { + 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, 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, + 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; + // 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..2a9c8d91a --- /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 + { + 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. 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) + { + using (MpStyle.Set(cat.tint)) + GUI.DrawTexture(iconRect, iconTex); + } + + // 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)) + { + 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) + => "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 new file mode 100644 index 000000000..3f16bed08 --- /dev/null +++ b/Source/Client/UI/PingCategoryExtensions.cs @@ -0,0 +1,29 @@ +using Multiplayer.Common.Networking.Packet; +using Verse; + +namespace Multiplayer.Client +{ + // 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 + { + // 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) + { + 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 3809575dc..46ac6df9d 100644 --- a/Source/Client/UI/PingInfo.cs +++ b/Source/Client/UI/PingInfo.cs @@ -1,27 +1,50 @@ 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); + // 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; + + // ++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 +60,115 @@ 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 (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; + + // 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 +198,173 @@ 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 (!IsUntyped) + { + var iconTex = category.IconTexture; + 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 if (!string.IsNullOrEmpty(category.glyph)) + { + 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 (!IsUntyped) + { + 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); + + // 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 = PingCategoryExtensions.ResolveFromWire(catHash); + + 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"); + + // 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"); + + 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..a4eb6bbab --- /dev/null +++ b/Source/Client/UI/PingSelectionUI.cs @@ -0,0 +1,606 @@ +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 + ? "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) + { + 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 = "MpPingSel_MutePlayerDesc".Translate(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 = "MpPingSel_MuteFactionDesc".Translate(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 = "MpPingSel_MuteSpectatorsDesc".Translate(), + 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 = "MpPingSel_TransparencyDesc".Translate(), + icon = MultiplayerStatic.PingTransparencyIcon, + action = () => + { + Find.WindowStack.Add(new MarkerAlphaWindow(markerIds)); + SoundDefOf.Click.PlayOneShotOnCamera(); + }, + }); + + result.Add(new Command_Action + { + defaultLabel = anyHidden ? UnhideLocallyLabel() : HideLocallyLabel(), + defaultDesc = "MpPingSel_HideLocallyDesc".Translate(), + 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 = "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 = "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; + } + + // 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() + => "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(); + } +} 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/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..2d949b470 --- /dev/null +++ b/Source/Client/Windows/MarkerAlphaWindow.cs @@ -0,0 +1,156 @@ +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, "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(); + } + } + + 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..c9e71c782 --- /dev/null +++ b/Source/Client/Windows/PingFiltersDialog.cs @@ -0,0 +1,420 @@ +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, "MpPingFilters_Title".Translate()); + + 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; + + 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), "MpPingFilters_FactionsHeader".Translate()); + 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; + + DrawSectionHeader(new Rect(0f, yy, viewRect.width, SectionHeaderH), "MpPingFilters_PlayersHeader".Translate()); + yy += SectionHeaderH; + + if (players.Count == 0) + { + 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; + } + } + + Widgets.EndScrollView(); + + var resetRect = new Rect(inRect.x, listOutRect.yMax + ResetGap, inRect.width, ResetH); + if (Widgets.ButtonText(resetRect, "MpPingFilters_ResetAll".Translate())) + { + 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, "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; + + 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 ?? "?") + " " + "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); + } + + 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 + " " + "MpPingFilters_OfflineSuffix".Translate(), color)); + } + } + + 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(); + } +} diff --git a/Source/Client/Windows/PingHostSettingsDialog.cs b/Source/Client/Windows/PingHostSettingsDialog.cs new file mode 100644 index 000000000..3a7070a66 --- /dev/null +++ b/Source/Client/Windows/PingHostSettingsDialog.cs @@ -0,0 +1,150 @@ +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, "MpPingHostSettings_Title".Translate()); + + 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 = "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; + + 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())) + { + Find.WindowStack.Add(Dialog_MessageBox.CreateConfirmation( + "MpPingHostSettings_ClearAllMarkers_Confirm".Translate(), + () => + { + 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, "MpPingHostSettings_ClearAllPings".Translate())) + { + loc?.SendClearAllPings(); + SoundDefOf.Click.PlayOneShotOnCamera(); + } + y = clearPingsRect.yMax; + } + + public static string OpenTooltipLabel() + => "MpPingHostSettings_OpenTooltip".Translate(); + } +} diff --git a/Source/Client/Windows/PingInspectPane.cs b/Source/Client/Windows/PingInspectPane.cs new file mode 100644 index 000000000..8a4dddd49 --- /dev/null +++ b/Source/Client/Windows/PingInspectPane.cs @@ -0,0 +1,264 @@ +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(); + // 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); + } + + 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("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); + } + + 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 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); + } + } +} diff --git a/Source/Client/Windows/PingLabelWindow.cs b/Source/Client/Windows/PingLabelWindow.cs new file mode 100644 index 000000000..858d0ba7d --- /dev/null +++ b/Source/Client/Windows/PingLabelWindow.cs @@ -0,0 +1,100 @@ +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), "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; + + 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), "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), "MpPingLabel_Confirm".Translate()) || enterPressed) + { + if (enterPressed) Event.current.Use(); + Confirm(); + } + } + + private void Confirm() + { + 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("MpPingLabel_NoLongerOwned".Translate(), + 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..702e91f32 --- /dev/null +++ b/Source/Client/Windows/PingMenuWindow.cs @@ -0,0 +1,535 @@ +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 (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); + } + + 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, "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 + 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, "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 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, "MpPingMenuWindow_PlacementType".Translate()); + 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, ("MpPingMode_" + Multiplayer.settings.pingPlaceMode + "_Description").Translate()); + 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, "MpPingMenuWindow_ListHeaderWithCount".Translate(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, "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; + } + + 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), "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?.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)) + { + 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 "MpPingMenuWindow_TargetPlanet".Translate(); + var map = Find.Maps.GetById(info.mapId); + return map?.Parent?.LabelCap + ?? "MpPingMenuWindow_TargetMap".Translate(); + } + + private static string RemainingTimeLabel(PingInfo p) + { + var remaining = Mathf.Max(0f, PingInfo.PingDuration - p.timeAlive); + return $"{remaining:0.0}s"; + } + + 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); + + if (loc?.armedCategory is { } cat) + { + 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/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..06abda1c2 --- /dev/null +++ b/Source/Common/Networking/Packet/ClearMarkersPacket.cs @@ -0,0 +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 + { + 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..c0a0ce0a4 --- /dev/null +++ b/Source/Common/Networking/Packet/DeleteMarkerPacket.cs @@ -0,0 +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 + { + 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/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 5e7f5f00e..7c1333826 100644 --- a/Source/Common/Networking/Packet/PingLocationPacket.cs +++ b/Source/Common/Networking/Packet/PingLocationPacket.cs @@ -1,33 +1,65 @@ -namespace Multiplayer.Common.Networking.Packet; - -[PacketDefinition(Packets.Server_PingLocation)] -public record struct ServerPingLocPacket(int playerId, ClientPingLocPacket data) : IPacket +namespace Multiplayer.Common.Networking.Packet { - public int playerId = playerId; - 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 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) : IPacket -{ - public int mapId = mapId; - public int planetTileId = planetTileId; - public int planetTileLayer = planetTileLayer; - public float x = x, y = y, z = z; + 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); + 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 new file mode 100644 index 000000000..b2e7d75a1 --- /dev/null +++ b/Source/Common/Networking/Packet/RenameMarkerPacket.cs @@ -0,0 +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 + { + 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..b5941cd9e 100644 --- a/Source/Common/Networking/State/ServerPlayingState.cs +++ b/Source/Common/Networking/State/ServerPlayingState.cs @@ -170,8 +170,73 @@ public void HandleSelected(ClientSelectedPacket packet) => Server.SendToPlaying(new ServerSelectedPacket(Player.id, packet), excluding: Player); [TypedPacketHandler] - public void HandlePing(ClientPingLocPacket packet) => - Server.SendToPlaying(new ServerPingLocPacket(Player.id, packet)); + public void HandlePing(ClientPingLocPacket packet) + { + 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); + } + + [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 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) 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..bdb307bc7 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 = 56; public static readonly string? GitHash = Assembly.GetExecutingAssembly() .GetCustomAttributes() diff --git a/Source/Tests/PacketTest.cs b/Source/Tests/PacketTest.cs index c98994699..79e8c1727 100644 --- a/Source/Tests/PacketTest.cs +++ b/Source/Tests/PacketTest.cs @@ -55,12 +55,54 @@ 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)); + // 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, (ushort)0x1234, false, "rush this", 60000); + + 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, (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, (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, (ushort)PingCategoryWire.UnknownHash, 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..a46e3e7e7 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-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/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..83ea1a176 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-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) 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