diff --git a/Content.Client/Access/UI/AccessOverriderWindow.xaml.cs b/Content.Client/Access/UI/AccessOverriderWindow.xaml.cs index 6025c3b551..b5c480ff71 100644 --- a/Content.Client/Access/UI/AccessOverriderWindow.xaml.cs +++ b/Content.Client/Access/UI/AccessOverriderWindow.xaml.cs @@ -32,7 +32,7 @@ public AccessOverriderWindow(AccessOverriderBoundUserInterface owner, IPrototype { if (!prototypeManager.TryIndex(access, out var accessLevel)) { - logMill.Error($"Unable to find accesslevel for {access}"); + logMill.Error($"Unable to find access level for {access}"); continue; } @@ -66,11 +66,11 @@ public void UpdateState(AccessOverriderBoundUserInterfaceState state) if (state.MissingPrivilegesList != null && state.MissingPrivilegesList.Any()) { - List missingPrivileges = new List(); + var missingPrivileges = new List(); foreach (string tag in state.MissingPrivilegesList) { - string privilege = Loc.GetString(_prototypeManager.Index(tag)?.Name ?? "generic-unknown"); + var privilege = Loc.GetString(_prototypeManager.Index(tag)?.Name ?? "generic-unknown"); missingPrivileges.Add(privilege); } @@ -83,20 +83,20 @@ public void UpdateState(AccessOverriderBoundUserInterfaceState state) foreach (var (accessName, button) in _accessButtons) { button.Disabled = !interfaceEnabled; - if (interfaceEnabled) - { - button.Pressed = state.TargetAccessReaderIdAccessList?.Contains(accessName) ?? false; - button.Disabled = (!state.AllowedModifyAccessList?.Contains(accessName)) ?? true; - } + if (!interfaceEnabled) + return; + + button.Pressed = state.TargetAccessReaderIdAccessList?.Contains>(accessName) ?? false; + button.Disabled = (!state.AllowedModifyAccessList?.Contains>(accessName)) ?? true; } } - private void SubmitData() - { + private void SubmitData() => _owner.SubmitData( - // Iterate over the buttons dictionary, filter by `Pressed`, only get key from the key/value pair - _accessButtons.Where(x => x.Value.Pressed).Select(x => new ProtoId(x.Key)).ToList()); - } + _accessButtons.Where(x => x.Value.Pressed) + .Select(x => new ProtoId(x.Key)) + .ToList() + ); } } diff --git a/Content.Client/Atmos/Consoles/AtmosAlertsComputerWindow.xaml.cs b/Content.Client/Atmos/Consoles/AtmosAlertsComputerWindow.xaml.cs index a55321833c..309445f2a5 100644 --- a/Content.Client/Atmos/Consoles/AtmosAlertsComputerWindow.xaml.cs +++ b/Content.Client/Atmos/Consoles/AtmosAlertsComputerWindow.xaml.cs @@ -25,6 +25,7 @@ public sealed partial class AtmosAlertsComputerWindow : FancyWindow { private readonly IEntityManager _entManager; private readonly SpriteSystem _spriteSystem; + private readonly SharedNavMapSystem _navMapSystem; private EntityUid? _owner; private NetEntity? _trackedEntity; @@ -49,6 +50,7 @@ public AtmosAlertsComputerWindow(AtmosAlertsComputerBoundUserInterface userInter RobustXamlLoader.Load(this); _entManager = IoCManager.Resolve(); _spriteSystem = _entManager.System(); + _navMapSystem = _entManager.System(); // Pass the owner to nav map _owner = owner; @@ -181,6 +183,9 @@ public void UpdateUI(EntityCoordinates? consoleCoords, AtmosAlertsComputerEntry[ // Add tracked entities to the nav map foreach (var device in console.AtmosDevices) { + if (!device.NetEntity.Valid) + continue; + if (!NavMap.Visible) continue; @@ -272,6 +277,34 @@ public void UpdateUI(EntityCoordinates? consoleCoords, AtmosAlertsComputerEntry[ else MasterTabContainer.SetTabTitle(0, Loc.GetString("atmos-alerts-window-tab-alerts", ("value", activeAlarmCount))); + // Update sensor regions + NavMap.RegionOverlays.Clear(); + var prioritizedRegionOverlays = new Dictionary(); + + if (_owner != null && + _entManager.TryGetComponent(_owner, out var xform) && + _entManager.TryGetComponent(xform.GridUid, out var navMap)) + { + var regionOverlays = _navMapSystem.GetNavMapRegionOverlays(_owner.Value, navMap, AtmosAlertsComputerUiKey.Key); + + foreach (var (regionOwner, regionOverlay) in regionOverlays) + { + var alarmState = GetAlarmState(regionOwner); + + if (!TryGetSensorRegionColor(regionOwner, alarmState, out var regionColor)) + continue; + + regionOverlay.Color = regionColor; + + var priority = (_trackedEntity == regionOwner) ? 999 : (int)alarmState; + prioritizedRegionOverlays.Add(regionOverlay, priority); + } + + // Sort overlays according to their priority + var sortedOverlays = prioritizedRegionOverlays.OrderBy(x => x.Value).Select(x => x.Key).ToList(); + NavMap.RegionOverlays = sortedOverlays; + } + // Auto-scroll re-enable if (_autoScrollAwaitsUpdate) { @@ -300,6 +333,24 @@ private void AddTrackedEntityToNavMap(AtmosAlertsDeviceNavMapData metaData, Atmo NavMap.TrackedEntities[metaData.NetEntity] = blip; } + private bool TryGetSensorRegionColor(NetEntity regionOwner, AtmosAlarmType alarmState, out Color color) + { + color = Color.White; + + var blip = GetBlipTexture(alarmState); + + if (blip == null) + return false; + + // Color the region based on alarm state and entity tracking + color = blip.Value.Item2 * new Color(154, 154, 154); + + if (_trackedEntity != null && _trackedEntity != regionOwner) + color *= Color.DimGray; + + return true; + } + private void UpdateUIEntry(AtmosAlertsComputerEntry entry, int index, Control table, AtmosAlertsComputerComponent console, AtmosAlertsFocusDeviceData? focusData = null) { // Make new UI entry if required diff --git a/Content.Client/Chat/UI/EmotesMenu.xaml.cs b/Content.Client/Chat/UI/EmotesMenu.xaml.cs index a26d319920..3340755343 100644 --- a/Content.Client/Chat/UI/EmotesMenu.xaml.cs +++ b/Content.Client/Chat/UI/EmotesMenu.xaml.cs @@ -1,7 +1,8 @@ -using System.Numerics; +using System.Numerics; using Content.Client.UserInterface.Controls; using Content.Shared.Chat.Prototypes; using Content.Shared.Speech; +using Content.Shared.Whitelist; using Robust.Client.AutoGenerated; using Robust.Client.GameObjects; using Robust.Client.UserInterface.Controls; @@ -19,6 +20,7 @@ public sealed partial class EmotesMenu : RadialMenu [Dependency] private readonly ISharedPlayerManager _playerManager = default!; private readonly SpriteSystem _spriteSystem; + private readonly EntityWhitelistSystem _whitelistSystem; public event Action>? OnPlayEmote; @@ -28,6 +30,7 @@ public EmotesMenu() RobustXamlLoader.Load(this); _spriteSystem = _entManager.System(); + _whitelistSystem = _entManager.System(); var main = FindControl("Main"); @@ -37,8 +40,8 @@ public EmotesMenu() var player = _playerManager.LocalSession?.AttachedEntity; if (emote.Category == EmoteCategory.Invalid || emote.ChatTriggers.Count == 0 || - !(player.HasValue && (emote.Whitelist?.IsValid(player.Value, _entManager) ?? true)) || - (emote.Blacklist?.IsValid(player.Value, _entManager) ?? false)) + !(player.HasValue && _whitelistSystem.IsWhitelistPassOrNull(emote.Whitelist, player.Value)) || + _whitelistSystem.IsBlacklistPass(emote.Blacklist, player.Value)) continue; if (!emote.Available && diff --git a/Content.Client/Chemistry/UI/ChemMasterWindow.xaml.cs b/Content.Client/Chemistry/UI/ChemMasterWindow.xaml.cs index 5eace08a7f..a0871c16a1 100644 --- a/Content.Client/Chemistry/UI/ChemMasterWindow.xaml.cs +++ b/Content.Client/Chemistry/UI/ChemMasterWindow.xaml.cs @@ -182,7 +182,7 @@ private void UpdatePanelInfo(ChemMasterBoundUserInterfaceState state) }; bufferHBox.AddChild(bufferVol); - foreach (var (reagent, quantity) in state.BufferReagents) + foreach (var (reagent, quantity) in state.BufferReagents.OrderBy(x => x.Reagent.Prototype)) { // Try to get the prototype for the given reagent. This gives us its name. _prototypeManager.TryIndex(reagent.Prototype, out ReagentPrototype? proto); diff --git a/Content.Client/Construction/UI/ConstructionMenuPresenter.cs b/Content.Client/Construction/UI/ConstructionMenuPresenter.cs index 9a09436176..0c7912e0bc 100644 --- a/Content.Client/Construction/UI/ConstructionMenuPresenter.cs +++ b/Content.Client/Construction/UI/ConstructionMenuPresenter.cs @@ -2,6 +2,7 @@ using Content.Client.UserInterface.Systems.MenuBar.Widgets; using Content.Shared.Construction.Prototypes; using Content.Shared.Tag; +using Content.Shared.Whitelist; using Robust.Client.GameObjects; using Robust.Client.Graphics; using Robust.Client.Placement; @@ -23,6 +24,7 @@ namespace Content.Client.Construction.UI /// internal sealed class ConstructionMenuPresenter : IDisposable { + [Dependency] private readonly EntityManager _entManager = default!; [Dependency] private readonly IEntitySystemManager _systemManager = default!; [Dependency] private readonly IPrototypeManager _prototypeManager = default!; [Dependency] private readonly IPlacementManager _placementManager = default!; @@ -30,6 +32,7 @@ internal sealed class ConstructionMenuPresenter : IDisposable [Dependency] private readonly IPlayerManager _playerManager = default!; private readonly IConstructionMenuView _constructionView; + private readonly EntityWhitelistSystem _whitelistSystem; private ConstructionSystem? _constructionSystem; private ConstructionPrototype? _selected; @@ -78,6 +81,7 @@ public ConstructionMenuPresenter() // This is a lot easier than a factory IoCManager.InjectDependencies(this); _constructionView = new ConstructionMenu(); + _whitelistSystem = _entManager.System(); // This is required so that if we load after the system is initialized, we can bind to it immediately if (_systemManager.TryGetEntitySystem(out var constructionSystem)) @@ -157,7 +161,7 @@ private void OnViewPopulateRecipes(object? sender, (string search, string catago if (_playerManager.LocalSession == null || _playerManager.LocalEntity == null - || (recipe.EntityWhitelist != null && !recipe.EntityWhitelist.IsValid(_playerManager.LocalEntity.Value))) + || _whitelistSystem.IsWhitelistFail(recipe.EntityWhitelist, _playerManager.LocalEntity.Value)) continue; if (!string.IsNullOrEmpty(search)) diff --git a/Content.Client/Guidebook/GuidebookSystem.cs b/Content.Client/Guidebook/GuidebookSystem.cs index 0aa2c85142..86dcf76942 100644 --- a/Content.Client/Guidebook/GuidebookSystem.cs +++ b/Content.Client/Guidebook/GuidebookSystem.cs @@ -148,7 +148,7 @@ private void OnGuidebookControlsTestInteractHand(EntityUid uid, GuidebookControl public void FakeClientActivateInWorld(EntityUid activated) { - var activateMsg = new ActivateInWorldEvent(GetGuidebookUser(), activated); + var activateMsg = new ActivateInWorldEvent(GetGuidebookUser(), activated, true); RaiseLocalEvent(activated, activateMsg); } diff --git a/Content.Client/Lobby/UI/LoadoutPreferenceSelector.xaml.cs b/Content.Client/Lobby/UI/LoadoutPreferenceSelector.xaml.cs index 659fbbbfa3..eb472e7d95 100644 --- a/Content.Client/Lobby/UI/LoadoutPreferenceSelector.xaml.cs +++ b/Content.Client/Lobby/UI/LoadoutPreferenceSelector.xaml.cs @@ -148,21 +148,21 @@ public LoadoutPreferenceSelector(LoadoutPrototype loadout, JobPrototype highJob, // Manage the info button void UpdateGuidebook() => GuidebookButton.Visible = - prototypeManager.HasIndex(DefaultLoadoutInfoGuidebook + Loadout.ID); + prototypeManager.HasIndex(loadout.GuideEntry); UpdateGuidebook(); prototypeManager.PrototypesReloaded += _ => UpdateGuidebook(); GuidebookButton.OnPressed += _ => { - if (!prototypeManager.TryIndex(DefaultLoadoutInfoGuidebook, out var guideRoot)) + if (!prototypeManager.TryIndex(loadout.GuideEntry, out var guideRoot)) return; var guidebookController = UserInterfaceManager.GetUIController(); //TODO: Don't close the guidebook if its already open, just go to the correct page guidebookController.ToggleGuidebook( - new Dictionary { { DefaultLoadoutInfoGuidebook, guideRoot } }, + new Dictionary { { loadout.GuideEntry, guideRoot } }, includeChildren: true, - selected: DefaultLoadoutInfoGuidebook + Loadout.ID); + selected: loadout.GuideEntry); }; // Create a checkbox to get the loadout diff --git a/Content.Client/Pinpointer/NavMapSystem.Regions.cs b/Content.Client/Pinpointer/NavMapSystem.Regions.cs new file mode 100644 index 0000000000..4cc775418e --- /dev/null +++ b/Content.Client/Pinpointer/NavMapSystem.Regions.cs @@ -0,0 +1,303 @@ +using Content.Shared.Atmos; +using Content.Shared.Pinpointer; +using System.Linq; + +namespace Content.Client.Pinpointer; + +public sealed partial class NavMapSystem +{ + private (AtmosDirection, Vector2i, AtmosDirection)[] _regionPropagationTable = + { + (AtmosDirection.East, new Vector2i(1, 0), AtmosDirection.West), + (AtmosDirection.West, new Vector2i(-1, 0), AtmosDirection.East), + (AtmosDirection.North, new Vector2i(0, 1), AtmosDirection.South), + (AtmosDirection.South, new Vector2i(0, -1), AtmosDirection.North), + }; + + public override void Update(float frameTime) + { + // To prevent compute spikes, only one region is flood filled per frame + var query = AllEntityQuery(); + + while (query.MoveNext(out var ent, out var entNavMapRegions)) + FloodFillNextEnqueuedRegion(ent, entNavMapRegions); + } + + private void FloodFillNextEnqueuedRegion(EntityUid uid, NavMapComponent component) + { + if (!component.QueuedRegionsToFlood.Any()) + return; + + var regionOwner = component.QueuedRegionsToFlood.Dequeue(); + + // If the region is no longer valid, flood the next one in the queue + if (!component.RegionProperties.TryGetValue(regionOwner, out var regionProperties) || + !regionProperties.Seeds.Any()) + { + FloodFillNextEnqueuedRegion(uid, component); + return; + } + + // Flood fill the region, using the region seeds as starting points + var (floodedTiles, floodedChunks) = FloodFillRegion(uid, component, regionProperties); + + // Combine the flooded tiles into larger rectangles + var gridCoords = GetMergedRegionTiles(floodedTiles); + + // Create and assign the new region overlay + var regionOverlay = new NavMapRegionOverlay(regionProperties.UiKey, gridCoords) + { + Color = regionProperties.Color + }; + + component.RegionOverlays[regionOwner] = regionOverlay; + + // To reduce unnecessary future flood fills, we will track which chunks have been flooded by a region owner + + // First remove an old assignments + if (component.RegionOwnerToChunkTable.TryGetValue(regionOwner, out var oldChunks)) + { + foreach (var chunk in oldChunks) + { + if (component.ChunkToRegionOwnerTable.TryGetValue(chunk, out var oldOwners)) + { + oldOwners.Remove(regionOwner); + component.ChunkToRegionOwnerTable[chunk] = oldOwners; + } + } + } + + // Now update with the new assignments + component.RegionOwnerToChunkTable[regionOwner] = floodedChunks; + + foreach (var chunk in floodedChunks) + { + if (!component.ChunkToRegionOwnerTable.TryGetValue(chunk, out var owners)) + owners = new(); + + owners.Add(regionOwner); + component.ChunkToRegionOwnerTable[chunk] = owners; + } + } + + private (HashSet, HashSet) FloodFillRegion(EntityUid uid, NavMapComponent component, NavMapRegionProperties regionProperties) + { + if (!regionProperties.Seeds.Any()) + return (new(), new()); + + var visitedChunks = new HashSet(); + var visitedTiles = new HashSet(); + var tilesToVisit = new Stack(); + + foreach (var regionSeed in regionProperties.Seeds) + { + tilesToVisit.Push(regionSeed); + + while (tilesToVisit.Count > 0) + { + // If the max region area is hit, exit + if (visitedTiles.Count > regionProperties.MaxArea) + return (new(), new()); + + // Pop the top tile from the stack + var current = tilesToVisit.Pop(); + + // If the current tile position has already been visited, + // or is too far away from the seed, continue + if ((regionSeed - current).Length > regionProperties.MaxRadius) + continue; + + if (visitedTiles.Contains(current)) + continue; + + // Determine the tile's chunk index + var chunkOrigin = SharedMapSystem.GetChunkIndices(current, ChunkSize); + var relative = SharedMapSystem.GetChunkRelative(current, ChunkSize); + var idx = GetTileIndex(relative); + + // Extract the tile data + if (!component.Chunks.TryGetValue(chunkOrigin, out var chunk)) + continue; + + var flag = chunk.TileData[idx]; + + // If the current tile is entirely occupied, continue + if ((FloorMask & flag) == 0) + continue; + + if ((WallMask & flag) == WallMask) + continue; + + if ((AirlockMask & flag) == AirlockMask) + continue; + + // Otherwise the tile can be added to this region + visitedTiles.Add(current); + visitedChunks.Add(chunkOrigin); + + // Determine if we can propagate the region into its cardinally adjacent neighbors + // To propagate to a neighbor, movement into the neighbors closest edge must not be + // blocked, and vice versa + + foreach (var (direction, tileOffset, reverseDirection) in _regionPropagationTable) + { + if (!RegionCanPropagateInDirection(chunk, current, direction)) + continue; + + var neighbor = current + tileOffset; + var neighborOrigin = SharedMapSystem.GetChunkIndices(neighbor, ChunkSize); + + if (!component.Chunks.TryGetValue(neighborOrigin, out var neighborChunk)) + continue; + + visitedChunks.Add(neighborOrigin); + + if (!RegionCanPropagateInDirection(neighborChunk, neighbor, reverseDirection)) + continue; + + tilesToVisit.Push(neighbor); + } + } + } + + return (visitedTiles, visitedChunks); + } + + private bool RegionCanPropagateInDirection(NavMapChunk chunk, Vector2i tile, AtmosDirection direction) + { + var relative = SharedMapSystem.GetChunkRelative(tile, ChunkSize); + var idx = GetTileIndex(relative); + var flag = chunk.TileData[idx]; + + if ((FloorMask & flag) == 0) + return false; + + var directionMask = 1 << (int)direction; + var wallMask = (int)direction << (int)NavMapChunkType.Wall; + var airlockMask = (int)direction << (int)NavMapChunkType.Airlock; + + if ((wallMask & flag) > 0) + return false; + + if ((airlockMask & flag) > 0) + return false; + + return true; + } + + private List<(Vector2i, Vector2i)> GetMergedRegionTiles(HashSet tiles) + { + if (!tiles.Any()) + return new(); + + var x = tiles.Select(t => t.X); + var minX = x.Min(); + var maxX = x.Max(); + + var y = tiles.Select(t => t.Y); + var minY = y.Min(); + var maxY = y.Max(); + + var matrix = new int[maxX - minX + 1, maxY - minY + 1]; + + foreach (var tile in tiles) + { + var a = tile.X - minX; + var b = tile.Y - minY; + + matrix[a, b] = 1; + } + + return GetMergedRegionTiles(matrix, new Vector2i(minX, minY)); + } + + private List<(Vector2i, Vector2i)> GetMergedRegionTiles(int[,] matrix, Vector2i offset) + { + var output = new List<(Vector2i, Vector2i)>(); + + var rows = matrix.GetLength(0); + var cols = matrix.GetLength(1); + + var dp = new int[rows, cols]; + var coords = (new Vector2i(), new Vector2i()); + var maxArea = 0; + + var count = 0; + + while (!IsArrayEmpty(matrix)) + { + count++; + + if (count > rows * cols) + break; + + // Clear old values + dp = new int[rows, cols]; + coords = (new Vector2i(), new Vector2i()); + maxArea = 0; + + // Initialize the first row of dp + for (int j = 0; j < cols; j++) + { + dp[0, j] = matrix[0, j]; + } + + // Calculate dp values for remaining rows + for (int i = 1; i < rows; i++) + { + for (int j = 0; j < cols; j++) + dp[i, j] = matrix[i, j] == 1 ? dp[i - 1, j] + 1 : 0; + } + + // Find the largest rectangular area seeded for each position in the matrix + for (int i = 0; i < rows; i++) + { + for (int j = 0; j < cols; j++) + { + int minWidth = dp[i, j]; + + for (int k = j; k >= 0; k--) + { + if (dp[i, k] <= 0) + break; + + minWidth = Math.Min(minWidth, dp[i, k]); + var currArea = Math.Max(maxArea, minWidth * (j - k + 1)); + + if (currArea > maxArea) + { + maxArea = currArea; + coords = (new Vector2i(i - minWidth + 1, k), new Vector2i(i, j)); + } + } + } + } + + // Save the recorded rectangle vertices + output.Add((coords.Item1 + offset, coords.Item2 + offset)); + + // Removed the tiles covered by the rectangle from matrix + for (int i = coords.Item1.X; i <= coords.Item2.X; i++) + { + for (int j = coords.Item1.Y; j <= coords.Item2.Y; j++) + matrix[i, j] = 0; + } + } + + return output; + } + + private bool IsArrayEmpty(int[,] matrix) + { + for (int i = 0; i < matrix.GetLength(0); i++) + { + for (int j = 0; j < matrix.GetLength(1); j++) + { + if (matrix[i, j] == 1) + return false; + } + } + + return true; + } +} diff --git a/Content.Client/Pinpointer/NavMapSystem.cs b/Content.Client/Pinpointer/NavMapSystem.cs index 9aeb792a42..47469d4ea7 100644 --- a/Content.Client/Pinpointer/NavMapSystem.cs +++ b/Content.Client/Pinpointer/NavMapSystem.cs @@ -1,3 +1,4 @@ +using System.Linq; using Content.Shared.Pinpointer; using Robust.Shared.GameStates; @@ -16,6 +17,7 @@ private void OnHandleState(EntityUid uid, NavMapComponent component, ref Compone { Dictionary modifiedChunks; Dictionary beacons; + Dictionary regions; switch (args.Current) { @@ -23,6 +25,8 @@ private void OnHandleState(EntityUid uid, NavMapComponent component, ref Compone { modifiedChunks = delta.ModifiedChunks; beacons = delta.Beacons; + regions = delta.Regions; + foreach (var index in component.Chunks.Keys) { if (!delta.AllChunks!.Contains(index)) @@ -35,6 +39,8 @@ private void OnHandleState(EntityUid uid, NavMapComponent component, ref Compone { modifiedChunks = state.Chunks; beacons = state.Beacons; + regions = state.Regions; + foreach (var index in component.Chunks.Keys) { if (!state.Chunks.ContainsKey(index)) @@ -47,13 +53,54 @@ private void OnHandleState(EntityUid uid, NavMapComponent component, ref Compone return; } + // Update region data and queue new regions for flooding + var prevRegionOwners = component.RegionProperties.Keys.ToList(); + var validRegionOwners = new List(); + + component.RegionProperties.Clear(); + + foreach (var (regionOwner, regionData) in regions) + { + if (!regionData.Seeds.Any()) + continue; + + component.RegionProperties[regionOwner] = regionData; + validRegionOwners.Add(regionOwner); + + if (component.RegionOverlays.ContainsKey(regionOwner)) + continue; + + if (component.QueuedRegionsToFlood.Contains(regionOwner)) + continue; + + component.QueuedRegionsToFlood.Enqueue(regionOwner); + } + + // Remove stale region owners + var regionOwnersToRemove = prevRegionOwners.Except(validRegionOwners); + + foreach (var regionOwnerRemoved in regionOwnersToRemove) + RemoveNavMapRegion(uid, component, regionOwnerRemoved); + + // Modify chunks foreach (var (origin, chunk) in modifiedChunks) { var newChunk = new NavMapChunk(origin); Array.Copy(chunk, newChunk.TileData, chunk.Length); component.Chunks[origin] = newChunk; + + // If the affected chunk intersects one or more regions, re-flood them + if (!component.ChunkToRegionOwnerTable.TryGetValue(origin, out var affectedOwners)) + continue; + + foreach (var affectedOwner in affectedOwners) + { + if (!component.QueuedRegionsToFlood.Contains(affectedOwner)) + component.QueuedRegionsToFlood.Enqueue(affectedOwner); + } } + // Refresh beacons component.Beacons.Clear(); foreach (var (nuid, beacon) in beacons) { diff --git a/Content.Client/Pinpointer/UI/NavMapControl.cs b/Content.Client/Pinpointer/UI/NavMapControl.cs index 3c99a18818..3c277f4ded 100644 --- a/Content.Client/Pinpointer/UI/NavMapControl.cs +++ b/Content.Client/Pinpointer/UI/NavMapControl.cs @@ -48,6 +48,7 @@ public partial class NavMapControl : MapGridControl public List<(Vector2, Vector2)> TileLines = new(); public List<(Vector2, Vector2)> TileRects = new(); public List<(Vector2[], Color)> TilePolygons = new(); + public List RegionOverlays = new(); // Default colors public Color WallColor = new(102, 217, 102); @@ -319,6 +320,22 @@ protected override void Draw(DrawingHandleScreen handle) } } + // Draw region overlays + if (_grid != null) + { + foreach (var regionOverlay in RegionOverlays) + { + foreach (var gridCoords in regionOverlay.GridCoords) + { + var positionTopLeft = ScalePosition(new Vector2(gridCoords.Item1.X, -gridCoords.Item1.Y) - new Vector2(offset.X, -offset.Y)); + var positionBottomRight = ScalePosition(new Vector2(gridCoords.Item2.X + _grid.TileSize, -gridCoords.Item2.Y - _grid.TileSize) - new Vector2(offset.X, -offset.Y)); + + var box = new UIBox2(positionTopLeft, positionBottomRight); + handle.DrawRect(box, regionOverlay.Color); + } + } + } + // Draw map lines if (TileLines.Any()) { diff --git a/Content.Client/Shipyard/ShipyardConsoleSystem.cs b/Content.Client/Shipyard/ShipyardConsoleSystem.cs new file mode 100644 index 0000000000..11847f8137 --- /dev/null +++ b/Content.Client/Shipyard/ShipyardConsoleSystem.cs @@ -0,0 +1,5 @@ +using Content.Shared.Shipyard; + +namespace Content.Client.Shipyard; + +public sealed class ShipyardConsoleSystem : SharedShipyardConsoleSystem; diff --git a/Content.Client/Shipyard/UI/ShipyardBoundUserInterface.cs b/Content.Client/Shipyard/UI/ShipyardBoundUserInterface.cs new file mode 100644 index 0000000000..5efe063b3b --- /dev/null +++ b/Content.Client/Shipyard/UI/ShipyardBoundUserInterface.cs @@ -0,0 +1,58 @@ +using Content.Shared.Access.Systems; +using Content.Shared.Shipyard; +using Content.Shared.Whitelist; +using Robust.Client.Player; +using Robust.Shared.Prototypes; + +namespace Content.Client.Shipyard.UI; + +public sealed class ShipyardConsoleBoundUserInterface : BoundUserInterface +{ + [Dependency] private readonly IPrototypeManager _proto = default!; + [Dependency] private readonly IPlayerManager _player = default!; + + private readonly AccessReaderSystem _access; + private readonly EntityWhitelistSystem _whitelist; + + [ViewVariables] + private ShipyardConsoleMenu? _menu; + + public ShipyardConsoleBoundUserInterface(EntityUid owner, Enum uiKey) : base(owner, uiKey) + { + _access = EntMan.System(); + _whitelist = EntMan.System(); + } + + protected override void Open() + { + base.Open(); + + _menu = new ShipyardConsoleMenu(Owner, _proto, EntMan, _player, _access, _whitelist); + _menu.OpenCentered(); + _menu.OnClose += Close; + _menu.OnPurchased += Purchase; + } + + protected override void UpdateState(BoundUserInterfaceState state) + { + base.UpdateState(state); + + if (state is not ShipyardConsoleState cast) + return; + + _menu?.UpdateState(cast); + } + + protected override void Dispose(bool disposing) + { + base.Dispose(disposing); + + if (disposing) + _menu?.Dispose(); + } + + private void Purchase(string id) + { + SendMessage(new ShipyardConsolePurchaseMessage(id)); + } +} diff --git a/Content.Client/Shipyard/UI/ShipyardConsoleMenu.xaml b/Content.Client/Shipyard/UI/ShipyardConsoleMenu.xaml new file mode 100644 index 0000000000..9eccd45b69 --- /dev/null +++ b/Content.Client/Shipyard/UI/ShipyardConsoleMenu.xaml @@ -0,0 +1,29 @@ + + + + diff --git a/Content.Client/Shipyard/UI/ShipyardConsoleMenu.xaml.cs b/Content.Client/Shipyard/UI/ShipyardConsoleMenu.xaml.cs new file mode 100644 index 0000000000..4559aa6762 --- /dev/null +++ b/Content.Client/Shipyard/UI/ShipyardConsoleMenu.xaml.cs @@ -0,0 +1,109 @@ +using Content.Client.UserInterface.Controls; +using Content.Shared.Access.Systems; +using Content.Shared.Shipyard; +using Content.Shared.Shipyard.Prototypes; +using Content.Shared.Whitelist; +using Robust.Client.AutoGenerated; +using Robust.Client.Player; +using Robust.Client.UserInterface.XAML; +using Robust.Shared.Prototypes; + +namespace Content.Client.Shipyard.UI; + +[GenerateTypedNameReferences] +public sealed partial class ShipyardConsoleMenu : FancyWindow +{ + private readonly AccessReaderSystem _access; + private readonly IPlayerManager _player; + + public event Action? OnPurchased; + + private readonly List _vessels = new(); + private readonly List _categories = new(); + + public Entity Console; + private string? _category; + + public ShipyardConsoleMenu(EntityUid console, IPrototypeManager proto, IEntityManager entMan, IPlayerManager player, AccessReaderSystem access, EntityWhitelistSystem whitelist) + { + RobustXamlLoader.Load(this); + IoCManager.InjectDependencies(this); + + Console = (console, entMan.GetComponent(console)); + _access = access; + _player = player; + + // don't include ships that aren't allowed by whitelist, server won't accept them anyway + foreach (var vessel in proto.EnumeratePrototypes()) + { + if (whitelist.IsWhitelistPassOrNull(vessel.Whitelist, console)) + _vessels.Add(vessel); + } + _vessels.Sort((x, y) => string.Compare(x.Name, y.Name, StringComparison.CurrentCultureIgnoreCase)); + + // only list categories in said ships + foreach (var vessel in _vessels) + { + foreach (var category in vessel.Categories) + { + if (!_categories.Contains(category)) + _categories.Add(category); + } + } + + _categories.Sort(); + // inserting here and not adding at the start so it doesn't get affected by sort + _categories.Insert(0, Loc.GetString("cargo-console-menu-populate-categories-all-text")); + PopulateCategories(); + + SearchBar.OnTextChanged += _ => PopulateProducts(); + Categories.OnItemSelected += args => + { + _category = args.Id == 0 ? null : _categories[args.Id]; + Categories.SelectId(args.Id); + PopulateProducts(); + }; + } + + /// + /// Populates the list of products that will actually be shown, using the current filters. + /// + private void PopulateProducts() + { + Vessels.RemoveAllChildren(); + + var access = _player.LocalSession?.AttachedEntity is {} player + && _access.IsAllowed(player, Console); + + var search = SearchBar.Text.Trim().ToLowerInvariant(); + foreach (var vessel in _vessels) + { + if (search.Length != 0 && !vessel.Name.ToLowerInvariant().Contains(search)) + continue; + if (_category != null && !vessel.Categories.Contains(_category)) + continue; + + var vesselEntry = new VesselRow(vessel, access); + vesselEntry.OnPurchasePressed += () => OnPurchased?.Invoke(vessel.ID); + Vessels.AddChild(vesselEntry); + } + } + + /// + /// Populates the list categories that will actually be shown, using the current filters. + /// + private void PopulateCategories() + { + Categories.Clear(); + foreach (var category in _categories) + { + Categories.AddItem(category); + } + } + + public void UpdateState(ShipyardConsoleState state) + { + BankAccountLabel.Text = Loc.GetString("cargo-console-menu-points-amount", ("amount", state.Balance.ToString())); + PopulateProducts(); + } +} diff --git a/Content.Client/Shipyard/UI/VesselRow.xaml b/Content.Client/Shipyard/UI/VesselRow.xaml new file mode 100644 index 0000000000..eac2d3a1bd --- /dev/null +++ b/Content.Client/Shipyard/UI/VesselRow.xaml @@ -0,0 +1,16 @@ + + +