diff --git a/.github/DISCUSSION_TEMPLATE/bug-reports.yml b/.github/DISCUSSION_TEMPLATE/bug-reports.yml index 8e84514d6..e07a89239 100644 --- a/.github/DISCUSSION_TEMPLATE/bug-reports.yml +++ b/.github/DISCUSSION_TEMPLATE/bug-reports.yml @@ -73,7 +73,7 @@ body: label: Version description: Which version of the game did the bug happen in? You can see the current version number in the bottom left corner of your screen in the main menu. options: - - v1.7.7.0 (Winter Update) + - v1.8.6.2 (Calm Before the Storm update) - Other validations: required: true diff --git a/.gitignore b/.gitignore index e828c6532..b861f38bc 100644 --- a/.gitignore +++ b/.gitignore @@ -54,6 +54,7 @@ temp.txt # Private assets Barotrauma/BarotraumaShared/Content/* Barotrauma/**/GameAnalyticsKeys.cs +Deploy/DeployAll/PrivateKey.* .github/ISSUE_TEMPLATE/release-checklist.md #Rider diff --git a/Barotrauma/BarotraumaClient/ClientSource/Characters/AI/HumanAIController.cs b/Barotrauma/BarotraumaClient/ClientSource/Characters/AI/HumanAIController.cs index 3b097532d..c70862a41 100644 --- a/Barotrauma/BarotraumaClient/ClientSource/Characters/AI/HumanAIController.cs +++ b/Barotrauma/BarotraumaClient/ClientSource/Characters/AI/HumanAIController.cs @@ -1,6 +1,7 @@ using Microsoft.Xna.Framework; using FarseerPhysics; using System; +using System.Collections.Generic; using System.Linq; namespace Barotrauma @@ -63,6 +64,25 @@ namespace Barotrauma stringDrawPos += new Vector2(0, 20); GUI.DrawString(spriteBatch, stringDrawPos, $"ACTIVE OBJECTIVE: {activeObjective.DebugTag} ({activeObjective.Priority.FormatZeroDecimal()})", Color.White, Color.Black); } + if (currentObjective is AIObjectiveCombat + { + Weapon: Item weapon, + BlockedPositions: List blockedPositions + }) + { + Vector2 weaponPos = weapon.DrawPosition; + weaponPos.Y = -weaponPos.Y; + foreach (Vector2 blockedPosition in blockedPositions) + { + Vector2 blockedPos = blockedPosition; + if (Character.Submarine != null) + { + blockedPos += Character.Submarine.DrawPosition; + } + blockedPos.Y = -blockedPos.Y; + GUI.DrawLine(spriteBatch, weaponPos, blockedPos, Color.Red); + } + } } Vector2 objectiveStringDrawPos = stringDrawPos + new Vector2(120, 40); diff --git a/Barotrauma/BarotraumaClient/ClientSource/Characters/Animation/Ragdoll.cs b/Barotrauma/BarotraumaClient/ClientSource/Characters/Animation/Ragdoll.cs index b1e635d96..2d61423aa 100644 --- a/Barotrauma/BarotraumaClient/ClientSource/Characters/Animation/Ragdoll.cs +++ b/Barotrauma/BarotraumaClient/ClientSource/Characters/Animation/Ragdoll.cs @@ -23,13 +23,13 @@ namespace Barotrauma partial void UpdateNetPlayerPositionProjSpecific(float deltaTime, float lowestSubPos) { - if (character != GameMain.Client.Character || !character.CanMove) + if (character != GameMain.Client.Character) { //remove states without a timestamp (there may still be ID-based states //in the list when the controlled character switches to timestamp-based interpolation) character.MemState.RemoveAll(m => m.Timestamp == 0.0f); - //use simple interpolation for other players' characters and characters that can't move + //use simple interpolation for other players' characters if (character.MemState.Count > 0) { CharacterStateInfo serverPos = character.MemState.Last(); @@ -142,7 +142,8 @@ namespace Barotrauma else { float mainLimbDistSqrd = Vector2.DistanceSquared(MainLimb.PullJointWorldAnchorA, newPosition); - float mainLimbErrorTolerance = 0.1f; + float mainLimbErrorTolerance = character == GameMain.Client.Character ? 0.25f : 0.1f; + MainLimb.body.LinearVelocity = newVelocity; //if the main limb is roughly at the correct position and the collider isn't moving (much at least), //don't attempt to correct the position. if (mainLimbDistSqrd > mainLimbErrorTolerance) @@ -151,12 +152,13 @@ namespace Barotrauma MainLimb.PullJointEnabled = true; if (!ColliderControlsMovement && newVelocity.LengthSquared() < 0.01f) { TryPlatformCorrection(newPosition); } } - else - { - MainLimb.body.LinearVelocity = newVelocity; - } } } + else if (!ColliderControlsMovement) + { + //correct velocity regardless of the positional error + MainLimb.body.LinearVelocity = newVelocity; + } } character.MemLocalState.Clear(); } @@ -192,6 +194,8 @@ namespace Barotrauma CharacterStateInfo serverPos = character.MemState.Last(); + Collider.LastServerState = serverPos; + if (!character.isSynced) { SetPosition(serverPos.Position, lerp: false); @@ -289,15 +293,30 @@ namespace Barotrauma } else if (errorMagnitude > 0.01f) { - Collider.TargetPosition = Collider.SimPosition + positionError; - Collider.TargetRotation = Collider.Rotation + rotationError; - Collider.MoveToTargetPosition(lerp: true); + if (ColliderControlsMovement) + { + Collider.TargetPosition = Collider.SimPosition + positionError; + Collider.TargetRotation = Collider.Rotation + rotationError; + Collider.MoveToTargetPosition(lerp: true); + } + else + { + float mainLimbErrorTolerance = character == GameMain.Client.Character ? 0.25f : 0.1f; + //if the main limb is roughly at the correct position and the collider isn't moving (much at least), + //don't attempt to correct the position. + if (errorMagnitude > mainLimbErrorTolerance) + { + MainLimb.PullJointWorldAnchorB = MainLimb.SimPosition + positionError; + MainLimb.PullJointEnabled = true; + if (serverPos.LinearVelocity.LengthSquared() < 0.01f) { TryPlatformCorrection(MainLimb.SimPosition + positionError); } + } + } } } } - if (character.MemLocalState.Count > 120) character.MemLocalState.RemoveRange(0, character.MemLocalState.Count - 120); + if (character.MemLocalState.Count > 120) { character.MemLocalState.RemoveRange(0, character.MemLocalState.Count - 120); } character.MemState.Clear(); } } @@ -602,15 +621,19 @@ namespace Barotrauma void AdjustDepthOffset(Item item) { - if (item?.GetComponent() is { ControlCharacterPose: true, UserInCorrectPosition: true } controller && controller.User == character) + if (item == null) { return; } + foreach (var controller in item.GetComponents()) { - if (controller.Item.SpriteDepth <= maxDepth || controller.DrawUserBehind) + if (controller is { ControlCharacterPose: true, UserInCorrectPosition: true } && controller.User == character) { - depthOffset = Math.Max(controller.Item.GetDrawDepth() + 0.0001f - minDepth, -minDepth); - } - else - { - depthOffset = Math.Max(controller.Item.GetDrawDepth() - 0.0001f - maxDepth, 0.0f); + if (controller.Item.SpriteDepth <= maxDepth || controller.DrawUserBehind) + { + depthOffset = Math.Max(controller.Item.GetDrawDepth() + 0.0001f - minDepth, -minDepth); + } + else + { + depthOffset = Math.Max(controller.Item.GetDrawDepth() - 0.0001f - maxDepth, 0.0f); + } } } } diff --git a/Barotrauma/BarotraumaClient/ClientSource/Characters/Character.cs b/Barotrauma/BarotraumaClient/ClientSource/Characters/Character.cs index fee47ca70..0d0b65cb1 100644 --- a/Barotrauma/BarotraumaClient/ClientSource/Characters/Character.cs +++ b/Barotrauma/BarotraumaClient/ClientSource/Characters/Character.cs @@ -9,6 +9,7 @@ using Microsoft.Xna.Framework; using Microsoft.Xna.Framework.Graphics; using System; using System.Collections.Generic; +using System.Collections.Immutable; using System.Linq; namespace Barotrauma @@ -251,6 +252,8 @@ namespace Barotrauma public Vector2 DrawPosition; public float MoveUpAmount; public readonly RichString Text; + public ImmutableArray? RichTextData { get; private set; } + public readonly Character Character; public readonly Submarine Submarine; public readonly Vector2 TextSize; @@ -260,8 +263,10 @@ namespace Barotrauma public SpeechBubble(Character character, float lifeTime, Color color, string text = "") { - Text = RichString.Rich(ToolBox.WrapText(text, GUI.IntScale(300), GUIStyle.SmallFont.GetFontForStr(text))); + var richStr = RichString.Rich(text); + Text = ToolBox.WrapText(richStr.SanitizedValue, GUI.IntScale(300), GUIStyle.SmallFont.GetFontForStr(text)); TextSize = GUIStyle.SmallFont.MeasureString(Text); + RichTextData = richStr.RichTextData; Character = character; Position = GetDesiredPosition(); @@ -329,7 +334,7 @@ namespace Barotrauma if (key == null) { continue; } key.Reset(); } - if (GUI.InputBlockingMenuOpen) + if (GUI.InputBlockingMenuOpen || ConversationAction.IsDialogOpen) { cursorPosition = Position + PlayerInput.MouseSpeed.ClampLength(10.0f); //apply a little bit of movement to the cursor pos to prevent AFK kicking @@ -1193,7 +1198,7 @@ namespace Barotrauma Vector2 bubbleSize = bubble.TextSize + Vector2.One * GUI.IntScale(15); speechBubbleIconSliced.Draw(spriteBatch, new RectangleF(iconPos - bubbleSize / 2, bubbleSize), bubble.Color * Math.Min(bubble.LifeTime, 1.0f) * alpha); } - GUI.DrawStringWithColors(spriteBatch, iconPos - bubble.TextSize / 2, bubble.Text.SanitizedValue, bubble.Color * Math.Min(bubble.LifeTime, 1.0f) * alpha, bubble.Text.RichTextData, font: GUIStyle.SmallFont); + GUI.DrawStringWithColors(spriteBatch, iconPos - bubble.TextSize / 2, bubble.Text.SanitizedValue, bubble.Color * Math.Min(bubble.LifeTime, 1.0f) * alpha, bubble.RichTextData, font: GUIStyle.SmallFont); } spriteBatch.End(); } diff --git a/Barotrauma/BarotraumaClient/ClientSource/Characters/CharacterHUD.cs b/Barotrauma/BarotraumaClient/ClientSource/Characters/CharacterHUD.cs index 7eb98838d..8c95ece49 100644 --- a/Barotrauma/BarotraumaClient/ClientSource/Characters/CharacterHUD.cs +++ b/Barotrauma/BarotraumaClient/ClientSource/Characters/CharacterHUD.cs @@ -392,6 +392,7 @@ namespace Barotrauma foreach (var target in mission.HudIconTargets) { if (target.Submarine != character.Submarine) { continue; } + if (target.Removed) { continue; } float alpha = GetDistanceBasedIconAlpha(target, maxDistance: mission.Prefab.HudIconMaxDistance); if (alpha <= 0.0f) { continue; } GUI.DrawIndicator(spriteBatch, target.DrawPosition, cam, 100.0f, mission.Prefab.HudIcon, mission.Prefab.HudIconColor * alpha); @@ -750,7 +751,8 @@ namespace Barotrauma } textPos.X += 10.0f * GUI.Scale; - if (!character.FocusedCharacter.IsIncapacitated && character.FocusedCharacter.IsPet && character.IsFriendly(character.FocusedCharacter)) + if (!character.FocusedCharacter.IsIncapacitated && character.FocusedCharacter.IsPet && + character.FocusedCharacter.AIController is EnemyAIController enemyAI && enemyAI.PetBehavior.CanPlayWith(character)) { GUI.DrawString(spriteBatch, textPos, GetCachedHudText("PlayHint", InputType.Use), GUIStyle.Green, Color.Black, 2, GUIStyle.SmallFont); @@ -811,7 +813,7 @@ namespace Barotrauma private static void AddBossProgressBar(ProgressBar progressBar) { var healthBarMode = GameMain.NetworkMember?.ServerSettings.ShowEnemyHealthBars ?? GameSettings.CurrentConfig.ShowEnemyHealthBars; - if (healthBarMode == EnemyHealthBarMode.HideAll) + if (healthBarMode == EnemyHealthBarMode.HideAll && progressBar is not MissionProgressBar) { return; } @@ -885,7 +887,7 @@ namespace Barotrauma for (int i = bossProgressBars.Count - 1; i >= 0 ; i--) { var bossHealthBar = bossProgressBars[i]; - if (bossHealthBar.FadeTimer <= 0 || healthBarMode == EnemyHealthBarMode.HideAll) + if (bossHealthBar.FadeTimer <= 0 || (healthBarMode == EnemyHealthBarMode.HideAll && bossHealthBar is not MissionProgressBar)) { bossHealthBar.SideContainer.Parent?.RemoveChild(bossHealthBar.SideContainer); bossHealthBar.TopContainer.Parent?.RemoveChild(bossHealthBar.TopContainer); diff --git a/Barotrauma/BarotraumaClient/ClientSource/Characters/CharacterInfo.cs b/Barotrauma/BarotraumaClient/ClientSource/Characters/CharacterInfo.cs index 8e9c5d602..5afb4c2d0 100644 --- a/Barotrauma/BarotraumaClient/ClientSource/Characters/CharacterInfo.cs +++ b/Barotrauma/BarotraumaClient/ClientSource/Characters/CharacterInfo.cs @@ -552,6 +552,8 @@ namespace Barotrauma string newName = inc.ReadString(); string originalName = inc.ReadString(); bool renamingEnabled = inc.ReadBoolean(); + BotStatus botStatus = (BotStatus)inc.ReadByte(); + int salary = inc.ReadInt32(); int tagCount = inc.ReadByte(); HashSet tagSet = new HashSet(); for (int i = 0; i < tagCount; i++) @@ -602,6 +604,8 @@ namespace Barotrauma MinReputationToHire = (factionId, minReputationToHire), RenamingEnabled = renamingEnabled }; + ch.BotStatus = botStatus; + ch.Salary = salary; ch.RecreateHead(tagSet.ToImmutableHashSet(), hairIndex, beardIndex, moustacheIndex, faceAttachmentIndex); ch.Head.SkinColor = skinColor; ch.Head.HairColor = hairColor; diff --git a/Barotrauma/BarotraumaClient/ClientSource/Characters/CharacterNetworking.cs b/Barotrauma/BarotraumaClient/ClientSource/Characters/CharacterNetworking.cs index dee206a96..06e788eb3 100644 --- a/Barotrauma/BarotraumaClient/ClientSource/Characters/CharacterNetworking.cs +++ b/Barotrauma/BarotraumaClient/ClientSource/Characters/CharacterNetworking.cs @@ -274,9 +274,7 @@ namespace Barotrauma if (!fixedRotation) { rotation = msg.ReadSingle(); - float MaxAngularVel = NetConfig.MaxPhysicsBodyAngularVelocity; - angularVelocity = msg.ReadRangedSingle(-MaxAngularVel, MaxAngularVel, 8); - angularVelocity = NetConfig.Quantize(angularVelocity.Value, -MaxAngularVel, MaxAngularVel, 8); + angularVelocity = msg.ReadSingle(); } bool ignorePlatforms = msg.ReadBoolean(); @@ -318,7 +316,7 @@ namespace Barotrauma msg.ReadPadBits(); int index = 0; - if (GameMain.Client.Character == this && CanMove) + if (GameMain.Client.Character == this) { var posInfo = new CharacterStateInfo( pos, rotation, @@ -386,6 +384,9 @@ namespace Barotrauma GameMain.Client.HasSpawned = true; GameMain.Client.Character = this; GameMain.LightManager.LosEnabled = true; +#if DEBUG + GameMain.LightManager.LosEnabled = !GameMain.DevMode; +#endif GameMain.LightManager.LosAlpha = 1f; GameMain.Client.WaitForNextRoundRespawn = null; } @@ -408,6 +409,7 @@ namespace Barotrauma break; case EventType.Status: ReadStatus(msg); + GodMode = msg.ReadBoolean(); break; case EventType.UpdateSkills: Identifier skillIdentifier = msg.ReadIdentifier(); @@ -764,7 +766,7 @@ namespace Barotrauma if (character.IsHuman && character.TeamID != CharacterTeamType.FriendlyNPC && character.TeamID != CharacterTeamType.None) { - CharacterInfo duplicateCharacterInfo = GameMain.GameSession.CrewManager.GetCharacterInfos().FirstOrDefault(c => c.ID == info.ID); + CharacterInfo duplicateCharacterInfo = GameMain.GameSession.CrewManager.GetCharacterInfos(includeReserveBench: true).FirstOrDefault(c => c.ID == info.ID); GameMain.GameSession.CrewManager.RemoveCharacterInfo(duplicateCharacterInfo); if (character.isDead) { @@ -784,6 +786,9 @@ namespace Barotrauma if (!character.IsDead) { Controlled = character; } GameMain.LightManager.LosEnabled = true; +#if DEBUG + GameMain.LightManager.LosEnabled = !GameMain.DevMode; +#endif GameMain.LightManager.LosAlpha = 1f; GameMain.NetLobbyScreen.CampaignCharacterDiscarded = false; @@ -851,6 +856,7 @@ namespace Barotrauma if (IsDead) { Revive(); } CharacterHealth.ClientRead(msg); } + byte severedLimbCount = msg.ReadByte(); for (int i = 0; i < severedLimbCount; i++) { diff --git a/Barotrauma/BarotraumaClient/ClientSource/Characters/Health/CharacterHealth.cs b/Barotrauma/BarotraumaClient/ClientSource/Characters/Health/CharacterHealth.cs index 2500b7ede..275d5ce4d 100644 --- a/Barotrauma/BarotraumaClient/ClientSource/Characters/Health/CharacterHealth.cs +++ b/Barotrauma/BarotraumaClient/ClientSource/Characters/Health/CharacterHealth.cs @@ -1143,6 +1143,8 @@ namespace Barotrauma if (!statusIconVisibleTime.ContainsKey(afflictionPrefab)) { statusIconVisibleTime.Add(afflictionPrefab, 0.0f); } statusIconVisibleTime[afflictionPrefab] += deltaTime; + Color color = GetAfflictionIconColor(afflictionPrefab, affliction); + var matchingIcon = afflictionIconContainer.GetChildByUserData(afflictionPrefab) ?? hiddenAfflictionIconContainer.GetChildByUserData(afflictionPrefab); @@ -1151,9 +1153,13 @@ namespace Barotrauma matchingIcon = new GUIButton(new RectTransform(new Point(afflictionIconContainer.Rect.Height), afflictionIconContainer.RectTransform), style: null) { UserData = afflictionPrefab, - ToolTip = affliction.Prefab.Name, + ToolTip = $"‖color:{color.ToStringHex()}‖{affliction.Prefab.Name}‖color:end‖", CanBeSelected = false }; + if (affliction.Prefab.ShowDescriptionInTooltip) + { + matchingIcon.ToolTip = matchingIcon.ToolTip + "\n" + affliction.Prefab.GetDescription(affliction.Strength, AfflictionPrefab.Description.TargetType.Self); + } if (affliction == pressureAffliction) { matchingIcon.ToolTip = TextManager.Get("PressureHUDWarning"); @@ -1162,6 +1168,8 @@ namespace Barotrauma { matchingIcon.ToolTip = TextManager.Get("OxygenHUDWarning"); } + matchingIcon.ToolTip = RichString.Rich(matchingIcon.ToolTip); + new GUIImage(new RectTransform(Vector2.One, matchingIcon.RectTransform, Anchor.BottomCenter), afflictionPrefab.Icon, scaleToFit: true) { CanBeFocused = false @@ -1172,7 +1180,7 @@ namespace Barotrauma matchingIcon.RectTransform.Parent = hiddenAfflictionIconContainer.RectTransform; } var image = matchingIcon.GetChild(); - image.Color = GetAfflictionIconColor(afflictionPrefab, affliction); + image.Color = color; image.HoverColor = Color.Lerp(image.Color, Color.White, 0.5f); if (affliction.DamagePerSecond > 1.0f && matchingIcon.FlashTimer <= 0.0f) diff --git a/Barotrauma/BarotraumaClient/ClientSource/Characters/InteractionLabelManager.cs b/Barotrauma/BarotraumaClient/ClientSource/Characters/InteractionLabelManager.cs index 73e733677..897637950 100644 --- a/Barotrauma/BarotraumaClient/ClientSource/Characters/InteractionLabelManager.cs +++ b/Barotrauma/BarotraumaClient/ClientSource/Characters/InteractionLabelManager.cs @@ -17,13 +17,16 @@ public static class InteractionLabelManager public RectangleF TextRect { get; set; } + public RichString Text; + public readonly Vector2 OriginalItemPosition; public bool OverlapPreventionDone; - public LabelData(Item item, RectangleF textRect, Camera drawCamera) + public LabelData(Item item, RectangleF textRect, RichString text, Camera drawCamera) { Item = item; + Text = text; TextRect = textRect; OriginalItemPosition = item.Position; this.drawCamera = drawCamera; @@ -106,7 +109,7 @@ public static class InteractionLabelManager if (labels.None(l => l.Item == interactableInRange)) { - var labelData = new LabelData(interactableInRange, textRect, cam); + var labelData = new LabelData(interactableInRange, textRect, RichString.Rich(interactableInRange.Prefab.Name), cam); labels.Add(labelData); } } @@ -124,7 +127,7 @@ public static class InteractionLabelManager private static RectangleF GetLabelRect(Item item, Camera cam) { // create rectangle for overlap prevention - Vector2 itemTextSizeScreen = GUIStyle.SubHeadingFont.MeasureString(item.Name) * LabelScale; + Vector2 itemTextSizeScreen = GUIStyle.SubHeadingFont.MeasureString(RichString.Rich(item.Prefab.Name).SanitizedValue) * LabelScale; Vector2 interactablePosScreen = cam.WorldToScreen(item.Position); RectangleF textRect = new RectangleF(interactablePosScreen.X, interactablePosScreen.Y, itemTextSizeScreen.X, itemTextSizeScreen.Y); // center the rectangle on the item @@ -320,9 +323,11 @@ public static class InteractionLabelManager GUIStyle.InteractionLabelBackground.Draw(spriteBatch, backgroundRect, color * 0.7f); - GUIStyle.SubHeadingFont.DrawString(spriteBatch, - labelData.Item.Name, - textDrawPosScreen, color, rotation: 0, origin: Vector2.Zero, scale, spriteEffects: SpriteEffects.None, layerDepth: 0.0f, + GUIStyle.SubHeadingFont.DrawStringWithColors(spriteBatch, + labelData.Text.SanitizedValue, + textDrawPosScreen, color, rotation: 0, origin: Vector2.Zero, scale, spriteEffects: SpriteEffects.None, + layerDepth: 0.0f, + richTextData: labelData.Text.RichTextData, forceUpperCase: ForceUpperCase.No); } } \ No newline at end of file diff --git a/Barotrauma/BarotraumaClient/ClientSource/Characters/Jobs/JobPrefab.cs b/Barotrauma/BarotraumaClient/ClientSource/Characters/Jobs/JobPrefab.cs index ee5cf0055..be656e146 100644 --- a/Barotrauma/BarotraumaClient/ClientSource/Characters/Jobs/JobPrefab.cs +++ b/Barotrauma/BarotraumaClient/ClientSource/Characters/Jobs/JobPrefab.cs @@ -8,23 +8,41 @@ namespace Barotrauma { public GUIButton CreateInfoFrame(bool isPvP, out GUIComponent buttonContainer) { - int width = 500, height = 400; - + int windowPixelWidth = 500, windowPixelHeight = 400; + Point absoluteWindowSize = new Point((int)(windowPixelWidth * GUI.xScale), (int)(windowPixelHeight * GUI.yScale)); + GUIButton frameHolder = new GUIButton(new RectTransform(Vector2.One, GUI.Canvas, Anchor.Center), style: null); new GUIFrame(new RectTransform(GUI.Canvas.RelativeSize, frameHolder.RectTransform, Anchor.Center), style: "GUIBackgroundBlocker"); - GUIFrame frame = new GUIFrame(new RectTransform(new Point(width, height), frameHolder.RectTransform, Anchor.Center)); + GUIFrame frame = new GUIFrame(new RectTransform(absoluteWindowSize, frameHolder.RectTransform, Anchor.Center)); GUIFrame paddedFrame = new GUIFrame(new RectTransform(new Vector2(0.9f, 0.9f), frame.RectTransform, Anchor.Center), style: null); + + new GUITextBlock(new RectTransform(new Vector2(1.0f, 0.1f), paddedFrame.RectTransform), Name, font: GUIStyle.LargeFont) + { + CanBeFocused = false + }; + + var contentList = new GUIListBox(new RectTransform(new Vector2(1.0f, 0.75f), paddedFrame.RectTransform) { RelativeOffset = new Vector2(0.0f, 0.1f) }) + { + ScrollBarVisible = true, + AutoHideScrollBar = true, + CurrentSelectMode = GUIListBox.SelectMode.None, + Padding = new Vector4(0, GUI.Scale * 10, 0, 0), + Spacing = (int)(GUI.Scale * 5) + }; - new GUITextBlock(new RectTransform(new Vector2(1.0f, 0.1f), paddedFrame.RectTransform), Name, font: GUIStyle.LargeFont); - - var descriptionBlock = new GUITextBlock(new RectTransform(new Vector2(1.0f, 0.0f), paddedFrame.RectTransform) { RelativeOffset = new Vector2(0.0f, 0.15f) }, - Description, font: GUIStyle.SmallFont, wrap: true); - - var skillContainer = new GUILayoutGroup(new RectTransform(new Vector2(0.45f, 0.5f), paddedFrame.RectTransform) - { RelativeOffset = new Vector2(0.0f, 0.2f + descriptionBlock.RectTransform.RelativeSize.Y) }); - new GUITextBlock(new RectTransform(new Vector2(1.0f, 0.0f), skillContainer.RectTransform), - TextManager.Get("Skills"), font: GUIStyle.LargeFont); + var descriptionBlock = new GUITextBlock(new RectTransform(new Vector2(1.0f, 0.0f), contentList.Content.RectTransform), + Description, font: GUIStyle.SmallFont, wrap: true, textAlignment: Alignment.TopLeft) + { + CanBeFocused = false, + }; + + new GUITextBlock(new RectTransform(new Vector2(1.0f, 0.0f), contentList.Content.RectTransform), + TextManager.Get("Skills"), font: GUIStyle.LargeFont) + { + CanBeFocused = false + }; + foreach (SkillPrefab skill in Skills) { var levelRange = skill.GetLevelRange(isPvP); @@ -33,9 +51,12 @@ namespace Barotrauma levelRange.End > levelRange.Start ? (int)levelRange.Start + " - " + (int)levelRange.End : ((int)levelRange.Start).ToString(); - new GUITextBlock(new RectTransform(new Vector2(1.0f, 0.0f), skillContainer.RectTransform), + new GUITextBlock(new RectTransform(new Vector2(1.0f, 0.0f), contentList.Content.RectTransform), " - " + TextManager.AddPunctuation(':', TextManager.Get("SkillName." + skill.Identifier), levelStr), - font: GUIStyle.SmallFont); + font: GUIStyle.SmallFont, wrap: true) + { + CanBeFocused = false + }; } buttonContainer = paddedFrame; diff --git a/Barotrauma/BarotraumaClient/ClientSource/Characters/Limb.cs b/Barotrauma/BarotraumaClient/ClientSource/Characters/Limb.cs index cea46ff0f..47e90a6cb 100644 --- a/Barotrauma/BarotraumaClient/ClientSource/Characters/Limb.cs +++ b/Barotrauma/BarotraumaClient/ClientSource/Characters/Limb.cs @@ -511,7 +511,7 @@ namespace Barotrauma else { //2. check if the character file defines the texture directly - texturePath = character.Params.VariantFile?.Root?.GetAttributeContentPath("texture", character.Prefab.ContentPackage); + texturePath = character.Params.VariantFile?.GetRootExcludingOverride()?.GetAttributeContentPath("texture", character.Prefab.ContentPackage); } //3. check if the base prefab defines the texture @@ -824,11 +824,13 @@ namespace Barotrauma { if (ActiveDeformations.Any()) { - var deformation = SpriteDeformation.GetDeformation(ActiveDeformations, deformSprite.Size); + var deformation = SpriteDeformation.GetDeformation(ActiveDeformations, deformSprite.Size, flippedHorizontally: IsFlipped, false); deformSprite.Deform(deformation); if (LightSource != null && LightSource.DeformableLightSprite != null) { - deformation = SpriteDeformation.GetDeformation(ActiveDeformations, deformSprite.Size, dir == Direction.Left); + //apparently inversing on the y-axis is only necessary for light sprites (see 345a65ca6) + //it's a mystery why this is the case, something to do with sprite flipping being handled differently in light rendering? + deformation = SpriteDeformation.GetDeformation(ActiveDeformations, deformSprite.Size, flippedHorizontally: IsFlipped, inverseY: dir == Direction.Left); LightSource.DeformableLightSprite.Deform(deformation); } } @@ -879,7 +881,7 @@ namespace Barotrauma var defSprite = conditionalSprite.DeformableSprite; if (ActiveDeformations.Any()) { - var deformation = SpriteDeformation.GetDeformation(ActiveDeformations, defSprite.Size); + var deformation = SpriteDeformation.GetDeformation(ActiveDeformations, defSprite.Size, flippedHorizontally: IsFlipped); defSprite.Deform(deformation); } else diff --git a/Barotrauma/BarotraumaClient/ClientSource/DebugConsole.cs b/Barotrauma/BarotraumaClient/ClientSource/DebugConsole.cs index 8c8d32cbe..5f2fabb17 100644 --- a/Barotrauma/BarotraumaClient/ClientSource/DebugConsole.cs +++ b/Barotrauma/BarotraumaClient/ClientSource/DebugConsole.cs @@ -1006,17 +1006,11 @@ namespace Barotrauma AssignOnExecute("teleportcharacter|teleport", (string[] args) => { Vector2 cursorWorldPos = GameMain.GameScreen.Cam.ScreenToWorld(PlayerInput.MousePosition); - TeleportCharacter(cursorWorldPos, Character.Controlled, args); + TeleportCharacter(cursorWorldPos, Character.Controlled, args); }); - AssignOnExecute("spawn|spawncharacter", (string[] args) => - { - SpawnCharacter(args, GameMain.GameScreen.Cam.ScreenToWorld(PlayerInput.MousePosition), out string errorMsg); - if (!string.IsNullOrWhiteSpace(errorMsg)) - { - ThrowError(errorMsg); - } - }); + AssignOnExecute("spawn|spawncharacter", args => SpawnCharacter(args, GameMain.GameScreen.Cam.ScreenToWorld(PlayerInput.MousePosition))); + AssignOnExecute("spawnnpc", args => SpawnCharacter(args, GameMain.GameScreen.Cam.ScreenToWorld(PlayerInput.MousePosition), true)); AssignOnExecute("los", (string[] args) => { @@ -2937,33 +2931,60 @@ namespace Barotrauma Barotrauma.IO.Validation.SkipValidationInDebugBuilds = false; })); - commands.Add(new Command("dumpeventtexts", "dumpeventtexts [filepath]: gets the texts from event files and and writes them into a file along with xml tags that can be used in translation files. If the filepath is omitted, the file is written to Content/Texts/EventTexts.txt", (string[] args) => + commands.Add(new Command("dumpeventtexts", "dumpeventtexts [sourcepath] [destinationpath]: gets the texts from event files and writes them into a file along with xml tags that can be used in translation files. If the filepath arguments are omitted, all event files are gone through and written to Content/Texts/EventTexts.txt", (string[] args) => { - string filePath = args.Length > 0 ? args[0] : "Content/Texts/EventTexts.txt"; + string sourcePath = args.Length > 0 ? Path.GetFullPath(args[0]) : string.Empty; + string destinationPath = args.Length > 1 ? args[1] : "Content/Texts/EventTexts.txt"; List lines = new List(); HashSet docs = new HashSet(); HashSet textIds = new HashSet(); - Dictionary existingTexts = new Dictionary(); - + Dictionary existingTexts = new Dictionary(); foreach (EventPrefab eventPrefab in EventSet.GetAllEventPrefabs()) { - if (eventPrefab.Identifier.IsEmpty) - { - continue; + string dir = Path.GetDirectoryName(eventPrefab.FilePath.FullPath); + if (!sourcePath.IsNullOrEmpty() && + Path.GetFullPath(eventPrefab.FilePath.FullPath) != sourcePath && + Path.GetDirectoryName(eventPrefab.FilePath.FullPath) != sourcePath) + { + continue; } + if (eventPrefab.Identifier.IsEmpty) { continue; } docs.Add(eventPrefab.ConfigElement.Document); getTextsFromElement(eventPrefab.ConfigElement, lines, eventPrefab.Identifier.Value); + NewMessage($"Collecting event texts from event \"{eventPrefab.Identifier}\"...", Color.Cyan); } + + if (lines.None()) + { + if (sourcePath.IsNullOrEmpty()) + { + ThrowError("Could not find any event texts. Have all the texts already been moved from the event files to the text files?"); + } + else + { + ThrowError($"Could not find any event texts from \"{sourcePath}\". Are you sure the path is to a valid event xml file or a directory that contains event xml files?"); + } + return; + } + Barotrauma.IO.Validation.SkipValidationInDebugBuilds = true; - File.WriteAllLines(filePath, lines); try { - ToolBox.OpenFileWithShell(Path.GetFullPath(filePath)); + File.WriteAllLines(destinationPath, lines); } catch (Exception e) { - ThrowError($"Failed to open the file \"{filePath}\".", e); + ThrowError($"Failed to write to the file \"{destinationPath}\".", e); + } + try + { + ToolBox.OpenFileWithShell(Path.GetFullPath(destinationPath)); + NewMessage($"Wrote the event texts to a text file in \"{destinationPath}\".", Color.Cyan); + } + catch (Exception e) + { + ThrowError($"Failed to open the file \"{destinationPath}\".", e); } System.Xml.XmlWriterSettings settings = new System.Xml.XmlWriterSettings @@ -2973,10 +2994,12 @@ namespace Barotrauma }; foreach (XDocument doc in docs) { - using (var writer = XmlWriter.Create(new System.Uri(doc.BaseUri).LocalPath, settings)) + string filePath = new System.Uri(doc.BaseUri).LocalPath; + using (var writer = XmlWriter.Create(filePath, settings)) { doc.WriteTo(writer); writer.Flush(); + NewMessage($"Updated the event file \"{filePath}\".", Color.Cyan); } } Barotrauma.IO.Validation.SkipValidationInDebugBuilds = false; @@ -2995,10 +3018,6 @@ namespace Barotrauma text = subTextElement?.GetAttributeString(textAttribute, null); textElement = subTextElement; } - if (text == null) - { - AddWarning("Failed to find text from the element " + element.ToString()); - } } string textId = $"EventText.{parentName}"; @@ -3760,6 +3779,29 @@ namespace Barotrauma string fileName = args[1]; character.AnimController.TryLoadAnimation(animationType, Path.GetFileNameWithoutExtension(fileName), out _, throwErrors: true); }, isCheat: true)); + + commands.Add(new Command("startlocalmptestsession", "startlocalmptestsession [(optional) number of clients, defaults to 2]: starts a new mp test session with multiple clients connected to local dedicated server", (string[] args) => + { + // if we are not in main menu, exit out + if (Screen.Selected != GameMain.MainMenuScreen) + { + ThrowError("Must be in main menu to start."); + return; + } + + // try to parse the number of clients + int numClients = 2; + if (args.Length > 0) + { + if (!int.TryParse(args[0], out numClients)) + { + ThrowError("Failed to parse the number of clients."); + return; + } + } + + StartLocalMPSession(numClients); + })); commands.Add(new Command("reloadwearables", "Reloads the sprites of all limbs and wearable sprites (clothing) of the controlled character. Provide id or name if you want to target another character.", args => { @@ -4253,5 +4295,44 @@ namespace Barotrauma componentCost += itemPrefab.DefaultPrice.Price; } } + + public static void StartLocalMPSession(int numClients = 2) + { + try + { + if (Process.GetProcessesByName("DedicatedServer").Length == 0) + { +#if WINDOWS + Process.Start("DedicatedServer.exe", arguments: "-multiclienttestmode"); +#else + Process.Start("./DedicatedServer", arguments: "-multiclienttestmode"); +#endif + System.Threading.Thread.Sleep(1000); + } + + GameMain.Client = new GameClient("client1", + new LidgrenEndpoint(System.Net.IPAddress.Loopback, NetConfig.DefaultPort), "localhost", Option.None()); + + numClients = MathHelper.Clamp(numClients, 1, 4); + + if (numClients > 1) + { + for (int i = 2; i <= numClients; i++) + { + System.Threading.Thread.Sleep(1000); +#if WINDOWS + Process.Start("Barotrauma.exe", arguments: "-connect server localhost -username client" + i); +#else + Process.Start("./Barotrauma", arguments: "-connect server localhost -username client" + i); +#endif + } + } + } + catch (Exception e) + { + DebugConsole.ThrowError("Failed to start the local MP test session", e); + } + + } } } diff --git a/Barotrauma/BarotraumaClient/ClientSource/Events/Missions/AbandonedOutpostMission.cs b/Barotrauma/BarotraumaClient/ClientSource/Events/Missions/AbandonedOutpostMission.cs index 0ac3d1fd6..e3a607d75 100644 --- a/Barotrauma/BarotraumaClient/ClientSource/Events/Missions/AbandonedOutpostMission.cs +++ b/Barotrauma/BarotraumaClient/ClientSource/Events/Missions/AbandonedOutpostMission.cs @@ -1,4 +1,4 @@ -using Barotrauma.Extensions; +using Barotrauma.Extensions; using Barotrauma.Networking; namespace Barotrauma @@ -21,7 +21,12 @@ namespace Barotrauma } } - public override bool DisplayAsCompleted => State > 0 && requireRescue.None(); + public override bool DisplayAsCompleted => + !DisplayAsFailed && + State > 0 && + //don't display as completed mid-round if there's NPCs to rescue (mission isn't guaranteed to complete yet) + requireRescue.None(); + public override bool DisplayAsFailed => State == HostagesKilledState; public override void ClientReadInitial(IReadMessage msg) @@ -47,7 +52,7 @@ namespace Barotrauma #if CLIENT if (allowOrderingRescuees) { - GameMain.GameSession.CrewManager.AddCharacterToCrewList(character); + GameMain.GameSession.CrewManager?.AddCharacterToCrewList(character); } #endif } diff --git a/Barotrauma/BarotraumaClient/ClientSource/Events/Missions/Mission.cs b/Barotrauma/BarotraumaClient/ClientSource/Events/Missions/Mission.cs index 0d9e499cf..397097230 100644 --- a/Barotrauma/BarotraumaClient/ClientSource/Events/Missions/Mission.cs +++ b/Barotrauma/BarotraumaClient/ClientSource/Events/Missions/Mission.cs @@ -4,7 +4,6 @@ using System; using System.Collections.Generic; using System.Globalization; using System.Linq; -using static Barotrauma.MissionPrefab; namespace Barotrauma { @@ -127,6 +126,7 @@ namespace Barotrauma return string.Empty; } } + partial void DistributeExperienceToCrew(IEnumerable crew, int experienceGain) { foreach (Character character in crew) diff --git a/Barotrauma/BarotraumaClient/ClientSource/Events/Missions/SalvageMission.cs b/Barotrauma/BarotraumaClient/ClientSource/Events/Missions/SalvageMission.cs index f4af6fdcb..ac06e10fd 100644 --- a/Barotrauma/BarotraumaClient/ClientSource/Events/Missions/SalvageMission.cs +++ b/Barotrauma/BarotraumaClient/ClientSource/Events/Missions/SalvageMission.cs @@ -1,4 +1,6 @@ -using Barotrauma.Networking; +using System.Collections.Generic; +using System.Linq; +using Barotrauma.Networking; using FarseerPhysics; namespace Barotrauma @@ -7,26 +9,51 @@ namespace Barotrauma { public override bool DisplayAsCompleted => false; public override bool DisplayAsFailed => false; + + private void TryShowPickedUpMessage() => HandleMessage(ref pickedUpMessage); private void TryShowRetrievedMessage() { if (DetermineCompleted()) { - if (!allRetrievedMessage.IsNullOrEmpty()) { CreateMessageBox(string.Empty, allRetrievedMessage); } - //no need to show this again, clear it - allRetrievedMessage = string.Empty; + HandleMessage(ref allRetrievedMessage); } else { - if (!partiallyRetrievedMessage.IsNullOrEmpty()) { CreateMessageBox(string.Empty, partiallyRetrievedMessage); } - //no need to show this again, clear it - partiallyRetrievedMessage = string.Empty; + HandleMessage(ref partiallyRetrievedMessage); } } + + private void HandleMessage(ref LocalizedString message) + { + if (!message.IsNullOrEmpty()) { CreateMessageBox(string.Empty, message); } + //no need to show this again, clear it + message = string.Empty; + } public override void ClientReadInitial(IReadMessage msg) { base.ClientReadInitial(msg); + + byte characterCount = msg.ReadByte(); + for (int i = 0; i < characterCount; i++) + { + Character character = Character.ReadSpawnData(msg); + characters.Add(character); + ushort itemCount = msg.ReadUInt16(); + for (int j = 0; j < itemCount; j++) + { + Item.ReadSpawnData(msg); + } + } + if (characters.Contains(null)) + { + throw new System.Exception("Error in SalvageMission.ClientReadInitial: character list contains null (mission: " + Prefab.Identifier + ")"); + } + if (characters.Count != characterCount) + { + throw new System.Exception("Error in SalvageMission.ClientReadInitial: character count does not match the server count (" + characters + " != " + characters.Count + "mission: " + Prefab.Identifier + ")"); + } foreach (var target in targets) { @@ -81,24 +108,37 @@ namespace Barotrauma { base.ClientRead(msg); bool atLeastOneTargetWasRetrieved = false; + bool showPickedUpMsg = false; int targetCount = msg.ReadByte(); for (int i = 0; i < targetCount; i++) { var state = (Target.RetrievalState)msg.ReadByte(); if (i < targets.Count) { - bool wasRetrieved = targets[i].Retrieved; + Target target = targets[i]; + bool wasRetrieved = target.Retrieved; + bool wasPickedUp = target.State == Target.RetrievalState.PickedUp; targets[i].State = state; - if (!wasRetrieved && targets[i].Retrieved) + if (!wasRetrieved && target.Retrieved) { atLeastOneTargetWasRetrieved = true; } + else if (!wasPickedUp && target.State == Target.RetrievalState.PickedUp) + { + showPickedUpMsg = true; + } } } if (atLeastOneTargetWasRetrieved) { TryShowRetrievedMessage(); } + if (showPickedUpMsg) + { + TryShowPickedUpMessage(); + } } + + public override IEnumerable HudIconTargets => targets.Where(static t => !t.Retrieved && t.Item.GetRootInventoryOwner() is not Character { IsLocalPlayer: true }).Select(static t => t.Item); } } diff --git a/Barotrauma/BarotraumaClient/ClientSource/GUI/ChatBox.cs b/Barotrauma/BarotraumaClient/ClientSource/GUI/ChatBox.cs index 1b9c385d4..deb4201bf 100644 --- a/Barotrauma/BarotraumaClient/ClientSource/GUI/ChatBox.cs +++ b/Barotrauma/BarotraumaClient/ClientSource/GUI/ChatBox.cs @@ -18,17 +18,40 @@ namespace Barotrauma public readonly ChatManager ChatManager = new ChatManager(); public bool IsSinglePlayer { get; private set; } - + private bool _toggleOpen = true; public bool ToggleOpen { - get { return _toggleOpen; } - set - { - _toggleOpen = PreferChatBoxOpen = value; - if (value) { hideableElements.Visible = true; } - } + get => _toggleOpen; + set => SetToggleOpenState(value, setPreference: true); } + + public static ChatBox GetChatBox() + { + if (GameMain.GameSession?.GameMode is not GameMode gameMode) { return null; } + return gameMode.IsSinglePlayer ? GameMain.GameSession.CrewManager?.ChatBox : GameMain.Client?.ChatBox; + } + + public static void AutoHideChatBox() => SetChatBoxOpen(false); + + private void SetToggleOpenState(bool value, bool setPreference = true) + { + _toggleOpen = value; + if (setPreference) + { + PreferChatBoxOpen = value; + } + if (value) { hideableElements.Visible = true; } + } + + public static void ResetChatBoxOpenState() => GetChatBox()?.ResetOpenState(); + + public void ResetOpenState() => SetOpen(PreferChatBoxOpen); + + private static void SetChatBoxOpen(bool isOpen) => GetChatBox()?.SetOpen(isOpen); + + private void SetOpen(bool value) => SetToggleOpenState(value, setPreference: false); + private float openState; public static bool PreferChatBoxOpen = true; @@ -199,7 +222,7 @@ namespace Barotrauma if (channelMemPending) { int.TryParse(channelText.Text, out int newChannel); - radio.SetChannelMemory(index, newChannel); + SetChannelMemory(index, newChannel); btn.ToolTip = TextManager.GetWithVariables("radiochannelpreset", ("[index]", index.ToString()), ("[channel]", radio.GetChannelMemory(index).ToString())); @@ -330,7 +353,7 @@ namespace Barotrauma }; showNewMessagesButton.Visible = false; - ToggleOpen = PreferChatBoxOpen = GameSettings.CurrentConfig.ChatOpen; + SetToggleOpenState(GameSettings.CurrentConfig.ChatOpen, setPreference: true); } public void Toggle() @@ -802,6 +825,15 @@ namespace Barotrauma } } } + + private void SetChannelMemory(int index, int channel) + { + if (Character.Controlled != null && ChatMessage.CanUseRadio(Character.Controlled, out WifiComponent radio)) + { + radio.SetChannelMemory(index, channel); + radio.Item.CreateClientEvent(radio); + } + } public void ApplySelectionInputs() => ApplySelectionInputs(InputBox, true, ChatKeyStates.GetChatKeyStates()); diff --git a/Barotrauma/BarotraumaClient/ClientSource/GUI/DeathPrompt.cs b/Barotrauma/BarotraumaClient/ClientSource/GUI/DeathPrompt.cs index 817fc30fe..4390037fd 100644 --- a/Barotrauma/BarotraumaClient/ClientSource/GUI/DeathPrompt.cs +++ b/Barotrauma/BarotraumaClient/ClientSource/GUI/DeathPrompt.cs @@ -11,13 +11,14 @@ internal class DeathPrompt { private static CoroutineHandle? createPromptCoroutine; + private GUIFrame? deathPromptFrame; private GUIComponent? skillPanel; private GUIComponent? newCharacterPanel; private GUIComponent? takeOverBotPanel; private GUIComponent? content; - - public static GUIComponent? takeOverBotPanelFrame; + + private static GUIComponent? takeOverBotPanelFrame; /// /// Private constructor, because these should only be created using the Show method @@ -73,11 +74,11 @@ internal class DeathPrompt foreground.FadeIn(wait: 0, duration: 5.0f); foreground.Pulsate(startScale: Vector2.One, Vector2.One * 0.8f, duration: 25.0f); - var frame = new GUIFrame(new RectTransform(new Vector2(0.3f, 0.3f), background.RectTransform, Anchor.Center)) + deathPromptFrame = new GUIFrame(new RectTransform(new Vector2(0.3f, 0.3f), background.RectTransform, Anchor.Center)) { UserData = this }; - frame.FadeIn(wait: 0, duration: FadeInDuration); + deathPromptFrame.FadeIn(wait: 0, duration: FadeInDuration); new GUITextBlock(new RectTransform(new Vector2(0.5f, 0.1f), background.RectTransform, Anchor.TopCenter) { RelativeOffset = new Vector2(0.0f, 0.2f) }, string.Empty, font: GUIStyle.LargeFont, textAlignment: Alignment.TopCenter) { @@ -90,7 +91,7 @@ internal class DeathPrompt } }; - var content = new GUILayoutGroup(new RectTransform(new Vector2(0.8f, 0.8f), frame.RectTransform, Anchor.Center)) + var content = new GUILayoutGroup(new RectTransform(new Vector2(0.8f, 0.8f), deathPromptFrame.RectTransform, Anchor.Center)) { Stretch = true, RelativeSpacing = 0.05f @@ -188,7 +189,7 @@ internal class DeathPrompt { if (takeOverBotPanel == null) { - CreateTakeOverBotPanel(frame, this); + CreateTakeOverBotPanel(deathPromptFrame, this); } else { @@ -202,7 +203,7 @@ internal class DeathPrompt } else { - new GUIButton(new RectTransform(new Vector2(1.0f, 1.0f), buttonContainerRight.RectTransform), TextManager.Get("deathprompt.respawnnow")) + var respawnNowButton = new GUIButton(new RectTransform(new Vector2(1.0f, 1.0f), buttonContainerRight.RectTransform), TextManager.Get("deathprompt.respawnnow")) { OnClicked = (btn, userdata) => { @@ -211,7 +212,13 @@ internal class DeathPrompt return true; }, Enabled = GameMain.NetworkMember is { ServerSettings.RespawnMode: RespawnMode.MidRound } - }.FadeIn(wait: FadeInInterval * 4, duration: FadeInDuration, alsoChildren: true); + }; + if (GameMain.NetworkMember is { ServerSettings.RespawnMode: RespawnMode.BetweenRounds }) + { + respawnNowButton.ToolTip = TextManager.Get("respawnnotavailable.respawnmode.betweenrounds"); + } + + respawnNowButton.FadeIn(wait: FadeInInterval * 4, duration: FadeInDuration, alsoChildren: true); } //"info buttons" at the bottom @@ -249,7 +256,7 @@ internal class DeathPrompt { if (skillPanel == null) { - CreateSkillPanel(frame, GameMain.Client?.Character?.Info ?? GameMain.Client?.CharacterInfo); + CreateSkillPanel(deathPromptFrame, GameMain.Client?.Character?.Info ?? GameMain.Client?.CharacterInfo); } else { @@ -266,7 +273,7 @@ internal class DeathPrompt { if (newCharacterPanel == null) { - CreateNewCharacterPanel(frame); + CreateNewCharacterPanel(deathPromptFrame); } else { @@ -279,15 +286,6 @@ internal class DeathPrompt } } - //TODO - /*new GUIButton(new RectTransform(new Vector2(0.4f, 1.0f), infoButtonContainer.RectTransform), "Respawn settings", style: "GUIButtonSmall") - { - OnClicked = (btn, userdata) => - { - return true; - } - }.FadeIn(wait: FadeInInterval * 5, duration: FadeInDuration, alsoChildren: true);*/ - this.content = background; } @@ -382,8 +380,10 @@ internal class DeathPrompt { OnClicked = (btn, userdata) => { - GameMain.NetLobbyScreen.TryDiscardCampaignCharacter(onYes: () => - { + GameMain.NetLobbyScreen.TryDiscardCampaignCharacter(onYes: () => + { + GameMain.Client?.SendCharacterInfo(GameMain.Client.PendingName); + GameMain.NetLobbyScreen.CampaignCharacterDiscarded = false; frame.Parent?.RemoveChild(frame); newCharacterPanel = null; }); @@ -465,6 +465,11 @@ internal class DeathPrompt { if (botList.SelectedData is CharacterInfo selectedCharacter && GameMain.Client is GameClient client) { + if (!GetAvailableBots().Contains(selectedCharacter)) // Someone may have taken over the bot while the list was open, etc + { + CreateTakeOverBotPanel(frame, deathPrompt); // Update + return true; + } client.SendTakeOverBotRequest(selectedCharacter); GUIMessageBox.MessageBoxes.Remove(frame.Parent); deathPrompt?.Close(); @@ -484,15 +489,26 @@ internal class DeathPrompt return frame; } + public void UpdateBotList() + { + if (deathPromptFrame != null && takeOverBotPanelFrame != null) + { + CloseBotPanel(); + CreateTakeOverBotPanel(deathPromptFrame, deathPrompt: this); + } + } + private static IEnumerable GetAvailableBots() { if (GameMain.GameSession?.CrewManager is { } crewManager) { - return crewManager.GetCharacterInfos().Where(c => - /*either an alive bot */ - c is { Character.IsBot: true, Character.IsDead: false } || - /* or a newly hired bot that hasn't spawned yet */ - (c.IsNewHire && c.Character == null)); + return crewManager.GetCharacterInfos(includeReserveBench: true).Where(c => + // a bot on reserve bench + c.IsOnReserveBench || + // an alive bot + (c.Character != null && c.Character is { IsBot: true, IsDead: false }) || + // a newly hired bot that hasn't spawned yet + (c.Character == null && c.IsNewHire)); } else { diff --git a/Barotrauma/BarotraumaClient/ClientSource/GUI/GUI.cs b/Barotrauma/BarotraumaClient/ClientSource/GUI/GUI.cs index 99db34df3..e8b7164e1 100644 --- a/Barotrauma/BarotraumaClient/ClientSource/GUI/GUI.cs +++ b/Barotrauma/BarotraumaClient/ClientSource/GUI/GUI.cs @@ -106,12 +106,35 @@ namespace Barotrauma public static float VerticalAspectRatio => GameMain.GraphicsHeight / (float)GameMain.GraphicsWidth; public static float RelativeHorizontalAspectRatio => HorizontalAspectRatio / (ReferenceResolution.X / ReferenceResolution.Y); public static float RelativeVerticalAspectRatio => VerticalAspectRatio / (ReferenceResolution.Y / ReferenceResolution.X); + + /// + /// Returns the difference of the current aspect ratio to the reference aspect ratio (16:9). + /// E.g. if the aspect ratio is 16:9, returns 0; if it's 4:3, returns 0.444; if the aspect ratio is 12:5, returns -0.623. + /// + public static float AspectRatioDifference + { + get + { + // ~ 1.777 + float referenceAspectRatio = ReferenceResolution.X / ReferenceResolution.Y; + float aspectRatioDifference = referenceAspectRatio - HorizontalAspectRatio; + if (MathUtils.NearlyEqual(aspectRatioDifference, 0)) + { + // Handle possible rounding errors, so that we can trust that this returns 0 when the aspect ratio matches the reference aspect ratio. + return 0; + } + return aspectRatioDifference; + } + } + /// /// A horizontal scaling factor for low aspect ratios (small width relative to height) /// public static float AspectRatioAdjustment => HorizontalAspectRatio < 1.4f ? (1.0f - (1.4f - HorizontalAspectRatio)) : 1.0f; public static bool IsUltrawide => HorizontalAspectRatio > 2.3f; + + public static bool IsHUDScaled => GameSettings.CurrentConfig.Graphics.HUDScale > 1 || GameSettings.CurrentConfig.Graphics.InventoryScale > 1; public static int UIWidth { @@ -625,9 +648,13 @@ namespace Barotrauma DrawMessages(spriteBatch, cam); - if (MouseOn != null && !MouseOn.ToolTip.IsNullOrWhiteSpace()) - { - MouseOn.DrawToolTip(spriteBatch); + if (MouseOn != null) + { + if (!MouseOn.ToolTip.IsNullOrWhiteSpace()) + { + MouseOn.DrawToolTip(spriteBatch); + } + MouseOn.OnDrawToolTip?.Invoke(MouseOn); } if (SubEditorScreen.IsSubEditor()) @@ -884,6 +911,11 @@ namespace Barotrauma PauseMenu.AddToGUIUpdateList(); } + foreach (var openAccordion in GUIComponent.OpenAccordionPopups) + { + openAccordion.AddToGUIUpdateList(order: 1); + } + SocialOverlay.Instance?.AddToGuiUpdateList(); GUIContextMenu.AddActiveToGUIUpdateList(); @@ -2495,7 +2527,12 @@ namespace Barotrauma { IgnoreLayoutGroups = true, ToolTip = TextManager.Get("bugreportbutton") + $" (v{GameMain.Version})", - OnClicked = (btn, userdata) => { GameMain.Instance.ShowBugReporter(); return true; } + OnClicked = (btn, userdata) => + { + if (PauseMenuOpen) { TogglePauseMenu(); } + GameMain.Instance.ShowBugReporter(); + return true; + } }; CreateButton("PauseMenuResume", buttonContainer, null); @@ -2531,23 +2568,37 @@ namespace Barotrauma GameMain.GameSession?.EndRound(""); }); } - else if (!GameMain.GameSession.GameMode.IsSinglePlayer && GameMain.Client != null && GameMain.Client.HasPermission(ClientPermissions.ManageRound)) + else if (!GameMain.GameSession.GameMode.IsSinglePlayer && GameMain.Client != null) { - bool canSave = GameMain.GameSession.GameMode is CampaignMode && IsFriendlyOutpostLevel(); - if (canSave) + //server owner (host) can't return to the lobby without ending the round for everyone + if (!GameMain.Client.IsServerOwner) { - CreateButton("PauseMenuSaveQuit", buttonContainer, verificationTextTag: "PauseMenuSaveAndReturnToServerLobbyVerification", action: () => - { - GameMain.Client?.RequestRoundEnd(save: true); - }); + CreateButton("ReturnToServerlobby", buttonContainer, + verificationTextTag: "PauseMenuReturnToServerLobbyVerificationSelf", + action: () => + { + GameMain.Client?.EndRoundForSelf(); + }); } - CreateButton(GameMain.GameSession.GameMode is CampaignMode ? "ReturnToServerlobby" : "EndRound", buttonContainer, - verificationTextTag: GameMain.GameSession.GameMode is CampaignMode ? "PauseMenuReturnToServerLobbyVerification" : "EndRoundSubNotAtLevelEnd", - action: () => + if (GameMain.Client.HasPermission(ClientPermissions.ManageRound)) + { + bool canSave = GameMain.GameSession.GameMode is CampaignMode && IsFriendlyOutpostLevel(); + if (canSave) { - GameMain.Client?.RequestRoundEnd(save: false); - }); + CreateButton("PauseMenuSaveQuit", buttonContainer, verificationTextTag: "PauseMenuSaveAndReturnToServerLobbyVerification", action: () => + { + GameMain.Client?.RequestEndRound(save: true); + }, color: GUIStyle.Red); + } + + CreateButton("EndRound", buttonContainer, + verificationTextTag: GameMain.GameSession.GameMode is CampaignMode ? "PauseMenuReturnToServerLobbyVerification" : "EndRoundSubNotAtLevelEnd", + action: () => + { + GameMain.Client?.RequestEndRound(save: false); + }, color: GUIStyle.Red); + } } } @@ -2575,9 +2626,9 @@ namespace Barotrauma } - void CreateButton(string textTag, GUIComponent parent, Action action, string verificationTextTag = null) + void CreateButton(string textTag, GUIComponent parent, Action action, string verificationTextTag = null, Color? color = null) { - new GUIButton(new RectTransform(new Vector2(1.0f, 0.1f), parent.RectTransform), TextManager.Get(textTag)) + var button = new GUIButton(new RectTransform(new Vector2(1.0f, 0.1f), parent.RectTransform), TextManager.Get(textTag)) { OnClicked = (btn, userData) => { @@ -2593,25 +2644,29 @@ namespace Barotrauma return true; } }; + if (color.HasValue) + { + button.Color = color.Value; + } } - void CreateVerificationPrompt(string textTag, Action confirmAction) + } + public static void CreateVerificationPrompt(string textTag, Action confirmAction) + { + var msgBox = new GUIMessageBox("", TextManager.Get(textTag), + new LocalizedString[] { TextManager.Get("Yes"), TextManager.Get("No") }) { - var msgBox = new GUIMessageBox("", TextManager.Get(textTag), - new LocalizedString[] { TextManager.Get("Yes"), TextManager.Get("No") }) - { - UserData = "verificationprompt", - DrawOnTop = true - }; - msgBox.Buttons[0].OnClicked = (_, __) => - { - PauseMenuOpen = false; - confirmAction?.Invoke(); - return true; - }; - msgBox.Buttons[0].OnClicked += msgBox.Close; - msgBox.Buttons[1].OnClicked += msgBox.Close; - } + UserData = "verificationprompt", + DrawOnTop = true + }; + msgBox.Buttons[0].OnClicked = (_, __) => + { + PauseMenuOpen = false; + confirmAction?.Invoke(); + return true; + }; + msgBox.Buttons[0].OnClicked += msgBox.Close; + msgBox.Buttons[1].OnClicked += msgBox.Close; } private static bool TogglePauseMenu(GUIButton button, object obj) @@ -2697,12 +2752,6 @@ namespace Barotrauma } } - public static bool IsFourByThree() - { - float aspectRatio = HorizontalAspectRatio; - return aspectRatio > 1.3f && aspectRatio < 1.4f; - } - public static void SetSavingIndicatorState(bool enabled) { if (enabled) diff --git a/Barotrauma/BarotraumaClient/ClientSource/GUI/GUIComponent.cs b/Barotrauma/BarotraumaClient/ClientSource/GUI/GUIComponent.cs index 25150f8a9..918b67d16 100644 --- a/Barotrauma/BarotraumaClient/ClientSource/GUI/GUIComponent.cs +++ b/Barotrauma/BarotraumaClient/ClientSource/GUI/GUIComponent.cs @@ -9,6 +9,7 @@ using Barotrauma.IO; using RestSharp; using System.Net; using Barotrauma.Steam; +using Steamworks; namespace Barotrauma { @@ -158,6 +159,12 @@ namespace Barotrauma public Action OnAddedToGUIUpdateList; + /// + /// Triggers when a tooltip should be draw on the component. + /// Note that the callback triggers even if the item has no tooltip (which can be useful for e.g. only contructing the tooltip when needed). + /// + public Action OnDrawToolTip; + public enum ComponentState { None, Hover, Pressed, Selected, HoverSelected }; protected Alignment alignment; @@ -1120,7 +1127,7 @@ namespace Barotrauma component = LoadGUIImage(element, parent); break; case "accordion": - return LoadAccordion(element, parent); + return LoadAccordion(element, parent, openOnTop: element.GetAttributeBool("openontop", false)); case "gridtext": LoadGridText(element, parent); return null; @@ -1138,6 +1145,21 @@ namespace Barotrauma FromXML(subElement, component is GUIListBox listBox ? listBox.Content.RectTransform : component.RectTransform); } + component.toolTip = element.GetAttributeString("tooltip", string.Empty); + + GUITextBlock textBlock = component as GUITextBlock ?? (component as GUIButton)?.TextBlock; + if (textBlock != null) + { + if (element.GetAttributeBool("autoscalevertical", false)) + { + textBlock.AutoScaleVertical = true; + } + if (element.GetAttributeBool("autoscalehorizontal", false)) + { + textBlock.AutoScaleHorizontal = true; + } + } + if (element.GetAttributeBool("resizetofitchildren", false)) { Vector2 relativeResizeScale = element.GetAttributeVector2("relativeresizescale", Vector2.One); @@ -1180,7 +1202,8 @@ namespace Barotrauma { foreach (XAttribute attribute in element.Attributes()) { - switch (attribute.Name.ToString().ToLowerInvariant()) + string conditionName = attribute.Name.ToString().ToLowerInvariant(); + switch (conditionName) { case "language": var languages = element.GetAttributeIdentifierArray(attribute.Name.ToString(), Array.Empty()) @@ -1199,6 +1222,10 @@ namespace Barotrauma var maxVersion = new Version(attribute.Value); if (GameMain.Version > maxVersion) { return false; } break; + case "identifierdismissed": + Identifier identifier = element.GetAttributeIdentifier(attribute.Name.ToString(), Identifier.Empty); + if (MainMenuScreen.DismissedNotifications.Contains(identifier)) { return false; } + break; case "buildconfiguration": switch (attribute.Value.ToString().ToLowerInvariant()) { @@ -1222,6 +1249,20 @@ namespace Barotrauma #endif } return false; + case "mingamelaunches": + if (int.TryParse(attribute.Value, out int minLaunches)) + { + return SteamManager.GetStatInt(AchievementStat.GameLaunchCount) > minLaunches; + } + return false; + case "appsubscribed": + case "appnotsubscribed": + if (SteamManager.IsInitialized && + int.TryParse(attribute.Value, out int appId)) + { + return SteamApps.IsSubscribedToApp(appId) == (conditionName == "appsubscribed"); + } + return false; } } @@ -1262,12 +1303,18 @@ namespace Barotrauma private static GUIButton LoadLink(XElement element, RectTransform parent) { + Identifier identifier = element.GetAttributeIdentifier("identifier", Identifier.Empty); + var button = LoadGUIButton(element, parent); string url = element.GetAttributeString("url", ""); button.OnClicked = (btn, userdata) => { try { + if (!identifier.IsEmpty) + { + MainMenuScreen.AddDismissedNotification(identifier); + } if (SteamManager.IsInitialized) { SteamManager.OverlayCustomUrl(url); @@ -1324,6 +1371,8 @@ namespace Barotrauma private static GUIButton LoadGUIButton(XElement element, RectTransform parent) { + Identifier identifier = element.GetAttributeIdentifier("identifier", Identifier.Empty); + string style = element.GetAttributeString("style", ""); if (style == "null") { style = null; } @@ -1335,10 +1384,19 @@ namespace Barotrauma element.GetAttributeString("text", ""); text = text.Replace(@"\n", "\n"); - return new GUIButton(RectTransform.Load(element, parent), + var button = new GUIButton(RectTransform.Load(element, parent), text: text, textAlignment: textAlignment, style: style); + button.OnClicked = (btn, userdata) => + { + if (!identifier.IsEmpty) + { + MainMenuScreen.AddDismissedNotification(identifier); + } + return true; + }; + return button; } private static GUIListBox LoadGUIListBox(XElement element, RectTransform parent) @@ -1391,11 +1449,14 @@ namespace Barotrauma { sprite = new Sprite(element); } - - return new GUIImage(RectTransform.Load(element, parent), sprite, scaleToFit: true); + var scaleToFit = element.GetAttributeEnum("scaletofit", GUIImage.ScalingMode.ScaleToFitSmallestExtent); + return new GUIImage(RectTransform.Load(element, parent), sprite, scaleToFit: scaleToFit); } - private static GUIButton LoadAccordion(ContentXElement element, RectTransform parent) + public static readonly List OpenAccordionPopups = new List(); + + /// Should the contents of the accordion be forced to open on top of other UI elements? + private static GUIButton LoadAccordion(ContentXElement element, RectTransform parent, bool openOnTop) { var button = LoadGUIButton(element, parent); List content = new List(); @@ -1407,6 +1468,7 @@ namespace Barotrauma contentElement.Visible = false; contentElement.IgnoreLayoutGroups = true; content.Add(contentElement); + contentElement.UserData = (contentElement.RectTransform.Anchor, contentElement.RectTransform.Pivot); } } button.OnClicked = (btn, userdata) => @@ -1414,8 +1476,32 @@ namespace Barotrauma bool visible = content.FirstOrDefault()?.Visible ?? true; foreach (GUIComponent contentElement in content) { - contentElement.Visible = !visible; - contentElement.IgnoreLayoutGroups = !contentElement.Visible; + if (openOnTop) + { + contentElement.rectTransform.Parent = null; + //the element is drawn in screen space over anything (no longer a child of the original parent), + //we need to calculate the screen space position manually + contentElement.rectTransform.SetPosition(Anchor.TopLeft); + (Anchor anchor, Pivot pivot) = ((Anchor anchor, Pivot pivot))contentElement.UserData; + contentElement.rectTransform.ScreenSpaceOffset = + RectTransform.CalculateAnchorPoint(anchor, button.Rect) + + RectTransform.CalculatePivotOffset(pivot, contentElement.Rect.Size); + contentElement.Visible = true; + if (OpenAccordionPopups.Contains(contentElement)) + { + OpenAccordionPopups.Remove(contentElement); + } + else + { + OpenAccordionPopups.Clear(); + OpenAccordionPopups.Add(contentElement); + } + } + else + { + contentElement.Visible = !visible; + contentElement.IgnoreLayoutGroups = !contentElement.Visible; + } } if (button.Parent is GUILayoutGroup layoutGroup) { diff --git a/Barotrauma/BarotraumaClient/ClientSource/GUI/GUIImage.cs b/Barotrauma/BarotraumaClient/ClientSource/GUI/GUIImage.cs index 7f599ac11..170e262be 100644 --- a/Barotrauma/BarotraumaClient/ClientSource/GUI/GUIImage.cs +++ b/Barotrauma/BarotraumaClient/ClientSource/GUI/GUIImage.cs @@ -2,7 +2,6 @@ using Microsoft.Xna.Framework.Graphics; using System; using System.Collections.Generic; -using System.Linq; using System.Threading.Tasks; namespace Barotrauma @@ -14,6 +13,24 @@ namespace Barotrauma private static bool loadingTextures; + public enum ScalingMode + { + /// + /// No automatic scaling, the image is drawn using + /// + None, + /// + /// Automatically scales the image so it fits the smallest extent of the component + /// (leaving empty space around the image if its aspect ratio is different than that of the GUIImage) + /// + ScaleToFitSmallestExtent, + /// + /// Automatically scales the image so it fits the largest extent of the component, + /// cutting out the parts that go outside the bounds of the component. + /// + ScaleToFitLargestExtent, + } + public static bool LoadingTextures { get @@ -30,7 +47,7 @@ namespace Barotrauma private bool crop; - private readonly bool scaleToFit; + private readonly ScalingMode scaleToFit; private bool lazyLoaded, loading; @@ -89,7 +106,7 @@ namespace Barotrauma sprite = value; sourceRect = value == null ? Rectangle.Empty : value.SourceRect; origin = value == null ? Vector2.Zero : value.size / 2; - if (scaleToFit) { RecalculateScale(); } + if (scaleToFit != ScalingMode.None) { RecalculateScale(); } } } @@ -97,17 +114,27 @@ namespace Barotrauma public ComponentState? OverrideState = null; - public GUIImage(RectTransform rectT, string style, bool scaleToFit = false) + public GUIImage(RectTransform rectT, string style, bool scaleToFit) + : this(rectT, null, null, scaleToFit ? ScalingMode.ScaleToFitSmallestExtent : ScalingMode.None, style) + { + } + + public GUIImage(RectTransform rectT, string style, ScalingMode scaleToFit = ScalingMode.None) : this(rectT, null, null, scaleToFit, style) { } - public GUIImage(RectTransform rectT, Sprite sprite, Rectangle? sourceRect = null, bool scaleToFit = false) + public GUIImage(RectTransform rectT, Sprite sprite, bool scaleToFit, Rectangle? sourceRect = null) + : this(rectT, sprite, sourceRect, scaleToFit ? ScalingMode.ScaleToFitSmallestExtent : ScalingMode.None, null) + { + } + + public GUIImage(RectTransform rectT, Sprite sprite, Rectangle? sourceRect = null, ScalingMode scaleToFit = ScalingMode.None) : this(rectT, sprite, sourceRect, scaleToFit, null) { } - private GUIImage(RectTransform rectT, Sprite sprite, Rectangle? sourceRect, bool scaleToFit, string style) : base(style, rectT) + private GUIImage(RectTransform rectT, Sprite sprite, Rectangle? sourceRect, ScalingMode scaleToFit, string style) : base(style, rectT) { this.scaleToFit = scaleToFit; Sprite = sprite; @@ -123,7 +150,7 @@ namespace Barotrauma { color = hoverColor = selectedColor = pressedColor = disabledColor = Color.White; } - if (!scaleToFit) + if (scaleToFit == ScalingMode.None) { Scale = 1.0f; } @@ -176,9 +203,11 @@ namespace Barotrauma Color currentColor = GetColor(State); - if (BlendState != null) + Rectangle prevScissorRect = spriteBatch.GraphicsDevice.ScissorRectangle; + if (BlendState != null || scaleToFit == ScalingMode.ScaleToFitLargestExtent) { spriteBatch.End(); + spriteBatch.GraphicsDevice.ScissorRectangle = Rectangle.Intersect(prevScissorRect, Rect); spriteBatch.Begin(blendState: BlendState, samplerState: GUI.SamplerState, rasterizerState: GameMain.ScissorTestEnable); } @@ -205,9 +234,10 @@ namespace Barotrauma Scale, SpriteEffects, 0.0f); } - if (BlendState != null) + if (BlendState != null || scaleToFit == ScalingMode.ScaleToFitLargestExtent) { spriteBatch.End(); + spriteBatch.GraphicsDevice.ScissorRectangle = prevScissorRect; spriteBatch.Begin(SpriteSortMode.Deferred, samplerState: GUI.SamplerState, rasterizerState: GameMain.ScissorTestEnable); } } @@ -219,9 +249,18 @@ namespace Barotrauma sourceRect = sprite.SourceRect; } - Scale = sprite == null || sprite.SourceRect.Width == 0 || sprite.SourceRect.Height == 0 ? - 1.0f : - Math.Min(RectTransform.Rect.Width / (float)sprite.SourceRect.Width, RectTransform.Rect.Height / (float)sprite.SourceRect.Height); + if (sprite == null || sprite.SourceRect.Width == 0 || sprite.SourceRect.Height == 0) + { + Scale = 1.0f; + } + else if (scaleToFit == ScalingMode.ScaleToFitLargestExtent) + { + Scale = Math.Max(RectTransform.Rect.Width / (float)sprite.SourceRect.Width, RectTransform.Rect.Height / (float)sprite.SourceRect.Height); + } + else + { + Scale = Math.Min(RectTransform.Rect.Width / (float)sprite.SourceRect.Width, RectTransform.Rect.Height / (float)sprite.SourceRect.Height); + } } private async Task LoadTextureAsync() diff --git a/Barotrauma/BarotraumaClient/ClientSource/GUI/GUIListBox.cs b/Barotrauma/BarotraumaClient/ClientSource/GUI/GUIListBox.cs index 9a944ff5f..a96e57bf9 100644 --- a/Barotrauma/BarotraumaClient/ClientSource/GUI/GUIListBox.cs +++ b/Barotrauma/BarotraumaClient/ClientSource/GUI/GUIListBox.cs @@ -1030,7 +1030,7 @@ namespace Barotrauma while (index < Content.CountChildren) { GUIComponent child = Content.GetChild(index); - if (child.Visible) + if (child.Visible && child.CanBeFocused) { Select(index, force, GetAutoScroll(!SmoothScroll && autoScroll == AutoScroll.Enabled), takeKeyBoardFocus, playSelectSound); if (SmoothScroll) @@ -1049,7 +1049,7 @@ namespace Barotrauma while (index >= 0) { GUIComponent child = Content.GetChild(index); - if (child.Visible) + if (child.Visible && child.CanBeFocused) { Select(index, force, GetAutoScroll(!SmoothScroll && autoScroll == AutoScroll.Enabled), takeKeyBoardFocus, playSelectSound); if (SmoothScroll) diff --git a/Barotrauma/BarotraumaClient/ClientSource/GUI/GUIMessageBox.cs b/Barotrauma/BarotraumaClient/ClientSource/GUI/GUIMessageBox.cs index 45b64a5fb..0e9103d47 100644 --- a/Barotrauma/BarotraumaClient/ClientSource/GUI/GUIMessageBox.cs +++ b/Barotrauma/BarotraumaClient/ClientSource/GUI/GUIMessageBox.cs @@ -289,7 +289,7 @@ namespace Barotrauma GUIStyle.Apply(Text, "", this); Content.Recalculate(); Text.RectTransform.NonScaledSize = Text.RectTransform.MinSize = Text.RectTransform.MaxSize = - new Point(Text.Rect.Width, Text.Rect.Height); + new Point(Text.Rect.Width, Math.Min(Text.Rect.Height, GameMain.GraphicsHeight)); Text.RectTransform.IsFixedSize = true; if (headerText.IsNullOrWhiteSpace()) { diff --git a/Barotrauma/BarotraumaClient/ClientSource/GUI/HRManagerUI.cs b/Barotrauma/BarotraumaClient/ClientSource/GUI/HRManagerUI.cs index b5e0531d3..cff44b879 100644 --- a/Barotrauma/BarotraumaClient/ClientSource/GUI/HRManagerUI.cs +++ b/Barotrauma/BarotraumaClient/ClientSource/GUI/HRManagerUI.cs @@ -38,6 +38,8 @@ namespace Barotrauma private static bool ReplacingPermanentlyDeadCharacter => GameMain.NetworkMember?.ServerSettings is { RespawnMode: RespawnMode.Permadeath, IronmanMode: false } && GameMain.Client?.CharacterInfo is { PermanentlyDead: true }; + + private static bool ReserveBenchEnabled => GameMain.GameSession?.Campaign is MultiPlayerCampaign; private bool hadPermissionToHire; private static bool HasPermissionToHire => ReplacingPermanentlyDeadCharacter ? @@ -277,13 +279,42 @@ namespace Barotrauma } else { - PendingHires?.ForEach(ci => AddPendingHire(ci)); + PendingHires?.ForEach(ci => AddPendingHire(ci, createNetworkMessage: false)); } SetTotalHireCost(); } UpdateCrew(); } + /// + /// This will simply update each of the HR view lists (hireables, pending hires, and crew) from the most up to date information. + /// It is a sane version of UpdateLocationView that won't break things even if used outside of whatever arbitrary conditions that one was made for. + /// + public void RefreshHRView() + { + if (campaign?.CurrentLocation is not Location currentLocation) + { + return; + } + + if (characterPreviewFrame != null) + { + characterPreviewFrame.Parent?.RemoveChild(characterPreviewFrame); + characterPreviewFrame = null; + } + + UpdateHireables(currentLocation); + + if (pendingList != null) + { + pendingList.Content.ClearChildren(); + PendingHires?.ForEach(ci => AddPendingHire(ci, checkCrewSizeLimit: false, createNetworkMessage: false)); // don't check limits here, just display the data as it is + SetTotalHireCost(); + } + + UpdateCrew(); + } + public void UpdateHireables() { UpdateHireables(campaign?.CurrentLocation); @@ -329,10 +360,11 @@ namespace Barotrauma public void UpdateCrew() { crewList.Content.Children.ToList().ForEach(c => crewList.Content.RemoveChild(c)); - foreach (CharacterInfo c in GameMain.GameSession.CrewManager.GetCharacterInfos()) + foreach (CharacterInfo ci in GameMain.GameSession.CrewManager.GetCharacterInfos(includeReserveBench: true)) { - if (c == null || !((c.Character?.IsBot ?? true) || campaign is SinglePlayerCampaign)) { continue; } - CreateCharacterFrame(c, crewList); + // CrewManager is used to store info on all characters including players, but we only want bots in HR + if (ci.Character != null && (ci.Character.IsRemotePlayer || !ci.Character.IsBot)) { continue; } + CreateCharacterFrame(ci, crewList); } SortCharacters(crewList, SortingMethod.JobAsc); crewList.UpdateScrollBarSize(); @@ -369,6 +401,10 @@ namespace Barotrauma ((InfoSkill)x.GUIComponent.UserData).SkillLevel.CompareTo(((InfoSkill)y.GUIComponent.UserData).SkillLevel)); if (sortingMethod == SortingMethod.SkillDesc) { list.Content.RectTransform.ReverseChildren(); } } + + // Always apply this in the end to group by reserve bench status (does nothing if there are no reserve benched bots) + list.Content.RectTransform.SortChildren((x, y) => + ((InfoSkill)x.GUIComponent.UserData).CharacterInfo.BotStatus.CompareTo(((InfoSkill)y.GUIComponent.UserData).CharacterInfo.BotStatus)); int? CompareReputationRequirement(GUIComponent c1, GUIComponent c2) { @@ -401,6 +437,8 @@ namespace Barotrauma public GUIComponent CreateCharacterFrame(CharacterInfo characterInfo, GUIListBox listBox, bool hideSalary = false) { + string characterName = listBox == hireableList ? characterInfo.OriginalName : characterInfo.Name; + Skill skill = null; Color? jobColor = null; if (characterInfo.Job != null) @@ -415,6 +453,7 @@ namespace Barotrauma }; GUILayoutGroup mainGroup = new GUILayoutGroup(new RectTransform(new Vector2(0.95f, 0.95f), frame.RectTransform, anchor: Anchor.Center), isHorizontal: true, childAnchor: Anchor.CenterLeft) { + AbsoluteSpacing = 1, Stretch = true }; @@ -428,13 +467,15 @@ namespace Barotrauma GUILayoutGroup nameAndJobGroup = new GUILayoutGroup(new RectTransform(new Vector2(0.4f - portraitWidth, 0.8f), mainGroup.RectTransform)) { CanBeFocused = false }; GUILayoutGroup nameGroup = new GUILayoutGroup(new RectTransform(new Vector2(1.0f, 0.5f), nameAndJobGroup.RectTransform), isHorizontal: true, childAnchor: Anchor.CenterLeft) { CanBeFocused = false }; GUITextBlock nameBlock = new GUITextBlock(new RectTransform(Vector2.One, nameGroup.RectTransform), - listBox == hireableList ? characterInfo.OriginalName : characterInfo.Name, + characterName, textColor: jobColor, textAlignment: Alignment.BottomLeft) { CanBeFocused = false }; - nameBlock.Text = ToolBox.LimitString(nameBlock.Text, nameBlock.Font, nameBlock.Rect.Width); - + const float smallColumnWidth = 0.6f / 3; + const float skillColumnWidth = smallColumnWidth * 0.7f; + const float buttonWidth = 0.12f; + GUITextBlock jobBlock = new GUITextBlock(new RectTransform(new Vector2(1.0f, 0.5f), nameAndJobGroup.RectTransform), characterInfo.Title ?? characterInfo.Job.Name, textColor: Color.White, font: GUIStyle.SmallFont, textAlignment: Alignment.TopLeft) { @@ -449,33 +490,28 @@ namespace Barotrauma } } var fullJobText = jobBlock.Text; - jobBlock.Text = ToolBox.LimitString(fullJobText, jobBlock.Font, jobBlock.Rect.Width); - if (jobBlock.Text != fullJobText) - { - jobBlock.ToolTip = fullJobText; - jobBlock.CanBeFocused = true; - } - float width = 0.6f / 3; if (characterInfo.Job != null && skill != null) { - GUILayoutGroup skillGroup = new GUILayoutGroup(new RectTransform(new Vector2(width, 0.6f), mainGroup.RectTransform), isHorizontal: true); + GUILayoutGroup skillGroup = new GUILayoutGroup(new RectTransform(new Vector2(skillColumnWidth, 0.6f), mainGroup.RectTransform), isHorizontal: true); float iconWidth = (float)skillGroup.Rect.Height / skillGroup.Rect.Width; + new GUITextBlock(new RectTransform(new Vector2(1.0f - iconWidth, 1.0f), skillGroup.RectTransform), ((int)skill.Level).ToString(), + textAlignment: Alignment.CenterRight) + { + Padding = Vector4.Zero, + CanBeFocused = false + }; GUIImage skillIcon = new GUIImage(new RectTransform(Vector2.One, skillGroup.RectTransform, scaleBasis: ScaleBasis.Smallest), skill.Icon, scaleToFit: true) { CanBeFocused = false }; if (jobColor.HasValue) { skillIcon.Color = jobColor.Value; } - new GUITextBlock(new RectTransform(new Vector2(1.0f - iconWidth, 1.0f), skillGroup.RectTransform), ((int)skill.Level).ToString(), textAlignment: Alignment.CenterLeft) - { - CanBeFocused = false - }; } if (!hideSalary) { if (listBox != crewList) { - new GUITextBlock(new RectTransform(new Vector2(width, 1.0f), mainGroup.RectTransform), + new GUITextBlock(new RectTransform(new Vector2(smallColumnWidth, 1.0f), mainGroup.RectTransform), TextManager.FormatCurrency(ReplacingPermanentlyDeadCharacter ? campaign.NewCharacterCost(characterInfo) : HireManager.GetSalaryFor(characterInfo)), textAlignment: Alignment.Center) { @@ -485,19 +521,24 @@ namespace Barotrauma else { // Just a bit of padding to make list layouts similar - new GUIFrame(new RectTransform(new Vector2(width, 1.0f), mainGroup.RectTransform), style: null) { CanBeFocused = false }; + new GUIFrame(new RectTransform(new Vector2(smallColumnWidth, 1.0f), mainGroup.RectTransform), style: null) { CanBeFocused = false }; } } if (listBox == hireableList) { - var hireButton = new GUIButton(new RectTransform(new Vector2(width, 0.9f), mainGroup.RectTransform), style: "CrewManagementAddButton") + var hireButton = new GUIButton(new RectTransform(new Vector2(buttonWidth, 0.9f), mainGroup.RectTransform), style: "CrewManagementAddButton") { - ToolTip = TextManager.Get("hirebutton"), + ToolTip = TextManager.Get(ReserveBenchEnabled ? "hirebutton.crew" : "hirebutton"), ClickSound = GUISoundType.Cart, UserData = characterInfo, Enabled = CanHire(characterInfo) && !ReplacingPermanentlyDeadCharacter, - OnClicked = (b, o) => AddPendingHire(o as CharacterInfo) + OnClicked = (b, o) => + { + var currentCharacterInfo = (CharacterInfo)o; + currentCharacterInfo.BotStatus = BotStatus.PendingHireToActiveService; + return AddPendingHire(currentCharacterInfo); + } }; hireButton.OnAddedToGUIUpdateList += (GUIComponent btn) => { @@ -505,7 +546,7 @@ namespace Barotrauma { return; } - if (PendingHires.Count + campaign.CrewManager.GetCharacterInfos().Count() >= CrewManager.MaxCrewSize) + if (PendingHires.Count(ci => ci.BotStatus == BotStatus.PendingHireToActiveService) + campaign.CrewManager.GetCharacterInfos().Count() >= CrewManager.MaxCrewSize) { if (btn.Enabled) { @@ -523,7 +564,7 @@ namespace Barotrauma if (ReplacingPermanentlyDeadCharacter) { bool canHire = CanHire(characterInfo) && campaign.CanAffordNewCharacter(characterInfo); - var takeoverButton = new GUIButton(new RectTransform(new Vector2(width, 0.9f), mainGroup.RectTransform), style: "CrewManagementTakeControlButton") + var takeoverButton = new GUIButton(new RectTransform(new Vector2(buttonWidth, 0.9f), mainGroup.RectTransform), style: "CrewManagementTakeControlButton") { ToolTip = canHire ? TextManager.Get("hireandtakecontrol") : TextManager.Get("hireandtakecontroldisabled"), ClickSound = GUISoundType.ConfirmTransaction, @@ -554,25 +595,90 @@ namespace Barotrauma btn.Enabled = canHireCurrently; }; } + + if (ReserveBenchEnabled && !ReplacingPermanentlyDeadCharacter) + { + var hireToReserveBenchButton = new GUIButton(new RectTransform(new Vector2(buttonWidth, 0.9f), mainGroup.RectTransform), style: "CrewManagementAddAsReserveButton") + { + ToolTip = TextManager.Get("hirebutton.reservebench"), + ClickSound = GUISoundType.Cart, + UserData = characterInfo, + Enabled = CanHire(characterInfo), + OnClicked = (b, o) => + { + var currentCharacterInfo = (CharacterInfo)o; + currentCharacterInfo.BotStatus = BotStatus.PendingHireToReserveBench; + return AddPendingHire(currentCharacterInfo, checkCrewSizeLimit: false); + } + }; + hireToReserveBenchButton.OnAddedToGUIUpdateList += (GUIComponent btn) => + { + btn.Visible = ReserveBenchEnabled; + btn.Enabled = CanHire(characterInfo) && !ReplacingPermanentlyDeadCharacter; + }; + } } else if (listBox == pendingList) { - new GUIButton(new RectTransform(new Vector2(width, 0.9f), mainGroup.RectTransform), style: "CrewManagementRemoveButton") + if (ReserveBenchEnabled && !ReplacingPermanentlyDeadCharacter) + { + new GUIButton(new RectTransform(new Vector2(buttonWidth, 0.9f), mainGroup.RectTransform), + style: characterInfo.BotStatus == BotStatus.PendingHireToActiveService ? "CrewManagementReserveBenchButtonActive" : "CrewManagementReserveBenchButtonReserve") + { + UserData = characterInfo, + ToolTip = TextManager.Get(characterInfo.BotStatus == BotStatus.PendingHireToActiveService ? "ReserveBenchTogglePendingHire.Active" : "ReserveBenchTogglePendingHire.Reserve"), + Enabled = CanHire(characterInfo) && (characterInfo.BotStatus == BotStatus.PendingHireToActiveService || !ActiveServiceFull()), // note that this is a toggle + OnClicked = (btn, obj) => + { + SelectCharacter(null, null, null); + var currentCharacterInfo = (CharacterInfo)obj; + GameMain.Client?.ToggleReserveBench(currentCharacterInfo, pendingHire: true); + return true; + } + }; + } + + new GUIButton(new RectTransform(new Vector2(buttonWidth, 0.9f), mainGroup.RectTransform), style: "CrewManagementRemoveButton") { ClickSound = GUISoundType.Cart, UserData = characterInfo, - Enabled = CanHire(characterInfo), + Enabled = CanHire(characterInfo), // =just check user's rights OnClicked = (b, o) => RemovePendingHire(o as CharacterInfo) }; } else if (listBox == crewList && campaign != null) { - var currentCrew = GameMain.GameSession.CrewManager.GetCharacterInfos(); - new GUIButton(new RectTransform(new Vector2(width, 0.9f), mainGroup.RectTransform), style: "CrewManagementFireButton") + if (ReserveBenchEnabled && !ReplacingPermanentlyDeadCharacter) + { + new GUIButton(new RectTransform(new Vector2(buttonWidth, 0.9f), mainGroup.RectTransform), + style: characterInfo.BotStatus == BotStatus.ActiveService ? "CrewManagementReserveBenchButtonActive" : "CrewManagementReserveBenchButtonReserve") + { + UserData = characterInfo, + ToolTip = TextManager.Get(characterInfo.BotStatus == BotStatus.ActiveService ? "ReserveBenchToggle.Active" : "ReserveBenchToggle.Reserve"), + Enabled = CanHire(characterInfo) && (characterInfo.BotStatus == BotStatus.ActiveService || !ActiveServiceFull()), // note that this is a toggle + OnClicked = (btn, obj) => + { + SelectCharacter(null, null, null); + var currentCharacterInfo = (CharacterInfo)obj; + if (currentCharacterInfo.BotStatus == BotStatus.ActiveService && // switching to reserve bench + characterInfo.Character != null) // may not have a Character to remove if not spawned this round + { + GameMain.GameSession.CrewManager.RemoveCharacter(characterInfo.Character, removeInfo: true, resetCrewListIndex: true); + } + GameMain.Client?.ToggleReserveBench(currentCharacterInfo); // update changes to server + return true; + } + }; + } + + var cm = GameMain.GameSession.CrewManager; + // Can't fire if there's only one character in active service + var fireButtonEnabled = HasPermissionToHire && (characterInfo.IsOnReserveBench || + (cm.GetCharacterInfos().Contains(characterInfo) && cm.GetCharacterInfos().Count() > 1)); + new GUIButton(new RectTransform(new Vector2(buttonWidth, 0.9f), mainGroup.RectTransform), style: "CrewManagementFireButton") { UserData = characterInfo, - //can't fire if there's only one character in the crew - Enabled = currentCrew.Contains(characterInfo) && currentCrew.Count() > 1 && HasPermissionToHire, + Enabled = fireButtonEnabled, OnClicked = (btn, obj) => { var confirmDialog = new GUIMessageBox( @@ -587,11 +693,25 @@ namespace Barotrauma } }; } + else + { + if (ReserveBenchEnabled && characterInfo.IsOnReserveBench) // Applies to unspecified listings like the death prompt and the bot list after permadeath + { + new GUIImage(new RectTransform(new Vector2(smallColumnWidth / 2, 0.6f), mainGroup.RectTransform), style: "CrewManagementReserveBenchIconReserve") + { + ToolTip = TextManager.Get("ReserveBenchStatus.Reserve.WillSpawn") + }; + } + else + { + new GUILayoutGroup(new RectTransform(new Vector2(smallColumnWidth / 2, 0.6f), mainGroup.RectTransform)) { CanBeFocused = false }; + } + } if (listBox == pendingList || listBox == crewList) { nameBlock.RectTransform.Resize(new Point(nameBlock.Rect.Width - nameBlock.Rect.Height, nameBlock.Rect.Height)); - nameBlock.Text = ToolBox.LimitString(nameBlock.Text, nameBlock.Font, nameBlock.Rect.Width); + nameBlock.Text = ToolBox.LimitString(characterName, nameBlock.Font, nameBlock.Rect.Width); nameBlock.RectTransform.Resize(new Point((int)(nameBlock.Padding.X + nameBlock.TextSize.X + nameBlock.Padding.Z), nameBlock.Rect.Height)); Point size = new Point((int)(0.7f * nameBlock.Rect.Height)); new GUIImage(new RectTransform(size, nameGroup.RectTransform), "EditIcon") { CanBeFocused = false }; @@ -605,6 +725,16 @@ namespace Barotrauma }; } + //recalculate everything and truncate texts if needed + mainGroup.Recalculate(); + nameBlock.Text = ToolBox.LimitString(characterName, nameBlock.Font, nameBlock.Rect.Width); + jobBlock.Text = ToolBox.LimitString(fullJobText, jobBlock.Font, jobBlock.Rect.Width); + if (jobBlock.Text != fullJobText) + { + jobBlock.ToolTip = fullJobText; + jobBlock.CanBeFocused = true; + } + bool CanHire(CharacterInfo thisCharacterInfo) { if (!HasPermissionToHire) { return false; } @@ -613,6 +743,15 @@ namespace Barotrauma return frame; } + + /// + /// Is there (going to be) no space left in active service? + /// + private bool ActiveServiceFull() + { + int pendingHireCount = PendingHires?.Count(ci => ci.BotStatus == BotStatus.PendingHireToActiveService) ?? 0; + return pendingHireCount + campaign.CrewManager.GetCharacterInfos().Count() >= CrewManager.MaxCrewSize; + } private bool EnoughReputationToHire(CharacterInfo characterInfo) { @@ -656,7 +795,7 @@ namespace Barotrauma GUILayoutGroup infoLabelGroup = new GUILayoutGroup(new RectTransform(new Vector2(0.4f, 1.0f), infoGroup.RectTransform)) { Stretch = true }; GUILayoutGroup infoValueGroup = new GUILayoutGroup(new RectTransform(new Vector2(0.6f, 1.0f), infoGroup.RectTransform)) { Stretch = true }; float blockHeight = 1.0f / 4; - new GUITextBlock(new RectTransform(new Vector2(1.0f, blockHeight), infoLabelGroup.RectTransform), TextManager.Get("name")); + new GUITextBlock(new RectTransform(new Vector2(1.0f, blockHeight), infoLabelGroup.RectTransform), TextManager.Get("name"), textColor: GUIStyle.TextColorBright); GUITextBlock nameBlock = new GUITextBlock(new RectTransform(new Vector2(1.0f, blockHeight), infoValueGroup.RectTransform), ""); string name = listBox == hireableList ? characterInfo.OriginalName : characterInfo.Name; nameBlock.Text = ToolBox.LimitString(name, nameBlock.Font, nameBlock.Rect.Width); @@ -664,17 +803,17 @@ namespace Barotrauma if (characterInfo.HasSpecifierTags) { var menuCategoryVar = characterInfo.Prefab.MenuCategoryVar; - new GUITextBlock(new RectTransform(new Vector2(1.0f, blockHeight), infoLabelGroup.RectTransform), TextManager.Get(menuCategoryVar)); + new GUITextBlock(new RectTransform(new Vector2(1.0f, blockHeight), infoLabelGroup.RectTransform), TextManager.Get(menuCategoryVar), textColor: GUIStyle.TextColorBright); new GUITextBlock(new RectTransform(new Vector2(1.0f, blockHeight), infoValueGroup.RectTransform), TextManager.Get(characterInfo.ReplaceVars($"[{menuCategoryVar}]"))); } if (characterInfo.Job is Job job) { - new GUITextBlock(new RectTransform(new Vector2(1.0f, blockHeight), infoLabelGroup.RectTransform), TextManager.Get("tabmenu.job")); + new GUITextBlock(new RectTransform(new Vector2(1.0f, blockHeight), infoLabelGroup.RectTransform), TextManager.Get("tabmenu.job"), textColor: GUIStyle.TextColorBright); new GUITextBlock(new RectTransform(new Vector2(1.0f, blockHeight), infoValueGroup.RectTransform), job.Name); } if (characterInfo.PersonalityTrait is NPCPersonalityTrait trait) { - new GUITextBlock(new RectTransform(new Vector2(1.0f, blockHeight), infoLabelGroup.RectTransform), TextManager.Get("PersonalityTrait")); + new GUITextBlock(new RectTransform(new Vector2(1.0f, blockHeight), infoLabelGroup.RectTransform), TextManager.Get("PersonalityTrait"), textColor: GUIStyle.TextColorBright); new GUITextBlock(new RectTransform(new Vector2(1.0f, blockHeight), infoValueGroup.RectTransform), trait.DisplayName); } infoLabelGroup.Recalculate(); @@ -727,9 +866,9 @@ namespace Barotrauma return true; } - private bool AddPendingHire(CharacterInfo characterInfo, bool createNetworkMessage = true) + private bool AddPendingHire(CharacterInfo characterInfo, bool checkCrewSizeLimit = true, bool createNetworkMessage = true) { - if (PendingHires.Count + campaign.CrewManager.GetCharacterInfos().Count() >= CrewManager.MaxCrewSize) + if (checkCrewSizeLimit && characterInfo.BotStatus == BotStatus.PendingHireToActiveService && ActiveServiceFull()) { return false; } @@ -792,7 +931,7 @@ namespace Barotrauma List nonDuplicateHires = new List(); hires.ForEach(hireInfo => { - if (campaign.CrewManager.GetCharacterInfos().None(crewInfo => crewInfo.IsNewHire && crewInfo.GetIdentifierUsingOriginalName() == hireInfo.GetIdentifierUsingOriginalName())) + if (campaign.CrewManager.GetCharacterInfos(includeReserveBench: true).None(crewInfo => crewInfo.IsNewHire && crewInfo.GetIdentifierUsingOriginalName() == hireInfo.GetIdentifierUsingOriginalName())) { nonDuplicateHires.Add(hireInfo); } @@ -806,12 +945,21 @@ namespace Barotrauma if (!campaign.CanAfford(total)) { return false; } } - bool atLeastOneHired = false; + bool atLeastOneHiredToActiveDuty = false; + bool atLeastOneHiredToReserveBench = false; foreach (CharacterInfo ci in nonDuplicateHires) { + bool toReserveBench = ci.BotStatus == BotStatus.PendingHireToReserveBench; if (campaign.TryHireCharacter(campaign.Map.CurrentLocation, ci, takeMoney: takeMoney)) { - atLeastOneHired = true; + if (toReserveBench) + { + atLeastOneHiredToReserveBench = true; + } + else + { + atLeastOneHiredToActiveDuty = true; + } } else { @@ -819,15 +967,27 @@ namespace Barotrauma } } - if (atLeastOneHired) + if (atLeastOneHiredToActiveDuty || atLeastOneHiredToReserveBench) { UpdateLocationView(campaign.Map.CurrentLocation, true); SelectCharacter(null, null, null); if (createNotification) { + LocalizedString msg = string.Empty; + if (atLeastOneHiredToActiveDuty) + { + msg += TextManager.GetWithVariable("crewhiredmessage", "[location]", campaignUI?.Campaign?.Map?.CurrentLocation?.DisplayName); + } + if (atLeastOneHiredToReserveBench) + { + if (!msg.IsNullOrEmpty()) { msg += "\n\n"; } + msg += GameMain.NetworkMember?.ServerSettings is { RespawnMode: RespawnMode.Permadeath, IronmanMode: false } ? + TextManager.Get("crewhiredmessage.reservebench.permadeath") : + TextManager.Get( "crewhiredmessage.reservebench"); + } + var dialog = new GUIMessageBox( - TextManager.Get("newcrewmembers"), - TextManager.GetWithVariable("crewhiredmessage", "[location]", campaignUI?.Campaign?.Map?.CurrentLocation?.DisplayName), + TextManager.Get("newcrewmembers"), msg, new LocalizedString[] { TextManager.Get("Ok") }); dialog.Buttons[0].OnClicked += dialog.Close; } @@ -1034,7 +1194,7 @@ namespace Barotrauma } } - public void SetPendingHires(List characterInfos, Location location) + public void SetPendingHires(List characterInfos, bool[] characterInfoReserveBenchStatuses, Location location, bool checkCrewSizeLimit) { List oldHires = PendingHires.ToList(); foreach (CharacterInfo pendingHire in oldHires) @@ -1042,18 +1202,25 @@ namespace Barotrauma RemovePendingHire(pendingHire, createNetworkMessage: false); } PendingHires.Clear(); + int i = 0; foreach (UInt16 identifier in characterInfos) { CharacterInfo match = location.HireManager.AvailableCharacters.Find(info => info.ID == identifier); if (match != null) { - AddPendingHire(match, createNetworkMessage: false); + match.BotStatus = characterInfoReserveBenchStatuses[i] ? BotStatus.PendingHireToReserveBench : BotStatus.PendingHireToActiveService; + AddPendingHire(match, checkCrewSizeLimit: checkCrewSizeLimit, createNetworkMessage: false); + if (!PendingHires.Contains(match)) + { + DebugConsole.ThrowError("Failed to add a pending hire"); + } System.Diagnostics.Debug.Assert(PendingHires.Contains(match)); } else { DebugConsole.ThrowError("Received a hire that doesn't exist."); } + i++; } } @@ -1064,7 +1231,7 @@ namespace Barotrauma /// When not null tell the server to rename this character. Item1 is the character to rename, Item2 is the new name, Item3 indicates whether the renamed character is already a part of the crew. /// When not null tell the server to fire this character /// When set to true will tell the server to validate pending hires - public void SendCrewState(bool updatePending, (CharacterInfo info, string newName) renameCharacter = default, CharacterInfo firedCharacter = null, bool validateHires = false) + public void SendCrewState(bool updatePending = false, (CharacterInfo info, string newName) renameCharacter = default, CharacterInfo firedCharacter = null, bool validateHires = false) { if (campaign is MultiPlayerCampaign) { @@ -1078,6 +1245,7 @@ namespace Barotrauma foreach (CharacterInfo pendingHire in PendingHires) { msg.WriteUInt16(pendingHire.ID); + msg.WriteBoolean(pendingHire.BotStatus == BotStatus.PendingHireToReserveBench); } } @@ -1089,7 +1257,9 @@ namespace Barotrauma { msg.WriteUInt16(renameCharacter.info.ID); msg.WriteString(renameCharacter.newName); - bool existingCrewMember = campaign.CrewManager?.GetCharacterInfos().Any(ci => ci.ID == renameCharacter.info.ID) ?? false; + bool existingCrewMember = + campaign.CrewManager is CrewManager crewManager && + crewManager.GetCharacterInfos(includeReserveBench: true).Any(ci => ci.ID == renameCharacter.info.ID); msg.WriteBoolean(existingCrewMember); } diff --git a/Barotrauma/BarotraumaClient/ClientSource/GUI/LoadingScreen.cs b/Barotrauma/BarotraumaClient/ClientSource/GUI/LoadingScreen.cs index 52749f42b..3231c2c8f 100644 --- a/Barotrauma/BarotraumaClient/ClientSource/GUI/LoadingScreen.cs +++ b/Barotrauma/BarotraumaClient/ClientSource/GUI/LoadingScreen.cs @@ -25,8 +25,6 @@ namespace Barotrauma private Video currSplashScreen; private DateTime videoStartTime; - private bool mirrorBackground; - public struct PendingSplashScreen { public string Filename; @@ -108,8 +106,7 @@ namespace Barotrauma spriteBatch.Begin(SpriteSortMode.Deferred, BlendState.NonPremultiplied, samplerState: GUI.SamplerState); - GUI.DrawBackgroundSprite(spriteBatch, currentBackgroundTexture, Color.White, drawArea, - spriteEffects: mirrorBackground ? SpriteEffects.FlipHorizontally : SpriteEffects.None); + GUI.DrawBackgroundSprite(spriteBatch, currentBackgroundTexture, Color.White, drawArea); overlay.Draw(spriteBatch, Vector2.Zero, scale: overlayScale); double noiseT = Timing.TotalTime * 0.02f; @@ -386,7 +383,6 @@ namespace Barotrauma { currentBackgroundTexture = missions.Where(m => m.Prefab.HasPortraits).First().Prefab.GetPortrait(Rand.Int(int.MaxValue)); } - mirrorBackground = Rand.Range(0.0f, 1.0f) < 0.5f; while (!drawn) { diff --git a/Barotrauma/BarotraumaClient/ClientSource/GUI/Store.cs b/Barotrauma/BarotraumaClient/ClientSource/GUI/Store.cs index 202d44da6..b635014ad 100644 --- a/Barotrauma/BarotraumaClient/ClientSource/GUI/Store.cs +++ b/Barotrauma/BarotraumaClient/ClientSource/GUI/Store.cs @@ -6,6 +6,7 @@ using System.Collections.Generic; using System.Diagnostics; using System.Globalization; using System.Linq; +using Microsoft.Xna.Framework.Input; using PlayerBalanceElement = Barotrauma.CampaignUI.PlayerBalanceElement; namespace Barotrauma @@ -1573,15 +1574,24 @@ namespace Barotrauma if (locationHasDealOnItem) { var relativeWidth = (0.9f * nameAndQuantityFrame.Rect.Height) / nameAndQuantityFrame.Rect.Width; + Vector2 dealIconSize = new Vector2(relativeWidth, 0.9f) * 0.5f; var dealIcon = new GUIImage( - new RectTransform(new Vector2(relativeWidth, 0.9f), nameAndQuantityFrame.RectTransform, anchor: Anchor.CenterLeft) + new RectTransform(dealIconSize, nameAndQuantityFrame.RectTransform, anchor: Anchor.CenterRight) { AbsoluteOffset = new Point((int)nameBlock.Padding.X, 0) }, "StoreDealIcon", scaleToFit: true) { - CanBeFocused = false + CanBeFocused = false, + UserData = "StoreDealIcon" }; + var dealIconColor = dealIcon.Color; + if (forceDisable) + { + dealIconColor.A = 0; + } + + dealIcon.Color = dealIconColor; dealIcon.SetAsFirstChild(); } bool isParentOnLeftSideOfInterface = parentComponent == storeBuyList || parentComponent == storeDailySpecialsGroup || @@ -1713,7 +1723,7 @@ namespace Barotrauma mainGroup.Recalculate(); mainGroup.RectTransform.RecalculateChildren(true, true); amountInput?.LayoutGroup.Recalculate(); - nameBlock.Text = ToolBox.LimitString(nameBlock.Text, nameBlock.Font, nameBlock.Rect.Width); + nameBlock.Text = ToolBox.LimitString(nameBlock.Text.SanitizedString, nameBlock.Font, nameBlock.Rect.Width); mainGroup.RectTransform.Children.ForEach(c => c.IsFixedSize = true); return frame; @@ -1795,6 +1805,9 @@ namespace Barotrauma private void SetItemFrameStatus(GUIComponent itemFrame, bool enabled) { + float full = 1f; + float dim = 0.7f; + float alpha = (enabled ? full : dim); if (itemFrame?.UserData is not PurchasedItem pi) { return; } bool refreshFrameStatus = !pi.IsStoreComponentEnabled.HasValue || pi.IsStoreComponentEnabled.Value != enabled; if (!refreshFrameStatus) { return; } @@ -1802,14 +1815,14 @@ namespace Barotrauma { if (pi.ItemPrefab?.InventoryIcon != null) { - icon.Color = pi.ItemPrefab.InventoryIconColor * (enabled ? 1.0f : 0.5f); + icon.Color = pi.ItemPrefab.InventoryIconColor * alpha; } else if (pi.ItemPrefab?.Sprite != null) { - icon.Color = pi.ItemPrefab.SpriteColor * (enabled ? 1.0f : 0.5f); + icon.Color = pi.ItemPrefab.SpriteColor * alpha; } }; - var color = Color.White * (enabled ? 1.0f : 0.5f); + var color = Color.White * alpha; if (itemFrame.FindChild("name", recursive: true) is GUITextBlock name) { name.TextColor = color; @@ -1835,7 +1848,7 @@ namespace Barotrauma } if (itemFrame.FindChild("price", recursive: true) is GUITextBlock priceBlock) { - priceBlock.TextColor = isDiscounted ? storeSpecialColor * (enabled ? 1.0f : 0.5f) : color; + priceBlock.TextColor = isDiscounted ? storeSpecialColor * alpha : color; } if (itemFrame.FindChild("addbutton", recursive: true) is GUIButton addButton) { @@ -1845,6 +1858,10 @@ namespace Barotrauma { removeButton.Enabled = enabled; } + if (itemFrame.FindChild("StoreDealIcon", recursive: true) is GUIImage dealIcon) + { + dealIcon.Color = dealIcon.Color * alpha; + } pi.IsStoreComponentEnabled = enabled; itemFrame.UserData = pi; } @@ -2271,6 +2288,15 @@ namespace Barotrauma { updateStopwatch.Restart(); + if (GameMain.DevMode) + { + if (PlayerInput.KeyDown(Keys.D0)) + { + CreateUI(); + needsRefresh = true; + } + } + if (GameMain.GraphicsWidth != resolutionWhenCreated.X || GameMain.GraphicsHeight != resolutionWhenCreated.Y) { CreateUI(); diff --git a/Barotrauma/BarotraumaClient/ClientSource/GUI/SubmarineSelection.cs b/Barotrauma/BarotraumaClient/ClientSource/GUI/SubmarineSelection.cs index dac7ac2d0..214f0e2cf 100644 --- a/Barotrauma/BarotraumaClient/ClientSource/GUI/SubmarineSelection.cs +++ b/Barotrauma/BarotraumaClient/ClientSource/GUI/SubmarineSelection.cs @@ -255,7 +255,7 @@ namespace Barotrauma pageIndicators = new GUIImage[pageCount]; for (int i = 0; i < pageCount; i++) { - pageIndicators[i] = new GUIImage(new RectTransform(indicatorSize, pageIndicatorHolder.RectTransform) { AbsoluteOffset = new Point(xPos, yPos) }, pageIndicator, null, true); + pageIndicators[i] = new GUIImage(new RectTransform(indicatorSize, pageIndicatorHolder.RectTransform) { AbsoluteOffset = new Point(xPos, yPos) }, pageIndicator, scaleToFit: true); xPos += indicatorSize.X + HUDLayoutSettings.Padding; } diff --git a/Barotrauma/BarotraumaClient/ClientSource/GUI/TabMenu.cs b/Barotrauma/BarotraumaClient/ClientSource/GUI/TabMenu.cs index 01ffb44d3..87a7c8cb5 100644 --- a/Barotrauma/BarotraumaClient/ClientSource/GUI/TabMenu.cs +++ b/Barotrauma/BarotraumaClient/ClientSource/GUI/TabMenu.cs @@ -120,9 +120,20 @@ namespace Barotrauma { if (Client == null) { return; } if (currentPing == Client.Ping) { return; } - currentPing = Client.Ping; - textBlock.Text = currentPing.ToString(); - textBlock.TextColor = GetPingColor(); + if (GameMain.NetworkMember != null && GameMain.NetworkMember.ConnectedClients.Contains(Client)) + { + currentPing = Client.Ping; + textBlock.Text = currentPing.ToString(); + textBlock.TextColor = GetPingColor(); + textBlock.ToolTip = string.Empty; + } + else + { + currentPing = 0; + textBlock.Text = "-"; + textBlock.TextColor = GUIStyle.Red; + textBlock.ToolTip = TextManager.Get("causeofdeathdescription.disconnected"); + } } public void TryPermissionIconRefresh(Sprite icon) @@ -461,7 +472,7 @@ namespace Barotrauma CreateSubmarineInfo(infoFrameHolder, Submarine.MainSub); break; case InfoFrameTab.Talents: - talentMenu.CreateGUI(infoFrameHolder, Character.Controlled ?? GameMain.Client?.Character); + talentMenu.CreateGUI(infoFrameHolder, Character.Controlled?.Info ?? GameMain.Client?.CharacterInfo); break; } } @@ -709,38 +720,49 @@ namespace Barotrauma var connectedClients = GameMain.Client.ConnectedClients; - for (int i = 0; i < teamIDs.Count; i++) + for (int teamID = 0; teamID < teamIDs.Count; teamID++) { - foreach (Character character in crew.Where(c => c.TeamID == teamIDs[i])) + foreach (Character character in crew.Where(c => c.TeamID == teamIDs[teamID])) { if (character is not AICharacter && connectedClients.Any(c => c.Character == null && c.Name == character.Name)) { continue; } - CreateMultiPlayerCharacterElement(character, GameMain.Client.PreviouslyConnectedClients.FirstOrDefault(c => c.Character == character), i); + CreateMultiPlayerCharacterElement(character, GameMain.Client.PreviouslyConnectedClients.FirstOrDefault(c => c.Character == character), teamID); + } + + foreach (CharacterInfo characterInfo in GameMain.GameSession.CrewManager?.GetReserveBenchInfos() ?? Enumerable.Empty()) + { + CreateMultiPlayerCharacterElement(character: null, client: null, teamID, justCharacterInfo: characterInfo); } } for (int j = 0; j < connectedClients.Count; j++) { Client client = connectedClients[j]; - if (!client.InGame || client.Character == null || client.Character.IsDead) + if (client.Character == null || client.Character.IsDead) { CreateMultiPlayerClientElement(client); } } } - - private void CreateMultiPlayerCharacterElement(Character character, Client client, int i) + + /// The character element can be generated based on just a CharacterInfo, and Character and Client can be left null. Otherwise, those are required and the CharacterInfo of the Character is used. + private void CreateMultiPlayerCharacterElement(Character character, Client client, int teamID, CharacterInfo justCharacterInfo = null) { - GUIFrame frame = new GUIFrame(new RectTransform(new Point(crewListArray[i].Content.Rect.Width, GUI.IntScale(33f)), crewListArray[i].Content.RectTransform), style: "ListBoxElement") + CharacterInfo characterInfo = justCharacterInfo ?? character.Info; + + GUIFrame frame = new GUIFrame(new RectTransform(new Point(crewListArray[teamID].Content.Rect.Width, GUI.IntScale(33f)), crewListArray[teamID].Content.RectTransform), style: "ListBoxElement") { - UserData = character, + UserData = character != null ? character : characterInfo, Color = (GameMain.NetworkMember != null && GameMain.Client.Character == character) ? OwnCharacterBGColor : Color.Transparent }; - - frame.OnSecondaryClicked += (component, data) => + + if (client != null) { - NetLobbyScreen.CreateModerationContextMenu(client); - return true; - }; + frame.OnSecondaryClicked += (component, data) => + { + NetLobbyScreen.CreateModerationContextMenu(client); + return true; + }; + } var paddedFrame = new GUILayoutGroup(new RectTransform(new Vector2(1.0f, 0.9f), frame.RectTransform, Anchor.Center), isHorizontal: true) { @@ -748,7 +770,18 @@ namespace Barotrauma Stretch = true }; - new GUICustomComponent(new RectTransform(new Point(jobColumnWidth, paddedFrame.Rect.Height), paddedFrame.RectTransform, Anchor.Center), onDraw: (sb, component) => character.Info.DrawJobIcon(sb, component.Rect)) + new GUICustomComponent(new RectTransform(new Point(jobColumnWidth, paddedFrame.Rect.Height), paddedFrame.RectTransform, Anchor.Center), + onDraw: (sb, component) => + { + if (client == null) + { + characterInfo?.DrawJobIcon(sb, component.Rect); + } + else + { + DrawClientJobIcon(sb, component.Rect, client); + } + }) { CanBeFocused = false, HoverColor = Color.White, @@ -778,39 +811,60 @@ namespace Barotrauma else { GUITextBlock characterNameBlock = new GUITextBlock(new RectTransform(new Point(characterColumnWidth, paddedFrame.Rect.Height), paddedFrame.RectTransform), - ToolBox.LimitString(character.Info.Name, GUIStyle.Font, characterColumnWidth), textAlignment: Alignment.Center, textColor: character.Info.Job.Prefab.UIColor); + ToolBox.LimitString(characterInfo.Name, GUIStyle.Font, characterColumnWidth), textAlignment: Alignment.Center, textColor: characterInfo.Job.Prefab.UIColor); if (GameMain.GameSession?.GameMode is PvPMode) { new GUITextBlock(new RectTransform(new Point(killColumnWidth, paddedFrame.Rect.Height), paddedFrame.RectTransform), string.Empty, textAlignment: Alignment.Center) { - TextGetter = () => GameMain.GameSession.Missions.Sum(m => (m as CombatMission)?.GetBotKillCount(character.Info) ?? 0).ToString() + TextGetter = () => GameMain.GameSession.Missions.Sum(m => (m as CombatMission)?.GetBotKillCount(characterInfo) ?? 0).ToString() }; new GUITextBlock(new RectTransform(new Point(deathColumnWidth, paddedFrame.Rect.Height), paddedFrame.RectTransform), string.Empty, textAlignment: Alignment.Center) { - TextGetter = () => GameMain.GameSession.Missions.Sum(m => (m as CombatMission)?.GetBotDeathCount(character.Info) ?? 0).ToString() + TextGetter = () => GameMain.GameSession.Missions.Sum(m => (m as CombatMission)?.GetBotDeathCount(characterInfo) ?? 0).ToString() }; } if (character is AICharacter) { - linkedGUIList.Add(new LinkedGUI(character, frame, - new GUITextBlock(new RectTransform(new Point(pingColumnWidth, paddedFrame.Rect.Height), paddedFrame.RectTransform), TextManager.Get("tabmenu.bot"), textAlignment: Alignment.Center) { ForceUpperCase = ForceUpperCase.Yes })); + // "BOT" instead of ping (which isn't relevant for bots) + linkedGUIList.Add(new LinkedGUI(character, frame, + new GUITextBlock(new RectTransform(new Point(pingColumnWidth, paddedFrame.Rect.Height), paddedFrame.RectTransform), TextManager.Get("tabmenu.bot"), textAlignment: Alignment.Center) { ForceUpperCase = ForceUpperCase.Yes })); } - else + else if (characterInfo.IsOnReserveBench) { - linkedGUIList.Add(new LinkedGUI(client: null, frame, textBlock: null, permissionIcon: null)); - - new GUICustomComponent(new RectTransform(new Point(pingColumnWidth, paddedFrame.Rect.Height), paddedFrame.RectTransform, Anchor.Center), onDraw: (sb, component) => DrawDisconnectedIcon(sb, component.Rect)) + // Reserve bench icon + new GUIImage(new RectTransform(new Point(pingColumnWidth, paddedFrame.Rect.Height - 4), paddedFrame.RectTransform), style: "CrewManagementReserveBenchIconReserve", scaleToFit: true) { - CanBeFocused = false, - HoverColor = Color.White, - SelectedColor = Color.White + ToolTip = TextManager.Get("ReserveBenchStatus.Reserve") + }; + } + + if (characterInfo.IsOnReserveBench) + { + //black bar to dim out the elements (1px shorter and to the right so it won't dim the left border too) + new GUIFrame( + new RectTransform(new Point(paddedFrame.Rect.Width - 1, frame.Rect.Height), paddedFrame.RectTransform, Anchor.Center) + { + AbsoluteOffset = new Point(1, 0) + }, + style: null, color: Color.Black * 0.7f) + { + IgnoreLayoutGroups = true, + CanBeFocused = false }; } } - - CreateWalletCrewFrame(character, paddedFrame); + + if (character != null) + { + CreateWalletCrewFrame(character, paddedFrame); + } + else if (characterInfo.IsOnReserveBench) + { + // Empty column for reserve benched bots + new GUILayoutGroup(new RectTransform(new Point(walletColumnWidth, paddedFrame.Rect.Height), paddedFrame.RectTransform, Anchor.Center), childAnchor: Anchor.Center) { CanBeFocused = false }; + } paddedFrame.Recalculate(); } @@ -839,7 +893,7 @@ namespace Barotrauma }; new GUICustomComponent(new RectTransform(new Point(jobColumnWidth, paddedFrame.Rect.Height), paddedFrame.RectTransform, Anchor.Center), - onDraw: (sb, component) => DrawNotInGameIcon(sb, component.Rect, client)) + onDraw: (sb, component) => DrawClientJobIcon(sb, component.Rect, client)) { CanBeFocused = false, HoverColor = Color.White, @@ -864,10 +918,7 @@ namespace Barotrauma new GUITextBlock(new RectTransform(new Point(pingColumnWidth, paddedFrame.Rect.Height), paddedFrame.RectTransform), client.Ping.ToString(), textAlignment: Alignment.Center), permissionIcon)); - if (client.Character is { } character) - { - CreateWalletCrewFrame(character, paddedFrame); - } + CreateWalletCrewFrame(client.Character, paddedFrame); paddedFrame.Recalculate(); } @@ -925,7 +976,7 @@ namespace Barotrauma ToolTip = TextManager.Get("walletdescription") }; - if (character.IsBot) { return; } + if (character == null || character.IsBot) { return; } Sprite walletSprite = GUIStyle.CrewWalletIconSmall.Value.Sprite; @@ -1033,13 +1084,13 @@ namespace Barotrauma } } - private void DrawNotInGameIcon(SpriteBatch spriteBatch, Rectangle area, Client client) + private void DrawClientJobIcon(SpriteBatch spriteBatch, Rectangle area, Client client) { if (client.Spectating) { spectateIcon.Draw(spriteBatch, area, Color.White); } - else if (client.Character != null && client.Character.IsDead) + else if (client.Character != null && client.InGame) { client.Character.Info?.DrawJobIcon(spriteBatch, area); } @@ -1065,7 +1116,13 @@ namespace Barotrauma GUIComponent existingPreview = infoFrameHolder.FindChild("SelectedCharacter"); if (existingPreview != null) { infoFrameHolder.RemoveChild(existingPreview); } - + + if (userData is CharacterInfo { IsOnReserveBench: true }) + { + return true; + } + + // Modal info panel that pops up on the right GUIFrame background = new GUIFrame(new RectTransform(new Vector2(0.543f, 0.69f), infoFrameHolder.RectTransform, Anchor.TopRight, Pivot.TopLeft) { RelativeOffset = new Vector2(-0.061f, 0) }) { UserData = "SelectedCharacter" @@ -1089,7 +1146,7 @@ namespace Barotrauma { talentButton.OnClicked = (button, o) => { - talentMenu.CreateGUI(infoFrameHolder, character); + talentMenu.CreateGUI(infoFrameHolder, character.Info); return true; }; } @@ -1474,7 +1531,7 @@ namespace Barotrauma var headerArea = new GUILayoutGroup(new RectTransform(new Vector2(1.0f, 0.322f), paddedFrame.RectTransform), isHorizontal: true); new GUICustomComponent(new RectTransform(new Vector2(0.425f, 1.0f), headerArea.RectTransform), - onDraw: (sb, component) => DrawNotInGameIcon(sb, component.Rect, client)); + onDraw: (sb, component) => DrawClientJobIcon(sb, component.Rect, client)); GUIFont font = paddedFrame.Rect.Width < 280 ? GUIStyle.SmallFont : GUIStyle.Font; @@ -1564,6 +1621,11 @@ namespace Barotrauma } linkedGUIList.Clear(); + + foreach (GUIListBox crewList in crewListArray) + { + crewList.Content.ClearChildren(); + } } private void AddLineToLog(string line, PlayerConnectionChangeType type) @@ -1648,7 +1710,7 @@ namespace Barotrauma } new GUITextBlock(new RectTransform(new Vector2(1.0f, 0.0f), locationInfoContainer.RectTransform), location.DisplayName, font: GUIStyle.LargeFont); - new GUITextBlock(new RectTransform(new Vector2(1.0f, 0.0f), locationInfoContainer.RectTransform), location.Type.Name, font: GUIStyle.SubHeadingFont); + new GUITextBlock(new RectTransform(new Vector2(1.0f, 0.0f), locationInfoContainer.RectTransform), location.GetLocationTypeToDisplay().Name, font: GUIStyle.SubHeadingFont); if (location.Faction?.Prefab != null) { diff --git a/Barotrauma/BarotraumaClient/ClientSource/GUI/TalentMenu.cs b/Barotrauma/BarotraumaClient/ClientSource/GUI/TalentMenu.cs index 017a60625..6ddc6a467 100644 --- a/Barotrauma/BarotraumaClient/ClientSource/GUI/TalentMenu.cs +++ b/Barotrauma/BarotraumaClient/ClientSource/GUI/TalentMenu.cs @@ -88,17 +88,17 @@ namespace Barotrauma private StartAnimation? startAnimation; private GUIComponent? talentMainArea; - public void CreateGUI(GUIFrame parent, Character? targetCharacter) + public void CreateGUI(GUIFrame parent, CharacterInfo? characterInfo) { + this.characterInfo = characterInfo; + character = characterInfo?.Character; + parent.ClearChildren(); talentButtons.Clear(); talentShowCaseButtons.Clear(); talentCornerIcons.Clear(); showCaseTalentFrames.Clear(); - character = targetCharacter; - characterInfo = targetCharacter?.Info; - GUIFrame background = new GUIFrame(new RectTransform(Vector2.One, parent.RectTransform, Anchor.TopCenter), style: "GUIFrameListBox"); int padding = GUI.IntScale(15); GUIFrame frame = new GUIFrame(new RectTransform(new Point(background.Rect.Width - padding, background.Rect.Height - padding), parent.RectTransform, Anchor.Center), style: null); @@ -999,8 +999,8 @@ namespace Barotrauma if (characterInfo is null || talentResetButton is null || talentApplyButton is null) { return; } int talentCount = selectedTalents.Count - characterInfo.GetUnlockedTalentsInTree().Count(); - talentApplyButton.Enabled = talentCount > 0; - talentResetButton.Enabled = talentCount > 0 || characterInfo.TalentRefundPoints > 0; + talentApplyButton.Enabled = character != null && talentCount > 0; + talentResetButton.Enabled = character != null && (talentCount > 0 || characterInfo.TalentRefundPoints > 0); if (talentCount == 0 && characterInfo.TalentRefundPoints > 0) { diff --git a/Barotrauma/BarotraumaClient/ClientSource/GUI/UpgradeStore.cs b/Barotrauma/BarotraumaClient/ClientSource/GUI/UpgradeStore.cs index ea933efde..dc2913de1 100644 --- a/Barotrauma/BarotraumaClient/ClientSource/GUI/UpgradeStore.cs +++ b/Barotrauma/BarotraumaClient/ClientSource/GUI/UpgradeStore.cs @@ -127,9 +127,10 @@ namespace Barotrauma Campaign.OnMoneyChanged.RegisterOverwriteExisting(eventId, _ => RequestRefresh()); } - public void RequestRefresh() + public void RequestRefresh(bool refreshUpgrades = false) { needsRefresh = true; + if (refreshUpgrades) { SelectTab(UpgradeTab.Upgrade); } } private void RefreshAll() @@ -673,6 +674,10 @@ namespace Barotrauma } } } + if (!upgrades.ContainsKey(category) && HasSwappableItems(category)) + { + upgrades.Add(category, new List()); + } } foreach (var (category, prefabs) in upgrades) @@ -771,20 +776,28 @@ namespace Barotrauma { if (Submarine.MainSub == null) { return false; } subItems ??= GetSubItems(); - return subItems.Any(i => - i.Prefab.SwappableItem != null && - !i.IsHidden && i.AllowSwapping && - (i.Prefab.SwappableItem.CanBeBought || ItemPrefab.Prefabs.Any(ip => ip.SwappableItem?.ReplacementOnUninstall == i.Prefab.Identifier)) && - Submarine.MainSub.IsEntityFoundOnThisSub(i, true) && category.ItemTags.Any(t => i.HasTag(t))); + return subItems.Any(item => HasSwappableItems(category, item)); } + private static bool HasSwappableItems(UpgradeCategory category, Item item) + { + if (Submarine.MainSub == null) { return false; } + return + item.Prefab.SwappableItem != null && + !item.IsHidden && item.AllowSwapping && + (item.Prefab.SwappableItem.CanBeBought || ItemPrefab.Prefabs.Any(ip => ip.SwappableItem?.ReplacementOnUninstall == item.Prefab.Identifier)) && + Submarine.MainSub.IsEntityFoundOnThisSub(item, true) && category.ItemTags.Any(t => item.HasTag(t)); + } private static List GetSubItems() => Submarine.MainSub?.GetItems(true) ?? new List(); private void SelectUpgradeCategory(List prefabs, UpgradeCategory category, Submarine submarine) { if (selectedUpgradeCategoryLayout == null) { return; } - customizeTabOpen = false; + bool hasSwappableItems = HasSwappableItems(category); + bool hasUpgradeModules = prefabs.Count > 0; + + customizeTabOpen = !hasUpgradeModules && hasSwappableItems; GUIComponent[] categoryFrames = GetFrames(category); foreach (GUIComponent itemFrame in itemPreviews.Values) @@ -799,9 +812,7 @@ namespace Barotrauma GUIFrame frame = new GUIFrame(rectT(1.0f, 0.4f, selectedUpgradeCategoryLayout)); GUIFrame paddedFrame = new GUIFrame(rectT(0.93f, 0.9f, frame, Anchor.Center), style: null); - bool hasSwappableItems = HasSwappableItems(category); - - float listHeight = hasSwappableItems ? 0.9f : 1.0f; + float listHeight = hasSwappableItems && hasUpgradeModules ? 0.9f : 1.0f; GUIListBox prefabList = new GUIListBox(rectT(1.0f, listHeight, paddedFrame, Anchor.BottomLeft)) { @@ -810,7 +821,8 @@ namespace Barotrauma ScrollBarVisible = true }; - if (hasSwappableItems) + //both swappable items and upgrade modules -> create 2 tabs + if (hasSwappableItems && hasUpgradeModules) { GUILayoutGroup buttonLayout = new GUILayoutGroup(rectT(1.0f, 0.1f, paddedFrame, anchor: Anchor.TopLeft), isHorizontal: true); @@ -852,8 +864,15 @@ namespace Barotrauma return true; }; } - - CreateUpgradePrefabList(prefabList, category, prefabs, submarine); + //only either upgrade modules or swappable items -> just create the list + else if (hasUpgradeModules) + { + CreateUpgradePrefabList(prefabList, category, prefabs, submarine); + } + else if (hasSwappableItems) + { + CreateSwappableItemList(prefabList, category, submarine); + } } private void CreateUpgradePrefabList(GUIListBox parent, UpgradeCategory category, List prefabs, Submarine submarine) @@ -1370,9 +1389,10 @@ namespace Barotrauma Item[] entitiesOnSub = drawnSubmarine.GetItems(true).Where(i => drawnSubmarine.IsEntityFoundOnThisSub(i, true)).ToArray(); foreach (UpgradeCategory category in UpgradeCategory.Categories) { - //hide categories with no upgrades in them - if (UpgradePrefab.Prefabs.None(p => p.UpgradeCategories.Contains(category))) { continue; } - if (entitiesOnSub.Any(item => category.CanBeApplied(item, null))) + //hide categories with no upgrades or swappables in them + bool hasSwappableItems = HasSwappableItems(category); + if (!hasSwappableItems && UpgradePrefab.Prefabs.None(p => p.UpgradeCategories.Contains(category))) { continue; } + if (hasSwappableItems || entitiesOnSub.Any(item => category.CanBeApplied(item, null))) { yield return category; } @@ -1534,7 +1554,7 @@ namespace Barotrauma description.Padding = new Vector4(description.Padding.X, 24 * GUI.Scale, description.Padding.Z, description.Padding.W); List pointsOfInterest = (from category in UpgradeCategory.Categories from item in submarine.GetItems(UpgradeManager.UpgradeAlsoConnectedSubs) - where category.CanBeApplied(item, null) && item.IsPlayerTeamInteractable select item).Cast().Distinct().ToList(); + where (category.CanBeApplied(item, null) || HasSwappableItems(category, item)) && item.IsPlayerTeamInteractable select item).Cast().Distinct().ToList(); List ids = GameMain.GameSession.SubmarineInfo?.LeftBehindDockingPortIDs ?? new List(); pointsOfInterest.AddRange(submarine.GetItems(UpgradeManager.UpgradeAlsoConnectedSubs).Where(item => ids.Contains(item.ID))); @@ -1799,7 +1819,7 @@ namespace Barotrauma // Disables the parent and only re-enables if the submarine contains valid items if (!category.IsWallUpgrade && drawnSubmarine?.Info != null) { - if (UpgradePrefab.Prefabs.None(p => p.UpgradeCategories.Contains(category) && p.GetMaxLevel(drawnSubmarine.Info) > 0)) + if (UpgradePrefab.Prefabs.None(p => p.UpgradeCategories.Contains(category) && p.GetMaxLevel(drawnSubmarine.Info) > 0) && !HasSwappableItems(category)) { parent.ToolTip = TextManager.Get("upgradecategorynotapplicable"); parent.Enabled = false; diff --git a/Barotrauma/BarotraumaClient/ClientSource/GameMain.cs b/Barotrauma/BarotraumaClient/ClientSource/GameMain.cs index 82d01a2ae..c476a66ce 100644 --- a/Barotrauma/BarotraumaClient/ClientSource/GameMain.cs +++ b/Barotrauma/BarotraumaClient/ClientSource/GameMain.cs @@ -121,6 +121,7 @@ namespace Barotrauma private readonly GameTime fixedTime; public Option ConnectCommand = Option.None(); + private string clientName; private static SpriteBatch spriteBatch; @@ -255,6 +256,16 @@ namespace Barotrauma try { ConnectCommand = Barotrauma.Networking.ConnectCommand.Parse(ConsoleArguments); + + string clientNameFlagArg = args.FirstOrDefault(arg => arg.StartsWith("-username")); + if (clientNameFlagArg != null) + { + int nextIndex = args.IndexOf(clientNameFlagArg) + 1; + if (nextIndex < args.Length) + { + clientName = args[nextIndex]; + } + } } catch (IndexOutOfRangeException e) { @@ -740,7 +751,7 @@ namespace Barotrauma fixedTime.IsRunningSlowly = gameTime.IsRunningSlowly; TimeSpan addTime = new TimeSpan(0, 0, 0, 0, 16); fixedTime.ElapsedGameTime = addTime; - fixedTime.TotalGameTime.Add(addTime); + fixedTime.TotalGameTime = fixedTime.TotalGameTime.Add(addTime); base.Update(fixedTime); PlayerInput.Update(Timing.Step); @@ -821,19 +832,28 @@ namespace Barotrauma } MainMenuScreen.Select(); + string clientNameString = clientName ?? MultiplayerPreferences.Instance.PlayerName.FallbackNullOrEmpty(SteamManager.GetUsername()); + if (connectCommand.SteamLobbyIdOption.TryUnwrap(out var lobbyId)) { SteamManager.JoinLobby(lobbyId.Value, joinServer: true); } - else if (connectCommand.NameAndP2PEndpointsOption.TryUnwrap(out var nameAndEndpoint) - && nameAndEndpoint is { ServerName: var serverName, Endpoints: var endpoints }) + else if ((connectCommand.NameAndP2PEndpointsOption.TryUnwrap(out var nameAndEndpoint) && nameAndEndpoint is { ServerName: var serverName, Endpoints: var endpoints })) { - Client = new GameClient(MultiplayerPreferences.Instance.PlayerName.FallbackNullOrEmpty(SteamManager.GetUsername()), + Client = new GameClient(clientNameString, endpoints.Cast().ToImmutableArray(), string.IsNullOrWhiteSpace(serverName) ? endpoints.First().StringRepresentation : serverName, Option.None()); } - + else if ((connectCommand.NameAndLidgrenEndpointOption.TryUnwrap(out var nameAndLidgrenEndpoint) && nameAndLidgrenEndpoint is { ServerName: var lidgrenServerName, Endpoint: var endpoint })) + { + Client = new GameClient( + clientNameString, + endpoint, + string.IsNullOrWhiteSpace(lidgrenServerName) ? endpoint.StringRepresentation : lidgrenServerName, + Option.None()); + } + ConnectCommand = Option.None(); } @@ -1183,11 +1203,12 @@ namespace Barotrauma GameSession.GameMode?.Preset.Identifier.Value ?? "none", GameSession.RoundDuration); string eventId = "QuitRound:" + (GameSession.GameMode?.Preset.Identifier.Value ?? "none") + ":"; - GameAnalyticsManager.AddDesignEvent(eventId + "EventManager:CurrentIntensity", GameSession.EventManager.CurrentIntensity); + //disabled to reduce the amount of data we collect through GA + /*GameAnalyticsManager.AddDesignEvent(eventId + "EventManager:CurrentIntensity", GameSession.EventManager.CurrentIntensity); foreach (var activeEvent in GameSession.EventManager.ActiveEvents) { GameAnalyticsManager.AddDesignEvent(eventId + "EventManager:ActiveEvents:" + activeEvent.Prefab.Identifier); - } + }*/ GameSession.LogEndRoundStats(eventId); if (GameSession.GameMode is TutorialMode tutorialMode) { diff --git a/Barotrauma/BarotraumaClient/ClientSource/GameSession/CargoManager.cs b/Barotrauma/BarotraumaClient/ClientSource/GameSession/CargoManager.cs index 0d1df98e3..751ba60a9 100644 --- a/Barotrauma/BarotraumaClient/ClientSource/GameSession/CargoManager.cs +++ b/Barotrauma/BarotraumaClient/ClientSource/GameSession/CargoManager.cs @@ -166,7 +166,7 @@ namespace Barotrauma // check if the store can afford the item if (store.Balance < itemValue) { continue; } // TODO: Write logic for prioritizing certain items over others (e.g. lone Battery Cell should be preferred over one inside a Stun Baton) - var matchingItems = sellableItems.Where(i => i.Prefab == item.ItemPrefab); + var matchingItems = sellableItems.Where(i => i.Prefab.Identifier == item.ItemPrefabIdentifier); int count = Math.Min(item.Quantity, matchingItems.Count()); SoldItem.SellOrigin origin = sellingMode == Store.StoreTab.Sell ? SoldItem.SellOrigin.Character : SoldItem.SellOrigin.Submarine; if (origin == SoldItem.SellOrigin.Character || GameMain.IsSingleplayer) diff --git a/Barotrauma/BarotraumaClient/ClientSource/GameSession/CrewManager.cs b/Barotrauma/BarotraumaClient/ClientSource/GameSession/CrewManager.cs index 44b8f8234..a6735757e 100644 --- a/Barotrauma/BarotraumaClient/ClientSource/GameSession/CrewManager.cs +++ b/Barotrauma/BarotraumaClient/ClientSource/GameSession/CrewManager.cs @@ -47,7 +47,7 @@ namespace Barotrauma /// /// This property stores the preference in settings. Don't use for automatic logic. - /// Use AutoShowCrewList(), AutoHideCrewList(), and ResetCrewList(). + /// Use AutoHideCrewList(), and ResetCrewList(). /// public bool IsCrewMenuOpen { @@ -62,11 +62,9 @@ namespace Barotrauma public static bool PreferCrewMenuOpen = true; - public bool AutoShowCrewList() => _isCrewMenuOpen = true; - public void AutoHideCrewList() => _isCrewMenuOpen = false; - public void ResetCrewList() => _isCrewMenuOpen = PreferCrewMenuOpen; + public void ResetCrewListOpenState() => _isCrewMenuOpen = PreferCrewMenuOpen; const float CommandNodeAnimDuration = 0.2f; @@ -195,6 +193,12 @@ namespace Barotrauma ChatBox.InputBox.OnTextChanged += ChatBox.TypingChatMessage; } + else if (GameMain.Client == null) + { + //this method would throw a non-descriptive nullref exception later when trying to access the chatbox + //if we'd try to continue from here, better to throw a more descriptive one at this point + throw new InvalidOperationException($"Attempted to initialize {nameof(CrewManager)} for multiplayer, but no multiplayer client is active. Are you trying to load a multiplayer save in singleplayer?"); + } #endregion @@ -308,6 +312,14 @@ namespace Barotrauma #region Character list management + /// + /// Note: this is only works client-side. TODO: make it work server-side too? + /// + public IEnumerable GetCharacters() + { + return characters; + } + public Rectangle GetActiveCrewArea() { return crewArea.Rect; @@ -1357,6 +1369,16 @@ namespace Barotrauma { GameSession.TabMenuInstance.SelectInfoFrameTab(TabMenu.SelectedTab); } + if (character.SelectedItem?.GetComponent() == null && character.SelectedCharacter == null) + { + ResetCrewListOpenState(); + ChatBox.ResetChatBoxOpenState(); + } + else + { + AutoHideCrewList(); + ChatBox.AutoHideChatBox(); + } } private int TryAdjustIndex(int amount) @@ -1919,7 +1941,7 @@ namespace Barotrauma { get { - if (GameMain.GameSession?.CrewManager == null) + if (GameMain.GameSession?.CrewManager == null || Screen.Selected is { IsEditor: true }) { return false; } @@ -3700,7 +3722,7 @@ namespace Barotrauma bool hasLeaks = Character.Controlled.CurrentHull.ConnectedGaps.Any(g => !g.IsRoomToRoom && g.Open > 0.0f); ToggleReportButton("reportbreach", hasLeaks); - bool hasIntruders = Character.CharacterList.Any(c => c.CurrentHull == Character.Controlled.CurrentHull && AIObjectiveFightIntruders.IsValidTarget(c, Character.Controlled, false)); + bool hasIntruders = Character.CharacterList.Any(c => c.CurrentHull == Character.Controlled.CurrentHull && AIObjectiveFightIntruders.IsValidTarget(c, Character.Controlled, targetCharactersInOtherSubs: false)); ToggleReportButton("reportintruders", hasIntruders); foreach (GUIComponent reportButton in ReportButtonFrame.Children) @@ -3826,5 +3848,72 @@ namespace Barotrauma GameMain.GameSession?.CrewManager?.AddOrder(order, fadeOutTime); } } + + private class CharacterInfoComparer : IEqualityComparer + { + public bool Equals(CharacterInfo x, CharacterInfo y) + { + if (ReferenceEquals(x, y)) + { + return true; + } + + if (x is null || y is null) + { + return false; + } + + return x.ID == y.ID; + } + + public int GetHashCode(CharacterInfo obj) + { + return obj.ID; + } + } + + public bool UpdateReserveBenchIfNeeded(IEnumerable updatedReserveBench) + { + var newBench = updatedReserveBench.ToHashSet(new CharacterInfoComparer()); + var currentBench = reserveBench.ToHashSet(new CharacterInfoComparer()); + + bool updateNeeded = !newBench.SetEquals(currentBench); + if (updateNeeded) + { + reserveBench.Clear(); // since this is the reserve bench (characters not instantiated), there's no need to retain any references etc + reserveBench.AddRange(updatedReserveBench); + } + + return updateNeeded; + } + + /// + /// This will update which CharacterInfos should be in CrewManager and which shouldn't, excluding the reserve bench. + /// The CharacterInfos themselves aren't updated, they will only be either added, removed, or kept as-is. + /// + public bool UpdateCrewManagerIfNecessary(List updatedCrewManager) + { + // CharacterInfos no longer in the server's CrewManager + var toRemove = characterInfos.Where(original => updatedCrewManager.None(updated => updated.ID == original.ID)).ToList(); + // CharacterInfos that are in the server's CrewManager but not on the client yet + var toAdd = updatedCrewManager.Where(updated => characterInfos.None(original => original.ID == updated.ID)).ToList(); + + foreach (CharacterInfo characterInfo in toRemove) + { + if (characterInfo.Character is Character existingCharacter) + { + if (!existingCharacter.IsBot) { continue; } // on client side players are also stored here, we should skip those in this case + RemoveCharacter(characterInfo.Character, removeInfo: true, resetCrewListIndex: true); + } + else + { + characterInfos.Remove(characterInfo); + } + } + + characterInfos.AddRange(toAdd); + + return toRemove.Count > 0 || toAdd.Count > 0; + } } } diff --git a/Barotrauma/BarotraumaClient/ClientSource/GameSession/GameModes/CampaignMode.cs b/Barotrauma/BarotraumaClient/ClientSource/GameSession/GameModes/CampaignMode.cs index b08178d56..c3dbabd08 100644 --- a/Barotrauma/BarotraumaClient/ClientSource/GameSession/GameModes/CampaignMode.cs +++ b/Barotrauma/BarotraumaClient/ClientSource/GameSession/GameModes/CampaignMode.cs @@ -366,7 +366,7 @@ namespace Barotrauma default: ShowCampaignUI = true; CampaignUI.SelectTab(npc.CampaignInteractionType, npc); - CampaignUI.UpgradeStore?.RequestRefresh(); + CampaignUI.UpgradeStore?.RequestRefresh(refreshUpgrades: true); break; } diff --git a/Barotrauma/BarotraumaClient/ClientSource/GameSession/GameModes/MultiPlayerCampaign.cs b/Barotrauma/BarotraumaClient/ClientSource/GameSession/GameModes/MultiPlayerCampaign.cs index 67f363350..9a8190095 100644 --- a/Barotrauma/BarotraumaClient/ClientSource/GameSession/GameModes/MultiPlayerCampaign.cs +++ b/Barotrauma/BarotraumaClient/ClientSource/GameSession/GameModes/MultiPlayerCampaign.cs @@ -532,6 +532,7 @@ namespace Barotrauma bool isFirstRound = msg.ReadBoolean(); byte campaignID = msg.ReadByte(); + byte roundId = msg.ReadByte(); UInt16 saveID = msg.ReadUInt16(); string mapSeed = msg.ReadString(); @@ -553,7 +554,7 @@ namespace Barotrauma if (requiredFlags.HasFlag(NetFlags.Misc)) { - DebugConsole.Log("Received campaign update (Misc)"); + DebugConsole.Log("Received campaign update (Misc), round id: " + roundId); UInt16 id = msg.ReadUInt16(); bool purchasedHullRepairs = msg.ReadBoolean(); bool purchasedItemRepairs = msg.ReadBoolean(); @@ -571,7 +572,7 @@ namespace Barotrauma if (requiredFlags.HasFlag(NetFlags.MapAndMissions)) { - DebugConsole.Log("Received campaign update (MapAndMissions)"); + DebugConsole.Log("Received campaign update (MapAndMissions), round id: " + roundId); UInt16 id = msg.ReadUInt16(); bool forceMapUI = msg.ReadBoolean(); bool allowDebugTeleport = msg.ReadBoolean(); @@ -634,7 +635,7 @@ namespace Barotrauma if (requiredFlags.HasFlag(NetFlags.SubList)) { - DebugConsole.Log("Received campaign update (SubList)"); + DebugConsole.Log("Received campaign update (SubList), round id: " + roundId); UInt16 id = msg.ReadUInt16(); ushort ownedSubCount = msg.ReadUInt16(); List ownedSubIndices = new List(); @@ -679,7 +680,7 @@ namespace Barotrauma if (requiredFlags.HasFlag(NetFlags.UpgradeManager)) { - DebugConsole.Log("Received campaign update (UpgradeManager)"); + DebugConsole.Log("Received campaign update (UpgradeManager), round id: " + roundId); UInt16 id = msg.ReadUInt16(); ushort pendingUpgradeCount = msg.ReadUInt16(); @@ -737,7 +738,7 @@ namespace Barotrauma if (requiredFlags.HasFlag(NetFlags.ItemsInBuyCrate)) { - DebugConsole.Log("Received campaign update (ItemsInBuyCrate)"); + DebugConsole.Log("Received campaign update (ItemsInBuyCrate), round id: " + roundId); UInt16 id = msg.ReadUInt16(); var buyCrateItems = ReadPurchasedItems(msg, sender: null); if (ShouldApply(NetFlags.ItemsInBuyCrate, id, requireUpToDateSave: true)) @@ -753,7 +754,7 @@ namespace Barotrauma } if (requiredFlags.HasFlag(NetFlags.ItemsInSellFromSubCrate)) { - DebugConsole.Log("Received campaign update (ItemsInSellFromSubCrate)"); + DebugConsole.Log("Received campaign update (ItemsInSellFromSubCrate), round id: " + roundId); UInt16 id = msg.ReadUInt16(); var subSellCrateItems = ReadPurchasedItems(msg, sender: null); if (ShouldApply(NetFlags.ItemsInSellFromSubCrate, id, requireUpToDateSave: true)) @@ -769,7 +770,7 @@ namespace Barotrauma } if (requiredFlags.HasFlag(NetFlags.PurchasedItems)) { - DebugConsole.Log("Received campaign update (PuchasedItems)"); + DebugConsole.Log("Received campaign update (PuchasedItems), round id: " + roundId); UInt16 id = msg.ReadUInt16(); var purchasedItems = ReadPurchasedItems(msg, sender: null); if (ShouldApply(NetFlags.PurchasedItems, id, requireUpToDateSave: true)) @@ -785,7 +786,7 @@ namespace Barotrauma } if (requiredFlags.HasFlag(NetFlags.SoldItems)) { - DebugConsole.Log("Received campaign update (SoldItems)"); + DebugConsole.Log("Received campaign update (SoldItems), round id: " + roundId); UInt16 id = msg.ReadUInt16(); var soldItems = ReadSoldItems(msg); if (ShouldApply(NetFlags.SoldItems, id, requireUpToDateSave: true)) @@ -801,7 +802,7 @@ namespace Barotrauma } if (requiredFlags.HasFlag(NetFlags.Reputation)) { - DebugConsole.Log("Received campaign update (Reputation)"); + DebugConsole.Log("Received campaign update (Reputation), round id: " + roundId); UInt16 id = msg.ReadUInt16(); Dictionary factionReps = new Dictionary(); byte factionsCount = msg.ReadByte(); @@ -828,7 +829,7 @@ namespace Barotrauma } if (requiredFlags.HasFlag(NetFlags.CharacterInfo)) { - DebugConsole.Log("Received campaign update (CharacterInfo)"); + DebugConsole.Log("Received campaign update (CharacterInfo), round id: " + roundId); UInt16 id = msg.ReadUInt16(); bool hasCharacterData = msg.ReadBoolean(); CharacterInfo myCharacterInfo = null; @@ -837,16 +838,27 @@ namespace Barotrauma { myCharacterInfo = CharacterInfo.ClientRead(CharacterPrefab.HumanSpeciesName, msg, requireJobPrefabFound: !waitForModsDownloaded); } - if (!waitForModsDownloaded && ShouldApply(NetFlags.CharacterInfo, id, requireUpToDateSave: true)) + //don't require the correct round ID for the character info if we're in the lobby + // = allow updating the character to the latest one in the lobby, even though we've not loaded to the same round as the server + if (!waitForModsDownloaded && ShouldApply(NetFlags.CharacterInfo, id, requireUpToDateSave: true, requireCorrectRoundId: Screen.Selected != GameMain.NetLobbyScreen)) { if (myCharacterInfo != null) { GameMain.Client.CharacterInfo = myCharacterInfo; GameMain.NetLobbyScreen.SetCampaignCharacterInfo(myCharacterInfo); + GameMain.GameSession.RefreshAnyOpenPlayerInfo(); } else { + //don't reset the character info nor the open UI here here, + //the client needs it to be able to customize the character they want to next spawn as GameMain.NetLobbyScreen.SetCampaignCharacterInfo(null); + //if we've already discarded our current character and the server is just "verifying" that, + //no need to refresh the UI (no changes, refreshing would just throw the client out of the character settings panel) + if (!GameMain.NetLobbyScreen.CampaignCharacterDiscarded) + { + GameMain.GameSession.RefreshAnyOpenPlayerInfo(); + } } } } @@ -863,8 +875,14 @@ namespace Barotrauma } campaign.SuppressStateSending = false; - bool ShouldApply(NetFlags flag, UInt16 id, bool requireUpToDateSave) + bool ShouldApply(NetFlags flag, UInt16 id, bool requireUpToDateSave, bool requireCorrectRoundId = true) { + if (requireCorrectRoundId && roundId != campaign.RoundID) + { + DebugConsole.Log($"Received campaing update for a different round (client: {campaign.RoundID}, server: {roundId}), ignoring..."); + return false; + } + if (NetIdUtils.IdMoreRecent(id, campaign.GetLastUpdateIdForFlag(flag)) && (!requireUpToDateSave || saveID == campaign.LastSaveID)) { @@ -919,26 +937,42 @@ namespace Barotrauma ushort pendingHireLength = msg.ReadUInt16(); List pendingHires = new List(); + bool[] pendingHiresToReserveBench = new bool[pendingHireLength]; for (int i = 0; i < pendingHireLength; i++) { pendingHires.Add(msg.ReadUInt16()); + pendingHiresToReserveBench[i] = msg.ReadBoolean(); } - ushort hiredLength = msg.ReadUInt16(); List hiredCharacters = new List(); - for (int i = 0; i < hiredLength; i++) + List updatedCrewManager = new List(); + ushort crewLength = msg.ReadUInt16(); + for (int i = 0; i < crewLength; i++) { - CharacterInfo hired = CharacterInfo.ClientRead(CharacterPrefab.HumanSpeciesName, msg); - hired.Salary = msg.ReadInt32(); - hiredCharacters.Add(hired); + CharacterInfo crewMember = CharacterInfo.ClientRead(CharacterPrefab.HumanSpeciesName, msg); + if (crewMember.IsNewHire) + { + hiredCharacters.Add(crewMember); + } + updatedCrewManager.Add(crewMember); } - + bool crewManagerUpdated = GameMain.GameSession.CrewManager?.UpdateCrewManagerIfNecessary(updatedCrewManager) ?? false; + + ushort reserveBenchLength = msg.ReadUInt16(); + List updatedReserveBench = new List(); + for (int i = 0; i < reserveBenchLength; i++) + { + CharacterInfo info = CharacterInfo.ClientRead(CharacterPrefab.HumanSpeciesName, msg); + updatedReserveBench.Add(info); + } + bool reserveBenchUpdated = GameMain.GameSession.CrewManager?.UpdateReserveBenchIfNeeded(updatedReserveBench) ?? false; + bool renameCrewMember = msg.ReadBoolean(); if (renameCrewMember) { UInt16 renamedIdentifier = msg.ReadUInt16(); string newName = msg.ReadString(); - CharacterInfo renamedCharacter = CrewManager.GetCharacterInfos().FirstOrDefault(info => info.ID == renamedIdentifier); + CharacterInfo renamedCharacter = CrewManager.GetCharacterInfos(includeReserveBench: true).FirstOrDefault(info => info.ID == renamedIdentifier); if (renamedCharacter != null) { CrewManager.RenameCharacter(renamedCharacter, newName); @@ -955,7 +989,7 @@ namespace Barotrauma if (fireCharacter) { UInt16 firedIdentifier = msg.ReadUInt16(); - CharacterInfo firedCharacter = CrewManager.GetCharacterInfos().FirstOrDefault(info => info.ID == firedIdentifier); + CharacterInfo firedCharacter = CrewManager.GetCharacterInfos(includeReserveBench: true).FirstOrDefault(info => info.ID == firedIdentifier); // this one might and is allowed to be null since the character is already fired on the original sender's game if (firedCharacter != null) { CrewManager.FireCharacter(firedCharacter); } } @@ -967,8 +1001,8 @@ namespace Barotrauma { CampaignUI.HRManagerUI.SetHireables(map.CurrentLocation, availableHires); if (hiredCharacters.Any()) { CampaignUI.HRManagerUI.ValidateHires(hiredCharacters, takeMoney: false, createNotification: createNotification); } - CampaignUI.HRManagerUI.SetPendingHires(pendingHires, map.CurrentLocation); - if (renameCrewMember || fireCharacter) { CampaignUI.HRManagerUI.UpdateCrew(); } + //don't check the crew size limit: if the server says someone's hired, then it's so + CampaignUI.HRManagerUI.SetPendingHires(pendingHires, pendingHiresToReserveBench, map.CurrentLocation, checkCrewSizeLimit: false); } } else @@ -979,6 +1013,11 @@ namespace Barotrauma CurrentLocation?.ForceHireableCharacters(availableHires); } + if (fireCharacter || renameCrewMember || crewManagerUpdated || reserveBenchUpdated) + { + CampaignUI?.HRManagerUI?.RefreshHRView(); + GameMain.GameSession?.DeathPrompt?.UpdateBotList(); + } } public void ClientReadMoney(IReadMessage inc) diff --git a/Barotrauma/BarotraumaClient/ClientSource/GameSession/GameModes/SinglePlayerCampaign.cs b/Barotrauma/BarotraumaClient/ClientSource/GameSession/GameModes/SinglePlayerCampaign.cs index aa378710f..cdd4d140c 100644 --- a/Barotrauma/BarotraumaClient/ClientSource/GameSession/GameModes/SinglePlayerCampaign.cs +++ b/Barotrauma/BarotraumaClient/ClientSource/GameSession/GameModes/SinglePlayerCampaign.cs @@ -379,7 +379,7 @@ namespace Barotrauma if (success) { // Event history must be registered before ending the round or it will be cleared - GameMain.GameSession.EventManager.RegisterEventHistory(); + GameMain.GameSession.EventManager.StoreEventDataAtRoundEnd(); } GameMain.GameSession.EndRound("", transitionType); var continueButton = GameMain.GameSession.RoundSummary?.ContinueButton; diff --git a/Barotrauma/BarotraumaClient/ClientSource/GameSession/GameSession.cs b/Barotrauma/BarotraumaClient/ClientSource/GameSession/GameSession.cs index e09ff197b..df8b52048 100644 --- a/Barotrauma/BarotraumaClient/ClientSource/GameSession/GameSession.cs +++ b/Barotrauma/BarotraumaClient/ClientSource/GameSession/GameSession.cs @@ -110,9 +110,13 @@ namespace Barotrauma deathChoiceInfoFrame = new GUIFrame(new RectTransform(new Vector2(0.5f, 1.0f), parent: topLeftButtonGroup.RectTransform) { MaxSize = new Point(HUDLayoutSettings.ButtonAreaTop.Width / 3, int.MaxValue) }, style: null) { + CanBeFocused = false, Visible = false }; - respawnInfoText = new GUITextBlock(new RectTransform(new Vector2(0.5f, 1.0f), deathChoiceInfoFrame.RectTransform), "", wrap: true); + respawnInfoText = new GUITextBlock(new RectTransform(new Vector2(0.5f, 1.0f), deathChoiceInfoFrame.RectTransform), "", wrap: true) + { + CanBeFocused = false + }; deathChoiceButtonContainer = new GUILayoutGroup(new RectTransform(new Vector2(0.5f, 1.0f), deathChoiceInfoFrame.RectTransform, Anchor.CenterRight), isHorizontal: true, childAnchor: Anchor.CenterLeft) { AbsoluteSpacing = HUDLayoutSettings.Padding, @@ -189,7 +193,7 @@ namespace Barotrauma if (GameMain.NetworkMember != null) { GameMain.NetLobbyScreen.CharacterAppearanceCustomizationMenu?.AddToGUIUpdateList(); - GameMain.NetLobbyScreen?.JobSelectionFrame?.AddToGUIUpdateList(); + GameMain.NetLobbyScreen?.JobSelectionFrame?.AddToGUIUpdateList(order: 1); } DeathPrompt?.AddToGUIUpdateList(); @@ -335,6 +339,20 @@ namespace Barotrauma } } } + + /// + /// If there are any menu panels etc. open that contain information about the current player character, refresh it. + /// Useful when the player character changes, e.g. at permadeath, and subsequent taking over of a bot character. + /// + public void RefreshAnyOpenPlayerInfo() + { + DebugConsole.NewMessage($"Refreshing any open player info"); + if (IsTabMenuOpen && TabMenu.SelectedTab == TabMenu.InfoFrameTab.Talents) + { + TabMenuInstance.SelectInfoFrameTab(TabMenu.InfoFrameTab.Talents); + } + // TODO: This can be expanded as need arises + } public void Draw(SpriteBatch spriteBatch) { diff --git a/Barotrauma/BarotraumaClient/ClientSource/GameSession/HintManager.cs b/Barotrauma/BarotraumaClient/ClientSource/GameSession/HintManager.cs index 6cddda566..a308c988c 100644 --- a/Barotrauma/BarotraumaClient/ClientSource/GameSession/HintManager.cs +++ b/Barotrauma/BarotraumaClient/ClientSource/GameSession/HintManager.cs @@ -316,7 +316,7 @@ namespace Barotrauma if (affliction?.Prefab == null) { continue; } if (affliction.Prefab.IsBuff) { continue; } if (affliction.Prefab == AfflictionPrefab.OxygenLow) { continue; } - if (affliction.Prefab == AfflictionPrefab.RadiationSickness && (GameMain.GameSession.Map?.Radiation?.IsEntityRadiated(character) ?? false)) { continue; } + if (affliction.Prefab == AfflictionPrefab.RadiationSickness && (GameMain.GameSession.Map?.Radiation?.DepthInRadiation(character) ?? 0) > 0) { continue; } if (affliction.Strength < affliction.Prefab.ShowIconThreshold) { continue; } DisplayHint("onafflictiondisplayed".ToIdentifier(), variables: new[] { ("[key]".ToIdentifier(), GameSettings.CurrentConfig.KeyMap.KeyBindText(InputType.Health)) }, diff --git a/Barotrauma/BarotraumaClient/ClientSource/GameSession/RoundSummary.cs b/Barotrauma/BarotraumaClient/ClientSource/GameSession/RoundSummary.cs index e672460da..008011bf6 100644 --- a/Barotrauma/BarotraumaClient/ClientSource/GameSession/RoundSummary.cs +++ b/Barotrauma/BarotraumaClient/ClientSource/GameSession/RoundSummary.cs @@ -14,11 +14,11 @@ namespace Barotrauma private float crewListAnimDelay = 0.25f; private float missionIconAnimDelay; - private const float JobColumnWidthPercentage = 0.1f; - private const float CharacterColumnWidthPercentage = 0.4f; - private const float StatusColumnWidthPercentage = 0.12f; - private const float KillColumnWidthPercentage = 0.1f; - private const float DeathColumnWidthPercentage = 0.1f; + private const float JobColumnWidthPercentage = 0.05f; + private const float CharacterColumnWidthPercentage = 0.35f; + private const float StatusColumnWidthPercentage = 0.25f; + private const float KillColumnWidthPercentage = 0.05f; + private const float DeathColumnWidthPercentage = 0.05f; private int jobColumnWidth, characterColumnWidth, statusColumnWidth, killColumnWidth, deathColumnWidth; @@ -467,7 +467,7 @@ namespace Barotrauma }; if (icon != null) { - missionIcon = new GUIImage(new RectTransform(new Point(iconSize), content.RectTransform), icon, null, true) + missionIcon = new GUIImage(new RectTransform(new Point(iconSize), content.RectTransform), icon, scaleToFit: true) { Color = iconColor, HoverColor = iconColor, @@ -661,15 +661,18 @@ namespace Barotrauma GUIButton jobButton = new GUIButton(new RectTransform(new Vector2(JobColumnWidthPercentage, 1f), headerFrame.RectTransform), TextManager.Get("tabmenu.job"), style: "GUIButtonSmallFreeScale"); GUIButton characterButton = new GUIButton(new RectTransform(new Vector2(CharacterColumnWidthPercentage, 1f), headerFrame.RectTransform), TextManager.Get("name"), style: "GUIButtonSmallFreeScale"); - if (gameMode is PvPMode) + if (gameMode is PvPMode && GameMain.NetworkMember?.RespawnManager != null) { var killButton = new GUIButton(new RectTransform(new Vector2(KillColumnWidthPercentage, 1f), headerFrame.RectTransform), TextManager.Get("killcount"), style: "GUIButtonSmallFreeScale"); killColumnWidth = killButton.Rect.Width; var deathButton = new GUIButton(new RectTransform(new Vector2(DeathColumnWidthPercentage, 1f), headerFrame.RectTransform), TextManager.Get("deathcount"), style: "GUIButtonSmallFreeScale"); deathColumnWidth = deathButton.Rect.Width; } - - GUIButton statusButton = new GUIButton(new RectTransform(new Vector2(StatusColumnWidthPercentage, 1f), headerFrame.RectTransform), TextManager.Get("label.statuslabel"), style: "GUIButtonSmallFreeScale"); + else + { + GUIButton statusButton = new GUIButton(new RectTransform(new Vector2(StatusColumnWidthPercentage, 1f), headerFrame.RectTransform), TextManager.Get("label.statuslabel"), style: "GUIButtonSmallFreeScale"); + statusColumnWidth = statusButton.Rect.Width; + } foreach (var btn in headerFrame.GetAllChildren()) { @@ -680,7 +683,6 @@ namespace Barotrauma jobColumnWidth = jobButton.Rect.Width; characterColumnWidth = characterButton.Rect.Width; - statusColumnWidth = statusButton.Rect.Width; GUIListBox crewList = new GUIListBox(new RectTransform(Vector2.One, parent.RectTransform)) { @@ -758,7 +760,7 @@ namespace Barotrauma Character character = characterInfo.Character; if (character == null || character.IsDead) { - if (character == null && characterInfo.IsNewHire && characterInfo.CauseOfDeath == null) + if (character == null && (characterInfo.IsNewHire || characterInfo.BotStatus == BotStatus.ActiveService) && characterInfo.CauseOfDeath == null) { statusText = TextManager.Get("CampaignCrew.NewHire"); statusColor = GUIStyle.Blue; @@ -798,19 +800,21 @@ namespace Barotrauma } } - if (gameMode is PvPMode pvpMode) + if (gameMode is PvPMode && GameMain.NetworkMember?.RespawnManager != null) { new GUITextBlock(new RectTransform(new Point(killColumnWidth, paddedFrame.Rect.Height), paddedFrame.RectTransform), killCounts.GetValueOrDefault(characterInfo).ToString(), textAlignment: Alignment.Center); new GUITextBlock(new RectTransform(new Point(deathColumnWidth, paddedFrame.Rect.Height), paddedFrame.RectTransform), deathCounts.GetValueOrDefault(characterInfo).ToString(), textAlignment: Alignment.Center); } - - GUITextBlock statusBlock = new GUITextBlock(new RectTransform(new Point(statusColumnWidth, paddedFrame.Rect.Height), paddedFrame.RectTransform), - ToolBox.LimitString(statusText.Value, GUIStyle.Font, statusColumnWidth), textAlignment: Alignment.Center, textColor: statusColor, font: GUIStyle.SmallFont) + else { - ToolTip = statusText.Value - }; + GUITextBlock statusBlock = new GUITextBlock(new RectTransform(new Point(statusColumnWidth, paddedFrame.Rect.Height), paddedFrame.RectTransform), + ToolBox.LimitString(statusText.Value, GUIStyle.SmallFont, statusColumnWidth), textAlignment: Alignment.Center, textColor: statusColor, font: GUIStyle.SmallFont) + { + ToolTip = statusText.Value + }; + } frame.FadeIn(animDelay, 0.15f); foreach (var child in frame.GetAllChildren()) diff --git a/Barotrauma/BarotraumaClient/ClientSource/Items/CharacterInventory.cs b/Barotrauma/BarotraumaClient/ClientSource/Items/CharacterInventory.cs index c2501c220..8d3ad40d5 100644 --- a/Barotrauma/BarotraumaClient/ClientSource/Items/CharacterInventory.cs +++ b/Barotrauma/BarotraumaClient/ClientSource/Items/CharacterInventory.cs @@ -84,7 +84,7 @@ namespace Barotrauma get { return layout; } set { - if (layout == value) return; + if (layout == value) { return; } layout = value; SetSlotPositions(layout); } @@ -259,8 +259,8 @@ namespace Barotrauma int spacing = GUI.IntScale(5); SlotSize = (SlotSpriteSmall.size * UIScale * GUI.AspectRatioAdjustment).ToPoint(); - int bottomOffset = SlotSize.Y + spacing * 2 + ContainedIndicatorHeight; - int personalSlotY = GameMain.GraphicsHeight - bottomOffset * 2 - spacing * 2 - (int)(UnequippedIndicator.size.Y * UIScale); + int bottomOffset = GetBottomOffset(multiplier: 2); + int personalSlotY = GetVerticalOffsetFromBottom(multiplier: 2); if (visualSlots == null) { CreateSlots(); } if (visualSlots.None()) { return; } @@ -353,7 +353,15 @@ namespace Barotrauma case Layout.Left: { int x = HUDLayoutSettings.InventoryAreaLower.X; + if (!GUI.IsUltrawide && GUI.IsHUDScaled) + { + // On non-ultra-wide aspect ratios, the inventories can easily overlap with each other, if there's any scaling. + // So let's offset the other inventory to the left. + const float margin = 100; + x -= HUDLayoutSettings.ChatBoxArea.Width - (int)margin; + } int personalSlotX = x; + float y = GameMain.GraphicsHeight - bottomOffset; for (int i = 0; i < SlotPositions.Length; i++) { @@ -366,7 +374,7 @@ namespace Barotrauma } else { - SlotPositions[i] = new Vector2(x, GameMain.GraphicsHeight - bottomOffset); + SlotPositions[i] = new Vector2(x, y); x += visualSlots[i].Rect.Width + spacing; } } @@ -380,7 +388,7 @@ namespace Barotrauma continue; } if (!HideSlot(i) || SlotTypes[i] == InvSlotType.HealthInterface) { continue; } - SlotPositions[i] = new Vector2(x, GameMain.GraphicsHeight - bottomOffset); + SlotPositions[i] = new Vector2(x, y); x += visualSlots[i].Rect.Width + spacing; } } @@ -446,6 +454,9 @@ namespace Barotrauma visualSlots[i].DrawOffset = Vector2.Zero; } } + + int GetBottomOffset(int multiplier) => SlotSize.Y + spacing * multiplier + ContainedIndicatorHeight; + int GetVerticalOffsetFromBottom(int multiplier) => GameMain.GraphicsHeight - (GetBottomOffset(multiplier) + spacing) * multiplier - (int)(UnequippedIndicator.size.Y * UIScale); } protected override void ControlInput(Camera cam) diff --git a/Barotrauma/BarotraumaClient/ClientSource/Items/Components/Holdable/RangedWeapon.cs b/Barotrauma/BarotraumaClient/ClientSource/Items/Components/Holdable/RangedWeapon.cs index 540df9226..7ceb067e3 100644 --- a/Barotrauma/BarotraumaClient/ClientSource/Items/Components/Holdable/RangedWeapon.cs +++ b/Barotrauma/BarotraumaClient/ClientSource/Items/Components/Holdable/RangedWeapon.cs @@ -34,6 +34,8 @@ namespace Barotrauma.Items.Components } private Vector2 _chargeSoundWindupPitchSlide; + public Vector2 BarrelScreenPos => Screen.Selected.Cam.WorldToScreen(item.DrawPosition + ConvertUnits.ToDisplayUnits(TransformedBarrelPos)); + private readonly List particleEmitters = new List(); private readonly List particleEmitterCharges = new List(); @@ -86,28 +88,19 @@ namespace Barotrauma.Items.Components currentCrossHairScale = currentCrossHairPointerScale = cam == null ? 1.0f : cam.Zoom; if (crosshairSprite != null) { - Vector2 aimRefWorldPos = character.AimRefPosition; - if (character.Submarine != null) { aimRefWorldPos += character.Submarine.Position; } - Vector2 itemPos = cam.WorldToScreen(aimRefWorldPos); - float rotation = (item.body.Dir == 1.0f) ? item.body.Rotation : item.body.Rotation - MathHelper.Pi; - Vector2 barrelDir = new Vector2((float)Math.Cos(rotation), -(float)Math.Sin(rotation)); - - Vector2 mouseDiff = itemPos - PlayerInput.MousePosition; - crosshairPos = new Vector2( - MathHelper.Clamp(itemPos.X + barrelDir.X * mouseDiff.Length(), 0, GameMain.GraphicsWidth), - MathHelper.Clamp(itemPos.Y + barrelDir.Y * mouseDiff.Length(), 0, GameMain.GraphicsHeight)); + // Set position based on in-world aim + Vector2 barrelDir = (MathF.Cos(item.body.TransformedRotation), -MathF.Sin(item.body.TransformedRotation)); + float mouseDist = Vector2.Distance(BarrelScreenPos, PlayerInput.MousePosition); + crosshairPos = Vector2.Clamp(BarrelScreenPos + barrelDir * mouseDist, Vector2.Zero, (GameMain.GraphicsWidth, GameMain.GraphicsHeight)); + // Resize pointer based on current spread float spread = GetSpread(character); - Projectile projectile = FindProjectile(); - if (projectile != null) - { - spread += MathHelper.ToRadians(projectile.Spread); + if (FindProjectile() is Projectile projectile) + { + spread += MathHelper.ToRadians(projectile.Spread); } - - float crossHairDist = Vector2.Distance(item.WorldPosition, cam.ScreenToWorld(crosshairPos)); - float spreadDist = (float)Math.Sin(spread) * crossHairDist; - - currentCrossHairPointerScale = MathHelper.Clamp(spreadDist / Math.Min(crosshairSprite.size.X, crosshairSprite.size.Y), 0.1f, 10.0f); + float spreadAtRange = MathF.Sin(spread) * Vector2.Distance(BarrelScreenPos, crosshairPos); + currentCrossHairPointerScale = MathHelper.Clamp(spreadAtRange / Math.Min(crosshairSprite.size.X, crosshairSprite.size.Y), 0.1f, 10f); } currentCrossHairScale *= CrossHairScale; crosshairPointerPos = PlayerInput.MousePosition; diff --git a/Barotrauma/BarotraumaClient/ClientSource/Items/Components/ItemComponent.cs b/Barotrauma/BarotraumaClient/ClientSource/Items/Components/ItemComponent.cs index a80b76688..aa5e3883a 100644 --- a/Barotrauma/BarotraumaClient/ClientSource/Items/Components/ItemComponent.cs +++ b/Barotrauma/BarotraumaClient/ClientSource/Items/Components/ItemComponent.cs @@ -316,11 +316,13 @@ namespace Barotrauma.Items.Components 0.01f, loopingSound.RoundSound.GetRandomFrequencyMultiplier(), SoundPlayer.ShouldMuffleSound(Character.Controlled, item.WorldPosition, loopingSound.Range, Character.Controlled?.CurrentHull)); - loopingSoundChannel.Looping = true; - item.CheckNeedsSoundUpdate(this); - //TODO: tweak - loopingSoundChannel.Near = loopingSound.Range * 0.4f; - loopingSoundChannel.Far = loopingSound.Range; + if (loopingSoundChannel != null) + { + loopingSoundChannel.Looping = true; + item.CheckNeedsSoundUpdate(this); + loopingSoundChannel.Near = loopingSound.Range * 0.4f; + loopingSoundChannel.Far = loopingSound.Range; + } } // Looping sound with manual selection mode should be changed if value of ManuallySelectedSound has changed @@ -397,10 +399,12 @@ namespace Barotrauma.Items.Components if (volume <= 0.0001f) { return; } loopingSound = itemSound; loopingSoundChannel = SoundPlayer.PlaySound(loopingSound.RoundSound, position, volume: 0.01f, hullGuess: item.CurrentHull); - loopingSoundChannel.Looping = true; - //TODO: tweak - loopingSoundChannel.Near = loopingSound.Range * 0.4f; - loopingSoundChannel.Far = loopingSound.Range; + if (loopingSoundChannel != null) + { + loopingSoundChannel.Looping = true; + loopingSoundChannel.Near = loopingSound.Range * 0.4f; + loopingSoundChannel.Far = loopingSound.Range; + } } } else @@ -740,6 +744,9 @@ namespace Barotrauma.Items.Components }), new ContextMenuOption(TextManager.Get(LockGuiFramePosition ? "item.unlockuiposition" : "item.lockuiposition"), isEnabled: true, onSelected: () => { + //ensure the offset is set to where the frame is now + //(it may have been repositioned by the overlap prevention logic, which doesn't set this offset) + GuiFrameOffset = GuiFrame.RectTransform.ScreenSpaceOffset; LockGuiFramePosition = !LockGuiFramePosition; guiFrameDragHandle.Enabled = !LockGuiFramePosition; if (SerializableProperties.TryGetValue(nameof(LockGuiFramePosition).ToIdentifier(), out var property)) diff --git a/Barotrauma/BarotraumaClient/ClientSource/Items/Components/ItemContainer.cs b/Barotrauma/BarotraumaClient/ClientSource/Items/Components/ItemContainer.cs index 5bef2df86..353bb290b 100644 --- a/Barotrauma/BarotraumaClient/ClientSource/Items/Components/ItemContainer.cs +++ b/Barotrauma/BarotraumaClient/ClientSource/Items/Components/ItemContainer.cs @@ -474,11 +474,22 @@ namespace Barotrauma.Items.Components int i = 0; foreach (ContainedItem contained in containedItems) { - Vector2 itemPos = currentItemPos; - if (contained.Item?.Sprite == null) { continue; } - if (contained.Hide) { continue; } + + Vector2 itemPos = transformedItemPos; + int targetSlotIndex = ItemsUseInventoryPlacement ? Inventory.FindIndex(contained.Item) : i; + //interval set on both axes -> use a grid layout + if (Math.Abs(ItemInterval.X) > 0.001f && Math.Abs(ItemInterval.Y) > 0.001f) + { + itemPos += transformedItemIntervalHorizontal * (targetSlotIndex % ItemsPerRow); + itemPos += transformedItemIntervalVertical * (targetSlotIndex / ItemsPerRow); + } + else + { + itemPos += (transformedItemIntervalHorizontal + transformedItemIntervalVertical) * targetSlotIndex; + } + if (contained.ItemPos.HasValue) { Vector2 pos = contained.ItemPos.Value; @@ -531,7 +542,7 @@ namespace Barotrauma.Items.Components containedSpriteDepth = containedSpriteDepths[i]; } containedSpriteDepth = itemDepth + (containedSpriteDepth - (item.Sprite?.Depth ?? item.SpriteDepth)) / 10000.0f; - + SpriteEffects spriteEffects = SpriteEffects.None; float spriteRotation = ItemRotation; if (contained.Rotation != 0) @@ -570,20 +581,6 @@ namespace Barotrauma.Items.Components } i++; - if (Math.Abs(ItemInterval.X) > 0.001f && Math.Abs(ItemInterval.Y) > 0.001f) - { - //interval set on both axes -> use a grid layout - currentItemPos += transformedItemIntervalHorizontal; - if (i % ItemsPerRow == 0) - { - currentItemPos = transformedItemPos; - currentItemPos += transformedItemIntervalVertical * (i / ItemsPerRow); - } - } - else - { - currentItemPos += transformedItemIntervalHorizontal + transformedItemIntervalVertical; - } } } diff --git a/Barotrauma/BarotraumaClient/ClientSource/Items/Components/LightComponent.cs b/Barotrauma/BarotraumaClient/ClientSource/Items/Components/LightComponent.cs index 8e88d4189..4e637d1dc 100644 --- a/Barotrauma/BarotraumaClient/ClientSource/Items/Components/LightComponent.cs +++ b/Barotrauma/BarotraumaClient/ClientSource/Items/Components/LightComponent.cs @@ -35,6 +35,7 @@ namespace Barotrauma.Items.Components { Light.SpriteScale = Vector2.One * item.Scale; Light.Position = ParentBody != null ? ParentBody.Position : item.Position; + SetLightSourceTransformProjSpecific(); } partial void SetLightSourceState(bool enabled, float brightness) @@ -51,27 +52,42 @@ namespace Barotrauma.Items.Components partial void SetLightSourceTransformProjSpecific() { + Vector2 offset = Vector2.Zero; + if (LightOffset != Vector2.Zero) + { + offset = Vector2.Transform(LightOffset, Matrix.CreateRotationZ(item.FlippedY ? -item.RotationRad - MathHelper.Pi : -item.RotationRad)) * item.Scale; + } + if (ParentBody != null) { Light.ParentBody = ParentBody; + Light.OffsetFromBody = offset; } else if (turret != null) { - Light.Position = new Vector2(item.Rect.X + turret.TransformedBarrelPos.X, item.Rect.Y - turret.TransformedBarrelPos.Y); + Light.Position = new Vector2(item.Rect.X + turret.TransformedBarrelPos.X, item.Rect.Y - turret.TransformedBarrelPos.Y) + offset; } else if (item.body != null) { Light.ParentBody = item.body; + Light.OffsetFromBody = offset; } else { - Light.Position = item.Position; + Light.Position = item.Position + offset; } PhysicsBody body = Light.ParentBody; - if (body != null && body.Enabled) + if (body != null) { Light.Rotation = body.Dir > 0.0f ? body.DrawRotation : body.DrawRotation - MathHelper.Pi; - Light.LightSpriteEffect = (body.Dir > 0.0f) ? SpriteEffects.None : SpriteEffects.FlipVertically; + if (body.Enabled) + { + Light.LightSpriteEffect = (body.Dir > 0.0f) ? SpriteEffects.None : SpriteEffects.FlipVertically; + } + else + { + Light.LightSpriteEffect = item.SpriteEffects; + } } else { @@ -85,11 +101,13 @@ namespace Barotrauma.Items.Components if (Light?.LightSprite == null) { return; } if ((item.body == null || item.body.Enabled) && lightBrightness > 0.0f && IsOn && Light.Enabled) { + Vector2 offset = Vector2.Transform(LightOffset, Matrix.CreateRotationZ(item.FlippedY ? -item.RotationRad - MathHelper.Pi : -item.RotationRad)) * item.Scale; + Vector2 origin = Light.LightSprite.Origin; if ((Light.LightSpriteEffect & SpriteEffects.FlipHorizontally) == SpriteEffects.FlipHorizontally) { origin.X = Light.LightSprite.SourceRect.Width - origin.X; } if ((Light.LightSpriteEffect & SpriteEffects.FlipVertically) == SpriteEffects.FlipVertically) { origin.Y = Light.LightSprite.SourceRect.Height - origin.Y; } - Vector2 drawPos = item.body?.DrawPosition ?? item.DrawPosition; + Vector2 drawPos = item.body?.DrawPosition ?? item.DrawPosition + offset; Color color = lightColor; if (Light.OverrideLightSpriteAlpha.HasValue) diff --git a/Barotrauma/BarotraumaClient/ClientSource/Items/Components/Machines/Controller.cs b/Barotrauma/BarotraumaClient/ClientSource/Items/Components/Machines/Controller.cs index a01e81ab4..710dcb9f6 100644 --- a/Barotrauma/BarotraumaClient/ClientSource/Items/Components/Machines/Controller.cs +++ b/Barotrauma/BarotraumaClient/ClientSource/Items/Components/Machines/Controller.cs @@ -6,7 +6,6 @@ namespace Barotrauma.Items.Components { partial class Controller : ItemComponent { - private bool chatBoxOriginalState; private bool isHUDsHidden; public override void DrawHUD(SpriteBatch spriteBatch, Character character) @@ -36,45 +35,19 @@ namespace Barotrauma.Items.Components partial void HideHUDs(bool value) { if (isHUDsHidden == value) { return; } - if (value == true) + if (value) { GameMain.GameSession?.CrewManager?.AutoHideCrewList(); - ToggleChatBox(false, storeOriginalState: true); + ChatBox.AutoHideChatBox(); } else { - GameMain.GameSession?.CrewManager?.ResetCrewList(); - ToggleChatBox(chatBoxOriginalState, storeOriginalState: false); + GameMain.GameSession?.CrewManager?.ResetCrewListOpenState(); + ChatBox.ResetChatBoxOpenState(); } isHUDsHidden = value; } - private void ToggleChatBox(bool value, bool storeOriginalState) - { - var crewManager = GameMain.GameSession?.CrewManager; - if (crewManager == null) { return; } - - if (crewManager.IsSinglePlayer) - { - if (crewManager.ChatBox != null) - { - if (storeOriginalState) - { - chatBoxOriginalState = crewManager.ChatBox.ToggleOpen; - } - crewManager.ChatBox.ToggleOpen = value; - } - } - else if (GameMain.Client != null) - { - if (storeOriginalState) - { - chatBoxOriginalState = GameMain.Client.ChatBox.ToggleOpen; - } - GameMain.Client.ChatBox.ToggleOpen = value; - } - } - #if DEBUG public override void CreateEditingHUD(SerializableEntityEditor editor) { diff --git a/Barotrauma/BarotraumaClient/ClientSource/Items/Components/Machines/Fabricator.cs b/Barotrauma/BarotraumaClient/ClientSource/Items/Components/Machines/Fabricator.cs index a8cb83664..b54aaef03 100644 --- a/Barotrauma/BarotraumaClient/ClientSource/Items/Components/Machines/Fabricator.cs +++ b/Barotrauma/BarotraumaClient/ClientSource/Items/Components/Machines/Fabricator.cs @@ -11,11 +11,21 @@ namespace Barotrauma.Items.Components { partial class Fabricator : Powered, IServerSerializable, IClientSerializable { + private enum SortBy + { + Category, + Alphabetical, + SkillRequirement, + Price + } + private GUIListBox itemList; private GUIFrame selectedItemFrame; private GUIFrame selectedItemReqsFrame; + private GUILayoutGroup outputTopArea, paddedOutputArea; + private GUITextBlock amountTextMax; private GUIScrollBar amountInput; @@ -26,6 +36,8 @@ namespace Barotrauma.Items.Components private GUIButton activateButton; private GUITextBox itemFilterBox; + private GUITickBox availableOnlyTickBox; + private GUIDropDown sortByDropdown; private GUIComponent outputSlot; private GUIComponent inputInventoryHolder, outputInventoryHolder; @@ -33,6 +45,9 @@ namespace Barotrauma.Items.Components private readonly List itemCategoryButtons = new List(); private MapEntityCategory? selectedItemCategory; + private GUITextBlock requiresRecipeText; + private GUITextBlock nothingToShowText; + public FabricationRecipe SelectedItem { get { return selectedItem; } @@ -65,6 +80,15 @@ namespace Barotrauma.Items.Components [Serialize("vendingmachine.outofstock", IsPropertySaveable.Yes)] public string FabricationLimitReachedText { get; set; } + [Serialize(true, IsPropertySaveable.No)] + public bool ShowSortByDropdown { get; set; } + + [Serialize(true, IsPropertySaveable.No)] + public bool ShowAvailableOnlyTickBox { get; set; } + + [Serialize(true, IsPropertySaveable.No)] + public bool ShowCategoryButtons { get; set; } + public override bool RecreateGUIOnResolutionChange => true; protected override void OnResolutionChanged() @@ -99,7 +123,7 @@ namespace Barotrauma.Items.Components itemCategoryButtons.Clear(); //only create category buttons if there's more than one category in addition to "All" - if (itemCategories.Count > 2) + if (ShowCategoryButtons && itemCategories.Count > 2) { // === Item category buttons === var categoryButtonContainer = new GUILayoutGroup(new RectTransform(new Vector2(0.05f, 1.0f), innerArea.RectTransform)) @@ -154,24 +178,24 @@ namespace Barotrauma.Items.Components }; // === TOP AREA === - var topFrame = new GUIFrame(new RectTransform(new Vector2(1.0f, 0.65f), mainFrame.RectTransform), style: "InnerFrameDark"); + var topFrame = new GUIFrame(new RectTransform(new Vector2(1.0f, 0.8f), mainFrame.RectTransform), style: "InnerFrameDark"); // === ITEM LIST === var itemListFrame = new GUILayoutGroup(new RectTransform(new Vector2(0.5f, 1.0f), topFrame.RectTransform), childAnchor: Anchor.Center); - var paddedItemFrame = new GUILayoutGroup(new RectTransform(new Vector2(0.9f, 0.9f), itemListFrame.RectTransform)) + var paddedItemFrame = new GUILayoutGroup(new RectTransform(new Vector2(0.9f, 0.95f), itemListFrame.RectTransform), isHorizontal: false) { - Stretch = true, - RelativeSpacing = 0.03f + Stretch = true }; var filterArea = new GUILayoutGroup(new RectTransform(new Vector2(1.0f, 0.15f), paddedItemFrame.RectTransform), isHorizontal: true) { - Stretch = true, - RelativeSpacing = 0.03f, + Stretch = true, + RelativeSpacing = 0.03f, UserData = "filterarea" }; - new GUITextBlock(new RectTransform(new Vector2(0.2f, 1f), filterArea.RectTransform), TextManager.Get("serverlog.filter"), font: GUIStyle.SubHeadingFont) + new GUITextBlock(new RectTransform(new Vector2(0.4f, 1f), filterArea.RectTransform), TextManager.Get("serverlog.filter"), + font: GUIStyle.SubHeadingFont, textAlignment: Alignment.CenterLeft) { - Padding = Vector4.Zero, + Padding = Vector4.Zero, AutoScaleVertical = true }; itemFilterBox = new GUITextBox(new RectTransform(new Vector2(0.8f, 1.0f), filterArea.RectTransform), createClearButton: true) @@ -183,29 +207,91 @@ namespace Barotrauma.Items.Components FilterEntities(selectedItemCategory, text); return true; }; + filterArea.RectTransform.MinSize = new Point(0, itemFilterBox.Rect.Height); filterArea.RectTransform.MaxSize = new Point(int.MaxValue, itemFilterBox.Rect.Height); - itemList = new GUIListBox(new RectTransform(new Vector2(1f, 0.9f), paddedItemFrame.RectTransform), style: null) + var sortByArea = new GUILayoutGroup(new RectTransform(new Vector2(1.0f, 0.15f), paddedItemFrame.RectTransform), isHorizontal: true) + { + Stretch = true, + RelativeSpacing = 0.03f, + Visible = ShowSortByDropdown, + IgnoreLayoutGroups = !ShowSortByDropdown + }; + new GUITextBlock(new RectTransform(new Vector2(0.4f, 1f), sortByArea.RectTransform), TextManager.Get("campaignstore.sortby"), + font: GUIStyle.SubHeadingFont, textAlignment: Alignment.CenterLeft) + { + Padding = Vector4.Zero, + AutoScaleVertical = true + }; + sortByDropdown = new GUIDropDown(new RectTransform(new Vector2(0.8f, 1.0f), sortByArea.RectTransform)); + foreach (SortBy sortBy in Enum.GetValues()) + { + sortByDropdown.AddItem(TextManager.Get("fabricator.sortby." + sortBy), userData: sortBy); + } + sortByDropdown.Select(index: 0); + sortByDropdown.AfterSelected += (GUIComponent selected, object userdata) => + { + FilterEntities(selectedItemCategory, itemFilterBox.Text); + SortItems(character: Character.Controlled); + return true; + }; + sortByArea.RectTransform.MinSize = new Point(0, sortByDropdown.Rect.Height); + sortByArea.RectTransform.MaxSize = new Point(int.MaxValue, sortByDropdown.Rect.Height); + + var availableOnlyTickBoxArea = new GUILayoutGroup(new RectTransform(new Vector2(1.0f, 0.15f), paddedItemFrame.RectTransform), isHorizontal: true) + { + Stretch = true, + Visible = ShowAvailableOnlyTickBox, + IgnoreLayoutGroups = !ShowAvailableOnlyTickBox + }; + new GUITextBlock(new RectTransform(new Vector2(0.4f, 1f), availableOnlyTickBoxArea.RectTransform), TextManager.Get("fabricator.onlyshowavailable"), + font: GUIStyle.SubHeadingFont, textAlignment: Alignment.CenterLeft) + { + Padding = Vector4.Zero, + AutoScaleVertical = true + }; + availableOnlyTickBox = new GUITickBox(new RectTransform(new Vector2(1.0f), availableOnlyTickBoxArea.RectTransform, scaleBasis: ScaleBasis.BothHeight), label: string.Empty) + { + ToolTip = TextManager.Get("fabricator.onlyshowavailable.tooltip") + }; + availableOnlyTickBox.OnSelected += (tickbox) => + { + FilterEntities(selectedItemCategory, itemFilterBox.Text); + return true; + }; + availableOnlyTickBox.RectTransform.MinSize = new Point(availableOnlyTickBox.Rect.Height); + availableOnlyTickBox.RectTransform.IsFixedSize = true; + availableOnlyTickBoxArea.RectTransform.MinSize = new Point(0, availableOnlyTickBox.Rect.Height); + availableOnlyTickBoxArea.RectTransform.MaxSize = new Point(int.MaxValue, availableOnlyTickBox.Rect.Height); + + itemList = new GUIListBox(new RectTransform(new Vector2(1f, 0.8f), paddedItemFrame.RectTransform), style: null) { PlaySoundOnSelect = true, OnSelected = (component, userdata) => { - selectedItem = userdata as FabricationRecipe; - if (selectedItem != null) { SelectItem(Character.Controlled, selectedItem); } - return true; + if (userdata is FabricationRecipe fabricationRecipe) + { + selectedItem = fabricationRecipe; + SelectItem(Character.Controlled, selectedItem); + return true; + } + else + { + return false; + } } }; - // === SEPARATOR === // - new GUIFrame(new RectTransform(new Vector2(0.01f, 0.9f), topFrame.RectTransform, Anchor.Center), style: "VerticalLine"); + // === SEPARATOR === // + new GUIFrame(new RectTransform(new Vector2(0.01f, 0.9f), topFrame.RectTransform, Anchor.Center), style: "VerticalLine"); // === OUTPUT AREA === // var outputArea = new GUILayoutGroup(new RectTransform(new Vector2(0.5f, 1f), topFrame.RectTransform, Anchor.TopRight), childAnchor: Anchor.Center); - var paddedOutputArea = new GUILayoutGroup(new RectTransform(new Vector2(0.95f, 0.9f), outputArea.RectTransform)); - var outputTopArea = new GUILayoutGroup(new RectTransform(new Vector2(1f, 0.5F), paddedOutputArea.RectTransform, Anchor.Center), isHorizontal: true); + paddedOutputArea = new GUILayoutGroup(new RectTransform(new Vector2(0.95f, 0.95f), outputArea.RectTransform)) { Stretch = true }; + outputTopArea = new GUILayoutGroup(new RectTransform(new Vector2(1f, 0.5f), paddedOutputArea.RectTransform, Anchor.Center), isHorizontal: true); // === OUTPUT SLOT === // - outputSlot = new GUIFrame(new RectTransform(new Vector2(0.4f, 1f), outputTopArea.RectTransform), style: null); - outputInventoryHolder = new GUIFrame(new RectTransform(new Vector2(1f, 1.2f), outputSlot.RectTransform, Anchor.BottomCenter), style: null); + outputSlot = new GUIFrame(new RectTransform(new Vector2(0.4f, 0.4f), outputTopArea.RectTransform, scaleBasis: ScaleBasis.BothWidth), style: null); + outputInventoryHolder = new GUIFrame(new RectTransform(new Vector2(1f, 1.0f), outputSlot.RectTransform, Anchor.BottomCenter), style: null); new GUICustomComponent(new RectTransform(Vector2.One, outputInventoryHolder.RectTransform), DrawOutputOverLay) { CanBeFocused = false }; // === DESCRIPTION === // selectedItemFrame = new GUIFrame(new RectTransform(new Vector2(0.6f, 1f), outputTopArea.RectTransform), style: null); @@ -213,7 +299,7 @@ namespace Barotrauma.Items.Components selectedItemReqsFrame = new GUIFrame(new RectTransform(new Vector2(1f, 0.5f), paddedOutputArea.RectTransform), style: null); // === BOTTOM AREA === // - var bottomFrame = new GUIFrame(new RectTransform(new Vector2(1f, 0.3f), mainFrame.RectTransform), style: null); + var bottomFrame = new GUIFrame(new RectTransform(new Vector2(1f, 0.2f), mainFrame.RectTransform), style: null); if (inputContainer.Capacity > 0) { @@ -298,6 +384,33 @@ namespace Barotrauma.Items.Components CanBeFocused = false }; CreateRecipes(); + + foreach (MapEntityCategory category in itemCategories) + { + new GUITextBlock(new RectTransform(new Vector2(1.0f, 0.15f), itemList.Content.RectTransform), + TextManager.Get("MapEntityCategory." + category), textColor: GUIStyle.TextColorBright) + { + CanBeFocused = false, + UserData = category, + Visible = false + }; + } + + requiresRecipeText = new GUITextBlock(new RectTransform(new Vector2(1.0f, 0.15f), itemList.Content.RectTransform), + TextManager.Get("fabricatorrequiresrecipe"), textColor: Color.Red, font: GUIStyle.SubHeadingFont) + { + AutoScaleHorizontal = true, + CanBeFocused = false + }; + + nothingToShowText = new GUITextBlock(new RectTransform(new Vector2(1.0f, 0.8f), itemList.Content.RectTransform), TextManager.Get("noitemsheader"), + textAlignment: Alignment.Center, textColor: GUIStyle.TextColorDim) + { + CanBeFocused = false, + Visible = false + }; + + SortItems(character: Character.Controlled); } private void RefreshActivateButtonText() @@ -343,7 +456,8 @@ namespace Barotrauma.Items.Components }; } - new GUITextBlock(new RectTransform(new Vector2(0.85f, 1f), container.RectTransform), GetRecipeNameAndAmount(fi)) + new GUITextBlock(new RectTransform(new Vector2(0.85f, 1f), container.RectTransform), + RichString.Rich(GetRecipeNameAndAmount(fi)), font: GUIStyle.SmallFont) { Padding = Vector4.Zero, AutoScaleVertical = true, @@ -397,73 +511,106 @@ namespace Barotrauma.Items.Components if (character != Character.Controlled) { return; } var nonItems = itemList.Content.Children.Where(c => c.UserData is not FabricationRecipe).ToList(); - nonItems.ForEach(i => itemList.Content.RemoveChild(i)); + nonItems.ForEach(i => i.Visible = false); + + SortItems(character: null); + FilterEntities(selectedItemCategory, itemFilterBox?.Text ?? string.Empty); + HideEmptyItemListCategories(); + } + + private void SortItems(Character character) + { + SortBy sortBy = (SortBy)sortByDropdown.SelectedData; itemList.Content.RectTransform.SortChildren((c1, c2) => { var item1 = c1.GUIComponent.UserData as FabricationRecipe; var item2 = c2.GUIComponent.UserData as FabricationRecipe; - int itemPlacement1 = calculatePlacement(item1); - int itemPlacement2 = calculatePlacement(item2); - if (itemPlacement1 != itemPlacement2) + if (item1 == null && item2 == null) { - return itemPlacement1 > itemPlacement2 ? -1 : 1; + return 0; + } + else if (item1 == null) + { + return -1; + } + else if (item2 == null) + { + return 1; } - int calculatePlacement(FabricationRecipe recipe) + bool missingRecipe1 = MissingRequiredRecipe(item1, character); + bool missingRecipe2 = MissingRequiredRecipe(item2, character); + if (missingRecipe1 != missingRecipe2) { - if (recipe.RequiresRecipe && !AnyOneHasRecipeForItem(character, recipe.TargetItem)) - { - return -2; - } - int placement = FabricationDegreeOfSuccess(character, recipe.RequiredSkills) >= 0.5f ? 0 : -1; - return placement; + return missingRecipe1.CompareTo(missingRecipe2); } - return string.Compare(item1.DisplayName.Value, item2.DisplayName.Value); + switch (sortBy) + { + case SortBy.Alphabetical: + return string.Compare(item1.DisplayName.Value, item2.DisplayName.Value); + case SortBy.Category: + var category1 = EnumExtensions.GetIndividualFlags(item1.TargetItem.Category).FirstOrDefault(); + var category2 = EnumExtensions.GetIndividualFlags(item2.TargetItem.Category).FirstOrDefault(); + if (category1 == category2) + { + return string.Compare(item1.DisplayName.Value, item2.DisplayName.Value); + } + return category1.CompareTo(category2); + case SortBy.SkillRequirement: + float skillRequirement1 = item1.RequiredSkills.Sum(skill => skill.Level); + float skillRequirement2 = item2.RequiredSkills.Sum(skill => skill.Level); + if (MathUtils.NearlyEqual(skillRequirement1, skillRequirement2)) + { + return string.Compare(item1.DisplayName.Value, item2.DisplayName.Value); + } + return skillRequirement1.CompareTo(skillRequirement2); + case SortBy.Price: + float itemValue1 = item1.TargetItem.DefaultPrice?.Price ?? 0; + float itemValue2 = item2.TargetItem.DefaultPrice?.Price ?? 0; + if (MathUtils.NearlyEqual(itemValue1, itemValue2)) + { + return string.Compare(item1.DisplayName.Value, item2.DisplayName.Value); + } + return itemValue2.CompareTo(itemValue1); + default: + throw new NotImplementedException($"Sorting by {sortBy} has not been implemented."); + } }); - var sufficientSkillsText = new GUITextBlock(new RectTransform(new Vector2(1.0f, 0.15f), itemList.Content.RectTransform), - TextManager.Get("fabricatorsufficientskills"), textColor: GUIStyle.Green, font: GUIStyle.SubHeadingFont) + if (sortBy == SortBy.Category) { - AutoScaleHorizontal = true, - CanBeFocused = false - }; - sufficientSkillsText.RectTransform.SetAsFirstChild(); + foreach (var categoryText in itemList.Content.Children.Where(c => c.UserData?.GetType() == typeof(MapEntityCategory)).ToList()) + { + categoryText.RectTransform.SetAsLastChild(); + var category = (MapEntityCategory)categoryText.UserData; + var firstChildWithMatchingCategory = itemList.Content.Children.FirstOrDefault(c => c.UserData is FabricationRecipe recipe && EnumExtensions.GetIndividualFlags(recipe.TargetItem.Category).FirstOrDefault() == category); + if (firstChildWithMatchingCategory != null) + { + categoryText.RectTransform.RepositionChildInHierarchy(itemList.Content.GetChildIndex(firstChildWithMatchingCategory)); + categoryText.Visible = true; + } + else + { + categoryText.Visible = false; + } + } + } - var insufficientSkillsText = new GUITextBlock(new RectTransform(new Vector2(1.0f, 0.15f), itemList.Content.RectTransform), - TextManager.Get("fabricatorinsufficientskills"), textColor: Color.Orange, font: GUIStyle.SubHeadingFont) + requiresRecipeText.RectTransform.SetAsLastChild(); + var firstMissingRecipe = itemList.Content.Children.FirstOrDefault(c => c.UserData is FabricationRecipe recipe && MissingRequiredRecipe(recipe, character)); + if (firstMissingRecipe != null) { - AutoScaleHorizontal = true, - CanBeFocused = false - }; - var firstinSufficient = itemList.Content.Children.FirstOrDefault(c => c.UserData is FabricationRecipe fabricableItem && FabricationDegreeOfSuccess(character, fabricableItem.RequiredSkills) < 0.5f); - if (firstinSufficient != null) - { - insufficientSkillsText.RectTransform.RepositionChildInHierarchy(itemList.Content.RectTransform.GetChildIndex(firstinSufficient.RectTransform)); + requiresRecipeText.RectTransform.RepositionChildInHierarchy(itemList.Content.GetChildIndex(firstMissingRecipe)); + requiresRecipeText.Visible = true; } else { - sufficientSkillsText.Visible = insufficientSkillsText.Visible = false; - sufficientSkillsText.Enabled = insufficientSkillsText.Enabled = false; + requiresRecipeText.Visible = false; } - var requiresRecipeText = new GUITextBlock(new RectTransform(new Vector2(1.0f, 0.15f), itemList.Content.RectTransform), - TextManager.Get("fabricatorrequiresrecipe"), textColor: Color.Red, font: GUIStyle.SubHeadingFont) - { - AutoScaleHorizontal = true, - CanBeFocused = false - }; - var firstRequiresRecipe = itemList.Content.Children.FirstOrDefault(c => - c.UserData is FabricationRecipe fabricableItem && - fabricableItem.RequiresRecipe && !AnyOneHasRecipeForItem(character, fabricableItem.TargetItem)); - if (firstRequiresRecipe != null) - { - requiresRecipeText.RectTransform.RepositionChildInHierarchy(itemList.Content.RectTransform.GetChildIndex(firstRequiresRecipe.RectTransform)); - } - - FilterEntities(selectedItemCategory, itemFilterBox?.Text ?? string.Empty); HideEmptyItemListCategories(); } @@ -743,7 +890,7 @@ namespace Barotrauma.Items.Components itemIcon.Draw( spriteBatch, slotRect.Center.ToVector2(), - color: targetItem.TargetItem.InventoryIconColor * 0.4f, + color: Color.Lerp(targetItem.TargetItem.InventoryIconColor, Color.TransparentBlack, 0.5f), scale: Math.Min(slotRect.Width / itemIcon.size.X, slotRect.Height / itemIcon.size.Y) * 0.9f); } } @@ -757,6 +904,9 @@ namespace Barotrauma.Items.Components private bool FilterEntities(MapEntityCategory? category, string filter) { + bool onlyShowAvailable = availableOnlyTickBox is { Selected: true }; + + bool anyVisible = false; foreach (GUIComponent child in itemList.Content.Children) { FabricationRecipe recipe = child.UserData as FabricationRecipe; @@ -771,16 +921,35 @@ namespace Barotrauma.Items.Components } } + if (recipe.RequiresRecipe && recipe.HideIfNoRecipe) + { + if (Character.Controlled != null) + { + if (!AnyOneHasRecipeForItem(Character.Controlled, recipe.TargetItem)) + { + child.Visible = false; + continue; + } + } + } + child.Visible = (string.IsNullOrWhiteSpace(filter) || recipe.DisplayName.Contains(filter, StringComparison.OrdinalIgnoreCase)) && - (!category.HasValue || recipe.TargetItem.Category.HasFlag(category.Value)); - } + (!category.HasValue || recipe.TargetItem.Category.HasFlag(category.Value)) && + (!onlyShowAvailable || CanBeFabricated(recipe, availableIngredients, Character.Controlled)); + if (child.Visible) + { + anyVisible = true; + } + } foreach (GUIButton btn in itemCategoryButtons) { btn.Selected = (MapEntityCategory?)btn.UserData == selectedItemCategory; } HideEmptyItemListCategories(); + nothingToShowText.Visible = !anyVisible; + itemList.UserData = "itemlist"; return true; } @@ -788,7 +957,7 @@ namespace Barotrauma.Items.Components private void HideEmptyItemListCategories() { bool visibleElementsChanged = false; - //go through the elements backwards, and disable the labels ("insufficient skills to fabricate", "recipe required...") if there's no items below them + //go through the elements backwards, and disable the labels if there's no items below them bool recipeVisible = false; foreach (GUIComponent child in itemList.Content.Children.Reverse()) { @@ -810,6 +979,12 @@ namespace Barotrauma.Items.Components } } + SortBy sortBy = (SortBy)sortByDropdown.SelectedData; + if (sortBy != SortBy.Category) + { + itemList.Content.Children.Where(c => c.UserData?.GetType() == typeof(MapEntityCategory)).ForEach(c => c.Visible = false); + } + if (visibleElementsChanged) { itemList.UpdateScrollBarSize(); @@ -841,8 +1016,8 @@ namespace Barotrauma.Items.Components private void CreateSelectedItemUI(SelectedRecipe recipe) { - var (user, selectedItem, overrideRequiredTime) = recipe; - int max = Math.Max(selectedItem.TargetItem.GetMaxStackSize(outputContainer.Inventory) / selectedItem.Amount, 1); + var (user, selectedRecipe, overrideRequiredTime) = recipe; + int max = Math.Max(selectedRecipe.TargetItem.GetMaxStackSize(outputContainer.Inventory) / selectedRecipe.Amount, 1); if (amountInput != null) { @@ -859,18 +1034,59 @@ namespace Barotrauma.Items.Components selectedItemFrame.ClearChildren(); selectedItemReqsFrame.ClearChildren(); - var paddedFrame = new GUILayoutGroup(new RectTransform(new Vector2(1f, 0.9f), selectedItemFrame.RectTransform, Anchor.Center)) { RelativeSpacing = 0.03f }; + var paddedFrame = new GUILayoutGroup(new RectTransform(new Vector2(1f, 0.9f), selectedItemFrame.RectTransform, Anchor.Center)) { RelativeSpacing = 0.03f, CanBeFocused = true }; var paddedReqFrame = new GUILayoutGroup(new RectTransform(new Vector2(1f, 0.9f), selectedItemReqsFrame.RectTransform, Anchor.Center)) { RelativeSpacing = 0.03f }; - LocalizedString itemName = GetRecipeNameAndAmount(selectedItem); + LocalizedString itemName = GetRecipeNameAndAmount(selectedRecipe); LocalizedString name = itemName; - QualityResult result = GetFabricatedItemQuality(selectedItem, user); + QualityResult result = GetFabricatedItemQuality(selectedRecipe, user); - float quality = selectedItem.Quality ?? result.Quality; - if (quality > 0 || result.HasRandomQualityRollChance) + float minimumQuality = selectedRecipe.Quality ?? result.Quality; + + LocalizedString qualityTooltip = string.Empty; + if (result.HasRandomQualityRollChance) { - name = TextManager.GetWithVariable("itemname.quality" + (int)quality, "[itemname]", itemName + '\n') + float plusOnePercentage = result.TotalPlusOnePercentage; + float plusTwoPercentage = result.TotalPlusTwoPercentage; + + string plusOnePercentageText = plusOnePercentage.ToString("F1", CultureInfo.InvariantCulture); + string plusTwoPercentageText = plusTwoPercentage.ToString("F1", CultureInfo.InvariantCulture); + + int plusOneQuality = Math.Clamp(result.Quality + 1, min: 0, max: 3); + int plusTwoQuality = Math.Clamp(result.Quality + 2, min: 0, max: 3); + + LocalizedString plusOneQualityText = TextManager.Get($"quality{plusOneQuality}"); + LocalizedString plusTwoQualityText = TextManager.Get($"quality{plusTwoQuality}"); + + string localizationTag = plusTwoPercentage > 0f && plusOnePercentage > 0 && plusOneQuality != plusTwoQuality ? "meetsbonusrequirementtwice" : "meetsbonusrequirement"; + + var variables = new (string Key, LocalizedString Value)[] + { + ("[chance]", plusOnePercentageText), ("[quality]", plusOneQualityText), + ("[chance2]", plusTwoPercentageText), ("[quality2]", plusTwoQualityText) + }; + + if (MathUtils.NearlyEqual(plusOnePercentage, 0)) + { + variables = new[] { ("[chance]", plusTwoPercentageText), ("[quality]", plusTwoQualityText) }; + } + + if (plusOneQuality == plusTwoQuality) + { + LocalizedString rawPercentage = result.PlusOnePercentage.ToString("F1", CultureInfo.InvariantCulture); + variables = new[] { ("[chance]", rawPercentage), ("[quality]", plusOneQualityText) }; + } + + if (plusOnePercentage >= 100.0f) { minimumQuality = plusOneQuality; } + if (plusTwoPercentage >= 100.0f) { minimumQuality = plusTwoQuality; } + + qualityTooltip = TextManager.GetWithVariables(localizationTag, variables); + } + + if (minimumQuality > 0 || result.HasRandomQualityRollChance) + { + name = TextManager.GetWithVariable("itemname.quality" + (int)minimumQuality, "[itemname]", itemName + '\n') .Fallback(TextManager.GetWithVariable("itemname.quality3", "[itemname]", itemName + '\n')); } @@ -884,44 +1100,13 @@ namespace Barotrauma.Items.Components { var iconLayout = new GUIFrame(new RectTransform(new Vector2(0.4f, 1f), selectedItemFrame.RectTransform, anchor: Anchor.TopRight), style: null); var icon = GameSession.CreateNotificationIcon(iconLayout, offset: true); - - float percentage1 = result.TotalPlusOnePercentage; - float percentage2 = result.TotalPlusTwoPercentage; - - string chance1text = percentage1.ToString("F1", CultureInfo.InvariantCulture); - string chance2text = percentage2.ToString("F1", CultureInfo.InvariantCulture); - - int quality1 = Math.Clamp(result.Quality + 1, min: 0, max: 3); - int quality2 = Math.Clamp(result.Quality + 2, min: 0, max: 3); - - LocalizedString quality1Text = TextManager.Get($"quality{quality1}"); - LocalizedString quality2Text = TextManager.Get($"quality{quality2}"); - - string localizationTag = percentage2 > 0f && percentage1 > 0 && quality1 != quality2 ? "meetsbonusrequirementtwice" : "meetsbonusrequirement"; - - var variables = new (string Key, LocalizedString Value)[] - { - ("[chance]", chance1text), ("[quality]", quality1Text), - ("[chance2]", chance2text), ("[quality2]", quality2Text) - }; - - if (MathUtils.NearlyEqual(percentage1, 0)) - { - variables = new[] { ("[chance]", chance2text), ("[quality]", quality2Text) }; - } - - if (quality1 == quality2) - { - LocalizedString rawPercentage = result.PlusOnePercentage.ToString("F1", CultureInfo.InvariantCulture); - variables = new[] { ("[chance]", rawPercentage), ("[quality]", quality1Text) }; - } - - LocalizedString qualityTooltip = TextManager.GetWithVariables(localizationTag, variables); - icon.ToolTip = RichString.Rich(qualityTooltip); icon.Visible = icon.CanBeFocused = true; } + outputTopArea.RectTransform.MaxSize = new Point(int.MaxValue, outputInventoryHolder.Rect.Height); + paddedOutputArea.Recalculate(); + nameBlock.Padding = new Vector4(0, nameBlock.Padding.Y, GUI.IntScale(5), nameBlock.Padding.W); if (nameBlock.TextScale < 0.7f) { @@ -932,31 +1117,41 @@ namespace Barotrauma.Items.Components nameBlock.RectTransform.MinSize = new Point(0, (int)(nameBlock.TextSize.Y * nameBlock.TextScale)); } - if (!selectedItem.TargetItem.Description.IsNullOrEmpty()) + bool largeUI = GuiFrame.Rect.Height > GUI.IntScale(500); + if (largeUI) { - var description = new GUITextBlock(new RectTransform(new Vector2(1.0f, 0.0f), paddedFrame.RectTransform), - RichString.Rich(selectedItem.TargetItem.Description), - font: GUIStyle.SmallFont, wrap: true); - description.Padding = new Vector4(0, description.Padding.Y, description.Padding.Z, description.Padding.W); + paddedFrame.ChildAnchor = Anchor.CenterLeft; + } - while (description.Rect.Height + nameBlock.Rect.Height > paddedFrame.Rect.Height) + if (!selectedRecipe.TargetItem.Description.IsNullOrEmpty()) + { + var descriptionParent = largeUI ? paddedReqFrame : paddedFrame; + var description = new GUITextBlock(new RectTransform(new Vector2(1.0f, 0.0f), descriptionParent.RectTransform), + RichString.Rich(selectedRecipe.TargetItem.Description), + font: GUIStyle.SmallFont, wrap: true); + if (!largeUI) + { + description.Padding = new Vector4(0, description.Padding.Y, description.Padding.Z, description.Padding.W); + } + + while (description.Rect.Height + nameBlock.Rect.Height > descriptionParent.Rect.Height) { var lines = description.WrappedText.Split('\n'); if (lines.Count <= 1) { break; } var newString = string.Join('\n', lines.Take(lines.Count - 1)); description.Text = newString.Substring(0, newString.Length - 4) + "..."; description.CalculateHeightFromText(); - description.ToolTip = selectedItem.TargetItem.Description; + description.ToolTip = selectedRecipe.TargetItem.Description; } } IEnumerable inadequateSkills = Enumerable.Empty(); if (user != null) { - inadequateSkills = selectedItem.RequiredSkills.Where(skill => user.GetSkillLevel(skill.Identifier) < Math.Round(skill.Level * SkillRequirementMultiplier)); + inadequateSkills = selectedRecipe.RequiredSkills.Where(skill => user.GetSkillLevel(skill.Identifier) < Math.Round(skill.Level * SkillRequirementMultiplier)); } - if (selectedItem.RequiredSkills.Any()) + if (selectedRecipe.RequiredSkills.Any()) { LocalizedString text = ""; new GUITextBlock(new RectTransform(new Vector2(1.0f, 0.0f), paddedReqFrame.RectTransform), @@ -965,20 +1160,20 @@ namespace Barotrauma.Items.Components AutoScaleHorizontal = true, ToolTip = TextManager.Get("fabricatorrequiredskills.tooltip") }; - foreach (Skill skill in selectedItem.RequiredSkills) + foreach (Skill skill in selectedRecipe.RequiredSkills) { text += TextManager.Get("SkillName." + skill.Identifier) + " " + TextManager.Get("Lvl").ToLower() + " " + Math.Round(skill.Level * SkillRequirementMultiplier); - if (skill != selectedItem.RequiredSkills.Last()) { text += "\n"; } + if (skill != selectedRecipe.RequiredSkills.Last()) { text += "\n"; } } new GUITextBlock(new RectTransform(new Vector2(1.0f, 0.0f), paddedReqFrame.RectTransform), text, font: GUIStyle.SmallFont); } - float degreeOfSuccess = user == null ? 0.0f : FabricationDegreeOfSuccess(user, selectedItem.RequiredSkills); + float degreeOfSuccess = user == null ? 0.0f : FabricationDegreeOfSuccess(user, selectedRecipe.RequiredSkills); if (degreeOfSuccess > 0.5f) { degreeOfSuccess = 1.0f; } float requiredTime = overrideRequiredTime.TryUnwrap(out var time) ? time - : (user == null ? selectedItem.RequiredTime : GetRequiredTime(selectedItem, user)); + : (user == null ? selectedRecipe.RequiredTime : GetRequiredTime(selectedRecipe, user)); if ((int)requiredTime > 0) { @@ -991,7 +1186,7 @@ namespace Barotrauma.Items.Components font: GUIStyle.SmallFont); } - if (SelectedItem.RequiredMoney > 0) + if (selectedRecipe.RequiredMoney > 0) { new GUITextBlock(new RectTransform(new Vector2(1.0f, 0.0f), paddedReqFrame.RectTransform), TextManager.Get("subeditor.price"), textColor: ToolBox.GradientLerp(degreeOfSuccess, GUIStyle.Red, Color.Yellow, GUIStyle.Green), font: GUIStyle.SubHeadingFont) @@ -1000,7 +1195,6 @@ namespace Barotrauma.Items.Components }; new GUITextBlock(new RectTransform(new Vector2(1.0f, 0.0f), paddedReqFrame.RectTransform), TextManager.FormatCurrency(SelectedItem.RequiredMoney), font: GUIStyle.SmallFont); - } } @@ -1068,6 +1262,15 @@ namespace Barotrauma.Items.Components if (!IsActive) { + if (outputContainer != null && outputContainer.Inventory.AllItems.Any()) + { + if (outputContainer.Inventory.visualSlots is { } visualSlots && visualSlots.Any() && + visualSlots[0].HighlightTimer <= 0.0f) + { + visualSlots[0].ShowBorderHighlight(GUIStyle.Green, 0.5f, 0.5f); + } + } + if (selectedItem != null && displayingForCharacter != character) { //reselect to recreate the info based on the new user's skills @@ -1101,8 +1304,14 @@ namespace Barotrauma.Items.Components activateButton.Enabled = canBeFabricated; } + bool sufficientSkills = FabricationDegreeOfSuccess(character, recipe.RequiredSkills) >= 0.5f; + + Color baseColor = MissingRequiredRecipe(recipe, character) ? + GUIStyle.Red : + (sufficientSkills ? GUIStyle.TextColorNormal : GUIStyle.Orange); + var childContainer = child.GetChild(); - childContainer.GetChild().TextColor = Color.White * (canBeFabricated ? 1.0f : 0.5f); + childContainer.GetChild().TextColor = baseColor * (canBeFabricated ? 1.0f : 0.5f); childContainer.GetChild().Color = recipe.TargetItem.InventoryIconColor * (canBeFabricated ? 1.0f : 0.5f); var limitReachedText = child.FindChild(nameof(FabricationLimitReachedText)); diff --git a/Barotrauma/BarotraumaClient/ClientSource/Items/Components/Machines/Sonar.cs b/Barotrauma/BarotraumaClient/ClientSource/Items/Components/Machines/Sonar.cs index 17f13953f..dc0d6dcbf 100644 --- a/Barotrauma/BarotraumaClient/ClientSource/Items/Components/Machines/Sonar.cs +++ b/Barotrauma/BarotraumaClient/ClientSource/Items/Components/Machines/Sonar.cs @@ -219,8 +219,9 @@ namespace Barotrauma.Items.Components Vector2 size = isConnectedToSteering ? controlBoxSize : new Vector2(0.46f, 0.4f); controlContainer = new GUIFrame(new RectTransform(size, GuiFrame.RectTransform, Anchor.BottomLeft), "ItemUI"); - if (!isConnectedToSteering && !GUI.IsFourByThree()) + if (!isConnectedToSteering && GUI.AspectRatioDifference <= 0) { + // In wider than 4:3 aspect ratio, we'll limit the max size. controlContainer.RectTransform.MaxSize = new Point((int)(380 * GUI.xScale), (int)(300 * GUI.yScale)); } var paddedControlContainer = new GUIFrame(new RectTransform(controlContainer.Rect.Size - GUIStyle.ItemFrameMargin, controlContainer.RectTransform, Anchor.Center) diff --git a/Barotrauma/BarotraumaClient/ClientSource/Items/Components/Machines/Steering.cs b/Barotrauma/BarotraumaClient/ClientSource/Items/Components/Machines/Steering.cs index eef3e3b4c..931672145 100644 --- a/Barotrauma/BarotraumaClient/ClientSource/Items/Components/Machines/Steering.cs +++ b/Barotrauma/BarotraumaClient/ClientSource/Items/Components/Machines/Steering.cs @@ -57,24 +57,42 @@ namespace Barotrauma.Items.Components private GUIMessageBox enterOutpostPrompt, exitOutpostPrompt; private bool levelStartSelected; + [Serialize(defaultValue: false, isSaveable: IsPropertySaveable.Yes, AlwaysUseInstanceValues = true)] public bool LevelStartSelected { - get { return levelStartTickBox.Selected; } - set { levelStartTickBox.Selected = value; } + get + { + return levelStartTickBox?.Selected ?? levelStartSelected; + } + set + { + TrySetTickBoxSelected(levelStartTickBox, ref levelStartSelected, value); + } } private bool levelEndSelected; + [Serialize(defaultValue: false, isSaveable: IsPropertySaveable.Yes, AlwaysUseInstanceValues = true)] public bool LevelEndSelected { - get { return levelEndTickBox.Selected; } - set { levelEndTickBox.Selected = value; } + get { return levelEndTickBox?.Selected ?? levelEndSelected; } + set + { + TrySetTickBoxSelected(levelEndTickBox, ref levelEndSelected, value); + } } private bool maintainPos; + [Serialize(defaultValue: false, isSaveable: IsPropertySaveable.Yes, AlwaysUseInstanceValues = true)] public bool MaintainPos { - get { return maintainPosTickBox.Selected; } - set { maintainPosTickBox.Selected = value; } + get + { + return maintainPosTickBox?.Selected ?? maintainPos; + } + set + { + TrySetTickBoxSelected(maintainPosTickBox, ref maintainPos, value); + } } private float steerRadius; @@ -924,6 +942,21 @@ namespace Barotrauma.Items.Components } } + /// + /// Sets the value of the specified tickbox, or if it hasn't been instantiated (yet?), just the value of the backing field. + /// + private void TrySetTickBoxSelected(GUITickBox tickBox, ref bool backingValue, bool newValue) + { + if (tickBox == null) + { + backingValue = newValue; + } + else + { + tickBox.Selected = newValue; + } + } + private bool NudgeButtonClicked(GUIButton btn, object userdata) { if (!MaintainPos || !AutoPilot) diff --git a/Barotrauma/BarotraumaClient/ClientSource/Items/Components/Projectile.cs b/Barotrauma/BarotraumaClient/ClientSource/Items/Components/Projectile.cs index 2fe30e473..4aa4c0ed4 100644 --- a/Barotrauma/BarotraumaClient/ClientSource/Items/Components/Projectile.cs +++ b/Barotrauma/BarotraumaClient/ClientSource/Items/Components/Projectile.cs @@ -180,7 +180,11 @@ namespace Barotrauma.Items.Components switch (subElement.Name.ToString().ToLowerInvariant()) { case "particleemitter": - particleEmitters.Add(new ParticleEmitter(subElement)); + var emitter = new ParticleEmitter(subElement); + //backwards compatibility: previously it was not possible to change if the particles use tracer points, they were always used on projectiles + //now emitters don't use them by default, except on projectiles + emitter.Prefab.Properties.UseTracerPoints = subElement.GetAttributeBool(nameof(emitter.Prefab.Properties.UseTracerPoints), true); + particleEmitters.Add(emitter); break; } } diff --git a/Barotrauma/BarotraumaClient/ClientSource/Items/Components/RepairTool.cs b/Barotrauma/BarotraumaClient/ClientSource/Items/Components/RepairTool.cs index 1b76a65ef..c7413f039 100644 --- a/Barotrauma/BarotraumaClient/ClientSource/Items/Components/RepairTool.cs +++ b/Barotrauma/BarotraumaClient/ClientSource/Items/Components/RepairTool.cs @@ -58,7 +58,6 @@ namespace Barotrauma.Items.Components } } - partial void UseProjSpecific(float deltaTime, Vector2 raystart) { foreach (ParticleEmitter particleEmitter in particleEmitters) @@ -88,25 +87,18 @@ namespace Barotrauma.Items.Components MathUtils.InverseLerp(targetStructure.Prefab.MinHealth, targetStructure.Health, targetStructure.Health - targetStructure.SectionDamage(sectionIndex)), GUIStyle.Red, GUIStyle.Green); - if (progressBar != null) progressBar.Size = new Vector2(60.0f, 20.0f); - - Vector2 particlePos = ConvertUnits.ToDisplayUnits(pickedPosition); - if (targetStructure.Submarine != null) particlePos += targetStructure.Submarine.DrawPosition; + if (progressBar != null) { progressBar.Size = new Vector2(60.0f, 20.0f); } foreach (var emitter in particleEmitterHitStructure) { - float particleAngle = item.body.Rotation + MathHelper.ToRadians(BarrelRotation) + ((item.body.Dir > 0.0f) ? 0.0f : MathHelper.Pi); - emitter.Emit(deltaTime, particlePos, item.CurrentHull, particleAngle + MathHelper.Pi, -particleAngle + MathHelper.Pi); + EmitParticle(emitter, deltaTime, pickedPosition, targetStructure.Submarine); } } partial void FixCharacterProjSpecific(Character user, float deltaTime, Character targetCharacter) { - Vector2 particlePos = ConvertUnits.ToDisplayUnits(pickedPosition); - if (targetCharacter.Submarine != null) particlePos += targetCharacter.Submarine.DrawPosition; foreach (var emitter in particleEmitterHitCharacter) { - float particleAngle = item.body.Rotation + MathHelper.ToRadians(BarrelRotation) + ((item.body.Dir > 0.0f) ? 0.0f : MathHelper.Pi); - emitter.Emit(deltaTime, particlePos, item.CurrentHull, particleAngle + MathHelper.Pi, -particleAngle + MathHelper.Pi); + EmitParticle(emitter, deltaTime, pickedPosition, targetCharacter.Submarine); } } @@ -134,15 +126,22 @@ namespace Barotrauma.Items.Components } } - Vector2 particlePos = ConvertUnits.ToDisplayUnits(pickedPosition); - if (targetItem.Submarine != null) particlePos += targetItem.Submarine.DrawPosition; foreach ((RelatedItem relatedItem, ParticleEmitter emitter) in particleEmitterHitItem) { if (!relatedItem.MatchesItem(targetItem)) { continue; } - float particleAngle = item.body.Rotation + MathHelper.ToRadians(BarrelRotation) + ((item.body.Dir > 0.0f) ? 0.0f : MathHelper.Pi); - emitter.Emit(deltaTime, particlePos, item.CurrentHull, particleAngle + MathHelper.Pi, -particleAngle + MathHelper.Pi); + EmitParticle(emitter, deltaTime, pickedPosition, targetItem.Submarine); } } + + private void EmitParticle(ParticleEmitter emitter, float deltaTime, Vector2 simPosition, Submarine targetSub) + { + Vector2 particlePos = ConvertUnits.ToDisplayUnits(simPosition); + if (targetSub != null) { particlePos += targetSub.DrawPosition; } + float particleAngle = item.body.Rotation + MathHelper.ToRadians(BarrelRotation) + ((item.body.Dir > 0.0f) ? 0.0f : MathHelper.Pi); + emitter.Emit(deltaTime, particlePos, item.CurrentHull, particleAngle + MathHelper.Pi, -particleAngle + MathHelper.Pi, + tracerPoints: new Tuple(item.WorldPosition + TransformedBarrelPos, particlePos)); + } + #if DEBUG public void Draw(SpriteBatch spriteBatch, bool editing, float itemDepth = -1, Color? overrideColor = null) { diff --git a/Barotrauma/BarotraumaClient/ClientSource/Items/Components/Signal/WifiComponent.cs b/Barotrauma/BarotraumaClient/ClientSource/Items/Components/Signal/WifiComponent.cs index e0e8ebd4a..7bbd26ea0 100644 --- a/Barotrauma/BarotraumaClient/ClientSource/Items/Components/Signal/WifiComponent.cs +++ b/Barotrauma/BarotraumaClient/ClientSource/Items/Components/Signal/WifiComponent.cs @@ -21,9 +21,14 @@ namespace Barotrauma.Items.Components ShapeExtensions.DrawCircle(spriteBatch, pos, range, 32, Color.Cyan * 0.5f, 3); } + public void ClientEventWrite(IWriteMessage msg, NetEntityEvent.IData extraData = null) + { + SharedEventWrite(msg); + } + public void ClientEventRead(IReadMessage msg, float sendingTime) { - Channel = msg.ReadRangedInteger(MinChannel, MaxChannel); + SharedEventRead(msg); } } } diff --git a/Barotrauma/BarotraumaClient/ClientSource/Items/Components/Signal/Wire.cs b/Barotrauma/BarotraumaClient/ClientSource/Items/Components/Signal/Wire.cs index 50707aa34..831a76783 100644 --- a/Barotrauma/BarotraumaClient/ClientSource/Items/Components/Signal/Wire.cs +++ b/Barotrauma/BarotraumaClient/ClientSource/Items/Components/Signal/Wire.cs @@ -106,13 +106,6 @@ namespace Barotrauma.Items.Components private static int? selectedNodeIndex; private static int? highlightedNodeIndex; - [Serialize(0.3f, IsPropertySaveable.No), Editable(MinValueFloat = 0.01f, MaxValueFloat = 10.0f, DecimalCount = 2)] - public float Width - { - get; - set; - } - public Vector2 DrawSize { get { return sectionExtents; } diff --git a/Barotrauma/BarotraumaClient/ClientSource/Items/Components/Turret.cs b/Barotrauma/BarotraumaClient/ClientSource/Items/Components/Turret.cs index 61f940b36..885af0a17 100644 --- a/Barotrauma/BarotraumaClient/ClientSource/Items/Components/Turret.cs +++ b/Barotrauma/BarotraumaClient/ClientSource/Items/Components/Turret.cs @@ -91,18 +91,19 @@ namespace Barotrauma.Items.Components { get { - float size = Math.Max(transformedBarrelPos.X, transformedBarrelPos.Y); - if (barrelSprite != null) + float size = Math.Max(transformedBarrelPos.X, transformedBarrelPos.Y); + if (railSprite != null && barrelSprite != null) { - if (railSprite != null) - { - size += Math.Max(Math.Max(barrelSprite.size.X, barrelSprite.size.Y), Math.Max(railSprite.size.X, railSprite.size.Y)) * item.Scale; - } - else - { - size += Math.Max(barrelSprite.size.X, barrelSprite.size.Y) * item.Scale; - } + size += Math.Max(Math.Max(barrelSprite.size.X, barrelSprite.size.Y), Math.Max(railSprite.size.X, railSprite.size.Y)) * item.Scale; } + else if (railSprite != null) + { + size += Math.Max(railSprite.size.X, railSprite.size.Y) * item.Scale; + } + else if (barrelSprite != null) + { + size += Math.Max(barrelSprite.size.X, barrelSprite.size.Y) * item.Scale; + } return Vector2.One * size * 2; } } diff --git a/Barotrauma/BarotraumaClient/ClientSource/Items/Inventory.cs b/Barotrauma/BarotraumaClient/ClientSource/Items/Inventory.cs index 3df2effc7..0c73cf49e 100644 --- a/Barotrauma/BarotraumaClient/ClientSource/Items/Inventory.cs +++ b/Barotrauma/BarotraumaClient/ClientSource/Items/Inventory.cs @@ -1603,7 +1603,8 @@ namespace Barotrauma shadowSprite.Draw(spriteBatch, new Rectangle(itemPos.ToPoint() - new Point((iconSize / 2 - shadowPadding.X) * textDir - shadowSize.X * textOffset, iconSize / 2 + shadowPadding.Y), shadowSize), Color.Black * 0.8f); - GUI.DrawString(spriteBatch, textPos + new Vector2(nameSize.X * textOffset, -iconSize / 2), DraggingItems.First().Name, Color.White); + var richString = RichString.Rich(DraggingItems.First().Name); + GUI.DrawStringWithColors(spriteBatch, textPos + new Vector2(nameSize.X * textOffset, -iconSize / 2), richString.SanitizedValue, Color.White, richString.RichTextData); GUI.DrawString(spriteBatch, textPos + new Vector2(toolTipSize.X * textOffset, 0), toolTip, color: toolTipColor, font: GUIStyle.SmallFont); diff --git a/Barotrauma/BarotraumaClient/ClientSource/Items/Item.cs b/Barotrauma/BarotraumaClient/ClientSource/Items/Item.cs index 0759cc603..e70235e79 100644 --- a/Barotrauma/BarotraumaClient/ClientSource/Items/Item.cs +++ b/Barotrauma/BarotraumaClient/ClientSource/Items/Item.cs @@ -676,12 +676,6 @@ namespace Barotrauma origin.Y = -origin.Y + decorativeSprite.Sprite.size.Y; spriteEffects |= SpriteEffects.FlipVertically; } - if (body != null) - { - var ca = MathF.Cos(-body.DrawRotation); - var sa = MathF.Sin(-body.DrawRotation); - offset = new Vector2(ca * offset.X + sa * offset.Y, -sa * offset.X + ca * offset.Y); - } decorativeSprite.Sprite.Draw(spriteBatch, new Vector2(drawPos.X + offset.X, -(drawPos.Y + offset.Y)), decorativeSpriteColor, origin, -rotation + spriteRotation, decorativeSprite.GetScale(ref spriteAnimState[decorativeSprite].ScaleState, spriteAnimState[decorativeSprite].RandomScaleFactor) * Scale, spriteEffects, depth: depth + (decorativeSprite.Sprite.Depth - activeSprite.Depth)); @@ -798,6 +792,11 @@ namespace Barotrauma } } } + + foreach (var containedItem in ContainedItems) + { + containedItem.UpdateSpriteStates(deltaTime); + } } public override void UpdateEditing(Camera cam, float deltaTime) @@ -1762,7 +1761,7 @@ namespace Barotrauma if (texts.Any() && !recreateHudTexts) { return texts; } texts.Clear(); - string nameText = Name; + string nameText = RichString.Rich(Prefab.Name).SanitizedValue; if (Prefab.Tags.Contains("identitycard") || Tags.Contains("despawncontainer")) { string[] readTags = Tags.Split(','); @@ -2502,18 +2501,24 @@ namespace Barotrauma if (inventory != null) { - if (inventorySlotIndex is >= 0 and < 255) + if (inventorySlotIndex is >= 0 and < 255 && + !inventory.TryPutItem(item, inventorySlotIndex, allowSwapping: false, allowCombine: false, user: null, createNetworkEvent: false, ignoreCondition: true) && + inventory.IsSlotEmpty(inventorySlotIndex)) { - if (!inventory.TryPutItem(item, inventorySlotIndex, allowSwapping: false, allowCombine: false, user: null, createNetworkEvent: false, ignoreCondition: true) && - inventory.IsSlotEmpty(inventorySlotIndex)) - { - //If the item won't go nicely, force it to the slot. If the server says the item is in the slot, it should go in the slot. - //May happen e.g. when a character is configured to spawn with an item that won't normally go in its inventory slots. - inventory.ForceToSlot(item, index: inventorySlotIndex); - return item; - } + //If the item won't go nicely, force it to the slot. If the server says the item is in the slot, it should go in the slot. + //May happen e.g. when a character is configured to spawn with an item that won't normally go in its inventory slots. + inventory.ForceToSlot(item, index: inventorySlotIndex); } - inventory.TryPutItem(item, user: null, allowedSlots: item.AllowedSlots, createNetworkEvent: false); + else + { + inventory.TryPutItem(item, user: null, allowedSlots: item.AllowedSlots, createNetworkEvent: false); + } + item.SetTransform(inventory.Owner.SimPosition, 0.0f); + item.Submarine = inventory.Owner.Submarine; + if (inventory.Owner is Character { Enabled: false } && item.body != null) + { + item.body.Enabled = false; + } } return item; diff --git a/Barotrauma/BarotraumaClient/ClientSource/Map/Levels/BackgroundCreatures/BackgroundCreature.cs b/Barotrauma/BarotraumaClient/ClientSource/Map/Levels/BackgroundCreatures/BackgroundCreature.cs index 7068a7ed6..cecbcef80 100644 --- a/Barotrauma/BarotraumaClient/ClientSource/Map/Levels/BackgroundCreatures/BackgroundCreature.cs +++ b/Barotrauma/BarotraumaClient/ClientSource/Map/Levels/BackgroundCreatures/BackgroundCreature.cs @@ -26,7 +26,7 @@ namespace Barotrauma private Vector3 velocity; - private float depth; + public float Depth { get; private set; } private float alpha = 1.0f; @@ -42,6 +42,8 @@ namespace Barotrauma Vector2 drawPosition; + private bool flippedHorizontally; + public Vector2[,] CurrentSpriteDeformation { get; @@ -88,6 +90,8 @@ namespace Barotrauma Rand.Range(-prefab.Speed, prefab.Speed, Rand.RandSync.ClientOnly), Rand.Range(0.0f, prefab.WanderZAmount, Rand.RandSync.ClientOnly)); + Depth = Rand.Range(prefab.MinDepth, prefab.MaxDepth, Rand.RandSync.ClientOnly); + checkWallsTimer = Rand.Range(0.0f, CheckWallsInterval, Rand.RandSync.ClientOnly); foreach (var subElement in prefab.Config.Elements()) @@ -104,6 +108,7 @@ namespace Barotrauma default: continue; } + int j = 0; foreach (XElement animationElement in subElement.Elements()) { SpriteDeformation deformation = null; @@ -118,7 +123,21 @@ namespace Barotrauma deformation = SpriteDeformation.Load(animationElement, prefab.Name); if (deformation != null) { + deformation.Params = Prefab.SpriteDeformations[j].Params; uniqueSpriteDeformations.Add(deformation); + if (prefab.DeformableSprite != null) + { + if (deformation.Resolution.X > prefab.DeformableSprite.Subdivisions.X || + deformation.Resolution.Y > prefab.DeformableSprite.Subdivisions.Y) + { + DebugConsole.AddWarning( + $"Potential error in background creature {Prefab.Identifier}: deformation {deformation.GetType()} has a larger resolution ({deformation.Resolution})"+ + $" than the amount of subdivisions on the deformable sprite ({prefab.DeformableSprite.Subdivisions}). Should the sprite be subdivided further to make full use of the deformation?", + contentPackage: Prefab.ContentPackage); + } + } + + j++; } } if (deformation != null) @@ -127,12 +146,14 @@ namespace Barotrauma } } } + + flashTimer = Rand.Range(0.0f, prefab.FlashInterval, Rand.RandSync.Unsynced); } public void Update(float deltaTime) { position += new Vector2(velocity.X, velocity.Y) * deltaTime; - depth = MathHelper.Clamp(depth + velocity.Z * deltaTime, Prefab.MinDepth, Prefab.MaxDepth * 10); + Depth = MathHelper.Clamp(Depth + velocity.Z * deltaTime, Prefab.MinDepth, Prefab.MaxDepth); if (Prefab.FlashInterval > 0.0f) { @@ -144,7 +165,7 @@ namespace Barotrauma else { //value goes from 0 to 1 and back to 0 during the flash - alpha = (float)Math.Sin(-flashTimer / Prefab.FlashDuration * MathHelper.Pi) * PerlinNoise.GetPerlin((float)Timing.TotalTime * 0.1f, (float)Timing.TotalTime * 0.2f); + alpha = (float)Math.Sin(-flashTimer / Prefab.FlashDuration * MathHelper.Pi) * PerlinNoise.GetPerlin((float)Timing.TotalTime, (float)Timing.TotalTime * 0.5f); if (flashTimer < -Prefab.FlashDuration) { flashTimer = Prefab.FlashInterval; @@ -228,7 +249,13 @@ namespace Barotrauma velocity = Vector3.Lerp(velocity, new Vector3(Steering.X, Steering.Y, velocity.Z), deltaTime); - UpdateDeformations(deltaTime); + //only flip if there's some horizontal movement speed (10% of the creature's speed) + //otherwise a creature swimming roughly up/down can flip around very frequently when the horizontal speed fluctuates around 0 + if (Math.Abs(velocity.X) > Prefab.Speed * 0.1f) + { + flippedHorizontally = !Prefab.DisableFlipping && velocity.X < 0.0f; + } + UpdateDeformations(deltaTime, flippedHorizontally); } public void DrawLightSprite(SpriteBatch spriteBatch, Camera cam) @@ -238,12 +265,17 @@ namespace Barotrauma public void Draw(SpriteBatch spriteBatch, Camera cam) { - Draw(spriteBatch, - cam, - Prefab.Sprite, - Prefab.DeformableSprite, - CurrentSpriteDeformation, - Color.Lerp(Color.White, Level.Loaded.BackgroundColor, depth / Math.Max(MaxDepth, Prefab.MaxDepth)) * alpha); + Color color = + Prefab.FadeOut ? + Color.Lerp(Color.White, Level.Loaded.BackgroundColor, Depth / Prefab.FadeOutDepth) * alpha : + Color.White * alpha; + + Draw(spriteBatch, + cam, + Prefab.Sprite, + Prefab.DeformableSprite, + CurrentSpriteDeformation, + color); } private void Draw(SpriteBatch spriteBatch, Camera cam, Sprite sprite, DeformableSprite deformableSprite, Vector2[,] currentSpriteDeformation, Color color) @@ -255,7 +287,7 @@ namespace Barotrauma if (!Prefab.DisableRotation) { rotation = MathUtils.VectorToAngle(new Vector2(velocity.X, -velocity.Y)); - if (velocity.X < 0.0f) { rotation -= MathHelper.Pi; } + if (flippedHorizontally) { rotation -= MathHelper.Pi; } } drawPosition = GetDrawPosition(cam); @@ -266,8 +298,8 @@ namespace Barotrauma color, rotation, scale, - Prefab.DisableFlipping || velocity.X > 0.0f ? SpriteEffects.None : SpriteEffects.FlipHorizontally, - Math.Min(depth / MaxDepth, 1.0f)); + flippedHorizontally ? SpriteEffects.FlipHorizontally : SpriteEffects.None, + Math.Min(Depth / MaxDepth, 1.0f)); if (deformableSprite != null) { @@ -280,29 +312,29 @@ namespace Barotrauma deformableSprite.Reset(); } deformableSprite?.Draw(cam, - new Vector3(drawPosition.X, drawPosition.Y, Math.Min(depth / 10000.0f, 1.0f)), + new Vector3(drawPosition.X, drawPosition.Y, Math.Min(Depth / 10000.0f, 1.0f)), deformableSprite.Origin, rotation, Vector2.One * scale, color, - mirror: Prefab.DisableFlipping || velocity.X <= 0.0f); + mirror: flippedHorizontally); } } public Vector2 GetDrawPosition(Camera cam) { Vector2 drawPosition = WorldPosition; - if (depth >= 0) + if (Depth >= 0) { Vector2 camOffset = drawPosition - cam.WorldViewCenter; - drawPosition -= camOffset * depth / MaxDepth; + drawPosition -= camOffset * Depth / MaxDepth; } return drawPosition; } public float GetScale() { - return Math.Max(1.0f - depth / MaxDepth, 0.05f) * Prefab.Scale; + return Math.Max(1.0f - Depth / MaxDepth, 0.05f) * Prefab.Scale; } public Rectangle GetExtents(Camera cam) @@ -328,7 +360,7 @@ namespace Barotrauma } } - private void UpdateDeformations(float deltaTime) + private void UpdateDeformations(float deltaTime, bool flippedHorizontally) { foreach (SpriteDeformation deformation in uniqueSpriteDeformations) { @@ -336,11 +368,13 @@ namespace Barotrauma } if (spriteDeformations.Count > 0) { - CurrentSpriteDeformation = SpriteDeformation.GetDeformation(spriteDeformations, Prefab.DeformableSprite.Size); + CurrentSpriteDeformation = SpriteDeformation.GetDeformation(spriteDeformations, Prefab.DeformableSprite.Size, + flippedHorizontally: flippedHorizontally); } if (lightSpriteDeformations.Count > 0) { - CurrentLightSpriteDeformation = SpriteDeformation.GetDeformation(lightSpriteDeformations, Prefab.DeformableLightSprite.Size); + CurrentLightSpriteDeformation = SpriteDeformation.GetDeformation(lightSpriteDeformations, Prefab.DeformableLightSprite.Size, + flippedHorizontally: flippedHorizontally); } } } diff --git a/Barotrauma/BarotraumaClient/ClientSource/Map/Levels/BackgroundCreatures/BackgroundCreatureManager.cs b/Barotrauma/BarotraumaClient/ClientSource/Map/Levels/BackgroundCreatures/BackgroundCreatureManager.cs index 1bdf40355..d997412ac 100644 --- a/Barotrauma/BarotraumaClient/ClientSource/Map/Levels/BackgroundCreatures/BackgroundCreatureManager.cs +++ b/Barotrauma/BarotraumaClient/ClientSource/Map/Levels/BackgroundCreatures/BackgroundCreatureManager.cs @@ -15,18 +15,19 @@ namespace Barotrauma private float checkVisibleTimer; - private readonly List prefabs = new List(); private readonly List creatures = new List(); - public BackgroundCreatureManager(IEnumerable files) + private readonly List visibleCreatures = new List(); + + public BackgroundCreatureManager() { - foreach(var file in files) + /*foreach(var file in files) { LoadConfig(file.Path); - } + }*/ } - public BackgroundCreatureManager(string path) + /*public BackgroundCreatureManager(string path) { DebugConsole.AddWarning($"Couldn't find any BackgroundCreaturePrefabs files, falling back to {path}"); LoadConfig(ContentPath.FromRaw(null, path)); @@ -42,35 +43,34 @@ namespace Barotrauma if (mainElement.IsOverride()) { mainElement = mainElement.FirstElement(); - prefabs.Clear(); + Prefabs.Clear(); DebugConsole.NewMessage($"Overriding all background creatures with '{configPath}'", Color.MediumPurple); } - else if (prefabs.Any()) + else if (Prefabs.Any()) { DebugConsole.NewMessage($"Loading additional background creatures from file '{configPath}'"); } foreach (var element in mainElement.Elements()) { - prefabs.Add(new BackgroundCreaturePrefab(element)); + Prefabs.Add(new BackgroundCreaturePrefab(element)); }; } catch (Exception e) { DebugConsole.ThrowError(String.Format("Failed to load BackgroundCreatures from {0}", configPath), e); } - } + }*/ public void SpawnCreatures(Level level, int count, Vector2? position = null) { creatures.Clear(); - if (prefabs.Count == 0) { return; } + List availablePrefabs = new List(BackgroundCreaturePrefab.Prefabs.OrderBy(p => p.Identifier.Value)); + if (availablePrefabs.Count == 0) { return; } count = Math.Min(count, MaxCreatures); - List availablePrefabs = new List(prefabs); - for (int i = 0; i < count; i++) { Vector2 pos = Vector2.Zero; @@ -93,7 +93,7 @@ namespace Barotrauma pos = (Vector2)position; } - var prefab = ToolBox.SelectWeightedRandom(availablePrefabs, availablePrefabs.Select(p => p.GetCommonness(level.GenerationParams)).ToList(), Rand.RandSync.ClientOnly); + var prefab = ToolBox.SelectWeightedRandom(availablePrefabs, availablePrefabs.Select(p => p.GetCommonness(level?.LevelData)).ToList(), Rand.RandSync.ClientOnly); if (prefab == null) { break; } int amount = Rand.Range(prefab.SwarmMin, prefab.SwarmMax + 1, Rand.RandSync.ClientOnly); @@ -125,16 +125,27 @@ namespace Barotrauma { if (checkVisibleTimer < 0.0f) { + visibleCreatures.Clear(); int margin = 500; foreach (BackgroundCreature creature in creatures) { Rectangle extents = creature.GetExtents(cam); - bool wasVisible = creature.Visible; creature.Visible = extents.Right >= cam.WorldView.X - margin && extents.X <= cam.WorldView.Right + margin && extents.Bottom >= cam.WorldView.Y - cam.WorldView.Height - margin && extents.Y <= cam.WorldView.Y + margin; + if (creature.Visible) + { + //insertion sort according to depth + int i = 0; + while (i < visibleCreatures.Count) + { + if (visibleCreatures[i].Depth < creature.Depth) { break; } + i++; + } + visibleCreatures.Insert(i, creature); + } } checkVisibleTimer = VisibilityCheckInterval; @@ -144,27 +155,24 @@ namespace Barotrauma checkVisibleTimer -= deltaTime; } - foreach (BackgroundCreature creature in creatures) + foreach (BackgroundCreature creature in visibleCreatures) { - if (!creature.Visible) { continue; } creature.Update(deltaTime); } } public void Draw(SpriteBatch spriteBatch, Camera cam) { - foreach (BackgroundCreature creature in creatures) + foreach (BackgroundCreature creature in visibleCreatures) { - if (!creature.Visible) { continue; } creature.Draw(spriteBatch, cam); } } public void DrawLights(SpriteBatch spriteBatch, Camera cam) { - foreach (BackgroundCreature creature in creatures) + foreach (BackgroundCreature creature in visibleCreatures) { - if (!creature.Visible) { continue; } creature.DrawLightSprite(spriteBatch, cam); } } diff --git a/Barotrauma/BarotraumaClient/ClientSource/Map/Levels/BackgroundCreatures/BackgroundCreaturePrefab.cs b/Barotrauma/BarotraumaClient/ClientSource/Map/Levels/BackgroundCreatures/BackgroundCreaturePrefab.cs index 7c9ef97f8..0ae0019ba 100644 --- a/Barotrauma/BarotraumaClient/ClientSource/Map/Levels/BackgroundCreatures/BackgroundCreaturePrefab.cs +++ b/Barotrauma/BarotraumaClient/ClientSource/Map/Levels/BackgroundCreatures/BackgroundCreaturePrefab.cs @@ -1,65 +1,91 @@ -using System.Collections.Generic; +using Barotrauma.SpriteDeformations; +using System.Collections.Generic; using System.Xml.Linq; namespace Barotrauma { - class BackgroundCreaturePrefab + class BackgroundCreaturePrefab : Prefab, ISerializableEntity { - public readonly Sprite Sprite, LightSprite; - public readonly DeformableSprite DeformableSprite, DeformableLightSprite; + public readonly static PrefabCollection Prefabs = new PrefabCollection(); - public readonly string Name; + public Sprite Sprite { get; private set; } + public Sprite LightSprite { get; private set; } + public DeformableSprite DeformableSprite { get; private set; } + public DeformableSprite DeformableLightSprite { get; private set; } + + private readonly string name; public readonly XElement Config; - [Serialize(1.0f, IsPropertySaveable.Yes)] + [Serialize(1.0f, IsPropertySaveable.Yes), Editable(MinValueFloat = 0.0f, MaxValueFloat = 1000.0f)] public float Speed { get; private set; } - [Serialize(0.0f, IsPropertySaveable.Yes)] + [Serialize(0.0f, IsPropertySaveable.Yes), Editable(MinValueFloat = 0.0f, MaxValueFloat = 1000.0f, DecimalCount = 3)] public float WanderAmount { get; private set; } - [Serialize(0.0f, IsPropertySaveable.Yes)] + [Serialize(0.0f, IsPropertySaveable.Yes), Editable(MinValueFloat = 0.0f, MaxValueFloat = 100.0f, DecimalCount = 3)] public float WanderZAmount { get; private set; } - [Serialize(1, IsPropertySaveable.Yes)] + [Serialize(1, IsPropertySaveable.Yes), Editable(MinValueInt = 0, MaxValueInt = 1000)] public int SwarmMin { get; private set; } - [Serialize(1, IsPropertySaveable.Yes)] + [Serialize(1, IsPropertySaveable.Yes), Editable(MinValueInt = 0, MaxValueInt = 1000)] public int SwarmMax { get; private set; } - [Serialize(200.0f, IsPropertySaveable.Yes)] + [Serialize(200.0f, IsPropertySaveable.Yes), Editable(MinValueFloat = 0.0f, MaxValueFloat = 10000.0f)] public float SwarmRadius { get; private set; } - [Serialize(0.2f, IsPropertySaveable.Yes)] + [Serialize(0.2f, IsPropertySaveable.Yes), Editable(MinValueFloat = 0.0f, MaxValueFloat = 10.0f)] public float SwarmCohesion { get; private set; } - [Serialize(10.0f, IsPropertySaveable.Yes)] + [Serialize(10.0f, IsPropertySaveable.Yes), Editable(MinValueFloat = 0.0f, MaxValueFloat = 10000.0f)] public float MinDepth { get; private set; } - [Serialize(1000.0f, IsPropertySaveable.Yes)] + [Serialize(1000.0f, IsPropertySaveable.Yes), Editable(MinValueFloat = 0.0f, MaxValueFloat = 10000.0f)] public float MaxDepth { get; private set; } - [Serialize(false, IsPropertySaveable.Yes)] + [Serialize(10000.0f, IsPropertySaveable.Yes, description: "Creatures fade out to the background color of the level the further they are from the camera. This value is the depth at which the object becomes \"maximally\" faded out."), Editable] + public float FadeOutDepth + { + get; + private set; + } + [Serialize(true, IsPropertySaveable.Yes), Editable] + public bool FadeOut { get; private set; } + + [Serialize(false, IsPropertySaveable.Yes), Editable] public bool DisableRotation { get; private set; } - [Serialize(false, IsPropertySaveable.Yes)] + [Serialize(false, IsPropertySaveable.Yes), Editable] public bool DisableFlipping { get; private set; } - [Serialize(1.0f, IsPropertySaveable.Yes)] + [Serialize(1.0f, IsPropertySaveable.Yes), Editable(MinValueFloat = 0.0f, MaxValueFloat = 100.0f)] public float Scale { get; private set; } - [Serialize(1.0f, IsPropertySaveable.Yes)] + [Serialize(1.0f, IsPropertySaveable.Yes), Editable(MinValueFloat = 0.0f, MaxValueFloat = 100.0f)] public float Commonness { get; private set; } - [Serialize(1000, IsPropertySaveable.Yes)] + [Serialize(1000, IsPropertySaveable.Yes), Editable(MinValueInt = 0, MaxValueInt = 1000)] public int MaxCount { get; private set; } - [Serialize(0.0f, IsPropertySaveable.Yes)] + [Serialize(0.0f, IsPropertySaveable.Yes), Editable(MinValueFloat = 0.0f, MaxValueFloat = 1000.0f)] public float FlashInterval { get; private set; } - [Serialize(0.0f, IsPropertySaveable.Yes)] + [Serialize(0.0f, IsPropertySaveable.Yes), Editable(MinValueFloat = 0.0f, MaxValueFloat = 1000.0f)] public float FlashDuration { get; private set; } + public string Name => name; + + public Dictionary SerializableProperties { get; private set; } + + /// + /// Only used for editing sprite deformation parameters. The actual LevelObjects use separate SpriteDeformation instances. + /// + public List SpriteDeformations + { + get; + private set; + } = new List(); /// /// Overrides the commonness of the object in a specific level type. @@ -67,13 +93,13 @@ namespace Barotrauma /// public Dictionary OverrideCommonness = new Dictionary(); - public BackgroundCreaturePrefab(ContentXElement element) + public BackgroundCreaturePrefab(ContentXElement element, BackgroundCreaturePrefabsFile file) : base(file, ParseIdentifier(element)) { - Name = element.Name.ToString(); + name = element.Name.ToString(); Config = element; - SerializableProperty.DeserializeProperties(this, element); + SerializableProperties = SerializableProperty.DeserializeProperties(this, element); foreach (var subElement in element.Elements()) { @@ -84,6 +110,14 @@ namespace Barotrauma break; case "deformablesprite": DeformableSprite = new DeformableSprite(subElement, lazyLoad: true); + foreach (XElement deformElement in subElement.Elements()) + { + var deformation = SpriteDeformation.Load(deformElement, Name); + if (deformation != null) + { + SpriteDeformations.Add(deformation); + } + } break; case "lightsprite": LightSprite = new Sprite(subElement, lazyLoad: true); @@ -102,17 +136,42 @@ namespace Barotrauma } } - public float GetCommonness(LevelGenerationParams generationParams) + public static Identifier ParseIdentifier(XElement element) { - if (generationParams != null && - !generationParams.Identifier.IsEmpty && - (OverrideCommonness.TryGetValue(generationParams.Identifier, out float commonness) || - (!generationParams.OldIdentifier.IsEmpty && OverrideCommonness.TryGetValue(generationParams.OldIdentifier, out commonness)))) + Identifier identifier = element.GetAttributeIdentifier("identifier", ""); + if (identifier.IsEmpty) + { + identifier = element.NameAsIdentifier(); + } + return identifier; + } + + public float GetCommonness(LevelData levelData) + { + if (levelData?.GenerationParams is not { } generationParams || generationParams.Identifier.IsEmpty) + { + return Commonness; + } + + if (OverrideCommonness.TryGetValue(generationParams.Identifier, out float commonness) || (!generationParams.OldIdentifier.IsEmpty && OverrideCommonness.TryGetValue(generationParams.OldIdentifier, out commonness)) || + OverrideCommonness.TryGetValue(levelData.Biome.Identifier, out commonness)) { return commonness; } return Commonness; } + + public override void Dispose() + { + Sprite?.Remove(); + Sprite = null; + LightSprite?.Remove(); + LightSprite = null; + DeformableLightSprite?.Remove(); + DeformableLightSprite = null; + DeformableSprite?.Remove(); + DeformableSprite = null; + } } } diff --git a/Barotrauma/BarotraumaClient/ClientSource/Map/Levels/LevelObjects/LevelObject.cs b/Barotrauma/BarotraumaClient/ClientSource/Map/Levels/LevelObjects/LevelObject.cs index 91a25af00..300427deb 100644 --- a/Barotrauma/BarotraumaClient/ClientSource/Map/Levels/LevelObjects/LevelObject.cs +++ b/Barotrauma/BarotraumaClient/ClientSource/Map/Levels/LevelObjects/LevelObject.cs @@ -167,7 +167,7 @@ namespace Barotrauma Prefab.OverrideProperties.Any(p => p != null && (p.Sprites.Any() || p.DeformableSprite != null)); } - public void Update(float deltaTime) + public void Update(float deltaTime, Camera cam) { CurrentRotation = Rotation; if (ActivePrefab.SwingFrequency > 0.0f) @@ -190,20 +190,24 @@ namespace Barotrauma ScaleOscillateTimer += deltaTime * ActivePrefab.ScaleOscillationFrequency; ScaleOscillateTimer = ScaleOscillateTimer % MathHelper.TwoPi; CurrentScaleOscillation = Vector2.Lerp(CurrentScaleOscillation, ActivePrefab.ScaleOscillation, deltaTime * 10.0f); - + float sin = (float)Math.Sin(ScaleOscillateTimer); CurrentScale *= new Vector2( 1.0f + sin * CurrentScaleOscillation.X, - 1.0f + sin * CurrentScaleOscillation.Y); + 1.0f + sin * CurrentScaleOscillation.Y); } if (LightSources != null) { + Vector2 position2D = new Vector2(Position.X, Position.Y); + Vector2 camDiff = position2D - cam.WorldViewCenter; for (int i = 0; i < LightSources.Length; i++) { - if (LightSourceTriggers[i] != null) LightSources[i].Enabled = LightSourceTriggers[i].IsTriggered; + if (LightSourceTriggers[i] != null) { LightSources[i].Enabled = LightSourceTriggers[i].IsTriggered; } LightSources[i].Rotation = -CurrentRotation; LightSources[i].SpriteScale = CurrentScale; + LightSources[i].Position = + position2D - camDiff * Position.Z * LevelObjectManager.ParallaxStrength; } } @@ -237,7 +241,10 @@ namespace Barotrauma { SoundChannels[i] = roundSound.Sound.Play(roundSound.Volume, roundSound.Range, roundSound.GetRandomFrequencyMultiplier(), soundPos); } - SoundChannels[i].Position = new Vector3(soundPos.X, soundPos.Y, 0.0f); + if (SoundChannels[i] != null) + { + SoundChannels[i].Position = new Vector3(soundPos.X, soundPos.Y, 0.0f); + } } } else if (SoundChannels[i] != null && SoundChannels[i].IsPlaying) @@ -259,7 +266,7 @@ namespace Barotrauma } deformation.Update(deltaTime); } - CurrentSpriteDeformation = SpriteDeformation.GetDeformation(spriteDeformations, ActivePrefab.DeformableSprite.Size); + CurrentSpriteDeformation = SpriteDeformation.GetDeformation(spriteDeformations, ActivePrefab.DeformableSprite.Size, flippedHorizontally: false); if (LightSources != null) { foreach (LightSource lightSource in LightSources) diff --git a/Barotrauma/BarotraumaClient/ClientSource/Map/Levels/LevelObjects/LevelObjectManager.cs b/Barotrauma/BarotraumaClient/ClientSource/Map/Levels/LevelObjects/LevelObjectManager.cs index bdb3ba290..f4b48feb5 100644 --- a/Barotrauma/BarotraumaClient/ClientSource/Map/Levels/LevelObjects/LevelObjectManager.cs +++ b/Barotrauma/BarotraumaClient/ClientSource/Map/Levels/LevelObjects/LevelObjectManager.cs @@ -33,19 +33,19 @@ namespace Barotrauma visibleObjectsFront.Clear(); } - partial void UpdateProjSpecific(float deltaTime) + partial void UpdateProjSpecific(float deltaTime, Camera cam) { foreach (LevelObject obj in visibleObjectsBack) { - obj.Update(deltaTime); + obj.Update(deltaTime, cam); } foreach (LevelObject obj in visibleObjectsMid) { - obj.Update(deltaTime); + obj.Update(deltaTime, cam); } foreach (LevelObject obj in visibleObjectsFront) { - obj.Update(deltaTime); + obj.Update(deltaTime, cam); } } @@ -225,7 +225,7 @@ namespace Barotrauma activeSprite?.Draw( spriteBatch, new Vector2(obj.Position.X, -obj.Position.Y) - camDiff * obj.Position.Z * ParallaxStrength, - Color.Lerp(obj.Prefab.SpriteColor, obj.Prefab.SpriteColor.Multiply(Level.Loaded.BackgroundTextureColor), obj.Position.Z / 3000.0f), + Color.Lerp(obj.Prefab.SpriteColor, obj.Prefab.SpriteColor.Multiply(Level.Loaded.BackgroundTextureColor), obj.Position.Z / obj.Prefab.FadeOutDepth), activeSprite.Origin, obj.CurrentRotation, obj.CurrentScale, diff --git a/Barotrauma/BarotraumaClient/ClientSource/Map/Levels/LevelRenderer.cs b/Barotrauma/BarotraumaClient/ClientSource/Map/Levels/LevelRenderer.cs index 3d7029c7a..02a3d3834 100644 --- a/Barotrauma/BarotraumaClient/ClientSource/Map/Levels/LevelRenderer.cs +++ b/Barotrauma/BarotraumaClient/ClientSource/Map/Levels/LevelRenderer.cs @@ -196,10 +196,7 @@ namespace Barotrauma if (flashCooldown <= 0.0f) { flashTimer = 1.0f; - if (level.GenerationParams.FlashSound != null) - { - level.GenerationParams.FlashSound.Play(1.0f, "default"); - } + level.GenerationParams.FlashSound?.Play(1.0f, Sounds.SoundManager.SoundCategoryDefault); flashCooldown = Rand.Range(level.GenerationParams.FlashInterval.X, level.GenerationParams.FlashInterval.Y, Rand.RandSync.Unsynced); } if (flashTimer > 0.0f) diff --git a/Barotrauma/BarotraumaClient/ClientSource/Map/Lights/LightManager.cs b/Barotrauma/BarotraumaClient/ClientSource/Map/Lights/LightManager.cs index 4180c890b..b65723a52 100644 --- a/Barotrauma/BarotraumaClient/ClientSource/Map/Lights/LightManager.cs +++ b/Barotrauma/BarotraumaClient/ClientSource/Map/Lights/LightManager.cs @@ -274,7 +274,7 @@ namespace Barotrauma.Lights { light.ParentBody.UpdateDrawPosition(); - Vector2 pos = light.ParentBody.DrawPosition; + Vector2 pos = light.ParentBody.DrawPosition + light.OffsetFromBody; if (light.ParentSub != null) { pos -= light.ParentSub.DrawPosition; } light.Position = pos; } diff --git a/Barotrauma/BarotraumaClient/ClientSource/Map/Lights/LightSource.cs b/Barotrauma/BarotraumaClient/ClientSource/Map/Lights/LightSource.cs index c8fcda5c3..e37bd5193 100644 --- a/Barotrauma/BarotraumaClient/ClientSource/Map/Lights/LightSource.cs +++ b/Barotrauma/BarotraumaClient/ClientSource/Map/Lights/LightSource.cs @@ -450,6 +450,8 @@ namespace Barotrauma.Lights set; } + public Vector2 OffsetFromBody; + public DeformableSprite DeformableLightSprite { get; @@ -561,9 +563,9 @@ namespace Barotrauma.Lights // center is in the opposite direction from the ray (cheapest check first) if (Vector2.Dot(ray, convexHull.BoundingBox.Center.ToVector2() - lightPos) <= 0 && /*ray doesn't hit the convex hull*/ - !MathUtils.GetLineRectangleIntersection(lightPos, lightPos + ray, bounds, out _) && + !MathUtils.GetLineWorldRectangleIntersection(lightPos, lightPos + ray, bounds, out _) && /*normal vectors of the ray don't hit the convex hull */ - !MathUtils.GetLineRectangleIntersection(lightPos + normal, lightPos - normal, bounds, out _)) + !MathUtils.GetLineWorldRectangleIntersection(lightPos + normal, lightPos - normal, bounds, out _)) { continue; } diff --git a/Barotrauma/BarotraumaClient/ClientSource/Map/Map/Map.cs b/Barotrauma/BarotraumaClient/ClientSource/Map/Map/Map.cs index 0636133f6..c2d8b40dc 100644 --- a/Barotrauma/BarotraumaClient/ClientSource/Map/Map/Map.cs +++ b/Barotrauma/BarotraumaClient/ClientSource/Map/Map/Map.cs @@ -378,17 +378,24 @@ namespace Barotrauma bool showReputation = hudVisibility > 0.0f && location.Type.HasOutpost && location.Reputation != null; + LocationType locationTypeToDisplay = location.GetLocationTypeToDisplay(out Identifier overrideDescriptionIdentifier); + new GUITextBlock(new RectTransform(new Vector2(1.0f, 0.0f), content.RectTransform), location.DisplayName, font: GUIStyle.LargeFont) { Padding = Vector4.Zero }; if (!location.Type.Name.IsNullOrEmpty()) { - new GUITextBlock(new RectTransform(new Vector2(1.0f, 0.0f), content.RectTransform), location.Type.Name, font: GUIStyle.SubHeadingFont) { Padding = Vector4.Zero }; + new GUITextBlock(new RectTransform(new Vector2(1.0f, 0.0f), content.RectTransform), locationTypeToDisplay.Name, font: GUIStyle.SubHeadingFont) { Padding = Vector4.Zero }; } CreateSpacing(10); - if (!location.Type.Description.IsNullOrEmpty()) + var description = locationTypeToDisplay.Description; + if (!overrideDescriptionIdentifier.IsEmpty) { - CreateTextWithIcon(location.Type.Description, location.Type.Sprite); + description = TextManager.Get(overrideDescriptionIdentifier); + } + if (!description.IsNullOrEmpty()) + { + CreateTextWithIcon(description, locationTypeToDisplay.Sprite); } int highestSubTier = location.HighestSubmarineTierAvailable(); @@ -699,6 +706,7 @@ namespace Barotrauma CurrentLocation.CreateStores(); ProgressWorld(campaign); Radiation?.OnStep(1); + mapAnimQueue.Clear(); } else { @@ -828,7 +836,7 @@ namespace Barotrauma if (!rect.Intersects(drawRect)) { continue; } - Color color = location.Type.SpriteColor; + Color color = location.OverrideIconColor ?? location.Type.SpriteColor; if (!location.Visited) { color = Color.White; } if (location.Connections.Find(c => c.Locations.Contains(currentDisplayLocation)) == null) { @@ -850,6 +858,27 @@ namespace Barotrauma iconScale *= notificationPulseAmount; } +#if DEBUG + if (generationParams.ShowStoreInfo) + { + if (location.Stores == null || location.Stores.None()) + { + color = Color.DarkBlue; + } + //stores created, but nothing in stock + else if (location.Stores.Values.None(s => s.Stock.Any())) + { + color = Color.Yellow; + } + else + { + color = Color.Green; + } + + GUI.DrawString(spriteBatch, pos + Vector2.One * 20, "Time since visited: " +location.WorldStepsSinceVisited, Color.Yellow); + } +#endif + locationSprite.Draw(spriteBatch, pos, color, scale: generationParams.LocationIconSize / locationSprite.size.X * iconScale * zoom); @@ -948,8 +977,7 @@ namespace Barotrauma DrawDecorativeHUD(spriteBatch, rect); - bool drawRadiationTooltip = true; - + bool drawRadiationTooltip = HighlightedLocation == null; if (tooltip != null) { GUIComponent.DrawToolTip(spriteBatch, tooltip.Value.tip, tooltip.Value.targetArea); @@ -1058,7 +1086,7 @@ namespace Barotrauma } else { - if (MathUtils.GetLineRectangleIntersection(start, end, new Rectangle(viewArea.X, viewArea.Y + viewArea.Height, viewArea.Width, viewArea.Height), out Vector2 intersection)) + if (MathUtils.GetLineWorldRectangleIntersection(start, end, new Rectangle(viewArea.X, viewArea.Y + viewArea.Height, viewArea.Width, viewArea.Height), out Vector2 intersection)) { if (!viewArea.Contains(start)) { diff --git a/Barotrauma/BarotraumaClient/ClientSource/Map/Map/Radiation.cs b/Barotrauma/BarotraumaClient/ClientSource/Map/Map/Radiation.cs index 8fa773813..125c9e46b 100644 --- a/Barotrauma/BarotraumaClient/ClientSource/Map/Map/Radiation.cs +++ b/Barotrauma/BarotraumaClient/ClientSource/Map/Map/Radiation.cs @@ -7,18 +7,18 @@ namespace Barotrauma { internal partial class Radiation { - private static readonly LocalizedString radiationTooltip = TextManager.Get("RadiationTooltip"); + private int? radiationMultiplier; private static float spriteIndex; - private readonly SpriteSheet? sheet = GUIStyle.RadiationAnimSpriteSheet; - private int maxFrames => (sheet?.FrameCount ?? 0) + 1; + private readonly SpriteSheet? radiationEdgeAnimSheet = GUIStyle.RadiationAnimSpriteSheet; + private int maxFrames => (radiationEdgeAnimSheet?.FrameCount ?? 0) + 1; - private bool isHovingOver; + private bool isHoveringOver; public void Draw(SpriteBatch spriteBatch, Rectangle container, float zoom) { if (!Enabled) { return; } - UISprite? uiSprite = GUIStyle.Radiation; + UISprite? radiationMainSprite = GUIStyle.Radiation; var (offsetX, offsetY) = Map.DrawOffset * zoom; var (centerX, centerY) = container.Center.ToVector2(); var (halfSizeX, halfSizeY) = new Vector2(container.Width / 2f, container.Height / 2f) * zoom; @@ -29,31 +29,41 @@ namespace Barotrauma Vector2 spriteScale = new Vector2(zoom); - uiSprite?.Sprite.DrawTiled(spriteBatch, topLeft, size, color: Params.RadiationAreaColor, startOffset: Vector2.Zero, textureScale: spriteScale); + radiationMainSprite?.Sprite.DrawTiled(spriteBatch, topLeft, size, color: Params.RadiationAreaColor, startOffset: Vector2.Zero, textureScale: spriteScale); Vector2 topRight = topLeft + Vector2.UnitX * size.X; int index = 0; - if (sheet != null) + if (radiationEdgeAnimSheet != null) { - for (float i = 0; i <= size.Y; i += sheet.FrameSize.Y / 2f * zoom) + for (float i = 0; i <= size.Y; i += radiationEdgeAnimSheet.FrameSize.Y / 2f * zoom) { bool isEven = ++index % 2 == 0; - Vector2 origin = new Vector2(0.5f, 0) * sheet.FrameSize.X; + Vector2 origin = new Vector2(0.5f, 0) * radiationEdgeAnimSheet.FrameSize.X; // every other sprite's animation is reversed to make it seem more chaotic int sprite = (int) MathF.Floor(isEven ? spriteIndex : maxFrames - spriteIndex); - sheet.Draw(spriteBatch, sprite, topRight + new Vector2(0, i), Params.RadiationBorderTint, origin, 0f, spriteScale); + radiationEdgeAnimSheet.Draw(spriteBatch, sprite, topRight + new Vector2(0, i), Params.RadiationBorderTint, origin, 0f, spriteScale); } } - isHovingOver = container.Contains(PlayerInput.MousePosition) && PlayerInput.MousePosition.X < topLeft.X + size.X; + radiationMultiplier = null; + if (container.Contains(PlayerInput.MousePosition)) + { + float rightEdge = topLeft.X + size.X; + float distanceFromRight = rightEdge - PlayerInput.MousePosition.X; + if (distanceFromRight >= 0) + { + radiationMultiplier = Math.Min(4, (int)(distanceFromRight / (Params.RadiationEffectMultipliedPerPixelDistance * zoom)) + 1); + } + } } public void DrawFront(SpriteBatch spriteBatch) { - if (isHovingOver) + if (radiationMultiplier is int multiplier) { - GUIComponent.DrawToolTip(spriteBatch, radiationTooltip, PlayerInput.MousePosition + new Vector2(18 * GUI.Scale)); + var tooltip = TextManager.GetWithVariable("RadiationTooltip", "[jovianmultiplier]", multiplier.ToString()); + GUIComponent.DrawToolTip(spriteBatch, tooltip, PlayerInput.MousePosition + new Vector2(18 * GUI.Scale)); } } diff --git a/Barotrauma/BarotraumaClient/ClientSource/Map/Structure.cs b/Barotrauma/BarotraumaClient/ClientSource/Map/Structure.cs index ce14db11d..f995583ef 100644 --- a/Barotrauma/BarotraumaClient/ClientSource/Map/Structure.cs +++ b/Barotrauma/BarotraumaClient/ClientSource/Map/Structure.cs @@ -512,11 +512,8 @@ namespace Barotrauma damageEffect.Parameters["aCutoff"].SetValue(newCutoff); damageEffect.Parameters["cCutoff"].SetValue(newCutoff * 1.2f); - damageEffect.CurrentTechnique.Passes[0].Apply(); - Submarine.DamageEffectCutoff = newCutoff; - Submarine.DamageEffectColor = color; } } if (!HasDamage && i == 0) diff --git a/Barotrauma/BarotraumaClient/ClientSource/Map/StructurePrefab.cs b/Barotrauma/BarotraumaClient/ClientSource/Map/StructurePrefab.cs index 587e21dab..7603d3dd6 100644 --- a/Barotrauma/BarotraumaClient/ClientSource/Map/StructurePrefab.cs +++ b/Barotrauma/BarotraumaClient/ClientSource/Map/StructurePrefab.cs @@ -89,9 +89,13 @@ namespace Barotrauma newRect = Submarine.AbsRect(placePosition, placeSize); } - Sprite.DrawTiled(spriteBatch, new Vector2(newRect.X, -newRect.Y), new Vector2(newRect.Width, newRect.Height), textureScale: TextureScale * Scale); - GUI.DrawRectangle(spriteBatch, new Rectangle(newRect.X - GameMain.GraphicsWidth, -newRect.Y, newRect.Width + GameMain.GraphicsWidth * 2, newRect.Height), Color.White); - GUI.DrawRectangle(spriteBatch, new Rectangle(newRect.X, -newRect.Y - GameMain.GraphicsHeight, newRect.Width, newRect.Height + GameMain.GraphicsHeight * 2), Color.White); + Sprite.DrawTiled(spriteBatch, new Vector2(newRect.X, -newRect.Y), new Vector2(newRect.Width, newRect.Height), textureScale: TextureScale * Scale, color: SpriteColor); + + float thickness = Math.Max(1.0f / cam.Zoom, 1.0f); + int zoomInvariantWidth = (int)(GameMain.GraphicsWidth / cam.Zoom); + int zoomInvariantHeight = (int)(GameMain.GraphicsHeight / cam.Zoom); + GUI.DrawRectangle(spriteBatch, new Rectangle(newRect.X - zoomInvariantWidth, -newRect.Y, newRect.Width + zoomInvariantWidth * 2, newRect.Height), Color.White, thickness: thickness); + GUI.DrawRectangle(spriteBatch, new Rectangle(newRect.X, -newRect.Y - zoomInvariantHeight, newRect.Width, newRect.Height + zoomInvariantHeight * 2), Color.White, thickness: thickness); } public override void DrawPlacing(SpriteBatch spriteBatch, Rectangle placeRect, float scale = 1.0f, float rotation = 0.0f, SpriteEffects spriteEffects = SpriteEffects.None) @@ -103,7 +107,7 @@ namespace Barotrauma spriteBatch, position, placeRect.Size.ToVector2(), - color: Color.White * 0.8f, + color: SpriteColor * 0.8f, origin: placeRect.Size.ToVector2() * 0.5f, rotation: rotation, textureScale: TextureScale * scale, diff --git a/Barotrauma/BarotraumaClient/ClientSource/Map/Submarine.cs b/Barotrauma/BarotraumaClient/ClientSource/Map/Submarine.cs index 3a6ce3137..be8ac5b7b 100644 --- a/Barotrauma/BarotraumaClient/ClientSource/Map/Submarine.cs +++ b/Barotrauma/BarotraumaClient/ClientSource/Map/Submarine.cs @@ -159,7 +159,6 @@ namespace Barotrauma } public static float DamageEffectCutoff; - public static Color DamageEffectColor; public static void DrawDamageable(SpriteBatch spriteBatch, Effect damageEffect, bool editing = false, Predicate predicate = null) { @@ -192,13 +191,6 @@ namespace Barotrauma } } - - if (damageEffect != null) - { - damageEffect.Parameters["aCutoff"].SetValue(0.0f); - damageEffect.Parameters["cCutoff"].SetValue(0.0f); - DamageEffectCutoff = 0.0f; - } } public static void DrawPaintedColors(SpriteBatch spriteBatch, bool editing = false, Predicate predicate = null) diff --git a/Barotrauma/BarotraumaClient/ClientSource/Networking/Client.cs b/Barotrauma/BarotraumaClient/ClientSource/Networking/Client.cs index 4d31e3c57..80117af12 100644 --- a/Barotrauma/BarotraumaClient/ClientSource/Networking/Client.cs +++ b/Barotrauma/BarotraumaClient/ClientSource/Networking/Client.cs @@ -120,7 +120,7 @@ namespace Barotrauma.Networking if (radioNoiseChannel == null || !radioNoiseChannel.IsPlaying) { radioNoiseChannel = SoundPlayer.PlaySound("radiostatic"); - radioNoiseChannel.Category = "voip"; + radioNoiseChannel.Category = SoundManager.SoundCategoryVoip; radioNoiseChannel.Looping = true; } radioNoiseChannel.Near = VoipSound.Near; diff --git a/Barotrauma/BarotraumaClient/ClientSource/Networking/ConnectCommand.cs b/Barotrauma/BarotraumaClient/ClientSource/Networking/ConnectCommand.cs index 52a8d476c..a0ce32aef 100644 --- a/Barotrauma/BarotraumaClient/ClientSource/Networking/ConnectCommand.cs +++ b/Barotrauma/BarotraumaClient/ClientSource/Networking/ConnectCommand.cs @@ -50,25 +50,29 @@ readonly record struct ConnectCommand( NameAndLidgrenEndpointOption: endpoint is LidgrenEndpoint lidgrenEndpoint ? Option.Some(new NameAndLidgrenEndpoint(ServerName: serverName, lidgrenEndpoint)) : Option.None, - SteamLobbyIdOption: Option.None) { } + SteamLobbyIdOption: Option.None) + { } public ConnectCommand(string serverName, ImmutableArray endpoints) : this( NameAndP2PEndpointsOption: Option.Some(new NameAndP2PEndpoints(ServerName: serverName, Endpoints: endpoints)), NameAndLidgrenEndpointOption: Option.None, - SteamLobbyIdOption: Option.None) { } + SteamLobbyIdOption: Option.None) + { } public ConnectCommand(string serverName, LidgrenEndpoint endpoint) : this( NameAndP2PEndpointsOption: Option.None, NameAndLidgrenEndpointOption: Option.Some(new NameAndLidgrenEndpoint(ServerName: serverName, Endpoint: endpoint)), - SteamLobbyIdOption: Option.None) { } + SteamLobbyIdOption: Option.None) + { } public ConnectCommand(SteamLobbyId lobbyId) : this( NameAndP2PEndpointsOption: Option.None, NameAndLidgrenEndpointOption: Option.None, - SteamLobbyIdOption: Option.Some(lobbyId)) { } + SteamLobbyIdOption: Option.Some(lobbyId)) + { } public static Option Parse(string str) => Parse(ToolBox.SplitCommand(str)); diff --git a/Barotrauma/BarotraumaClient/ClientSource/Networking/GameClient.cs b/Barotrauma/BarotraumaClient/ClientSource/Networking/GameClient.cs index 927086086..0e6e5bb8d 100644 --- a/Barotrauma/BarotraumaClient/ClientSource/Networking/GameClient.cs +++ b/Barotrauma/BarotraumaClient/ClientSource/Networking/GameClient.cs @@ -40,12 +40,12 @@ namespace Barotrauma.Networking { if (string.IsNullOrEmpty(value)) { return; } Name = value; - nameId++; + ForceNameJobTeamUpdate(); } public void ForceNameJobTeamUpdate() { - // Triggers SendLobbyUpdate() which causes the server to call GameServer.ClientReadLobby() + // Deviously triggers SendLobbyUpdate() which causes the server to call GameServer.ClientReadLobby() nameId++; } @@ -584,6 +584,9 @@ namespace Barotrauma.Networking private readonly List pendingIncomingMessages = new List(); private readonly List incomingMessagesToProcess = new List(); + private CoroutineHandle startGameCoroutine; + private bool requestNewRoundStart; + private void ReadDataMessage(IReadMessage inc) { ServerPacketHeader header = (ServerPacketHeader)inc.ReadByte(); @@ -718,9 +721,6 @@ namespace Barotrauma.Networking campaignUpdateIDs[flag] = inc.ReadUInt16(); } - IWriteMessage readyToStartMsg = new WriteOnlyMessage(); - readyToStartMsg.WriteByte((byte)ClientPacketHeader.RESPONSE_STARTGAME); - if (campaign != null) { campaign.PendingSubmarineSwitch = null; } GameMain.NetLobbyScreen.UsingShuttle = usingShuttle; bool readyToStart; @@ -742,13 +742,9 @@ namespace Barotrauma.Networking campaign.LastSaveID == campaignSaveID && campaignUpdateIDs.All(kvp => campaign.GetLastUpdateIdForFlag(kvp.Key) == kvp.Value); } - readyToStartMsg.WriteBoolean(readyToStart); DebugConsole.Log(readyToStart ? "Ready to start." : "Not ready to start."); - - WriteCharacterInfo(readyToStartMsg); - - ClientPeer.Send(readyToStartMsg, DeliveryMethod.Reliable); + SendStartGameResponse(readyToStart: readyToStart); if (readyToStart && !CoroutineManager.IsCoroutineRunning("WaitForStartRound")) { @@ -775,15 +771,34 @@ namespace Barotrauma.Networking break; case ServerPacketHeader.STARTGAME: DebugConsole.Log("Received STARTGAME packet."); - if (Screen.Selected == GameMain.GameScreen && GameMain.GameSession?.GameMode is CampaignMode) + if (GameMain.NetLobbyScreen is not { AFKSelected: true } || !ServerSettings.AllowAFK) { - //start without a loading screen if playing a campaign round - CoroutineManager.StartCoroutine(StartGame(inc)); + if (startGameCoroutine != null && CoroutineManager.IsCoroutineRunning(startGameCoroutine)) + { + DebugConsole.Log("New round started before the previous one had finished loading. Starting a new round once loading the round finishes..."); + requestNewRoundStart = true; + } + else + { + if (Screen.Selected == GameMain.GameScreen && GameMain.GameSession?.GameMode is CampaignMode) + { + //start without a loading screen if playing a campaign round + DebugConsole.Log($"Starting {nameof(StartGame)} coroutine..."); + startGameCoroutine = CoroutineManager.StartCoroutine(StartGame(inc)); + } + else + { + GUIMessageBox.CloseAll(); + DebugConsole.Log($"Starting {nameof(StartGame)} coroutine with a loading screen..."); + startGameCoroutine = GameMain.Instance.ShowLoading(StartGame(inc), false); + } + } } else { - GUIMessageBox.CloseAll(); - GameMain.Instance.ShowLoading(StartGame(inc), false); + //reselect to refresh the state of the screen (to indicate the round is running) + GameStarted = true; + GameMain.NetLobbyScreen?.Select(); } break; case ServerPacketHeader.STARTGAMEFINALIZE: @@ -930,10 +945,19 @@ namespace Barotrauma.Networking contentToPreload.AddIfNotNull(file); } + byte roundId = inc.ReadByte(); + string campaignErrorInfo = string.Empty; if (GameMain.GameSession?.Campaign is MultiPlayerCampaign campaign) { + if (roundId != campaign.RoundID) + { + DebugConsole.AddWarning($"Received a StartGameFinalize message for an incorrect round (client: {campaign.RoundID}, server: {roundId}). The server might have started a new round before the client finished loading the previous one."); + requestNewRoundStart = true; + return; + } campaignErrorInfo = $" Round start save ID: {debugStartGameCampaignSaveID}, last save id: {campaign.LastSaveID}, pending save id: {campaign.PendingSaveID}."; + } GameMain.GameSession.EventManager.PreloadContent(contentToPreload); @@ -1409,6 +1433,8 @@ namespace Barotrauma.Networking private IEnumerable StartGame(IReadMessage inc) { + DebugConsole.Log($"Running {nameof(StartGame)} coroutine"); + Character?.Remove(); Character = null; HasSpawned = false; @@ -1444,6 +1470,7 @@ namespace Barotrauma.Networking { DebugConsole.ThrowError("Game mode \"" + modeIdentifier + "\" not found!"); roundInitStatus = RoundInitStatus.Interrupted; + startGameCoroutine = null; yield return CoroutineStatus.Failure; } @@ -1461,7 +1488,11 @@ namespace Barotrauma.Networking GameMain.LightManager.LosMode = (LosMode)inc.ReadByte(); ServerSettings.ShowEnemyHealthBars = (EnemyHealthBarMode)inc.ReadByte(); bool includesFinalize = inc.ReadBoolean(); inc.ReadPadBits(); + GameMain.LightManager.LightingEnabled = true; +#if DEBUG + GameMain.LightManager.LightingEnabled = !GameMain.DevMode; +#endif ServerSettings.ReadMonsterEnabled(inc); @@ -1500,6 +1531,7 @@ namespace Barotrauma.Networking if (!GameMain.NetLobbyScreen.TrySelectSub(subName, subHash, SelectedSubType.Sub, GameMain.NetLobbyScreen.SubList)) { roundInitStatus = RoundInitStatus.Interrupted; + startGameCoroutine = null; yield return CoroutineStatus.Success; } @@ -1515,6 +1547,7 @@ namespace Barotrauma.Networking if (!GameMain.NetLobbyScreen.TrySelectSub(shuttleName, shuttleHash, SelectedSubType.Shuttle, GameMain.NetLobbyScreen.ShuttleList.ListBox)) { roundInitStatus = RoundInitStatus.Interrupted; + startGameCoroutine = null; yield return CoroutineStatus.Success; } @@ -1544,6 +1577,7 @@ namespace Barotrauma.Networking DebugConsole.ThrowError(errorMsg); GameAnalyticsManager.AddErrorEventOnce("GameClient.StartGame:FailedToSelectSub" + subName, GameAnalyticsManager.ErrorSeverity.Error, errorMsg); roundInitStatus = RoundInitStatus.Interrupted; + startGameCoroutine = null; yield return CoroutineStatus.Failure; } if (GameMain.NetLobbyScreen.SelectedShuttle == null || @@ -1556,6 +1590,7 @@ namespace Barotrauma.Networking DebugConsole.ThrowError(errorMsg); GameAnalyticsManager.AddErrorEventOnce("GameClient.StartGame:FailedToSelectShuttle" + shuttleName, GameAnalyticsManager.ErrorSeverity.Error, errorMsg); roundInitStatus = RoundInitStatus.Interrupted; + startGameCoroutine = null; yield return CoroutineStatus.Failure; } @@ -1568,14 +1603,15 @@ namespace Barotrauma.Networking } else { - if (!(GameMain.GameSession?.GameMode is MultiPlayerCampaign campaign)) + if (GameMain.GameSession?.GameMode is not MultiPlayerCampaign campaign) { throw new InvalidOperationException("Attempted to start a campaign round when a campaign was not active."); } - if (GameMain.GameSession?.CrewManager != null) { GameMain.GameSession.CrewManager.Reset(); } + GameMain.GameSession?.CrewManager?.Reset(); byte campaignID = inc.ReadByte(); + byte roundID = inc.ReadByte(); UInt16 campaignSaveID = inc.ReadUInt16(); int nextLocationIndex = inc.ReadInt32(); int nextConnectionIndex = inc.ReadInt32(); @@ -1588,6 +1624,7 @@ namespace Barotrauma.Networking DebugConsole.ThrowError("Failed to start campaign round (campaign ID does not match)."); GameMain.NetLobbyScreen.Select(); roundInitStatus = RoundInitStatus.Interrupted; + startGameCoroutine = null; yield return CoroutineStatus.Failure; } @@ -1604,6 +1641,7 @@ namespace Barotrauma.Networking new GUIMessageBox(TextManager.Get("error"), TextManager.Get("campaignsavetransfer.timeout")); GameMain.NetLobbyScreen.Select(); roundInitStatus = RoundInitStatus.Interrupted; + startGameCoroutine = null; //use success status, even though this is a failure (no need to show a console error because we show it in the message box) yield return CoroutineStatus.Success; } @@ -1617,6 +1655,7 @@ namespace Barotrauma.Networking DebugConsole.ThrowError("Failed to start campaign round (campaign map not loaded yet)."); GameMain.NetLobbyScreen.Select(); roundInitStatus = RoundInitStatus.Interrupted; + startGameCoroutine = null; yield return CoroutineStatus.Failure; } @@ -1630,12 +1669,18 @@ namespace Barotrauma.Networking if (roundSummary != null) { - loadTask = campaign.SelectSummaryScreen(roundSummary, levelData, mirrorLevel, null); + loadTask = campaign.SelectSummaryScreen(roundSummary, levelData, mirrorLevel, () => + { + DebugConsole.Log($"Set round ID from {campaign.RoundID} to {roundID}."); + campaign.RoundID = roundID; + }); roundSummary.ContinueButton.Visible = false; } else { GameMain.GameSession.StartRound(levelData, mirrorLevel, startOutpost: campaign?.GetPredefinedStartOutpost()); + DebugConsole.Log($"Set round ID from {campaign.RoundID} to {roundID}."); + campaign.RoundID = roundID; } isOutpost = levelData.Type == LevelData.LevelType.Outpost; } @@ -1654,9 +1699,16 @@ namespace Barotrauma.Networking { DebugConsole.ThrowError("There was an error initializing the round (disconnected during the StartGame coroutine.)"); roundInitStatus = RoundInitStatus.Error; + startGameCoroutine = null; yield return CoroutineStatus.Failure; } + if (requestNewRoundStart) + { + RequestNewRoundStart(); + yield return CoroutineStatus.Success; + } + roundInitStatus = RoundInitStatus.WaitingForStartGameFinalize; //wait for up to 30 seconds for the server to send the STARTGAMEFINALIZE message @@ -1678,6 +1730,12 @@ namespace Barotrauma.Networking { while (true) { + if (requestNewRoundStart) + { + RequestNewRoundStart(); + yield return CoroutineStatus.Success; + } + try { if (DateTime.Now > requestFinalizeTime) @@ -1742,10 +1800,12 @@ namespace Barotrauma.Networking { DebugConsole.ThrowError(roundInitStatus.ToString()); CoroutineManager.StartCoroutine(EndGame("")); + startGameCoroutine = null; yield return CoroutineStatus.Failure; } else { + startGameCoroutine = null; yield return CoroutineStatus.Success; } } @@ -1754,6 +1814,7 @@ namespace Barotrauma.Networking GameMain.GameSession.Submarine.Info.IsFileCorrupted) { DebugConsole.ThrowError($"Failed to start a round. Could not load the submarine \"{GameMain.GameSession.Submarine.Info.Name}\"."); + startGameCoroutine = null; yield return CoroutineStatus.Failure; } @@ -1801,6 +1862,17 @@ namespace Barotrauma.Networking AddChatMessage(message, ChatMessageType.Server); yield return CoroutineStatus.Success; + + void RequestNewRoundStart() + { + GameMain.GameSession?.EndRound(""); + GameMain.NetLobbyScreen.Select(); + CoroutineManager.StopCoroutines("LevelTransition"); + roundInitStatus = RoundInitStatus.Error; + startGameCoroutine = null; + SendJoinOngoingRequest(joinButton: null); + requestNewRoundStart = false; + } } public IEnumerable EndGame(string endMessage, CampaignMode.TransitionType transitionType = CampaignMode.TransitionType.None, TraitorManager.TraitorResults? traitorResults = null) @@ -1910,6 +1982,7 @@ namespace Barotrauma.Networking GameStarted = inc.ReadBoolean(); bool allowSpectating = inc.ReadBoolean(); + bool allowAFK = inc.ReadBoolean(); bool permadeathMode = inc.ReadBoolean(); bool ironmanMode = inc.ReadBoolean(); @@ -1929,7 +2002,7 @@ namespace Barotrauma.Networking message = TextManager.Get(allowSpectating ? "RoundRunningSpectateEnabled" : "RoundRunningSpectateDisabled"); } new GUIMessageBox(TextManager.Get("PleaseWait"), message); - if (!(Screen.Selected is ModDownloadScreen)) { GameMain.NetLobbyScreen.Select(); } + if (Screen.Selected is not ModDownloadScreen) { GameMain.NetLobbyScreen.Select(); } } } } @@ -2002,8 +2075,7 @@ namespace Barotrauma.Networking Name = tc.Name; nameId = tc.NameId; } - if (GameMain.NetLobbyScreen.CharacterNameBox != null && - !GameMain.NetLobbyScreen.CharacterNameBox.Selected) + if (GameMain.NetLobbyScreen.CharacterNameBox is { Selected: false, Enabled: true }) { GameMain.NetLobbyScreen.CharacterNameBox.Text = Name; } @@ -2117,6 +2189,7 @@ namespace Barotrauma.Networking bool voiceChatEnabled = inc.ReadBoolean(); bool allowSpectating = inc.ReadBoolean(); + bool allowAFK = inc.ReadBoolean(); float traitorProbability = inc.ReadSingle(); int traitorDangerLevel = inc.ReadRangedInteger(TraitorEventPrefab.MinDangerLevel, TraitorEventPrefab.MaxDangerLevel); @@ -2194,6 +2267,7 @@ namespace Barotrauma.Networking } GameMain.NetLobbyScreen.SetAllowSpectating(allowSpectating); + GameMain.NetLobbyScreen.SetAllowAFK(allowAFK); GameMain.NetLobbyScreen.SetLevelDifficulty(levelDifficulty); GameMain.NetLobbyScreen.SetBotSpawnMode(botSpawnMode); GameMain.NetLobbyScreen.SetBotCount(botCount); @@ -2386,6 +2460,7 @@ namespace Barotrauma.Networking outmsg.WriteUInt16(GameMain.NetLobbyScreen.LastUpdateID); outmsg.WriteUInt16(ChatMessage.LastID); outmsg.WriteUInt16(LastClientListUpdateID); + outmsg.WriteBoolean(GameMain.NetLobbyScreen.AFKSelected); outmsg.WriteUInt16(nameId); outmsg.WriteString(Name); var jobPreferences = GameMain.NetLobbyScreen.JobPreferences; @@ -2532,6 +2607,15 @@ namespace Barotrauma.Networking msg.WriteUInt16(bot.ID); ClientPeer?.Send(msg, DeliveryMethod.Reliable); } + + public void ToggleReserveBench(CharacterInfo bot, bool pendingHire = false) + { + IWriteMessage msg = new WriteOnlyMessage(); + msg.WriteByte((byte)ClientPacketHeader.TOGGLE_RESERVE_BENCH); + msg.WriteUInt16(bot.ID); + msg.WriteBoolean(pendingHire); + ClientPeer?.Send(msg, DeliveryMethod.Reliable); + } public void RequestFile(FileTransferType fileType, string file, string fileHash) { @@ -2796,8 +2880,12 @@ namespace Barotrauma.Networking public void WriteCharacterInfo(IWriteMessage msg, string newName = null) { msg.WriteBoolean(GameMain.NetLobbyScreen.Spectating); + msg.WriteBoolean(GameMain.NetLobbyScreen.CampaignCharacterDiscarded); + bool writeInfo = characterInfo != null; + msg.WriteBoolean(writeInfo); msg.WritePadBits(); - if (characterInfo == null) { return; } + + if (!writeInfo) { return; } var head = characterInfo.Head; @@ -3080,9 +3168,9 @@ namespace Barotrauma.Networking } /// - /// Tell the server to end the round (permission required) + /// Tell the server to end the round (permission required). /// - public void RequestRoundEnd(bool save, bool quitCampaign = false) + public void RequestEndRound(bool save, bool quitCampaign = false) { IWriteMessage msg = new WriteOnlyMessage(); msg.WriteByte((byte)ClientPacketHeader.SERVER_COMMAND); @@ -3094,7 +3182,31 @@ namespace Barotrauma.Networking ClientPeer.Send(msg, DeliveryMethod.Reliable); } - public bool JoinOnGoingClicked(GUIButton button, object _) + /// + /// End the round locally (just returning to the lobby without ending the round for everyone). + /// + public void EndRoundForSelf() + { + GameMain.GameSession?.EndRound(endMessage: string.Empty, createRoundSummary: false); + Submarine.Unload(); + GameMain.NetLobbyScreen.Select(); + Character.Controlled = null; + WaitForNextRoundRespawn = null; + RespawnManager = null; + + EntityEventManager?.Clear(); + LastSentEntityEventID = 0; + + MyClient.CharacterID = Entity.NullEntityID; + + roundInitStatus = RoundInitStatus.NotStarted; + + IWriteMessage msg = new WriteOnlyMessage(); + msg.WriteByte((byte)ClientPacketHeader.ENDROUND_SELF); + ClientPeer.Send(msg, DeliveryMethod.Reliable); + } + + public bool SendJoinOngoingRequest(GUIButton joinButton) { MultiPlayerCampaign campaign = GameMain.NetLobbyScreen.SelectedMode == GameMain.GameSession?.GameMode.Preset ? @@ -3106,23 +3218,32 @@ namespace Barotrauma.Networking new GUIMessageBox("", TextManager.Get("campaignfiletransferinprogress")); return false; } - if (button != null) { button.Enabled = false; } + if (joinButton != null) { joinButton.Enabled = false; } if (campaign != null) { LateCampaignJoin = true; } if (ClientPeer == null) { return false; } + //assume we have the required sub files to start the round + //(if not, we'll find out when the server sends the STARTGAME message and can initiate a file transfer) + SendStartGameResponse(readyToStart: true); + + return false; + } + + private void SendStartGameResponse(bool readyToStart) + { IWriteMessage readyToStartMsg = new WriteOnlyMessage(); readyToStartMsg.WriteByte((byte)ClientPacketHeader.RESPONSE_STARTGAME); //assume we have the required sub files to start the round //(if not, we'll find out when the server sends the STARTGAME message and can initiate a file transfer) - readyToStartMsg.WriteBoolean(true); + readyToStartMsg.WriteBoolean(readyToStart); + readyToStartMsg.WriteBoolean(GameMain.NetLobbyScreen.AFKSelected && ServerSettings.AllowAFK); WriteCharacterInfo(readyToStartMsg); ClientPeer.Send(readyToStartMsg, DeliveryMethod.Reliable); - return false; } public bool SetReadyToStart(GUITickBox tickBox) diff --git a/Barotrauma/BarotraumaClient/ClientSource/Networking/Primitives/Peers/ClientPeer.cs b/Barotrauma/BarotraumaClient/ClientSource/Networking/Primitives/Peers/ClientPeer.cs index 6a5889af2..7b489b369 100644 --- a/Barotrauma/BarotraumaClient/ClientSource/Networking/Primitives/Peers/ClientPeer.cs +++ b/Barotrauma/BarotraumaClient/ClientSource/Networking/Primitives/Peers/ClientPeer.cs @@ -41,6 +41,9 @@ namespace Barotrauma.Networking protected bool IsOwner => ownerKey.IsSome(); protected readonly Option ownerKey; + /// + /// Has the ClientPeer been started? Set to true in , set to false when shutting the client down . + /// public bool IsActive => isActive; protected bool isActive; @@ -104,8 +107,11 @@ namespace Barotrauma.Networking TaskPool.Add($"{GetType().Name}.{nameof(GetAccountId)}", GetAccountId(), t => { - // FIXME what to do with this? - //if (GameMain.Client?.ClientPeer is null) { return; } + if (!IsActive) + { + //client has become inactive (cancelled/disconnected while waiting for initialization) + return; + } if (!t.TryGetResult(out Option accountId)) { diff --git a/Barotrauma/BarotraumaClient/ClientSource/Networking/RespawnManager.cs b/Barotrauma/BarotraumaClient/ClientSource/Networking/RespawnManager.cs index 68a5e0acb..e96674791 100644 --- a/Barotrauma/BarotraumaClient/ClientSource/Networking/RespawnManager.cs +++ b/Barotrauma/BarotraumaClient/ClientSource/Networking/RespawnManager.cs @@ -63,6 +63,7 @@ namespace Barotrauma.Networking var teamId = (CharacterTeamType)msg.ReadByte(); bool respawnPromptPending = false; + bool clientHasChosenNewBotViaShuttle = false; var newState = (State)msg.ReadRangedInteger(0, Enum.GetNames(typeof(State)).Length); switch (newState) { @@ -70,18 +71,20 @@ namespace Barotrauma.Networking teamSpecificState.ReturnCountdownStarted = msg.ReadBoolean(); maxTransportTime = msg.ReadSingle(); float transportTimeLeft = msg.ReadSingle(); - teamSpecificState.ReturnTime = DateTime.Now + new TimeSpan(0, 0, 0, 0, milliseconds: (int)(transportTimeLeft * 1000.0f)); teamSpecificState.RespawnCountdownStarted = false; + SetShuttleBodyType(teamSpecificState.TeamID, FarseerPhysics.BodyType.Dynamic); break; case State.Waiting: teamSpecificState.PendingRespawnCount = msg.ReadUInt16(); teamSpecificState.RequiredRespawnCount = msg.ReadUInt16(); respawnPromptPending = msg.ReadBoolean(); + clientHasChosenNewBotViaShuttle = msg.ReadBoolean(); teamSpecificState.RespawnCountdownStarted = msg.ReadBoolean(); ResetShuttle(teamSpecificState); float newRespawnTime = msg.ReadSingle(); teamSpecificState.RespawnTime = DateTime.Now + new TimeSpan(0, 0, 0, 0, milliseconds: (int)(newRespawnTime * 1000.0f)); + SetShuttleBodyType(teamSpecificState.TeamID, FarseerPhysics.BodyType.Static); break; case State.Returning: teamSpecificState.RespawnCountdownStarted = false; @@ -89,7 +92,7 @@ namespace Barotrauma.Networking } teamSpecificState.CurrentState = newState; - if (respawnPromptPending) + if (respawnPromptPending && !clientHasChosenNewBotViaShuttle) { GameMain.Client.HasSpawned = true; DeathPrompt.Create(delay: 1.0f); diff --git a/Barotrauma/BarotraumaClient/ClientSource/Networking/ServerList/ServerInfo.cs b/Barotrauma/BarotraumaClient/ClientSource/Networking/ServerList/ServerInfo.cs index 1111afa09..55a748c19 100644 --- a/Barotrauma/BarotraumaClient/ClientSource/Networking/ServerList/ServerInfo.cs +++ b/Barotrauma/BarotraumaClient/ClientSource/Networking/ServerList/ServerInfo.cs @@ -180,7 +180,7 @@ namespace Barotrauma.Networking { float playStyleBannerAspectRatio = (float)playStyleBannerSprite.SourceRect.Width / (float)playStyleBannerSprite.SourceRect.Height; playStyleBanner = new GUIImage(new RectTransform(new Vector2(1.0f, 1.0f / playStyleBannerAspectRatio), frame.RectTransform, scaleBasis: ScaleBasis.BothWidth), - playStyleBannerSprite, null, true); + playStyleBannerSprite, scaleToFit: true); playStyleBannerColor = playStyleBannerSprite.SourceElement.GetAttributeColor("bannercolor", Color.Black); } else @@ -385,14 +385,24 @@ namespace Barotrauma.Networking { MinSize = new Point(0, 15) }, package.Name) { - CanBeFocused = false + Enabled = false }; + packageText.Box.DisabledColor = packageText.Box.Color; + packageText.TextBlock.DisabledTextColor = packageText.TextBlock.TextColor; if (!string.IsNullOrEmpty(package.Hash)) { - if (ContentPackageManager.AllPackages.Any(contentPackage => contentPackage.Hash.StringRepresentation == package.Hash)) + if (ContentPackageManager.AllPackages.FirstOrDefault(contentPackage => contentPackage.Hash.StringRepresentation == package.Hash) is { } matchingPackage) { packageText.TextColor = GUIStyle.Green; packageText.Selected = true; + matchingPackage.TryFetchUgcDescription(onFinished: (string? description) => + { + if (packageText.ToolTip.IsNullOrEmpty() && + !string.IsNullOrEmpty(description)) + { + packageText.ToolTip = description + "..."; + } + }); } //workshop download link found else if (package.Id.TryUnwrap(out var ugcId) && ugcId is SteamWorkshopId) @@ -437,7 +447,7 @@ namespace Barotrauma.Networking public void UpdateInfo(Func valueGetter) { - ServerMessage = valueGetter("message") ?? ""; + ServerMessage = ExtractServerMessage(valueGetter); if (Version.TryParse(valueGetter("version"), out var version)) { GameVersion = version; @@ -477,6 +487,22 @@ namespace Barotrauma.Networking } } + private static string ExtractServerMessage(Func valueGetter) + { + string msg = valueGetter("message") ?? string.Empty; + if (!msg.IsNullOrEmpty()) { return msg; } + + int messageIndex = 0; + string splitMessage; + do + { + splitMessage = valueGetter($"message{messageIndex}") ?? string.Empty; + msg += splitMessage; + messageIndex++; + } while (!splitMessage.IsNullOrEmpty()); + return msg; + } + private static ServerListContentPackageInfo[] ExtractContentPackageInfo(string serverName, Func valueGetter) { //workaround to ServerRules queries truncating the values to 255 bytes diff --git a/Barotrauma/BarotraumaClient/ClientSource/Networking/ServerList/ServerProviders/SteamDedicatedServerProvider.cs b/Barotrauma/BarotraumaClient/ClientSource/Networking/ServerList/ServerProviders/SteamDedicatedServerProvider.cs index e31ccd06f..305172748 100644 --- a/Barotrauma/BarotraumaClient/ClientSource/Networking/ServerList/ServerProviders/SteamDedicatedServerProvider.cs +++ b/Barotrauma/BarotraumaClient/ClientSource/Networking/ServerList/ServerProviders/SteamDedicatedServerProvider.cs @@ -1,4 +1,4 @@ -#nullable enable +#nullable enable using System; using System.Collections.Concurrent; using System.Collections.Generic; @@ -67,6 +67,7 @@ namespace Barotrauma return null; }); serverInfo.Checked = true; + serverInfo.HasPassword |= entry.Passworded; onServerDataReceived(serverInfo, this); }); diff --git a/Barotrauma/BarotraumaClient/ClientSource/Networking/ServerSettingsUI.cs b/Barotrauma/BarotraumaClient/ClientSource/Networking/ServerSettingsUI.cs index 82bb14d2e..46f9d88e1 100644 --- a/Barotrauma/BarotraumaClient/ClientSource/Networking/ServerSettingsUI.cs +++ b/Barotrauma/BarotraumaClient/ClientSource/Networking/ServerSettingsUI.cs @@ -465,6 +465,12 @@ namespace Barotrauma.Networking var allowSpecBox = new GUITickBox(new RectTransform(new Vector2(1.0f, 0.05f), listBox.Content.RectTransform), TextManager.Get("ServerSettingsAllowSpectating")); AssignGUIComponent(nameof(AllowSpectating), allowSpecBox); + var allowAfkBox = new GUITickBox(new RectTransform(new Vector2(1.0f, 0.05f), listBox.Content.RectTransform), TextManager.Get("ServerSettingsAllowAFK")) + { + ToolTip = TextManager.Get("ServerSettingsAllowAFK.tooltip") + }; + AssignGUIComponent(nameof(AllowAFK), allowAfkBox); + var losModeLabel = new GUITextBlock(new RectTransform(new Vector2(1.0f, 0.05f), listBox.Content.RectTransform), TextManager.Get("LosEffect")); var losModeSelection = new GUISelectionCarousel(new RectTransform(new Vector2(0.5f, 0.6f), losModeLabel.RectTransform, Anchor.CenterRight)); diff --git a/Barotrauma/BarotraumaClient/ClientSource/Networking/Voip/VoipClient.cs b/Barotrauma/BarotraumaClient/ClientSource/Networking/Voip/VoipClient.cs index f87161645..596a03649 100644 --- a/Barotrauma/BarotraumaClient/ClientSource/Networking/Voip/VoipClient.cs +++ b/Barotrauma/BarotraumaClient/ClientSource/Networking/Voip/VoipClient.cs @@ -116,12 +116,12 @@ namespace Barotrauma.Networking bool spectating = Character.Controlled == null; float rangeMultiplier = spectating ? 2.0f : 1.0f; WifiComponent senderRadio = null; + var messageType = !client.VoipQueue.ForceLocal && ChatMessage.CanUseRadio(client.Character, out senderRadio) && - ChatMessage.CanUseRadio(Character.Controlled, out var recipientRadio) && - senderRadio.CanReceive(recipientRadio) ? - ChatMessageType.Radio : ChatMessageType.Default; + (spectating || (ChatMessage.CanUseRadio(Character.Controlled, out var recipientRadio) && senderRadio.CanReceive(recipientRadio))) + ? ChatMessageType.Radio : ChatMessageType.Default; client.Character.ShowTextlessSpeechBubble(1.25f, ChatMessage.MessageColor[(int)messageType]); client.VoipSound.UseRadioFilter = messageType == ChatMessageType.Radio && !GameSettings.CurrentConfig.Audio.DisableVoiceChatFilters; @@ -149,7 +149,7 @@ namespace Barotrauma.Networking GameMain.NetLobbyScreen?.SetPlayerSpeaking(client); GameMain.GameSession?.CrewManager?.SetClientSpeaking(client); - if ((client.VoipSound.CurrentAmplitude * client.VoipSound.Gain * GameMain.SoundManager.GetCategoryGainMultiplier("voip")) > 0.1f) //TODO: might need to tweak + if ((client.VoipSound.CurrentAmplitude * client.VoipSound.Gain * GameMain.SoundManager.GetCategoryGainMultiplier(SoundManager.SoundCategoryVoip)) > 0.1f) //TODO: might need to tweak { if (client.Character != null && !client.Character.Removed && !client.Character.IsDead) { diff --git a/Barotrauma/BarotraumaClient/ClientSource/Particles/Particle.cs b/Barotrauma/BarotraumaClient/ClientSource/Particles/Particle.cs index 76deb5a15..fdee9f59e 100644 --- a/Barotrauma/BarotraumaClient/ClientSource/Particles/Particle.cs +++ b/Barotrauma/BarotraumaClient/ClientSource/Particles/Particle.cs @@ -1,5 +1,6 @@ using Barotrauma.Extensions; using FarseerPhysics; +using FarseerPhysics.Common; using Microsoft.Xna.Framework; using Microsoft.Xna.Framework.Graphics; using System; @@ -10,7 +11,7 @@ namespace Barotrauma.Particles class Particle { private ParticlePrefab prefab; - + private string debugName = "Particle (uninitialized)"; public delegate void OnChangeHullHandler(Vector2 position, Hull currentHull); @@ -110,9 +111,12 @@ namespace Barotrauma.Particles { return debugName; } - public void Init(ParticlePrefab prefab, Vector2 position, Vector2 speed, float rotation, Hull hullGuess = null, ParticleDrawOrder drawOrder = ParticleDrawOrder.Default, float collisionIgnoreTimer = 0f, float lifeTimeMultiplier = 1f, Tuple tracerPoints = null) + public void Init(ParticlePrefab prefab, Vector2 spawnPosition, Vector2 speed, float spawnRotation, Hull hullGuess = null, ParticleDrawOrder drawOrder = ParticleDrawOrder.Default, float collisionIgnoreTimer = 0f, float lifeTimeMultiplier = 1f, Tuple tracerPoints = null) { this.prefab = prefab; + + System.Diagnostics.Debug.Assert(position.IsValid(), "Attempted to spawn a particle at an invalid position."); + #if DEBUG debugName = $"Particle ({prefab.Name})"; #else @@ -124,14 +128,14 @@ namespace Barotrauma.Particles animState = 0; animFrame = 0; - currentHull = prefab.CanEnterSubs ? Hull.FindHull(position, hullGuess) : null; + currentHull = prefab.CanEnterSubs ? Hull.FindHull(spawnPosition, hullGuess) : null; size = prefab.StartSizeMin + (prefab.StartSizeMax - prefab.StartSizeMin) * Rand.Range(0.0f, 1.0f); if (tracerPoints != null) { size = new Vector2(Vector2.Distance(tracerPoints.Item1, tracerPoints.Item2), size.Y); - position = (tracerPoints.Item1 + tracerPoints.Item2) / 2; + spawnPosition = (tracerPoints.Item1 + tracerPoints.Item2) / 2; } RefreshColliderSize(); @@ -139,23 +143,19 @@ namespace Barotrauma.Particles sizeChange = prefab.SizeChangeMin + (prefab.SizeChangeMax - prefab.SizeChangeMin) * Rand.Range(0.0f, 1.0f); changesSize = !sizeChange.NearlyEquals(Vector2.Zero); - this.position = position; - prevPosition = position; - - drawPosition = position; - - velocity = MathUtils.IsValid(speed) ? speed : Vector2.Zero; - if (currentHull?.Submarine != null) { - velocity += ConvertUnits.ToDisplayUnits(currentHull.Submarine.Velocity); + //convert to the sub's coordinate space + spawnPosition -= currentHull.Submarine.Position; } + + position = prevPosition = drawPosition = spawnPosition; + velocity = MathUtils.IsValid(speed) ? speed : Vector2.Zero; - this.rotation = rotation + Rand.Range(prefab.StartRotationMinRad, prefab.StartRotationMaxRad); - prevRotation = rotation; + rotation = spawnRotation + Rand.Range(prefab.StartRotationMinRad, prefab.StartRotationMaxRad); + prevRotation = spawnRotation; angularVelocity = Rand.Range(prefab.AngularVelocityMinRad, prefab.AngularVelocityMaxRad); - if (prefab.LifeTimeMin <= 0.0f) { @@ -202,7 +202,7 @@ namespace Barotrauma.Particles { this.rotation = MathUtils.VectorToAngle(new Vector2(velocity.X, -velocity.Y)); - prevRotation = rotation; + prevRotation = spawnRotation; } DrawOrder = drawOrder; @@ -248,7 +248,7 @@ namespace Barotrauma.Particles rotation += angularVelocity * deltaTime; } - bool inWater = (currentHull == null || (currentHull.Submarine != null && position.Y - currentHull.Submarine.DrawPosition.Y < currentHull.Surface)); + bool inWater = (currentHull == null || (currentHull.Submarine != null && position.Y < currentHull.Surface)); if (inWater) { velocity.X += velocityChangeWater.X * VelocityChangeMultiplier * deltaTime; @@ -323,7 +323,7 @@ namespace Barotrauma.Particles if (collisionIgnoreTimer > 0f) { collisionIgnoreTimer -= deltaTime; - if (collisionIgnoreTimer <= 0f) { currentHull ??= Hull.FindHull(position); } + if (collisionIgnoreTimer <= 0f) { currentHull ??= Hull.FindHull(position, guess: currentHull, useWorldCoordinates: false); } return UpdateResult.Normal; } if (!prefab.UseCollision) { return UpdateResult.Normal; } @@ -350,7 +350,7 @@ namespace Barotrauma.Particles { if (currentHull == null) { - Hull collidedHull = Hull.FindHull(position); + Hull collidedHull = Hull.FindHull(position, useWorldCoordinates: true); if (collidedHull != null) { if (prefab.DeleteOnCollision) { return UpdateResult.Delete; } @@ -359,7 +359,7 @@ namespace Barotrauma.Particles } else { - Rectangle hullRect = currentHull.WorldRect; + Rectangle hullRect = currentHull.Rect; Vector2 collisionNormal = Vector2.Zero; if (velocity.Y < 0.0f && position.Y - colliderRadius.Y < hullRect.Y - hullRect.Height) { @@ -377,9 +377,9 @@ namespace Barotrauma.Particles { if (gap.Open <= 0.9f || gap.IsHorizontal) { continue; } - if (gap.WorldRect.X > position.X || gap.WorldRect.Right < position.X) { continue; } - float hullCenterY = currentHull.WorldRect.Y - currentHull.WorldRect.Height / 2; - int gapDir = Math.Sign(gap.WorldRect.Y - hullCenterY); + if (gap.Rect.X > position.X || gap.Rect.Right < position.X) { continue; } + float hullCenterY = currentHull.Rect.Y - currentHull.Rect.Height / 2; + int gapDir = Math.Sign(gap.Rect.Y - hullCenterY); if (Math.Sign(velocity.Y) != gapDir || Math.Sign(position.Y - hullCenterY) != gapDir) { continue; } gapFound = true; @@ -411,9 +411,9 @@ namespace Barotrauma.Particles { if (gap.Open <= 0.9f || !gap.IsHorizontal) { continue; } - if (gap.WorldRect.Y < position.Y || gap.WorldRect.Y - gap.WorldRect.Height > position.Y) { continue; } - int gapDir = Math.Sign(gap.WorldRect.Center.X - currentHull.WorldRect.Center.X); - if (Math.Sign(velocity.X) != gapDir || Math.Sign(position.X - currentHull.WorldRect.Center.X) != gapDir) { continue; } + if (gap.Rect.Y < position.Y || gap.WorldRect.Y - gap.Rect.Height > position.Y) { continue; } + int gapDir = Math.Sign(gap.Rect.Center.X - currentHull.Rect.Center.X); + if (Math.Sign(velocity.X) != gapDir || Math.Sign(position.X - currentHull.Rect.Center.X) != gapDir) { continue; } gapFound = true; break; @@ -434,7 +434,7 @@ namespace Barotrauma.Particles } else { - Hull newHull = Hull.FindHull(position, currentHull); + Hull newHull = Hull.FindHull(position, currentHull, useWorldCoordinates: false); if (newHull != currentHull) { currentHull = newHull; @@ -457,32 +457,25 @@ namespace Barotrauma.Particles private void ApplyDrag(float dragCoefficient, float deltaTime) { - Vector2 relativeVel = velocity; - if (currentHull?.Submarine != null) - { - relativeVel = velocity - ConvertUnits.ToDisplayUnits(currentHull.Submarine.Velocity); - } + Vector2 newVel = velocity; + float speed = velocity.Length(); - float speed = relativeVel.Length(); - - relativeVel /= speed; + if (speed < 0.01f) { return; } + + newVel /= speed; float drag = speed * speed * dragCoefficient * 0.01f * deltaTime; if (drag > speed) { - relativeVel = Vector2.Zero; + newVel = Vector2.Zero; } else { speed -= drag; - relativeVel *= speed; + newVel *= speed; } - velocity = relativeVel; - if (currentHull?.Submarine != null) - { - velocity += ConvertUnits.ToDisplayUnits(currentHull.Submarine.Velocity); - } + velocity = newVel; } @@ -490,10 +483,7 @@ namespace Barotrauma.Particles { if (prevHull == null) { return; } - Rectangle prevHullRect = prevHull.WorldRect; - - Vector2 subVel = prevHull?.Submarine != null ? ConvertUnits.ToDisplayUnits(prevHull.Submarine.Velocity) : Vector2.Zero; - velocity -= subVel; + Rectangle prevHullRect = prevHull.Rect; if (Math.Abs(collisionNormal.X) > Math.Abs(collisionNormal.Y)) { @@ -524,14 +514,12 @@ namespace Barotrauma.Particles } OnCollision?.Invoke(position, currentHull); - - velocity += subVel; } private void OnWallCollisionOutside(Hull collisionHull) { - Rectangle hullRect = collisionHull.WorldRect; + Rectangle hullRect = collisionHull.Rect; Vector2 center = new Vector2(hullRect.X + hullRect.Width / 2, hullRect.Y - hullRect.Height / 2); @@ -584,7 +572,13 @@ namespace Barotrauma.Particles Color currColor = new Color(color.ToVector4() * ColorMultiplier); - Vector2 drawPos = new Vector2(drawPosition.X, -drawPosition.Y); + Vector2 drawPos = drawPosition; + if (currentHull?.Submarine is Submarine sub) + { + drawPos += sub.DrawPosition; + } + + drawPos = new Vector2(drawPos.X, -drawPos.Y); if (prefab.Sprites[spriteIndex] is SpriteSheet sheet) { sheet.Draw( diff --git a/Barotrauma/BarotraumaClient/ClientSource/Particles/ParticleEmitter.cs b/Barotrauma/BarotraumaClient/ClientSource/Particles/ParticleEmitter.cs index 3a0411e73..4dd7c077c 100644 --- a/Barotrauma/BarotraumaClient/ClientSource/Particles/ParticleEmitter.cs +++ b/Barotrauma/BarotraumaClient/ClientSource/Particles/ParticleEmitter.cs @@ -99,6 +99,9 @@ namespace Barotrauma.Particles [Editable, Serialize(1f, IsPropertySaveable.Yes)] public float LifeTimeMultiplier { get; set; } + [Editable, Serialize(false, IsPropertySaveable.Yes, description: "Should the particle be drawn as a tracer (a line from a weapon to the point it hit)? Only supported on hitscan projectiles and repair tools. Defaults to true on hitscan projectiles.")] + public bool UseTracerPoints { get; set; } + [Editable, Serialize(ParticleDrawOrder.Default, IsPropertySaveable.Yes)] public ParticleDrawOrder DrawOrder { get; set; } @@ -224,7 +227,7 @@ namespace Barotrauma.Particles var particle = GameMain.ParticleManager.CreateParticle(particlePrefab, position, velocity, particleRotation, hullGuess, particlePrefab.DrawOrder != ParticleDrawOrder.Default ? particlePrefab.DrawOrder : Prefab.DrawOrder, - lifeTimeMultiplier: Prefab.Properties.LifeTimeMultiplier, tracerPoints: tracerPoints); + lifeTimeMultiplier: Prefab.Properties.LifeTimeMultiplier, tracerPoints: Prefab.Properties.UseTracerPoints ? tracerPoints : null); if (particle != null) { diff --git a/Barotrauma/BarotraumaClient/ClientSource/Particles/ParticleManager.cs b/Barotrauma/BarotraumaClient/ClientSource/Particles/ParticleManager.cs index adde46948..69fc90019 100644 --- a/Barotrauma/BarotraumaClient/ClientSource/Particles/ParticleManager.cs +++ b/Barotrauma/BarotraumaClient/ClientSource/Particles/ParticleManager.cs @@ -103,6 +103,9 @@ namespace Barotrauma.Particles if (prefab == null || prefab.Sprites.Count == 0) { return null; } if (particleCount >= MaxParticles) { + //maximum number of particles reached, and this is not a high-prio particle or something that should always draw + // -> the particle won't be created, we can return early + if (particleCount >= MaxParticles && prefab.Priority == 0 && !prefab.DrawAlways) { return null; } for (int i = 0; i < particleCount; i++) { if (particles[i].Prefab.Priority < prefab.Priority || diff --git a/Barotrauma/BarotraumaClient/ClientSource/Physics/PhysicsBody.cs b/Barotrauma/BarotraumaClient/ClientSource/Physics/PhysicsBody.cs index 339e66044..26746a5ea 100644 --- a/Barotrauma/BarotraumaClient/ClientSource/Physics/PhysicsBody.cs +++ b/Barotrauma/BarotraumaClient/ClientSource/Physics/PhysicsBody.cs @@ -1,6 +1,5 @@ using Barotrauma.Networking; using FarseerPhysics; -using Lidgren.Network; using Microsoft.Xna.Framework; using Microsoft.Xna.Framework.Graphics; using System; @@ -9,6 +8,18 @@ namespace Barotrauma { partial class PhysicsBody { + /// + /// Last known state the server has told us about. + /// + public PosInfo LastServerState; + + /// + /// An offset used to corrections to positional errors look smoother. When a large positional correction needs to be done in multiplayer, + /// the body is immediately moved to the correct position, but the draw position is interpolated to make the correction visually smoother. + /// This value means the offset from the "actual" corrected position of the body to the "fake", interpolated draw position. + /// + public Vector2 NetworkPositionErrorOffset => drawOffset; + public void Draw(DeformableSprite deformSprite, Camera cam, Vector2 scale, Color color, bool invert = false) { if (!Enabled) { return; } @@ -69,9 +80,26 @@ namespace Barotrauma GUI.DrawLine(spriteBatch, new Vector2(pos.X, -pos.Y), new Vector2(DrawPosition.X, -DrawPosition.Y), - Color.Purple * 0.75f, 0, 5); + Color.Purple * 0.5f, 0, 5); } if (IsValidShape(Radius, Height, Width)) + { + DrawShape(drawPosition, DrawRotation, color); + } + + if (LastServerState != null) + { + Vector2 drawPos = ConvertUnits.ToDisplayUnits(LastServerState.Position); + if (Submarine != null) + { + drawPos += Submarine.DrawPosition; + } + float rotation = LastServerState.Rotation ?? 0.0f; + + DrawShape(drawPos, rotation, Color.Purple * 0.75f); + } + + void DrawShape(Vector2 position, float rotation, Color color) { float radius = ConvertUnits.ToDisplayUnits(Radius); float height = ConvertUnits.ToDisplayUnits(Height); @@ -80,16 +108,16 @@ namespace Barotrauma switch (BodyShape) { case Shape.Rectangle: - GUI.DrawRectangle(spriteBatch, DrawPosition.FlipY(), new Vector2(width, height), new Vector2(width, height) / 2, -DrawRotation, color); + GUI.DrawRectangle(spriteBatch, position.FlipY(), new Vector2(width, height), new Vector2(width, height) / 2, -rotation, color); break; case Shape.Capsule: - GUI.DrawCapsule(spriteBatch, DrawPosition.FlipY(), height, radius, -DrawRotation - MathHelper.PiOver2, color); + GUI.DrawCapsule(spriteBatch, position.FlipY(), height, radius, -rotation - MathHelper.PiOver2, color); break; case Shape.HorizontalCapsule: - GUI.DrawCapsule(spriteBatch, DrawPosition.FlipY(), width, radius, -DrawRotation, color); + GUI.DrawCapsule(spriteBatch, position.FlipY(), width, radius, -rotation, color); break; case Shape.Circle: - GUI.DrawDonutSection(spriteBatch, DrawPosition.FlipY(), new Range(radius - 0.5f, radius + 0.5f), MathHelper.TwoPi, color, 0, -DrawRotation); + GUI.DrawDonutSection(spriteBatch, position.FlipY(), new Range(radius - 0.5f, radius + 0.5f), MathHelper.TwoPi, color, 0, -rotation); break; default: throw new NotImplementedException(); @@ -150,9 +178,10 @@ namespace Barotrauma return null; } - return lastProcessedNetworkState > sendingTime ? - null : - new PosInfo(newPosition, newRotation, newVelocity, newAngularVelocity, sendingTime); + if (lastProcessedNetworkState > sendingTime) { return null; } + + LastServerState = new PosInfo(newPosition, newRotation, newVelocity, newAngularVelocity, sendingTime); + return LastServerState; } } } diff --git a/Barotrauma/BarotraumaClient/ClientSource/Screens/CampaignSetupUI/SinglePlayerCampaignSetupUI.cs b/Barotrauma/BarotraumaClient/ClientSource/Screens/CampaignSetupUI/SinglePlayerCampaignSetupUI.cs index 4f38eb509..9cd40f9a1 100644 --- a/Barotrauma/BarotraumaClient/ClientSource/Screens/CampaignSetupUI/SinglePlayerCampaignSetupUI.cs +++ b/Barotrauma/BarotraumaClient/ClientSource/Screens/CampaignSetupUI/SinglePlayerCampaignSetupUI.cs @@ -632,7 +632,7 @@ namespace Barotrauma saveFrame.GetChild().TextColor = GUIStyle.Red; continue; } - if (docRoot.GetChildElement("multiplayercampaign") != null) + if (docRoot.GetAttributeBool("ismultiplayer", false)) { //multiplayer campaign save in the wrong folder -> don't show the save saveList.Content.RemoveChild(saveFrame); diff --git a/Barotrauma/BarotraumaClient/ClientSource/Screens/CampaignUI.cs b/Barotrauma/BarotraumaClient/ClientSource/Screens/CampaignUI.cs index ffb4b0086..280bc2c7f 100644 --- a/Barotrauma/BarotraumaClient/ClientSource/Screens/CampaignUI.cs +++ b/Barotrauma/BarotraumaClient/ClientSource/Screens/CampaignUI.cs @@ -259,7 +259,7 @@ namespace Barotrauma { AutoScaleHorizontal = true }; - new GUITextBlock(new RectTransform(new Vector2(1.0f, 0.0f), content.RectTransform), location.Type.Name, font: GUIStyle.SubHeadingFont); + new GUITextBlock(new RectTransform(new Vector2(1.0f, 0.0f), content.RectTransform), location.GetLocationTypeToDisplay().Name, font: GUIStyle.SubHeadingFont); Sprite portrait = location.Type.GetPortrait(location.PortraitId); portrait.EnsureLazyLoaded(); diff --git a/Barotrauma/BarotraumaClient/ClientSource/Screens/CharacterEditor/CharacterEditorScreen.cs b/Barotrauma/BarotraumaClient/ClientSource/Screens/CharacterEditor/CharacterEditorScreen.cs index 6f04215f9..6456d3594 100644 --- a/Barotrauma/BarotraumaClient/ClientSource/Screens/CharacterEditor/CharacterEditorScreen.cs +++ b/Barotrauma/BarotraumaClient/ClientSource/Screens/CharacterEditor/CharacterEditorScreen.cs @@ -1790,7 +1790,7 @@ namespace Barotrauma.CharacterEditor XElement element = animation.MainElement; if (element == null) { continue; } element.SetAttributeValue("type", name); - string fullPath = AnimationParams.GetDefaultFile(name, animation.AnimationType); + string fullPath = AnimationParams.GetDefaultFilePath(name, animation.AnimationType); element.Name = AnimationParams.GetDefaultFileName(name, animation.AnimationType); #if DEBUG element.Save(fullPath); @@ -1818,7 +1818,7 @@ namespace Barotrauma.CharacterEditor default: continue; } Type type = AnimationParams.GetParamTypeFromAnimType(animType, isHumanoid); - string fullPath = AnimationParams.GetDefaultFile(name, animType); + string fullPath = AnimationParams.GetDefaultFilePath(name, animType); AnimationParams.Create(fullPath, name, animType, type); } } diff --git a/Barotrauma/BarotraumaClient/ClientSource/Screens/GameScreen.cs b/Barotrauma/BarotraumaClient/ClientSource/Screens/GameScreen.cs index e4e5635be..f60237fd5 100644 --- a/Barotrauma/BarotraumaClient/ClientSource/Screens/GameScreen.cs +++ b/Barotrauma/BarotraumaClient/ClientSource/Screens/GameScreen.cs @@ -318,9 +318,15 @@ namespace Barotrauma graphics.SetRenderTarget(renderTargetDamageable); graphics.Clear(Color.Transparent); + DamageEffect.CurrentTechnique = DamageEffect.Techniques["StencilShader"]; + DamageEffect.CurrentTechnique.Passes[0].Apply(); spriteBatch.Begin(SpriteSortMode.BackToFront, BlendState.NonPremultiplied, SamplerState.LinearWrap, effect: DamageEffect, transformMatrix: cam.Transform); Submarine.DrawDamageable(spriteBatch, DamageEffect, false); + DamageEffect.Parameters["aCutoff"].SetValue(0.0f); + DamageEffect.Parameters["cCutoff"].SetValue(0.0f); + Submarine.DamageEffectCutoff = 0.0f; + DamageEffect.CurrentTechnique.Passes[0].Apply(); spriteBatch.End(); sw.Stop(); diff --git a/Barotrauma/BarotraumaClient/ClientSource/Screens/LevelEditorScreen.cs b/Barotrauma/BarotraumaClient/ClientSource/Screens/LevelEditorScreen.cs index 629189c13..2cb36e846 100644 --- a/Barotrauma/BarotraumaClient/ClientSource/Screens/LevelEditorScreen.cs +++ b/Barotrauma/BarotraumaClient/ClientSource/Screens/LevelEditorScreen.cs @@ -22,13 +22,16 @@ namespace Barotrauma public override Camera Cam { get; } private GUIFrame leftPanel, rightPanel, bottomPanel, topPanel; + + private Point prevResolution; private LevelGenerationParams selectedParams; private RuinGenerationParams selectedRuinGenerationParams; private OutpostGenerationParams selectedOutpostGenerationParams; private LevelObjectPrefab selectedLevelObject; + private BackgroundCreaturePrefab selectedBackgroundCreature; - private GUIListBox paramsList, ruinParamsList, caveParamsList, outpostParamsList, levelObjectList; + private GUIListBox paramsList, ruinParamsList, caveParamsList, outpostParamsList, levelObjectList, backgroundCreatureList; private GUIListBox editorContainer; private GUIButton spriteEditDoneButton; @@ -69,10 +72,12 @@ namespace Barotrauma UpdateCaveParamsList(); UpdateOutpostParamsList(); UpdateLevelObjectsList(); + UpdateBackgroundCreatureList(); } private void CreateUI() { + Frame.ClearChildren(); leftPanel?.ClearChildren(); rightPanel?.ClearChildren(); leftPanel = new GUIFrame(new RectTransform(new Vector2(0.125f, 0.8f), Frame.RectTransform) { MinSize = new Point(150, 0) }); @@ -92,6 +97,7 @@ namespace Barotrauma currentLevelData = LevelData.CreateRandom(seedBox.Text, generationParams: selectedParams); editorContainer.ClearChildren(); SortLevelObjectsList(currentLevelData); + SortBackgroundCreaturesList(currentLevelData); new SerializableEntityEditor(editorContainer.Content.RectTransform, selectedParams, inGame: false, showName: true, elementHeight: 20, titleFont: GUIStyle.LargeFont); forceDifficultyInput.FloatValue = (selectedParams.MinLevelDifficulty + selectedParams.MaxLevelDifficulty) / 2f; return true; @@ -310,6 +316,7 @@ namespace Barotrauma currentLevelData.AllowInvalidOutpost = allowInvalidOutpost.Selected; var dummyLocations = GameSession.CreateDummyLocations(currentLevelData); Level.Generate(currentLevelData, mirror: mirrorLevel.Selected, startLocation: dummyLocations[0], endLocation: dummyLocations[1]); + UpdateBackgroundCreatureList(); if (Submarine.MainSub != null) { @@ -385,9 +392,42 @@ namespace Barotrauma }; bottomPanel = new GUIFrame(new RectTransform(new Vector2(0.75f, 0.22f), Frame.RectTransform, Anchor.BottomLeft) - { MaxSize = new Point(GameMain.GraphicsWidth - rightPanel.Rect.Width, 1000) }, style: "GUIFrameBottom"); + { MaxSize = new Point(GameMain.GraphicsWidth - rightPanel.Rect.Width, 1000) }, style: "GUIFrameBottom"); - levelObjectList = new GUIListBox(new RectTransform(new Vector2(0.99f, 0.85f), bottomPanel.RectTransform, Anchor.Center)) + var bottomPanelContents = new GUILayoutGroup(new RectTransform(new Vector2(0.98f, 0.9f), bottomPanel.RectTransform, Anchor.Center)) + { + Stretch = true + }; + + var bottomPanelButtons = new GUILayoutGroup(new RectTransform(new Vector2(1.0f, 0.1f), bottomPanelContents.RectTransform), isHorizontal: true); + new GUIButton(new RectTransform(new Vector2(0.25f, 1.0f), bottomPanelButtons.RectTransform), TextManager.Get("leveleditor.levelobjects"), style: "GUITabButton") + { + Selected = true, + OnClicked = (btn, __) => + { + bottomPanelButtons.Children.ForEach(c => c.Selected = c == btn); + levelObjectList.Visible = true; + levelObjectList.IgnoreLayoutGroups = false; + backgroundCreatureList.Visible = false; + backgroundCreatureList.IgnoreLayoutGroups = true; + return true; + } + }; + new GUIButton(new RectTransform(new Vector2(0.25f, 1.0f), bottomPanelButtons.RectTransform), TextManager.Get("leveleditor.backgroundcreatures"), style: "GUITabButton") + { + OnClicked = (btn, __) => + { + bottomPanelButtons.Children.ForEach(c => c.Selected = c == btn); + backgroundCreatureList.Visible = true; + backgroundCreatureList.IgnoreLayoutGroups = false; + levelObjectList.Visible = false; + levelObjectList.IgnoreLayoutGroups = true; + return true; + } + }; + bottomPanelButtons.RectTransform.NonScaledSize = new Point(bottomPanelButtons.Rect.Width, bottomPanelButtons.Children.First().Rect.Height); + + levelObjectList = new GUIListBox(new RectTransform(new Vector2(1.0f, 0.85f), bottomPanelContents.RectTransform)) { PlaySoundOnSelect = true, UseGridLayout = true @@ -399,6 +439,20 @@ namespace Barotrauma return true; }; + backgroundCreatureList = new GUIListBox(new RectTransform(new Vector2(1.0f, 0.85f), bottomPanelContents.RectTransform)) + { + PlaySoundOnSelect = true, + UseGridLayout = true, + Visible = false, + IgnoreLayoutGroups = true + }; + backgroundCreatureList.OnSelected += (GUIComponent component, object obj) => + { + selectedBackgroundCreature = obj as BackgroundCreaturePrefab; + CreateBackgroundCreatureEditor(selectedBackgroundCreature); + return true; + }; + spriteEditDoneButton = new GUIButton(new RectTransform(new Point(200, 30), anchor: Anchor.BottomRight) { AbsoluteOffset = new Point(20, 20) }, TextManager.Get("leveleditor.spriteeditdone")) { @@ -411,6 +465,8 @@ namespace Barotrauma topPanel = new GUIFrame(new RectTransform(new Point(400, 100), GUI.Canvas) { RelativeOffset = new Vector2(leftPanel.RectTransform.RelativeSize.X * 2, 0.0f) }, style: "GUIFrameTop"); + + prevResolution = new Point(GameMain.GraphicsWidth, GameMain.GraphicsHeight); } public LevelEditorScreen() @@ -585,6 +641,44 @@ namespace Barotrauma } } + private void UpdateBackgroundCreatureList() + { + editorContainer.ClearChildren(); + backgroundCreatureList.Content.ClearChildren(); + + int objectsPerRow = (int)Math.Ceiling(backgroundCreatureList.Content.Rect.Width / Math.Max(100 * GUI.Scale, 100)); + float relWidth = 1.0f / objectsPerRow; + + foreach (BackgroundCreaturePrefab backgroundCreaturePrefab in BackgroundCreaturePrefab.Prefabs) + { + var frame = new GUIFrame(new RectTransform( + new Vector2(relWidth, relWidth * ((float)backgroundCreatureList.Content.Rect.Width / backgroundCreatureList.Content.Rect.Height)), + backgroundCreatureList.Content.RectTransform) + { MinSize = new Point(0, 60) }, style: "ListBoxElementSquare") + { + UserData = backgroundCreaturePrefab + }; + var paddedFrame = new GUIFrame(new RectTransform(new Vector2(0.9f, 0.9f), frame.RectTransform, Anchor.Center), style: null); + + GUITextBlock textBlock = new GUITextBlock(new RectTransform(new Vector2(1.0f, 0.0f), paddedFrame.RectTransform, Anchor.BottomCenter), + text: ToolBox.LimitString(backgroundCreaturePrefab.Name, GUIStyle.SmallFont, paddedFrame.Rect.Width), textAlignment: Alignment.Center, font: GUIStyle.SmallFont) + { + CanBeFocused = false, + ToolTip = backgroundCreaturePrefab.Name + }; + + Sprite sprite = backgroundCreaturePrefab.Sprite ?? backgroundCreaturePrefab.DeformableSprite?.Sprite; + new GUIImage(new RectTransform(new Point(paddedFrame.Rect.Height, paddedFrame.Rect.Height - textBlock.Rect.Height), + paddedFrame.RectTransform, Anchor.TopCenter), sprite, scaleToFit: true) + { + LoadAsynchronously = true, + CanBeFocused = false + }; + } + + SortBackgroundCreaturesList(currentLevelData); + } + private void CreateCaveParamsEditor(CaveGenerationParams caveGenerationParams) { editorContainer.ClearChildren(); @@ -913,6 +1007,99 @@ namespace Barotrauma }); } + private void SortBackgroundCreaturesList(LevelData levelData) + { + if (levelData == null) { return; } + //fade out levelobjects that don't spawn in this type of level + foreach (GUIComponent child in backgroundCreatureList.Content.Children) + { + if (child.UserData is not BackgroundCreaturePrefab creature) { continue; } + SetElementColorBasedOnCommonness(child, creature.GetCommonness(levelData)); + } + + //sort the levelobjects according to commonness in this level + backgroundCreatureList.Content.RectTransform.SortChildren((c1, c2) => + { + var creature1 = c1.GUIComponent.UserData as BackgroundCreaturePrefab; + var creature2 = c2.GUIComponent.UserData as BackgroundCreaturePrefab; + return Math.Sign(creature2.GetCommonness(levelData) - creature1.GetCommonness(levelData)); + }); + } + + private static void SetElementColorBasedOnCommonness(GUIComponent element, float commonness) + { + element.Color = commonness > 0.0f ? GUIStyle.Green * 0.4f : Color.Transparent; + element.SelectedColor = commonness > 0.0f ? GUIStyle.Green * 0.6f : Color.White * 0.5f; + element.HoverColor = commonness > 0.0f ? GUIStyle.Green * 0.7f : Color.White * 0.6f; + + element.GetAnyChild().Color = commonness > 0.0f ? Color.White : Color.DarkGray; + if (commonness <= 0.0f) + { + element.GetAnyChild().TextColor = Color.DarkGray; + } + } + + private void CreateBackgroundCreatureEditor(BackgroundCreaturePrefab backgroundCreaturePrefab) + { + editorContainer.ClearChildren(); + + var editor = new SerializableEntityEditor(editorContainer.Content.RectTransform, backgroundCreaturePrefab, false, true, elementHeight: 20, titleFont: GUIStyle.LargeFont); + + if (selectedParams != null) + { + List availableIdentifiers = new List() { selectedParams.Identifier }; + foreach (Identifier paramsId in availableIdentifiers) + { + var commonnessContainer = new GUILayoutGroup(new RectTransform(new Point(editor.Rect.Width, 70)) { IsFixedSize = true }, + isHorizontal: false, childAnchor: Anchor.TopCenter) + { + AbsoluteSpacing = 5, + Stretch = true + }; + new GUITextBlock(new RectTransform(new Vector2(1.0f, 0.4f), commonnessContainer.RectTransform), + TextManager.GetWithVariable("leveleditor.levelobjcommonness", "[leveltype]", paramsId.Value), textAlignment: Alignment.Center); + new GUINumberInput(new RectTransform(new Vector2(0.5f, 0.4f), commonnessContainer.RectTransform), NumberType.Float) + { + MinValueFloat = 0, + MaxValueFloat = 100, + FloatValue = backgroundCreaturePrefab.GetCommonness(currentLevelData), + OnValueChanged = (numberInput) => + { + backgroundCreaturePrefab.OverrideCommonness[paramsId] = numberInput.FloatValue; + } + }; + new GUIFrame(new RectTransform(new Vector2(1.0f, 0.2f), commonnessContainer.RectTransform), style: null); + editor.AddCustomContent(commonnessContainer, 1); + } + } + + Sprite sprite = backgroundCreaturePrefab.Sprite ?? backgroundCreaturePrefab.DeformableSprite?.Sprite; + if (sprite != null) + { + editor.AddCustomContent(new GUIButton(new RectTransform(new Point(editor.Rect.Width / 2, (int)(25 * GUI.Scale))) { IsFixedSize = true }, + TextManager.Get("leveleditor.editsprite")) + { + OnClicked = (btn, userdata) => + { + editingSprite = sprite; + GameMain.SpriteEditorScreen.SelectSprite(editingSprite); + return true; + } + }, 1); + } + + if (backgroundCreaturePrefab.DeformableSprite != null) + { + var deformEditor = backgroundCreaturePrefab.DeformableSprite.CreateEditor(editor, backgroundCreaturePrefab.SpriteDeformations, backgroundCreaturePrefab.Name); + deformEditor.GetChild().OnSelected += (selected, userdata) => + { + CreateBackgroundCreatureEditor(backgroundCreaturePrefab); + return true; + }; + editor.AddCustomContent(deformEditor, editor.ContentCount); + } + } + public override void AddToGUIUpdateList() { base.AddToGUIUpdateList(); @@ -1083,6 +1270,11 @@ namespace Barotrauma public override void Update(double deltaTime) { + if (GameMain.GraphicsWidth != prevResolution.X || GameMain.GraphicsHeight != prevResolution.Y) + { + RefreshUI(forceCreate: true); + } + if (lightingEnabled.Selected) { foreach (Item item in Item.ItemList) diff --git a/Barotrauma/BarotraumaClient/ClientSource/Screens/MainMenuScreen/MainMenuScreen.cs b/Barotrauma/BarotraumaClient/ClientSource/Screens/MainMenuScreen/MainMenuScreen.cs index 8af2cd575..31bfd266b 100644 --- a/Barotrauma/BarotraumaClient/ClientSource/Screens/MainMenuScreen/MainMenuScreen.cs +++ b/Barotrauma/BarotraumaClient/ClientSource/Screens/MainMenuScreen/MainMenuScreen.cs @@ -23,6 +23,8 @@ namespace Barotrauma { sealed class MainMenuScreen : Screen { + public static HashSet DismissedNotifications = new HashSet(); + private enum Tab { NewGame = 0, @@ -512,6 +514,22 @@ namespace Barotrauma return true; } }; + + new GUIButton(new RectTransform(new Point(300, 30), Frame.RectTransform, Anchor.TopRight) { AbsoluteOffset = new Point(40, 230) }, + "Local MP Quickstart", style: "GUIButtonLarge", color: GUIStyle.Red) + { + IgnoreLayoutGroups = true, + UserData = Tab.Empty, + ToolTip = "Starts a server and another client and connects both to localhost, using names 'client1' and 'client2'.", + OnClicked = (tb, userdata) => + { + SelectTab(tb, userdata); + + DebugConsole.StartLocalMPSession(numClients: 2); + + return true; + } + }; #endif new GUIButton(new RectTransform(new Point(300, 30), Frame.RectTransform, Anchor.TopLeft) { AbsoluteOffset = new Point(40, 50) }, $"Open LuaCs Settings", style: "MainMenuGUIButton", color: GUIStyle.Red) @@ -590,6 +608,12 @@ namespace Barotrauma SelectTab(Tab.Empty); } + public static void AddDismissedNotification(Identifier id) + { + DismissedNotifications.Add(id); + GameSettings.SaveCurrentConfig(); + } + private void SetMenuTabPositioning() { foreach (GUIFrame menuTab in menuTabs.Values) @@ -618,7 +642,7 @@ namespace Barotrauma }; var tutorialPreview = new GUILayoutGroup(new RectTransform(new Vector2(0.6f, 1.0f), tutorialContent.RectTransform)) { RelativeSpacing = 0.05f, Stretch = true }; var imageContainer = new GUIFrame(new RectTransform(new Vector2(1.0f, 0.5f), tutorialPreview.RectTransform), style: "InnerFrame"); - tutorialBanner = new GUIImage(new RectTransform(Vector2.One, imageContainer.RectTransform), style: null, scaleToFit: true); + tutorialBanner = new GUIImage(new RectTransform(Vector2.One, imageContainer.RectTransform), style: null, scaleToFit: GUIImage.ScalingMode.ScaleToFitSmallestExtent); var infoContainer = new GUIFrame(new RectTransform(new Vector2(1.0f, 0.5f), tutorialPreview.RectTransform), style: "GUIFrameListBox"); var infoContent = new GUILayoutGroup(new RectTransform(new Vector2(0.95f, 0.9f), infoContainer.RectTransform, Anchor.Center), childAnchor: Anchor.TopLeft) @@ -949,6 +973,7 @@ namespace Barotrauma DebugConsole.ThrowError("Failed to find the job \"" + job + "\"!"); } gamesession.CrewManager.AddCharacterInfo(characterInfo); + characterInfo.SetNameBasedOnJob(); } gamesession.CrewManager.InitSinglePlayerRound(); } @@ -1320,11 +1345,9 @@ namespace Barotrauma catch (Exception e) { DebugConsole.ThrowError("Loading save \"" + path + "\" failed", e); + GameMain.GameSession = null; return; } - - //TODO - //GameMain.LobbyScreen.Select(); } #region UI Methods @@ -1357,7 +1380,7 @@ namespace Barotrauma var serverSettings = XMLExtensions.TryLoadXml(ServerSettings.SettingsFile, out _)?.Root ?? new XElement("serversettings"); - var name = serverSettings.GetAttributeString("name", ""); + var name = serverSettings.GetAttributeString(nameof(ServerSettings.ServerName), ""); var password = serverSettings.GetAttributeString("password", ""); var isPublic = serverSettings.GetAttributeBool("IsPublic", true); var banAfterWrongPassword = serverSettings.GetAttributeBool("banafterwrongpassword", false); @@ -1391,7 +1414,7 @@ namespace Barotrauma var playstyleContainer = new GUIFrame(new RectTransform(new Vector2(1.35f, 0.1f), parent.RectTransform), style: null, color: Color.Black); playstyleBanner = new GUIImage(new RectTransform(new Vector2(1.0f, 0.1f), playstyleContainer.RectTransform), - GUIStyle.GetComponentStyle($"PlayStyleBanner.{PlayStyle.Serious}").GetSprite(GUIComponent.ComponentState.None), scaleToFit: true) + GUIStyle.GetComponentStyle($"PlayStyleBanner.{PlayStyle.Serious}").GetSprite(GUIComponent.ComponentState.None), scaleToFit: GUIImage.ScalingMode.ScaleToFitSmallestExtent) { UserData = PlayStyle.Serious }; diff --git a/Barotrauma/BarotraumaClient/ClientSource/Screens/NetLobbyScreen.cs b/Barotrauma/BarotraumaClient/ClientSource/Screens/NetLobbyScreen.cs index b89849fb3..c542c7ccb 100644 --- a/Barotrauma/BarotraumaClient/ClientSource/Screens/NetLobbyScreen.cs +++ b/Barotrauma/BarotraumaClient/ClientSource/Screens/NetLobbyScreen.cs @@ -67,8 +67,9 @@ namespace Barotrauma public GUIButton ServerMessageButton { get; private set; } public static GUIButton JobInfoFrame { get; set; } - private GUITickBox spectateBox; + private GUITickBox spectateBox, afkBox; public bool Spectating => spectateBox is { Selected: true, Visible: true }; + public bool AFKSelected => afkBox is { Selected: true, Visible: true }; public bool PermadeathMode => GameMain.Client?.ServerSettings?.RespawnMode == RespawnMode.Permadeath; public bool PermanentlyDead => campaignCharacterInfo?.PermanentlyDead ?? false; @@ -112,6 +113,8 @@ namespace Barotrauma private readonly List respawnSettings = new(); public CharacterInfo.AppearanceCustomizationMenu CharacterAppearanceCustomizationMenu { get; set; } + + private Point prevResolutionForJobSelectionFrame; public GUIFrame JobSelectionFrame { get; private set; } public GUIFrame JobPreferenceContainer { get; private set; } @@ -209,6 +212,7 @@ namespace Barotrauma private CharacterTeamType TeamPreference => SelectedMode == GameModePreset.PvP ? MultiplayerPreferences.Instance.TeamPreference : CharacterTeamType.Team1; public GUIButton StartButton { get; private set; } + public GUIButton EndButton { get; private set; } public GUITickBox ReadyToStartBox { get; private set; } @@ -224,7 +228,7 @@ namespace Barotrauma public bool UsingShuttle { - get { return shuttleTickBox.Selected && !PermadeathMode; } + get { return shuttleTickBox.Selected; } set { shuttleTickBox.Selected = value; } } @@ -473,10 +477,20 @@ namespace Barotrauma AbsoluteSpacing = GUI.IntScale(5) }; - Favorite = new GUITickBox(new RectTransform(new Vector2(0.5f, 0.5f), serverInfoContent.RectTransform, Anchor.TopRight, scaleBasis: ScaleBasis.BothHeight), + + var topRightContainer = new GUILayoutGroup(new RectTransform(new Vector2(0.5f, 0.5f), serverInfoContent.RectTransform, Anchor.TopRight), + isHorizontal: true, childAnchor: Anchor.TopRight) + { + AbsoluteSpacing = GUI.IntScale(5), + CanBeFocused = true + }; + + SettingsButton = new GUIButton(new RectTransform(new Vector2(0.4f, 1.0f), topRightContainer.RectTransform, Anchor.TopRight), + TextManager.Get("ServerSettingsButton"), style: "GUIButtonFreeScale"); + + Favorite = new GUITickBox(new RectTransform(Vector2.One, topRightContainer.RectTransform, Anchor.TopRight, scaleBasis: ScaleBasis.BothHeight), "", null, "GUIServerListFavoriteTickBox") { - IgnoreLayoutGroups = true, Selected = false, ToolTip = TextManager.Get("addtofavorites"), OnSelected = (tickbox) => @@ -496,8 +510,6 @@ namespace Barotrauma } }; - SettingsButton = new GUIButton(new RectTransform(new Vector2(0.25f, 0.4f), serverInfoContent.RectTransform, Anchor.TopRight), - TextManager.Get("ServerSettingsButton"), style: "GUIButtonFreeScale"); } private void CreateServerMessagePopup(string serverName, string message) @@ -698,11 +710,11 @@ namespace Barotrauma if (GameMain.Client == null) { return false; } if (GameMain.Client.GameStarted) { - GameMain.Client.RequestRoundEnd(save: false); + GameMain.Client.RequestEndRound(save: false); } else { - GameMain.Client.RequestRoundEnd(save: false, quitCampaign: true); + GameMain.Client.RequestEndRound(save: false, quitCampaign: true); } return true; } @@ -1216,7 +1228,7 @@ namespace Barotrauma shuttleTickBox = new GUITickBox(new RectTransform(Vector2.One, shuttleHolder.RectTransform), TextManager.Get("RespawnShuttle")) { ToolTip = TextManager.Get("RespawnShuttleExplanation"), - Selected = !PermadeathMode, + Selected = true, OnSelected = (GUITickBox box) => { GameMain.Client?.ServerSettings.ClientAdminWrite(ServerSettings.NetFlags.Properties); @@ -1241,7 +1253,10 @@ namespace Barotrauma { OnSelected = (component, obj) => { - SelectShuttle((SubmarineInfo)obj); + SubmarineInfo subInfo = (SubmarineInfo)obj; + ShuttleList.Text = subInfo.DisplayName; + ShuttleList.ToolTip = subInfo.Description; + SelectShuttle(subInfo); return true; } }; @@ -1539,8 +1554,8 @@ namespace Barotrauma foreach (var disembarkPerkPrefab in DisembarkPerkPrefab.Prefabs .OrderBy(static p => p.SortCategory) - .ThenBy(static p => p.Cost) - .ThenBy(static p => p.SortKey)) + .ThenBy(static p => p.SortKey) + .ThenBy(static p => p.Cost)) { if (disembarkPerkCategory != disembarkPerkPrefab.SortCategory) { @@ -1888,13 +1903,25 @@ namespace Barotrauma Stretch = true }; - spectateBox = new GUITickBox(new RectTransform(new Vector2(0.4f, 0.06f), myCharacterContent.RectTransform), + var checkBoxContainer = new GUILayoutGroup(new RectTransform(new Vector2(1.0f, 0.0f), myCharacterContent.RectTransform), isHorizontal: true) + { + Stretch = true + }; + + spectateBox = new GUITickBox(new RectTransform(new Vector2(0.6f, 1.0f), checkBoxContainer.RectTransform), TextManager.Get("spectatebutton")) { Selected = false, OnSelected = ToggleSpectate, UserData = "spectate" }; + afkBox = new GUITickBox(new RectTransform(new Vector2(0.4f, 1.0f), checkBoxContainer.RectTransform), + TextManager.Get("afkbutton")) + { + Selected = false, + ToolTip = TextManager.Get("afkbutton.tooltip") + }; + checkBoxContainer.RectTransform.MinSize = new Point(0, spectateBox.RectTransform.MinSize.Y); playerInfoContent = new GUILayoutGroup(new RectTransform(new Vector2(1.0f, 0.9f), myCharacterContent.RectTransform)) { @@ -2125,6 +2152,25 @@ namespace Barotrauma joinOnGoingRoundButton = new GUIButton(new RectTransform(Vector2.One, roundControlsHolder.RectTransform), TextManager.Get("ServerListJoin")); + EndButton = new GUIButton(new RectTransform(Vector2.One, roundControlsHolder.RectTransform), + TextManager.Get("endround")) + { + //spooky red color for a destructive action + Color = GUIStyle.Red, + OnClicked = (btn, obj) => + { + if (GameMain.Client == null) { return true; } + GUI.CreateVerificationPrompt(GameMain.GameSession.GameMode is CampaignMode ? "PauseMenuReturnToServerLobbyVerification" : "EndRoundSubNotAtLevelEnd", + () => + { + GameMain.Client?.RequestEndRound(save: false); + }); + return true; + }, + Visible = false, + IgnoreLayoutGroups = true + }; + // Start button StartButton = new GUIButton(new RectTransform(Vector2.One, roundControlsHolder.RectTransform), TextManager.Get("StartGameButton")) @@ -2132,6 +2178,14 @@ namespace Barotrauma OnClicked = (btn, obj) => { if (GameMain.Client == null) { return true; } + + //the player presumably no longer wants to be afk if they clicked the start button + if (afkBox.Selected) + { + afkBox.Flash(GUIStyle.Green); + } + afkBox.Selected = false; + if (CampaignSetupFrame.Visible && CampaignSetupUI != null) { CampaignSetupUI.StartGameClicked(btn, obj); @@ -2146,6 +2200,7 @@ namespace Barotrauma } }; clientHiddenElements.Add(StartButton); + bottomBar.RectTransform.MinSize = new Point(0, (int)Math.Max(ReadyToStartBox.RectTransform.MinSize.Y / 0.75f, StartButton.RectTransform.MinSize.Y)); @@ -2244,6 +2299,9 @@ namespace Barotrauma RefreshEnabledElements(); + createPendingChangesText = false; + TabMenu.PendingChanges = false; + if (GameMain.Client != null) { joinOnGoingRoundButton.Visible = GameMain.Client.GameStarted; @@ -2258,8 +2316,18 @@ namespace Barotrauma if (GameMain.Client != null) { + afkBox.Visible = !GameMain.Client.IsServerOwner && GameMain.Client.ServerSettings.AllowAFK; GameMain.Client.Voting.ResetVotes(GameMain.Client.ConnectedClients); - joinOnGoingRoundButton.OnClicked = GameMain.Client.JoinOnGoingClicked; + joinOnGoingRoundButton.OnClicked = (btn, userdata) => + { + if (afkBox is { Selected: true }) + { + afkBox.Selected = false; + afkBox.Flash(GUIStyle.Green); + } + GameMain.Client.SendJoinOngoingRequest(btn); + return true; + }; ReadyToStartBox.OnSelected = GameMain.Client.SetReadyToStart; } @@ -2293,7 +2361,7 @@ namespace Barotrauma traitorElements.ForEach(e => e.Enabled &= settings.TraitorProbability > 0); SetTraitorDangerIndicators(settings.TraitorDangerLevel); respawnModeSelection.Enabled = respawnModeLabel.Enabled = manageSettings && !gameStarted; - midRoundRespawnSettings.ForEach(e => e.Enabled &= settings.RespawnMode == RespawnMode.MidRound); + midRoundRespawnSettings.ForEach(e => e.Enabled &= settings.RespawnMode != RespawnMode.BetweenRounds); permadeathDisabledRespawnSettings.ForEach(e => e.Enabled &= settings.RespawnMode != RespawnMode.Permadeath); permadeathEnabledRespawnSettings.ForEach(e => e.Enabled &= settings.RespawnMode == RespawnMode.Permadeath && !gameStarted); ironmanDisabledRespawnSettings.ForEach(e => e.Enabled &= !settings.IronmanMode); @@ -2366,6 +2434,7 @@ namespace Barotrauma if (campaignCharacterInfo != newCampaignCharacterInfo) { campaignCharacterInfo = newCampaignCharacterInfo; + SaveAppearance(); UpdatePlayerFrame(campaignCharacterInfo, false); } } @@ -2391,7 +2460,7 @@ namespace Barotrauma createPendingText: createPendingText); } - private void UpdatePlayerFrame(CharacterInfo characterInfo, bool allowEditing, GUIComponent parent, bool createPendingText = true) + private void UpdatePlayerFrame(CharacterInfo characterInfo, bool allowEditing, GUIComponent parent, bool createPendingText = false) { if (GameMain.Client == null) { return; } @@ -2402,7 +2471,7 @@ namespace Barotrauma if (characterInfo == null || CampaignCharacterDiscarded) { characterInfo = new CharacterInfo(CharacterPrefab.HumanSpeciesName, GameMain.Client.Name, null); - characterInfo.RecreateHead(MultiplayerPreferences.Instance); + characterInfo.RecreateHead(MultiplayerPreferences.Instance); // not necessarily the head of the last character GameMain.Client.CharacterInfo = characterInfo; characterInfo.OmitJobInMenus = true; } @@ -2824,8 +2893,7 @@ namespace Barotrauma new GUITextBlock(new RectTransform(new Vector2(1.0f, 0.0f), content.RectTransform), TextManager.GetWithVariable("startingequipmentname", "[number]", (variant + 1).ToString()), font: GUIStyle.SubHeadingFont, textAlignment: Alignment.Center); - var itemIdentifiers = jobPrefab.JobItems[variant] - .Where(it => it.ShowPreview) + var itemIdentifiers = jobPrefab.GetJobItems(variant, it => it.ShowPreview) .Select(it => it.GetItemIdentifier(team, isPvPMode)) .Distinct(); @@ -2958,6 +3026,16 @@ namespace Barotrauma spectateBox.Visible = allowSpectating; } + public void SetAllowAFK(bool allowAFK) + { + if (afkBox.Visible != allowAFK) + { + //reset selection when the AFK option becomes available or unavailable + afkBox.Selected = false; + afkBox.Visible = allowAFK; + } + } + public void SetAutoRestart(bool enabled, float timer = 0.0f) { autoRestartBox.Selected = enabled; @@ -3059,20 +3137,26 @@ namespace Barotrauma var subTextBlock = new GUITextBlock(new RectTransform(new Vector2(0.7f, 1.0f), frameLayout.RectTransform, Anchor.CenterLeft), ToolBox.LimitString(sub.DisplayName.Value, GUIStyle.Font, subList.Rect.Width - 65), textAlignment: Alignment.CenterLeft) { + ToolTip = sub.Description, UserData = "nametext", CanBeFocused = true }; - var pvpContainer = new GUIFrame(new RectTransform(new Vector2(0.3f, 1f), frameLayout.RectTransform, Anchor.CenterRight), style: null); + var pvpContainer = new GUIFrame(new RectTransform(new Vector2(0.3f, 1f), frameLayout.RectTransform, Anchor.CenterRight), style: null) + { + CanBeFocused = false + }; var coalitionIcon = new GUIFrame(new RectTransform(new Vector2(0.5f, 1f), pvpContainer.RectTransform, Anchor.CenterLeft), style: "CoalitionIcon") { Visible = false, UserData = CoalitionIconUserData, + CanBeFocused = false }; var separatistsIcon = new GUIFrame(new RectTransform(new Vector2(0.5f, 1f), pvpContainer.RectTransform, Anchor.CenterRight), style: "SeparatistIcon") { Visible = false, UserData = SeparatistsIconUserData, + CanBeFocused = false }; var matchingSub = @@ -3973,7 +4057,7 @@ namespace Barotrauma JobInfoFrame?.AddToGUIUpdateList(); CharacterAppearanceCustomizationMenu?.AddToGUIUpdateList(); - JobSelectionFrame?.AddToGUIUpdateList(); + JobSelectionFrame?.AddToGUIUpdateList(order: 1); } public override void Update(double deltaTime) @@ -4119,10 +4203,10 @@ namespace Barotrauma } } - private static void DrawJobVariantItems(SpriteBatch spriteBatch, GUICustomComponent component, JobVariant jobPrefab, CharacterTeamType team, bool isPvPMode, int itemsPerRow) + private static void DrawJobVariantItems(SpriteBatch spriteBatch, GUICustomComponent component, JobVariant jobVariant, CharacterTeamType team, bool isPvPMode, int itemsPerRow) { - var itemIdentifiers = jobPrefab.Prefab.JobItems[jobPrefab.Variant] - .Where(it => it.ShowPreview) + var allJobItems = jobVariant.Prefab.GetJobItems(jobVariant.Variant, it => it.ShowPreview); + var itemIdentifiers = allJobItems .Select(it => it.GetItemIdentifier(team, isPvPMode)) .Distinct(); @@ -4162,7 +4246,7 @@ namespace Barotrauma float iconScale = Math.Min(Math.Min(slotSize.X / icon.size.X, slotSize.Y / icon.size.Y), 2.0f) * 0.9f; icon.Draw(spriteBatch, slotPos + slotSize.ToVector2() * 0.5f, scale: iconScale); - int count = jobPrefab.Prefab.JobItems[jobPrefab.Variant].Where(it => it.ShowPreview && it.ItemIdentifier == itemIdentifier).Sum(it => it.Amount); + int count = allJobItems.Where(it => it.GetItemIdentifier(team, isPvPMode) == itemIdentifier).Sum(it => it.Amount); if (count > 1) { string itemCountText = "x" + count; @@ -4352,57 +4436,103 @@ namespace Barotrauma private bool OpenJobSelection(GUIComponent _, object __) { + //recreate if resolution has changed + if (GameMain.GraphicsWidth != prevResolutionForJobSelectionFrame.X || + GameMain.GraphicsHeight != prevResolutionForJobSelectionFrame.Y) + { + JobSelectionFrame = null; + } + + prevResolutionForJobSelectionFrame = new Point(GameMain.GraphicsWidth, GameMain.GraphicsHeight); + if (JobSelectionFrame != null) { JobSelectionFrame.Visible = true; return true; } - Point frameSize = new Point(characterInfoFrame.Rect.Width, (int)(characterInfoFrame.Rect.Height * 2 * 0.6f)); - JobSelectionFrame = new GUIFrame(new RectTransform(frameSize, GUI.Canvas, Anchor.TopLeft) - { AbsoluteOffset = new Point(characterInfoFrame.Rect.Right - frameSize.X, characterInfoFrame.Rect.Bottom) }, style:"GUIFrameListBox"); + var allJobs = JobPrefab.Prefabs.Where(jobPrefab => !jobPrefab.HiddenJob && jobPrefab.MaxNumber > 0); + + //find the jobs that aren't currently visible in the job list, create a preview of the first variant + var availableJobs = + allJobs.Where(jobPrefab => JobList.Content.Children.All(c => c.UserData is not JobVariant prefab || prefab.Prefab != jobPrefab)) + .Select(j => new JobVariant(j, 0)); + + //find the jobs that are currently visible in the job list, create a preview of the variant chosen in the list + availableJobs = availableJobs.Concat( + allJobs.Where(jobPrefab => JobList.Content.Children.Any(c => (c.UserData is JobVariant prefab) && prefab.Prefab == jobPrefab)) + .Select(j => (JobVariant)JobList.Content.FindChild(c => (c.UserData is JobVariant prefab) && prefab.Prefab == j).UserData)); + + availableJobs = availableJobs.ToList(); + + const int JobsPerRow = 3; + const int MaxRows = 4; + + int rowCount = (int)Math.Ceiling(availableJobs.Count() / (float)JobsPerRow); + int jobButtonSize = GUI.IntScale(150); + + const float listBoxRelativeSize = 0.95f; + + Point frameSize = new Point(characterInfoFrame.Rect.Width, (int)(jobButtonSize * Math.Min(rowCount, MaxRows) / listBoxRelativeSize)); + JobSelectionFrame = new GUIFrame(new RectTransform(frameSize, GUI.Canvas, Anchor.TopLeft), style: "GUIFrameListBox"); + + PositionJobSelectionFrame(); characterInfoFrame.RectTransform.SizeChanged += () => { if (characterInfoFrame == null || JobSelectionFrame?.RectTransform == null) { return; } - Point size = new Point(characterInfoFrame.Rect.Width, (int)(characterInfoFrame.Rect.Height * 2 * 0.6f)); + Point size = new Point(characterInfoFrame.Rect.Width, (int)(jobButtonSize * Math.Min(rowCount, MaxRows) / listBoxRelativeSize)); JobSelectionFrame.RectTransform.Resize(size); - JobSelectionFrame.RectTransform.AbsoluteOffset = new Point(characterInfoFrame.Rect.Right - size.X, characterInfoFrame.Rect.Bottom); + PositionJobSelectionFrame(); }; + void PositionJobSelectionFrame() + { + JobSelectionFrame.RectTransform.AbsoluteOffset = new Point(characterInfoFrame.Rect.Right - JobSelectionFrame.Rect.Width, characterInfoFrame.Rect.Bottom); + if (characterInfoFrame.Rect.Bottom + JobSelectionFrame.Rect.Height > GameMain.GraphicsHeight) + { + //move to the left side of the info frame if the bottom goes below the screen + JobSelectionFrame.RectTransform.AbsoluteOffset = new Point(characterInfoFrame.Rect.X - JobSelectionFrame.Rect.Width, characterInfoFrame.Rect.Bottom - JobSelectionFrame.Rect.Height / 2); + if (JobSelectionFrame.Rect.X < 0) + { + //scale if goes outside the screen horizontally + JobSelectionFrame.RectTransform.Resize(new Point(characterInfoFrame.Rect.X, JobSelectionFrame.Rect.Height)); + JobSelectionFrame.RectTransform.AbsoluteOffset = new Point(characterInfoFrame.Rect.X - JobSelectionFrame.Rect.Width, JobSelectionFrame.RectTransform.AbsoluteOffset.Y); + } + } + } + new GUIFrame(new RectTransform(new Vector2(1.25f, 1.25f), JobSelectionFrame.RectTransform, anchor: Anchor.Center), style: "OuterGlow", color: Color.Black) { UserData = "outerglow", CanBeFocused = false }; - var rows = new GUILayoutGroup(new RectTransform(Vector2.One, JobSelectionFrame.RectTransform)) { Stretch = true }; - var row = new GUILayoutGroup(new RectTransform(Vector2.One, rows.RectTransform), true); + var jobSelectionList = new GUIListBox(new RectTransform(Vector2.One * listBoxRelativeSize, JobSelectionFrame.RectTransform, Anchor.Center), style: "GUIFrameListBox") + { + Padding = Vector4.One * GUI.IntScale(10) + }; + + var row = new GUILayoutGroup(new RectTransform(new Point(jobSelectionList.Content.Rect.Width, jobButtonSize), jobSelectionList.Content.RectTransform), isHorizontal: true) + { + Stretch = true + }; GUIButton jobButton = null; - var availableJobs = JobPrefab.Prefabs.Where(jobPrefab => - !jobPrefab.HiddenJob && jobPrefab.MaxNumber > 0 && JobList.Content.Children.All(c => c.UserData is not JobVariant prefab || prefab.Prefab != jobPrefab) - ).Select(j => new JobVariant(j, 0)); - - availableJobs = availableJobs.Concat( - JobPrefab.Prefabs.Where(jobPrefab => - !jobPrefab.HiddenJob && jobPrefab.MaxNumber > 0 && JobList.Content.Children.Any(c => (c.UserData is JobVariant prefab) && prefab.Prefab == jobPrefab) - ).Select(j => (JobVariant)JobList.Content.FindChild(c => (c.UserData is JobVariant prefab) && prefab.Prefab == j).UserData)); - - availableJobs = availableJobs.ToList(); - int itemsInRow = 0; - foreach (var jobPrefab in availableJobs) { - if (itemsInRow >= 3) + if (itemsInRow >= JobsPerRow) { - row = new GUILayoutGroup(new RectTransform(Vector2.One, rows.RectTransform), true); + row = new GUILayoutGroup(new RectTransform(new Point(jobSelectionList.Content.Rect.Width, jobButtonSize), jobSelectionList.Content.RectTransform), isHorizontal: true) + { + Stretch = true + }; itemsInRow = 0; } - jobButton = new GUIButton(new RectTransform(new Vector2(1.0f / 3.0f, 1.0f), row.RectTransform), style: "ListBoxElementSquare") + jobButton = new GUIButton(new RectTransform(new Point(jobButtonSize), row.RectTransform), style: "ListBoxElementSquare") { UserData = jobPrefab, OnClicked = (btn, usdt) => @@ -4725,6 +4855,7 @@ namespace Barotrauma public void RefreshStartButtonVisibility() { + bool campaignActive = GameMain.GameSession?.GameMode is CampaignMode; if (CampaignSetupUI != null && CampaignSetupFrame is { Visible: true }) { //setting up a campaign -> start button only visible if we're in the "new game" tab (load game menu not visible) @@ -4736,7 +4867,6 @@ namespace Barotrauma else { //if a campaign is currently running, we must show the start button to allow continuing - bool campaignActive = GameMain.GameSession?.GameMode is CampaignMode; StartButton.Visible = (SelectedMode != GameModePreset.MultiPlayerCampaign || campaignActive) && !GameMain.Client.GameStarted && GameMain.Client.HasPermission(ClientPermissions.ManageRound); @@ -4752,6 +4882,14 @@ namespace Barotrauma ? TextManager.Get("DisembarkPointsNotValid") : string.Empty; } + + StartButton.IgnoreLayoutGroups = !StartButton.Visible; + //can end the round if round is running + EndButton.Visible = + !StartButton.Visible && + GameMain.Client is { GameStarted: true } && + (GameMain.Client.HasPermission(ClientPermissions.ManageRound) || (campaignActive && GameMain.Client.HasPermission(ClientPermissions.ManageCampaign))); + EndButton.IgnoreLayoutGroups = !EndButton.Visible; } public void RefreshChatrow() @@ -4999,7 +5137,7 @@ namespace Barotrauma private static GUIButton CreateJobVariantButton(JobVariant jobPrefab, int variantIndex, int variantCount, GUIComponent slot) { - float relativeSize = 0.15f; + float relativeSize = 0.18f; var btn = new GUIButton(new RectTransform(new Vector2(relativeSize), slot.RectTransform, Anchor.TopCenter, scaleBasis: ScaleBasis.BothHeight) { RelativeOffset = new Vector2(relativeSize * 1.3f * (variantIndex - (variantCount - 1) / 2.0f), 0.02f) }, @@ -5375,6 +5513,7 @@ namespace Barotrauma text: sub.DisplayName) { UserData = "nametext", + ToolTip = sub.Description, CanBeFocused = false }; diff --git a/Barotrauma/BarotraumaClient/ClientSource/Screens/SubEditorScreen.cs b/Barotrauma/BarotraumaClient/ClientSource/Screens/SubEditorScreen.cs index 6303e08fb..783eaee16 100644 --- a/Barotrauma/BarotraumaClient/ClientSource/Screens/SubEditorScreen.cs +++ b/Barotrauma/BarotraumaClient/ClientSource/Screens/SubEditorScreen.cs @@ -2568,9 +2568,34 @@ namespace Barotrauma { outpostTagsBox.Text = MainSub.Info.OutpostTags.ConvertToString(); } - outpostTagsGroup.RectTransform.MaxSize = outpostTagsBox.RectTransform.MaxSize; + var triggerMissionTagsGroup = new GUILayoutGroup(new RectTransform(new Vector2(1.0f, 0.25f), outpostSettingsContainer.RectTransform), isHorizontal: true) + { + Stretch = true + }; + new GUITextBlock(new RectTransform(new Vector2(0.6f, 1.0f), triggerMissionTagsGroup.RectTransform), + TextManager.Get("outpost.triggeroutpostmissionevents"), textAlignment: Alignment.CenterLeft, wrap: true); + var triggerMissionTagsBox = new GUITextBox(new RectTransform(new Vector2(0.4f, 1.0f), triggerMissionTagsGroup.RectTransform)) + { + OnEnterPressed = (GUITextBox textBox, string text) => + { + MainSub.Info.TriggerOutpostMissionEvents = text.ToIdentifiers().ToImmutableHashSet(); + return true; + }, + ToolTip = TextManager.Get("outpost.triggeroutpostmissionevents.tooltip"), + OverflowClip = true, + Text = "default" + }; + triggerMissionTagsBox.OnDeselected += (textbox, _) => + { + MainSub.Info.TriggerOutpostMissionEvents = triggerMissionTagsBox.Text.ToIdentifiers().ToImmutableHashSet(); + }; + if (MainSub.Info.TriggerOutpostMissionEvents != null) + { + triggerMissionTagsBox.Text = MainSub.Info.TriggerOutpostMissionEvents.ConvertToString(); + } + triggerMissionTagsGroup.RectTransform.MaxSize = triggerMissionTagsBox.RectTransform.MaxSize; //--------------------------------------- var enemySubmarineSettingsContainer = new GUILayoutGroup(new RectTransform(Vector2.One, subTypeDependentSettingFrame.RectTransform)) @@ -5794,12 +5819,14 @@ namespace Barotrauma { foreach (LightComponent lightComponent in item.GetComponents()) { - lightComponent.Light.Color = - (item.body == null || item.body.Enabled || item.ParentInventory is ItemInventory { Container.HideItems: false }) && + bool visibleInContainer = item.FindParentInventory(static it => it is ItemInventory { Container.HideItems: true }) == null; + lightComponent.Light.Color = + ((item.body == null || !item.body.Enabled) && !visibleInContainer) || /*the light is only visible when worn -> can't be visible in the editor*/ - lightComponent.Parent is not Wearable ? - lightComponent.LightColor : - Color.Transparent; + lightComponent.Parent is Wearable ? + Color.Transparent : + lightComponent.LightColor; + lightComponent.Light.LightSpriteEffect = lightComponent.Item.SpriteEffects; } } diff --git a/Barotrauma/BarotraumaClient/ClientSource/Settings/SettingsMenu.cs b/Barotrauma/BarotraumaClient/ClientSource/Settings/SettingsMenu.cs index de3bf0e22..34ebe4aca 100644 --- a/Barotrauma/BarotraumaClient/ClientSource/Settings/SettingsMenu.cs +++ b/Barotrauma/BarotraumaClient/ClientSource/Settings/SettingsMenu.cs @@ -811,9 +811,14 @@ namespace Barotrauma DropdownEnum(leftColumn, v => TextManager.Get($"InteractionLabels.{v}"), null, unsavedConfig.InteractionLabelDisplayMode, v => unsavedConfig.InteractionLabelDisplayMode = v); Label(rightColumn, TextManager.Get("HUDScale"), GUIStyle.SubHeadingFont); - Slider(rightColumn, (0.75f, 1.25f), 51, Percentage, unsavedConfig.Graphics.HUDScale, v => unsavedConfig.Graphics.HUDScale = v); + // Restricts the max scale to 110% on 16:9, and to 100% on 4:3. + // Higher scales are allowed for wide aspect ratios, up to 125%. + //float scalar = MathUtils.InverseLerp(0f, 1.0f, 0.4f - GUI.AspectRatioDifference); + //float maxScale = MathHelper.Lerp(1.0f, 1.25f, scalar); + const float maxScale = 1.25f; + Slider(rightColumn, (0.75f, maxScale), 51, Percentage, unsavedConfig.Graphics.HUDScale, v => unsavedConfig.Graphics.HUDScale = v); Label(rightColumn, TextManager.Get("InventoryScale"), GUIStyle.SubHeadingFont); - Slider(rightColumn, (0.75f, 1.25f), 51, Percentage, unsavedConfig.Graphics.InventoryScale, v => unsavedConfig.Graphics.InventoryScale = v); + Slider(rightColumn, (0.75f, maxScale), 51, Percentage, unsavedConfig.Graphics.InventoryScale, v => unsavedConfig.Graphics.InventoryScale = v); Label(rightColumn, TextManager.Get("TextScale"), GUIStyle.SubHeadingFont); Slider(rightColumn, (0.75f, 1.25f), 51, Percentage, unsavedConfig.Graphics.TextScale, v => unsavedConfig.Graphics.TextScale = v); Spacer(rightColumn); diff --git a/Barotrauma/BarotraumaClient/ClientSource/Sounds/Sound.cs b/Barotrauma/BarotraumaClient/ClientSource/Sounds/Sound.cs index e1f002618..fa2f0080b 100644 --- a/Barotrauma/BarotraumaClient/ClientSource/Sounds/Sound.cs +++ b/Barotrauma/BarotraumaClient/ClientSource/Sounds/Sound.cs @@ -104,19 +104,22 @@ namespace Barotrauma.Sounds public virtual SoundChannel Play(float gain, float range, Vector2 position, bool muffle = false) { LogWarningIfStillLoading(); - return new SoundChannel(this, gain, new Vector3(position.X, position.Y, 0.0f), 1.0f, range * 0.4f, range, "default", muffle); + if (Owner.CountPlayingInstances(this) >= MaxSimultaneousInstances) { return null; } + return new SoundChannel(this, gain, new Vector3(position.X, position.Y, 0.0f), 1.0f, range * 0.4f, range, SoundManager.SoundCategoryDefault, muffle); } public virtual SoundChannel Play(float gain, float range, float freqMult, Vector2 position, bool muffle = false) { LogWarningIfStillLoading(); - return new SoundChannel(this, gain, new Vector3(position.X, position.Y, 0.0f), freqMult, range * 0.4f, range, "default", muffle); + if (Owner.CountPlayingInstances(this) >= MaxSimultaneousInstances) { return null; } + return new SoundChannel(this, gain, new Vector3(position.X, position.Y, 0.0f), freqMult, range * 0.4f, range, SoundManager.SoundCategoryDefault, muffle); } public virtual SoundChannel Play(Vector3? position, float gain, float freqMult = 1.0f, bool muffle = false) { LogWarningIfStillLoading(); - return new SoundChannel(this, gain, position, freqMult, BaseNear, BaseFar, "default", muffle); + if (Owner.CountPlayingInstances(this) >= MaxSimultaneousInstances) { return null; } + return new SoundChannel(this, gain, position, freqMult, BaseNear, BaseFar, SoundManager.SoundCategoryDefault, muffle); } public virtual SoundChannel Play(float gain) @@ -129,7 +132,7 @@ namespace Barotrauma.Sounds return Play(BaseGain); } - public virtual SoundChannel Play(float? gain, string category) + public virtual SoundChannel Play(float? gain, Identifier category) { if (Owner.CountPlayingInstances(this) >= MaxSimultaneousInstances) { return null; } return new SoundChannel(this, gain ?? BaseGain, null, 1.0f, BaseNear, BaseFar, category); diff --git a/Barotrauma/BarotraumaClient/ClientSource/Sounds/SoundChannel.cs b/Barotrauma/BarotraumaClient/ClientSource/Sounds/SoundChannel.cs index a67ad6fee..df9d68998 100644 --- a/Barotrauma/BarotraumaClient/ClientSource/Sounds/SoundChannel.cs +++ b/Barotrauma/BarotraumaClient/ClientSource/Sounds/SoundChannel.cs @@ -398,8 +398,8 @@ namespace Barotrauma.Sounds } } - private string category; - public string Category + private Identifier category; + public Identifier Category { get { return category; } set @@ -483,7 +483,7 @@ namespace Barotrauma.Sounds } } - public SoundChannel(Sound sound, float gain, Vector3? position, float freqMult, float near, float far, string category, bool muffle = false) + public SoundChannel(Sound sound, float gain, Vector3? position, float freqMult, float near, float far, Identifier category, bool muffle = false) { Sound = sound; diff --git a/Barotrauma/BarotraumaClient/ClientSource/Sounds/SoundManager.cs b/Barotrauma/BarotraumaClient/ClientSource/Sounds/SoundManager.cs index 781d0f17a..1f2f37dea 100644 --- a/Barotrauma/BarotraumaClient/ClientSource/Sounds/SoundManager.cs +++ b/Barotrauma/BarotraumaClient/ClientSource/Sounds/SoundManager.cs @@ -12,11 +12,11 @@ namespace Barotrauma.Sounds class SoundManager : IDisposable { public const int SourceCount = 32; - public const string SoundCategoryDefault = "default"; - public const string SoundCategoryUi = "ui"; - public const string SoundCategoryWaterAmbience = "waterambience"; - public const string SoundCategoryMusic = "music"; - public const string SoundCategoryVoip = "voip"; + public static readonly Identifier SoundCategoryDefault = "default".ToIdentifier(); + public static readonly Identifier SoundCategoryUi = "ui".ToIdentifier(); + public static readonly Identifier SoundCategoryWaterAmbience = "waterambience".ToIdentifier(); + public static readonly Identifier SoundCategoryMusic = "music".ToIdentifier(); + public static readonly Identifier SoundCategoryVoip = "voip".ToIdentifier(); public bool Disabled { @@ -201,7 +201,7 @@ namespace Barotrauma.Sounds } } - private readonly Dictionary categoryModifiers = new Dictionary(); + private readonly Dictionary categoryModifiers = new Dictionary(); public SoundManager() { @@ -548,10 +548,9 @@ namespace Barotrauma.Sounds } } - public void SetCategoryGainMultiplier(string category, float gain, int index=0) + public void SetCategoryGainMultiplier(Identifier category, float gain, int index=0) { if (Disabled) { return; } - category = category.ToLower(); lock (categoryModifiers) { if (!categoryModifiers.ContainsKey(category)) @@ -579,10 +578,9 @@ namespace Barotrauma.Sounds } } - public float GetCategoryGainMultiplier(string category, int index = -1) + public float GetCategoryGainMultiplier(Identifier category, int index = -1) { if (Disabled) { return 0.0f; } - category = category.ToLower(); lock (categoryModifiers) { if (categoryModifiers == null || !categoryModifiers.TryGetValue(category, out CategoryModifier categoryModifier)) { return 1.0f; } @@ -602,11 +600,10 @@ namespace Barotrauma.Sounds } } - public void SetCategoryMuffle(string category, bool muffle) + public void SetCategoryMuffle(Identifier category, bool muffle) { if (Disabled) { return; } - category = category.ToLower(); lock (categoryModifiers) { if (!categoryModifiers.ContainsKey(category)) @@ -627,18 +624,17 @@ namespace Barotrauma.Sounds { if (playingChannels[i][j] != null && playingChannels[i][j].IsPlaying) { - if (playingChannels[i][j]?.Category.ToLower() == category) { playingChannels[i][j].Muffled = muffle; } + if (playingChannels[i][j]?.Category == category) { playingChannels[i][j].Muffled = muffle; } } } } } } - public bool GetCategoryMuffle(string category) + public bool GetCategoryMuffle(Identifier category) { if (Disabled) { return false; } - category = category.ToLower(); lock (categoryModifiers) { if (categoryModifiers == null || !categoryModifiers.TryGetValue(category, out CategoryModifier categoryModifier)) { return false; } diff --git a/Barotrauma/BarotraumaClient/ClientSource/Sounds/SoundPlayer.cs b/Barotrauma/BarotraumaClient/ClientSource/Sounds/SoundPlayer.cs index 1f7bbf5b2..825ede7f0 100644 --- a/Barotrauma/BarotraumaClient/ClientSource/Sounds/SoundPlayer.cs +++ b/Barotrauma/BarotraumaClient/ClientSource/Sounds/SoundPlayer.cs @@ -191,9 +191,12 @@ namespace Barotrauma { if (volume < 0.01f) { return; } if (chn is not null) { waterAmbienceChannels.Remove(chn); } - chn = sound.Play(volume, "waterambience"); - chn.Looping = true; - waterAmbienceChannels.Add(chn); + chn = sound.Play(volume, SoundManager.SoundCategoryWaterAmbience); + if (chn != null) + { + chn.Looping = true; + waterAmbienceChannels.Add(chn); + } } else { @@ -307,6 +310,7 @@ namespace Barotrauma if (flowSoundChannels[i] == null || !flowSoundChannels[i].IsPlaying) { flowSoundChannels[i] = FlowSounds[i].Sound.Play(1.0f, FlowSoundRange, soundPos); + if (flowSoundChannels[i] == null) { continue; } flowSoundChannels[i].Looping = true; } flowSoundChannels[i].Gain = Math.Max(flowVolumeRight[i], flowVolumeLeft[i]); @@ -687,7 +691,22 @@ namespace Barotrauma } LogCurrentMusic(); - updateMusicTimer = UpdateMusicInterval; + updateMusicTimer = UpdateMusicInterval; + if (mainTrack != null) + { + updateMusicTimer += mainTrack.MinimumPlayDuration; + } + } + + bool muteBackgroundMusic = false; + for (int i = 0; i < SoundManager.SourceCount; i++) + { + SoundChannel playingSoundChannel = GameMain.SoundManager.GetSoundChannelFromIndex(SoundManager.SourcePoolIndex.Default, i); + if (playingSoundChannel is { MuteBackgroundMusic: true, IsPlaying: true }) + { + muteBackgroundMusic = true; + break; + } } bool muteBackgroundMusic = false; @@ -734,7 +753,7 @@ namespace Barotrauma DisposeMusicChannel(i); currentMusic[i] = targetMusic[i]; - musicChannel[i] = currentMusic[i].Sound.Play(0.0f, i == noiseLoopIndex ? "default" : "music"); + musicChannel[i] = currentMusic[i].Sound.Play(0.0f, i == noiseLoopIndex ? SoundManager.SoundCategoryDefault : SoundManager.SoundCategoryMusic); if (targetMusic[i].ContinueFromPreviousTime) { musicChannel[i].StreamSeekPos = targetMusic[i].PreviousTime; @@ -753,7 +772,7 @@ namespace Barotrauma if (musicChannel[i] == null || !musicChannel[i].IsPlaying) { musicChannel[i]?.Dispose(); - musicChannel[i] = currentMusic[i].Sound.Play(0.0f, i == noiseLoopIndex ? "default" : "music"); + musicChannel[i] = currentMusic[i].Sound.Play(0.0f, i == noiseLoopIndex ? SoundManager.SoundCategoryDefault : SoundManager.SoundCategoryMusic); musicChannel[i].Looping = true; } float targetGain = targetMusic[i].Volume; @@ -874,6 +893,51 @@ namespace Barotrauma } Submarine targetSubmarine = Character.Controlled?.Submarine; + + float intensity = (GameMain.GameSession?.EventManager?.MusicIntensity ?? 0) * 100.0f; + + float enemyDistThreshold = 5000.0f; + if (targetSubmarine != null) + { + enemyDistThreshold = Math.Max(enemyDistThreshold, Math.Max(targetSubmarine.Borders.Width, targetSubmarine.Borders.Height) * 2.0f); + } + + List monsterMusicCharacters = new List(); + foreach (Character character in Character.CharacterList) + { + if (character.IsDead || !character.Enabled) { continue; } + if (character.AIController is not EnemyAIController { Enabled: true } enemyAI) { continue; } + if (!enemyAI.AttackHumans && !enemyAI.AttackRooms) { continue; } + + bool specificMonsterMusicAvailable = + musicClips.Any(m => IsSuitableMusicClip(m, character.Params.MusicType, intensity)); + + if (specificMonsterMusicAvailable) + { + float maxDistSqr = MathF.Pow(enemyDistThreshold * character.Params.MusicRangeMultiplier, 2); + if (targetSubmarine != null) + { + if (Vector2.DistanceSquared(character.WorldPosition, targetSubmarine.WorldPosition) < maxDistSqr) + { + monsterMusicCharacters.Add(character); + } + } + else if (Character.Controlled != null) + { + if (Vector2.DistanceSquared(character.WorldPosition, Character.Controlled.WorldPosition) < maxDistSqr) + { + monsterMusicCharacters.Add(character); + } + } + } + } + + if (monsterMusicCharacters.Any()) + { + Character chosenCharacter = monsterMusicCharacters.GetRandomByWeight(c => c.Params.MusicCommonness, Rand.RandSync.Unsynced); + return chosenCharacter.Params.MusicType; + } + if (targetSubmarine != null && targetSubmarine.AtDamageDepth) { return "deep".ToIdentifier(); @@ -898,41 +962,6 @@ namespace Barotrauma if (totalArea > 0.0f && floodedArea / totalArea > 0.25f) { return "flooded".ToIdentifier(); } } - float intensity = (GameMain.GameSession?.EventManager?.MusicIntensity ?? 0) * 100.0f; - bool anyMonsterMusicAvailable = - musicClips.Any(m => IsSuitableMusicClip(m, "monster".ToIdentifier(), intensity) || IsSuitableMusicClip(m, "monsterambience".ToIdentifier(), intensity)); - - if (anyMonsterMusicAvailable) - { - float enemyDistThreshold = 5000.0f; - if (targetSubmarine != null) - { - enemyDistThreshold = Math.Max(enemyDistThreshold, Math.Max(targetSubmarine.Borders.Width, targetSubmarine.Borders.Height) * 2.0f); - } - foreach (Character character in Character.CharacterList) - { - if (character.IsDead || !character.Enabled) { continue; } - if (character.AIController is not EnemyAIController { Enabled: true } enemyAI) { continue; } - if (!enemyAI.AttackHumans && !enemyAI.AttackRooms) { continue; } - - if (targetSubmarine != null) - { - if (Vector2.DistanceSquared(character.WorldPosition, targetSubmarine.WorldPosition) < enemyDistThreshold * enemyDistThreshold) - { - return "monster".ToIdentifier(); - } - } - else if (Character.Controlled != null) - { - if (Vector2.DistanceSquared(character.WorldPosition, Character.Controlled.WorldPosition) < enemyDistThreshold * enemyDistThreshold) - { - return "monster".ToIdentifier(); - } - } - } - } - - if (GameMain.GameSession != null) { if (Submarine.Loaded != null && Level.Loaded != null && Submarine.MainSub != null && Submarine.MainSub.AtEndExit) @@ -1012,7 +1041,7 @@ namespace Barotrauma { GUISound.GUISoundPrefabs .Where(s => s.Type == soundType) - .GetRandomUnsynced()?.Sound?.Play(null, "ui"); + .GetRandomUnsynced()?.Sound?.Play(null, SoundManager.SoundCategoryUi); } public static void PlayUISound(GUISoundType? soundType) diff --git a/Barotrauma/BarotraumaClient/ClientSource/Sounds/SoundPrefab.cs b/Barotrauma/BarotraumaClient/ClientSource/Sounds/SoundPrefab.cs index 9861b961f..87e52d927 100644 --- a/Barotrauma/BarotraumaClient/ClientSource/Sounds/SoundPrefab.cs +++ b/Barotrauma/BarotraumaClient/ClientSource/Sounds/SoundPrefab.cs @@ -248,6 +248,10 @@ namespace Barotrauma public readonly bool StartFromRandomTime; public readonly bool ContinueFromPreviousTime; public int PreviousTime; + /// + /// The music is forced to play at least for this long when it triggers, even if the situation changes and makes the music no longer suitable. + /// + public readonly float MinimumPlayDuration; public BackgroundMusic(ContentXElement element, SoundsFile file) : base(element, file, stream: true) { @@ -260,7 +264,8 @@ namespace Barotrauma ForceIntensityTrack = element.GetAttributeFloat(nameof(ForceIntensityTrack), 0.0f); } StartFromRandomTime = element.GetAttributeBool(nameof(StartFromRandomTime), false); - ContinueFromPreviousTime = element.GetAttributeBool(nameof(ContinueFromPreviousTime), false); + ContinueFromPreviousTime = element.GetAttributeBool(nameof(ContinueFromPreviousTime), false); + MinimumPlayDuration = element.GetAttributeFloat(nameof(MinimumPlayDuration), 0.0f); } } diff --git a/Barotrauma/BarotraumaClient/ClientSource/Sounds/VideoSound.cs b/Barotrauma/BarotraumaClient/ClientSource/Sounds/VideoSound.cs index f462abe24..0e4ca8b1b 100644 --- a/Barotrauma/BarotraumaClient/ClientSource/Sounds/VideoSound.cs +++ b/Barotrauma/BarotraumaClient/ClientSource/Sounds/VideoSound.cs @@ -76,7 +76,7 @@ namespace Barotrauma.Sounds soundChannel = null; } } - chn = new SoundChannel(this, gain, null, 1.0f, 1.0f, 3.0f, "video", false); + chn = new SoundChannel(this, gain, null, 1.0f, 1.0f, 3.0f, "video".ToIdentifier(), false); lock (mutex) { soundChannel = chn; diff --git a/Barotrauma/BarotraumaClient/ClientSource/Sounds/VoipSound.cs b/Barotrauma/BarotraumaClient/ClientSource/Sounds/VoipSound.cs index 7aefc3594..f1e220189 100644 --- a/Barotrauma/BarotraumaClient/ClientSource/Sounds/VoipSound.cs +++ b/Barotrauma/BarotraumaClient/ClientSource/Sounds/VoipSound.cs @@ -81,7 +81,7 @@ namespace Barotrauma.Sounds soundChannel = null; - SoundChannel chn = new SoundChannel(this, 1.0f, null, 1.0f, 0.4f, 1.0f, "voip", false); + SoundChannel chn = new SoundChannel(this, 1.0f, null, 1.0f, 0.4f, 1.0f, SoundManager.SoundCategoryVoip, false); soundChannel = chn; Gain = 1.0f; } diff --git a/Barotrauma/BarotraumaClient/ClientSource/Sprite/DeformAnimations/CustomDeformation.cs b/Barotrauma/BarotraumaClient/ClientSource/Sprite/DeformAnimations/CustomDeformation.cs index 2f9b72f5b..cbe9d1527 100644 --- a/Barotrauma/BarotraumaClient/ClientSource/Sprite/DeformAnimations/CustomDeformation.cs +++ b/Barotrauma/BarotraumaClient/ClientSource/Sprite/DeformAnimations/CustomDeformation.cs @@ -23,7 +23,9 @@ namespace Barotrauma.SpriteDeformations class CustomDeformation : SpriteDeformation { - private List deformRows = new List(); + private readonly List deformRows = new List(); + + private readonly Vector2[,] flippedDeformation; private CustomDeformationParams CustomDeformationParams => Params as CustomDeformationParams; @@ -81,40 +83,25 @@ namespace Barotrauma.SpriteDeformations //construct an array for the desired resolution, //interpolating values if the resolution configured in the xml is smaller //deformation = new Vector2[Resolution.X, Resolution.Y]; - float divX = 1.0f / Resolution.X, divY = 1.0f / Resolution.Y; + int newWidth = Resolution.X, newHeight = Resolution.Y; + Deformation = MathUtils.ResizeVector2Array(configDeformation, newWidth, newHeight); + + flippedDeformation = new Vector2[Resolution.X, Resolution.Y]; for (int x = 0; x < Resolution.X; x++) { - float normalizedX = x / (float)(Resolution.X - 1); for (int y = 0; y < Resolution.Y; y++) { - float normalizedY = y / (float)(Resolution.Y - 1); - - Point indexTopLeft = new Point( - Math.Min((int)Math.Floor(normalizedX * (configDeformation.GetLength(0) - 1)), configDeformation.GetLength(0) - 1), - Math.Min((int)Math.Floor(normalizedY * (configDeformation.GetLength(1) - 1)), configDeformation.GetLength(1) - 1)); - Point indexBottomRight = new Point( - Math.Min(indexTopLeft.X + 1, configDeformation.GetLength(0) - 1), - Math.Min(indexTopLeft.Y + 1, configDeformation.GetLength(1) - 1)); - - Vector2 deformTopLeft = configDeformation[indexTopLeft.X, indexTopLeft.Y]; - Vector2 deformTopRight = configDeformation[indexBottomRight.X, indexTopLeft.Y]; - Vector2 deformBottomLeft = configDeformation[indexTopLeft.X, indexBottomRight.Y]; - Vector2 deformBottomRight = configDeformation[indexBottomRight.X, indexBottomRight.Y]; - - Deformation[x, y] = Vector2.Lerp( - Vector2.Lerp(deformTopLeft, deformTopRight, (normalizedX % divX) / divX), - Vector2.Lerp(deformBottomLeft, deformBottomRight, (normalizedX % divX) / divX), - (normalizedY % divY) / divY); + flippedDeformation[x, y] = Deformation[Resolution.X - x - 1, y]; // read the rows from right to left } } } - protected override void GetDeformation(out Vector2[,] deformation, out float multiplier, bool inverse) + protected override void GetDeformation(out Vector2[,] deformation, out float multiplier, bool flippedHorizontally, bool inverseY) { - deformation = Deformation; + deformation = flippedHorizontally ? flippedDeformation : Deformation; multiplier = CustomDeformationParams.Frequency <= 0.0f ? CustomDeformationParams.Amplitude : - (float)Math.Sin(inverse ? -phase : phase) * CustomDeformationParams.Amplitude; + (float)Math.Sin(inverseY ? -phase : phase) * CustomDeformationParams.Amplitude; multiplier *= Params.Strength; } diff --git a/Barotrauma/BarotraumaClient/ClientSource/Sprite/DeformAnimations/Inflate.cs b/Barotrauma/BarotraumaClient/ClientSource/Sprite/DeformAnimations/Inflate.cs index 5f4ef5f80..c05ca7520 100644 --- a/Barotrauma/BarotraumaClient/ClientSource/Sprite/DeformAnimations/Inflate.cs +++ b/Barotrauma/BarotraumaClient/ClientSource/Sprite/DeformAnimations/Inflate.cs @@ -54,7 +54,7 @@ namespace Barotrauma.SpriteDeformations phase = Rand.Range(0.0f, MathHelper.TwoPi); } - protected override void GetDeformation(out Vector2[,] deformation, out float multiplier, bool inverse) + protected override void GetDeformation(out Vector2[,] deformation, out float multiplier, bool flippedHorizontally, bool inverseY = false) { deformation = this.deformation; multiplier = InflateParams.Frequency <= 0.0f ? InflateParams.Scale : (float)(Math.Sin(phase) + 1.0f) / 2.0f * InflateParams.Scale; diff --git a/Barotrauma/BarotraumaClient/ClientSource/Sprite/DeformAnimations/JointBendDeformation.cs b/Barotrauma/BarotraumaClient/ClientSource/Sprite/DeformAnimations/JointBendDeformation.cs index 0c6d1058f..913772fe0 100644 --- a/Barotrauma/BarotraumaClient/ClientSource/Sprite/DeformAnimations/JointBendDeformation.cs +++ b/Barotrauma/BarotraumaClient/ClientSource/Sprite/DeformAnimations/JointBendDeformation.cs @@ -56,7 +56,7 @@ namespace Barotrauma.SpriteDeformations public JointBendDeformation(XElement element) : base(element, new JointBendDeformationParams(element)) { } - protected override void GetDeformation(out Vector2[,] deformation, out float multiplier, bool inverse) + protected override void GetDeformation(out Vector2[,] deformation, out float multiplier, bool flippedHorizontally, bool inverseY = false) { deformation = Deformation; multiplier = 1.0f;// this.multiplier; diff --git a/Barotrauma/BarotraumaClient/ClientSource/Sprite/DeformAnimations/NoiseDeformation.cs b/Barotrauma/BarotraumaClient/ClientSource/Sprite/DeformAnimations/NoiseDeformation.cs index 775aac2ec..94573efab 100644 --- a/Barotrauma/BarotraumaClient/ClientSource/Sprite/DeformAnimations/NoiseDeformation.cs +++ b/Barotrauma/BarotraumaClient/ClientSource/Sprite/DeformAnimations/NoiseDeformation.cs @@ -47,7 +47,7 @@ namespace Barotrauma.SpriteDeformations } } - protected override void GetDeformation(out Vector2[,] deformation, out float multiplier, bool inverse) + protected override void GetDeformation(out Vector2[,] deformation, out float multiplier, bool flippedHorizontally, bool inverseY = false) { deformation = Deformation; multiplier = NoiseDeformationParams.Amplitude * Params.Strength; diff --git a/Barotrauma/BarotraumaClient/ClientSource/Sprite/DeformAnimations/PositionalDeformation.cs b/Barotrauma/BarotraumaClient/ClientSource/Sprite/DeformAnimations/PositionalDeformation.cs index 26ad4c949..2e775d70c 100644 --- a/Barotrauma/BarotraumaClient/ClientSource/Sprite/DeformAnimations/PositionalDeformation.cs +++ b/Barotrauma/BarotraumaClient/ClientSource/Sprite/DeformAnimations/PositionalDeformation.cs @@ -100,7 +100,7 @@ namespace Barotrauma.SpriteDeformations } } - protected override void GetDeformation(out Vector2[,] deformation, out float multiplier, bool inverse) + protected override void GetDeformation(out Vector2[,] deformation, out float multiplier, bool flippedHorizontally, bool inverseY) { deformation = Deformation; multiplier = 1.0f; diff --git a/Barotrauma/BarotraumaClient/ClientSource/Sprite/DeformAnimations/SpriteDeformation.cs b/Barotrauma/BarotraumaClient/ClientSource/Sprite/DeformAnimations/SpriteDeformation.cs index 8abba88a0..b887b1b84 100644 --- a/Barotrauma/BarotraumaClient/ClientSource/Sprite/DeformAnimations/SpriteDeformation.cs +++ b/Barotrauma/BarotraumaClient/ClientSource/Sprite/DeformAnimations/SpriteDeformation.cs @@ -104,7 +104,7 @@ namespace Barotrauma.SpriteDeformations public virtual float Phase { get; set; } - protected Vector2[,] Deformation { get; private set; } + protected Vector2[,] Deformation { get; set; } public SpriteDeformationParams Params { get; set; } @@ -141,7 +141,13 @@ namespace Barotrauma.SpriteDeformations { typeName = element.GetAttributeString("typename", null) ?? element.GetAttributeString("type", ""); } - + + var resolution = element.GetAttributePoint(nameof(Resolution), new Point(0, 0)); + if (resolution.X < 2|| resolution.Y < 2) + { + DebugConsole.AddWarning($"Potential error in sprite deformation ({parentDebugName}): resolution must be at least 2x2."); + } + SpriteDeformation newDeformation = null; switch (typeName.ToLowerInvariant()) { @@ -195,12 +201,14 @@ namespace Barotrauma.SpriteDeformations Deformation = new Vector2[Params.Resolution.X, Params.Resolution.Y]; } - protected abstract void GetDeformation(out Vector2[,] deformation, out float multiplier, bool inverse); + /// Is the sprite flipped horizontally? + /// Should the y-coordinate of customdeformations be inverted? Legacy fix for mirroring deformable light sprites. + protected abstract void GetDeformation(out Vector2[,] deformation, out float multiplier, bool flippedHorizontally, bool inverseY); public abstract void Update(float deltaTime); private static readonly List yValues = new List(); - public static Vector2[,] GetDeformation(IEnumerable animations, Vector2 scale, bool inverseY = false) + public static Vector2[,] GetDeformation(IEnumerable animations, Vector2 scale, bool flippedHorizontally, bool inverseY = false) { foreach (SpriteDeformation animation in animations) { @@ -231,7 +239,7 @@ namespace Barotrauma.SpriteDeformations { yValues.Reverse(); } - animation.GetDeformation(out Vector2[,] animDeformation, out float multiplier, inverseY); + animation.GetDeformation(out Vector2[,] animDeformation, out float multiplier, flippedHorizontally, inverseY); for (int x = 0; x < resolution.X; x++) { for (int y = 0; y < resolution.Y; y++) diff --git a/Barotrauma/BarotraumaClient/ClientSource/Sprite/DeformableSprite.cs b/Barotrauma/BarotraumaClient/ClientSource/Sprite/DeformableSprite.cs index 6287642f0..d48428ea7 100644 --- a/Barotrauma/BarotraumaClient/ClientSource/Sprite/DeformableSprite.cs +++ b/Barotrauma/BarotraumaClient/ClientSource/Sprite/DeformableSprite.cs @@ -33,8 +33,11 @@ namespace Barotrauma get { return effect; } } + public Point Subdivisions => new Point(subDivX, subDivY); + public bool Invert { get; set; } + private Point spritePos; private Point spriteSize; @@ -48,7 +51,7 @@ namespace Barotrauma Invert = invert; //use subdivisions configured in the xml if the arguments passed to the method are null - Vector2 subdivisionsInXml = element.GetAttributeVector2("subdivisions", Vector2.One); + Vector2 subdivisionsInXml = element.GetAttributeVector2("subdivisions", element.GetAttributeVector2("resolution", Vector2.One)); subDivX = subdivisionsX ?? (int)subdivisionsInXml.X; subDivY = subdivisionsY ?? (int)subdivisionsInXml.Y; diff --git a/Barotrauma/BarotraumaClient/ClientSource/Steam/WorkshopMenu/Mutable/InstalledTab.cs b/Barotrauma/BarotraumaClient/ClientSource/Steam/WorkshopMenu/Mutable/InstalledTab.cs index b23b99d60..1b617aa07 100644 --- a/Barotrauma/BarotraumaClient/ClientSource/Steam/WorkshopMenu/Mutable/InstalledTab.cs +++ b/Barotrauma/BarotraumaClient/ClientSource/Steam/WorkshopMenu/Mutable/InstalledTab.cs @@ -1,6 +1,7 @@ #nullable enable using Barotrauma.Extensions; using Microsoft.Xna.Framework; +using Steamworks.Data; using System; using System.Collections.Generic; using System.Linq; @@ -537,6 +538,13 @@ namespace Barotrauma.Steam { if (!mod.UgcId.TryUnwrap(out var ugcId) || ugcId is not SteamWorkshopId workshopId) { return; } + + if (mod.UgcItem.TryUnwrap(out var cachedItem)) + { + onInstalledInfoButtonHit(cachedItem); + return; + } + TaskPool.Add($"PrepareToShow{mod.UgcId}Info", SteamManager.Workshop.GetItem(workshopId.Value), t => { @@ -634,7 +642,23 @@ namespace Barotrauma.Steam { UserData = mod }; - + //fetch the description in DrawToolTip, so we only need to fetch it if it's actually needed + modFrame.OnDrawToolTip += (GUIComponent component) => + { + if (modFrame.ToolTip.IsNullOrEmpty()) + { + mod.TryFetchUgcDescription(onFinished: (string? description) => + { + //check if the tooltip is empty still (in case it was changed after we started fetching the description + if (modFrame.ToolTip.IsNullOrEmpty() && + !string.IsNullOrEmpty(description)) + { + modFrame.ToolTip = description + "..."; + } + }); + } + }; + var frameContent = new GUILayoutGroup(new RectTransform((0.95f, 0.9f), modFrame.RectTransform, Anchor.Center), isHorizontal: true, childAnchor: Anchor.CenterLeft) { Stretch = true, @@ -696,6 +720,11 @@ namespace Barotrauma.Steam contextMenuOptions.Add(new("ViewWorkshopModDetails".ToIdentifier(), isEnabled: true, () => PrepareToShowModInfo(mod))); contextMenuOptions.Add(new("CopyWorkshopToLocal".ToIdentifier(), isEnabled: true, () => CopyToLocal())); } + if (mod.MissingDependencies.Any()) + { + contextMenuOptions.Add(new("workshop.dependencynotfound.showmissingdependencies".ToIdentifier(), isEnabled: true, + () => CreateDependencyErrorMessageBox(mod, mod.MissingDependencies))); + } if (selectedMods.All(ContentPackageManager.WorkshopPackages.Contains)) { if (parentList.AllSelected.All(c => c.GetChild()?.GetAllChildren().Last()?.Style?.Identifier == "WorkshopMenu.DownloadedIcon") && selectedMods.Length > 0 && SteamManager.IsInitialized) @@ -856,5 +885,80 @@ namespace Barotrauma.Steam UpdateModListItemVisibility(); } + + private void CreateDependencyErrorMessageBox(ContentPackage contentPackage, IEnumerable missingDependencies) + { + GUIMessageBox msgBox = new GUIMessageBox(TextManager.Get("Error"), + TextManager.GetWithVariable("workshop.dependencynotfoundtitle", "[name]", contentPackage.Name), new Vector2(0.25f, 0.0f), minSize: new Point(GUI.IntScale(650), GUI.IntScale(650))); + msgBox.Buttons[0].OnClicked = (btn, userdata) => + { + SettingsMenu.Instance?.ApplyInstalledModChanges(); + msgBox.Close(); + return true; + }; + var textListBox = new GUIListBox(new RectTransform(new Vector2(1.0f, 0.75f), msgBox.Content.RectTransform)); + + foreach (var dependency in missingDependencies) + { + var textBlock = new GUITextBlock(new RectTransform(new Vector2(1.0f, 0.0f), textListBox.Content.RectTransform), + $"- {TextManager.Get("unknown")} {dependency}") + { + CanBeFocused = false + }; + + var matchingPackage = ContentPackageManager.WorkshopPackages.FirstOrDefault(p => + p.UgcId.TryUnwrap(out var ugcId) && + ugcId is SteamWorkshopId workshopId && + workshopId.Value == dependency.Value); + if (matchingPackage != null && + disabledRegularModsList.Content.GetChildByUserData(matchingPackage) is GUIComponent matchingListElement) + { + textBlock.Text = $"- {matchingPackage.Name}"; + var enableButton = new GUIButton(new RectTransform(new Vector2(0.25f, 0.9f), textBlock.RectTransform, Anchor.CenterRight), TextManager.Get("workshopitemenabled")) + { + OnClicked = (btn, userdata) => + { + btn.Enabled = false; + textBlock.Flash(GUIStyle.Green); + matchingListElement.RectTransform.Parent = enabledRegularModsList.Content.RectTransform; + matchingListElement.Flash(GUIStyle.Green); + return true; + } + }; + textBlock.RectTransform.MinSize = new Point(0, (int)(enableButton.Rect.Height * 1.2f)); + } + else + { + var subscribeButton = new GUIButton(new RectTransform(new Vector2(0.25f, 0.9f), textBlock.RectTransform, Anchor.CenterRight), TextManager.Get("downloadbutton")) + { + Enabled = false + }; + textBlock.RectTransform.MinSize = new Point(0, (int)(subscribeButton.Rect.Height * 1.2f)); + + //fetch the workshop item based on the ID, update the text to show it's title and create a subscribe button + TaskPool.Add($"GetMissingDependencyInfo{dependency}", SteamManager.Workshop.GetItem(dependency), + t => + { + if (!t.TryGetResult(out Option itemOption)) { return; } + if (!itemOption.TryUnwrap(out var item)) { return; } + if (!item.Title.IsNullOrEmpty()) + { + textBlock.Text = $"- {item.Title}"; + } + if (!item.IsSubscribed) + { + subscribeButton.OnClicked = (btn, userdata) => + { + _ = item.Subscribe(); + subscribeButton.Enabled = false; + textBlock.Flash(GUIStyle.Green); + return true; + }; + subscribeButton.Enabled = true; + } + }); + } + } + } } } diff --git a/Barotrauma/BarotraumaClient/ClientSource/Steam/WorkshopMenu/Mutable/MutableWorkshopMenu.cs b/Barotrauma/BarotraumaClient/ClientSource/Steam/WorkshopMenu/Mutable/MutableWorkshopMenu.cs index ca2bd8eb5..1f8c70a06 100644 --- a/Barotrauma/BarotraumaClient/ClientSource/Steam/WorkshopMenu/Mutable/MutableWorkshopMenu.cs +++ b/Barotrauma/BarotraumaClient/ClientSource/Steam/WorkshopMenu/Mutable/MutableWorkshopMenu.cs @@ -1,6 +1,7 @@ #nullable enable using Barotrauma.Extensions; using Microsoft.Xna.Framework; +using Steamworks.Data; using System.Collections.Generic; using System.Linq; using System.Threading; @@ -189,8 +190,12 @@ namespace Barotrauma.Steam ContentPackageManager.EnabledPackages.SetCore(EnabledCorePackage); ContentPackageManager.EnabledPackages.SetRegular(enabledRegularModsList.Content.Children .Select(c => c.UserData as RegularPackage).OfType().ToArray()); + + ContentPackageManager.CheckMissingDependencies(); + PopulateInstalledModLists(forceRefreshEnabled: true, refreshDisabled: true); ContentPackageManager.LogEnabledRegularPackageErrors(); + enabledCoreDropdown.ButtonTextColor = EnabledCorePackage.HasAnyErrors ? GUIStyle.Red diff --git a/Barotrauma/BarotraumaClient/ClientSource/Steam/WorkshopMenu/UiUtil.cs b/Barotrauma/BarotraumaClient/ClientSource/Steam/WorkshopMenu/UiUtil.cs index b7890da55..07b10ce64 100644 --- a/Barotrauma/BarotraumaClient/ClientSource/Steam/WorkshopMenu/UiUtil.cs +++ b/Barotrauma/BarotraumaClient/ClientSource/Steam/WorkshopMenu/UiUtil.cs @@ -159,6 +159,16 @@ namespace Barotrauma.Steam uiElement.ToolTip += TextManager.GetWithVariable( "ContentPackageEnableError", "[packagename]", mod.Name); } + + if (mod.MissingDependencies.Any()) + { + nameText.TextColor = GUIStyle.Orange; + if (!uiElement.ToolTip.IsNullOrWhiteSpace()) { uiElement.ToolTip += "\n"; } + uiElement.ToolTip += + TextManager.GetWithVariables("workshop.dependencynotfound", + ("[name]", mod.Name), + ("[required]", string.Join(", ", mod.MissingDependencies.Select(c => c.ToString())))) + '\n' + TextManager.Get("workshop.dependencynotfound.moreinfo"); + } } } } diff --git a/Barotrauma/BarotraumaClient/LinuxClient.csproj b/Barotrauma/BarotraumaClient/LinuxClient.csproj index 477aef9a5..ea5f5d7f8 100644 --- a/Barotrauma/BarotraumaClient/LinuxClient.csproj +++ b/Barotrauma/BarotraumaClient/LinuxClient.csproj @@ -6,7 +6,7 @@ Barotrauma FakeFish, Undertow Games Barotrauma - 1.7.7.0 + 1.8.6.2 Copyright © FakeFish 2018-2024 AnyCPU;x64 Barotrauma diff --git a/Barotrauma/BarotraumaClient/MacClient.csproj b/Barotrauma/BarotraumaClient/MacClient.csproj index a68fc754e..482af049b 100644 --- a/Barotrauma/BarotraumaClient/MacClient.csproj +++ b/Barotrauma/BarotraumaClient/MacClient.csproj @@ -6,7 +6,7 @@ Barotrauma FakeFish, Undertow Games Barotrauma - 1.7.7.0 + 1.8.6.2 Copyright © FakeFish 2018-2024 AnyCPU;x64 Barotrauma diff --git a/Barotrauma/BarotraumaClient/WindowsClient.csproj b/Barotrauma/BarotraumaClient/WindowsClient.csproj index 7d15b2fcc..043c2891e 100644 --- a/Barotrauma/BarotraumaClient/WindowsClient.csproj +++ b/Barotrauma/BarotraumaClient/WindowsClient.csproj @@ -6,7 +6,7 @@ Barotrauma FakeFish, Undertow Games Barotrauma - 1.7.7.0 + 1.8.6.2 Copyright © FakeFish 2018-2024 AnyCPU;x64 Barotrauma diff --git a/Barotrauma/BarotraumaServer/LinuxServer.csproj b/Barotrauma/BarotraumaServer/LinuxServer.csproj index 8c562f0bc..445144538 100644 --- a/Barotrauma/BarotraumaServer/LinuxServer.csproj +++ b/Barotrauma/BarotraumaServer/LinuxServer.csproj @@ -6,7 +6,7 @@ Barotrauma FakeFish, Undertow Games Barotrauma Dedicated Server - 1.7.7.0 + 1.8.6.2 Copyright © FakeFish 2018-2023 AnyCPU;x64 DedicatedServer diff --git a/Barotrauma/BarotraumaServer/MacServer.csproj b/Barotrauma/BarotraumaServer/MacServer.csproj index 78e6ebc60..8f2ec13f4 100644 --- a/Barotrauma/BarotraumaServer/MacServer.csproj +++ b/Barotrauma/BarotraumaServer/MacServer.csproj @@ -6,7 +6,7 @@ Barotrauma FakeFish, Undertow Games Barotrauma Dedicated Server - 1.7.7.0 + 1.8.6.2 Copyright © FakeFish 2018-2023 AnyCPU;x64 DedicatedServer diff --git a/Barotrauma/BarotraumaServer/ServerSource/Characters/CharacterInfo.cs b/Barotrauma/BarotraumaServer/ServerSource/Characters/CharacterInfo.cs index c964fb354..21ca78783 100644 --- a/Barotrauma/BarotraumaServer/ServerSource/Characters/CharacterInfo.cs +++ b/Barotrauma/BarotraumaServer/ServerSource/Characters/CharacterInfo.cs @@ -57,6 +57,8 @@ namespace Barotrauma msg.WriteString(Name); msg.WriteString(OriginalName); msg.WriteBoolean(RenamingEnabled); + msg.WriteByte((byte)BotStatus); + msg.WriteInt32(Salary); msg.WriteByte((byte)Head.Preset.TagSet.Count); foreach (Identifier tag in Head.Preset.TagSet) { diff --git a/Barotrauma/BarotraumaServer/ServerSource/Characters/CharacterNetworking.cs b/Barotrauma/BarotraumaServer/ServerSource/Characters/CharacterNetworking.cs index 21c6e9f7e..ea9c001ac 100644 --- a/Barotrauma/BarotraumaServer/ServerSource/Characters/CharacterNetworking.cs +++ b/Barotrauma/BarotraumaServer/ServerSource/Characters/CharacterNetworking.cs @@ -85,7 +85,8 @@ namespace Barotrauma partial void UpdateNetInput() { - if (!(this is AICharacter) || IsRemotePlayer) + //non-ai character (a character that was previously controlled by a player) or a remote player (which can be an AI character controlled by a player) + if (this is not AICharacter || IsRemotePlayer) { if (!CanMove) { @@ -447,12 +448,7 @@ namespace Barotrauma if (!fixedRotation) { tempBuffer.WriteSingle(AnimController.Collider.Rotation); - float MaxAngularVel = NetConfig.MaxPhysicsBodyAngularVelocity; - AnimController.Collider.AngularVelocity = - AnimController.Collider.PhysEnabled ? - 0.0f : - NetConfig.Quantize(AnimController.Collider.AngularVelocity, -MaxAngularVel, MaxAngularVel, 8); - tempBuffer.WriteRangedSingle(MathHelper.Clamp(AnimController.Collider.AngularVelocity, -MaxAngularVel, MaxAngularVel), -MaxAngularVel, MaxAngularVel, 8); + tempBuffer.WriteSingle(AnimController.Collider.AngularVelocity); } @@ -497,6 +493,7 @@ namespace Barotrauma break; case CharacterStatusEventData statusEventData: WriteStatus(msg, statusEventData.ForceAfflictionData); + msg.WriteBoolean(GodMode); break; case UpdateSkillsEventData updateSkillsData: if (Info?.Job is { } job) @@ -532,7 +529,14 @@ namespace Barotrauma } break; case AssignCampaignInteractionEventData _: - msg.WriteByte((byte)CampaignInteractionType); + + bool canClientInteract = true; + if (CampaignInteractionType == CampaignMode.InteractionType.Talk && + ActiveConversation != null) + { + canClientInteract = ActiveConversation.CanClientStartConversation(c); + } + msg.WriteByte((byte)(canClientInteract ? CampaignInteractionType : CampaignMode.InteractionType.None)); msg.WriteBoolean(RequireConsciousnessForCustomInteract); break; case ObjectiveManagerStateEventData objectiveManagerStateEventData: @@ -683,6 +687,7 @@ namespace Barotrauma { CharacterHealth.ServerWrite(msg); } + if (AnimController?.LimbJoints == null) { //0 limbs severed @@ -738,7 +743,7 @@ namespace Barotrauma return; } - Client ownerClient = GameMain.Server.ConnectedClients.Find(c => c.Character == this); + Client ownerClient = GameMain.Server.ConnectedClients.Find(c => c.Character == this && (!c.SpectateOnly || !GameMain.Server.ServerSettings.AllowSpectating)); if (ownerClient != null) { msg.WriteBoolean(true); diff --git a/Barotrauma/BarotraumaServer/ServerSource/DebugConsole.cs b/Barotrauma/BarotraumaServer/ServerSource/DebugConsole.cs index 87e7e12af..812a348b6 100644 --- a/Barotrauma/BarotraumaServer/ServerSource/DebugConsole.cs +++ b/Barotrauma/BarotraumaServer/ServerSource/DebugConsole.cs @@ -378,11 +378,11 @@ namespace Barotrauma AssignOnExecute("killdisconnectedtimer", (string[] args) => { if (args.Length < 1 || GameMain.Server == null) return; - float seconds = GameMain.Server.ServerSettings.KillDisconnectedTime; - if (float.TryParse(args[0], out seconds)) + if (float.TryParse(args[0], out float seconds)) { seconds = Math.Max(0, seconds); NewMessage("Set kill disconnected timer to " + ToolBox.SecondsToReadableTime(seconds), Color.White); + GameMain.Server.ServerSettings.KillDisconnectedTime = seconds; } else { @@ -391,13 +391,13 @@ namespace Barotrauma }); AssignOnClientRequestExecute("killdisconnectedtimer", (Client client, Vector2 cursorPos, string[] args) => { - if (args.Length < 1 || GameMain.Server == null) return; - float seconds = GameMain.Server.ServerSettings.KillDisconnectedTime; - if (float.TryParse(args[0], out seconds)) + if (args.Length < 1 || GameMain.Server == null) { return; } + if (float.TryParse(args[0], out float seconds)) { seconds = Math.Max(0, seconds); GameMain.Server.SendConsoleMessage("Set kill disconnected timer to " + ToolBox.SecondsToReadableTime(seconds).Value, client); NewMessage(client.Name + " set kill disconnected timer to " + ToolBox.SecondsToReadableTime(seconds), Color.White); + GameMain.Server.ServerSettings.KillDisconnectedTime = seconds; } else { @@ -499,14 +499,10 @@ namespace Barotrauma NewMessage(GameMain.Server.ServerSettings.StartWhenClientsReady ? "Enabled starting the round automatically when clients are ready." : "Disabled starting the round automatically when clients are ready.", Color.White); }); - AssignOnExecute("spawn|spawncharacter", (string[] args) => - { - SpawnCharacter(args, Vector2.Zero, out string errorMsg); - if (!string.IsNullOrWhiteSpace(errorMsg)) - { - ThrowError(errorMsg); - } - }); + AssignOnExecute("spawn|spawncharacter", args => SpawnCharacter(args, Vector2.Zero)); + AssignOnExecute("spawnnpc", args => SpawnCharacter(args, Vector2.Zero, true)); + AssignOnClientRequestExecute("spawn|spawncharacter", (Client client, Vector2 cursorPos, string[] args) => SpawnCharacter(args, cursorPos)); + AssignOnClientRequestExecute("spawnnpc", (Client client, Vector2 cursorPos, string[] args) => SpawnCharacter(args, cursorPos, true)); AssignOnExecute("giveperm", (string[] args) => { @@ -1647,18 +1643,6 @@ namespace Barotrauma }); #endif - AssignOnClientRequestExecute( - "spawn|spawncharacter", - (Client client, Vector2 cursorPos, string[] args) => - { - SpawnCharacter(args, cursorPos, out string errorMsg); - if (!string.IsNullOrWhiteSpace(errorMsg)) - { - ThrowError(errorMsg); - } - } - ); - AssignOnClientRequestExecute( "banaddress|banip", (Client client, Vector2 cursorPos, string[] args) => @@ -1830,11 +1814,17 @@ namespace Barotrauma } ); + AssignOnExecute("teleportcharacter|teleport", (string[] args) => + { + //cursor doesn't exist server-side, use to the position of the sub instead + TeleportCharacter(cursorWorldPos: Submarine.MainSub?.WorldPosition ?? Vector2.Zero, Character.Controlled, args); + }); + AssignOnClientRequestExecute( "teleportcharacter|teleport", (Client client, Vector2 cursorWorldPos, string[] args) => { - TeleportCharacter(cursorWorldPos, client.Character, args); + TeleportCharacter(cursorWorldPos, client.Character, args); } ); @@ -1892,14 +1882,16 @@ namespace Barotrauma "godmode", (Client client, Vector2 cursorWorldPos, string[] args) => { - Character targetCharacter = (args.Length == 0) ? client.Character : FindMatchingCharacter(args, false); - - if (targetCharacter == null) { return; } - - targetCharacter.GodMode = !targetCharacter.GodMode; - - NewMessage(targetCharacter.Name + (targetCharacter.GodMode ? "'s godmode turned on by \"" : "'s godmode turned off by \"") + client.Name + "\"", Color.White); - GameMain.Server.SendConsoleMessage(targetCharacter.Name + (targetCharacter.GodMode ? "'s godmode on" : "'s godmode off"), client); + bool? godmodeStateOnFirstCharacter = null; + HandleCommandForCrewOrSingleCharacter(args, ToggleGodMode, client); + void ToggleGodMode(Character targetCharacter) + { + targetCharacter.GodMode = godmodeStateOnFirstCharacter ?? !targetCharacter.GodMode; + godmodeStateOnFirstCharacter = targetCharacter.GodMode; + GameMain.NetworkMember.CreateEntityEvent(targetCharacter, new Character.CharacterStatusEventData()); + NewMessage(targetCharacter.Name + (targetCharacter.GodMode ? "'s godmode turned on by \"" : "'s godmode turned off by \"") + client.Name + "\"", Color.White); + GameMain.Server.SendConsoleMessage(targetCharacter.Name + (targetCharacter.GodMode ? "'s godmode on" : "'s godmode off"), client); + } } ); @@ -1965,7 +1957,7 @@ namespace Barotrauma bool healAll = args.Length > 0 && args[0].Equals("all", StringComparison.OrdinalIgnoreCase); if (client.Character != null) { - HealCharacter(client.Character, healAll); + HealCharacter(client.Character, healAll, client); } } ); @@ -1975,11 +1967,7 @@ namespace Barotrauma (Client client, Vector2 cursorWorldPos, string[] args) => { bool healAll = args.Length > 1 && args[1].Equals("all", StringComparison.OrdinalIgnoreCase); - Character healedCharacter = (args.Length == 0) ? client.Character : FindMatchingCharacter(healAll ? args.Take(args.Length - 1).ToArray() : args); - if (healedCharacter != null) - { - HealCharacter(healedCharacter, healAll); - } + HandleCommandForCrewOrSingleCharacter(args, (Character targetCharacter) => HealCharacter(targetCharacter, healAll, client), client); } ); diff --git a/Barotrauma/BarotraumaServer/ServerSource/Events/EventActions/ConversationAction.cs b/Barotrauma/BarotraumaServer/ServerSource/Events/EventActions/ConversationAction.cs index 322aeee5b..159c83310 100644 --- a/Barotrauma/BarotraumaServer/ServerSource/Events/EventActions/ConversationAction.cs +++ b/Barotrauma/BarotraumaServer/ServerSource/Events/EventActions/ConversationAction.cs @@ -17,9 +17,15 @@ namespace Barotrauma private static readonly Dictionary lastActiveAction = new Dictionary(); + /// + /// Clients who this Conversation prompt is being currently shown to + /// private readonly HashSet targetClients = new HashSet(); private readonly Dictionary ignoredClients = new Dictionary(); + /// + /// Clients who this Conversation prompt is being currently shown to + /// public IEnumerable TargetClients { get @@ -52,10 +58,25 @@ namespace Barotrauma } } + public bool CanClientStartConversation(Client client) + { + if (!TargetTag.IsEmpty) + { + var targets = ParentEvent.GetTargets(TargetTag).Where(e => IsValidTarget(e)); + return targets.Contains(client.Character); + } + return true; + } + public void IgnoreClient(Client c, float seconds) { if (!ignoredClients.ContainsKey(c)) { ignoredClients.Add(c, DateTime.Now); } ignoredClients[c] = DateTime.Now + TimeSpan.FromSeconds(seconds); + //this action is not active for the client if they decided to ignore it + if (lastActiveAction.TryGetValue(c, out ConversationAction lastActive) && lastActive == this) + { + lastActiveAction.Remove(c); + } Reset(); } @@ -116,13 +137,14 @@ namespace Barotrauma { foreach (Client c in GameMain.Server.ConnectedClients) { - if (c.InGame && c.Character != null) + if (CanClientReceive(c)) { if (targetCharacter == null || targetCharacter == c.Character) { targetClients.Add(c); lastActiveAction[c] = this; lastActiveTime = Timing.TotalTime; + DebugConsole.Log($"Sending conversationaction {ParentEvent.Prefab.Identifier} to client..."); ServerWrite(speaker, c, interrupt); } } @@ -130,6 +152,18 @@ namespace Barotrauma } } + /// + /// Is it possible for the client to receive ConversationActions + /// (just checking if they're in game, controlling a character and not marked as ignoring the action, + /// but not accounting for whether this action targets them or not). + /// + /// + /// + private bool CanClientReceive(Client c) + { + return c != null && c.InGame && c.Character != null && !ignoredClients.ContainsKey(c); + } + public void ServerWrite(Character speaker, Client client, bool interrupt) { IWriteMessage outmsg = new WriteOnlyMessage(); diff --git a/Barotrauma/BarotraumaServer/ServerSource/Events/EventManager.cs b/Barotrauma/BarotraumaServer/ServerSource/Events/EventManager.cs index 74549b187..6850a5174 100644 --- a/Barotrauma/BarotraumaServer/ServerSource/Events/EventManager.cs +++ b/Barotrauma/BarotraumaServer/ServerSource/Events/EventManager.cs @@ -1,4 +1,5 @@ -using Barotrauma.Networking; +using Barotrauma.Extensions; +using Barotrauma.Networking; using System; using System.Linq; @@ -26,8 +27,11 @@ namespace Barotrauma public void ServerRead(IReadMessage inc, Client sender) { + const float IgnoreTime = 3f; + UInt16 actionId = inc.ReadUInt16(); byte selectedOption = inc.ReadByte(); + bool isIgnore = selectedOption == byte.MaxValue; foreach (Event ev in activeEvents) { @@ -40,24 +44,38 @@ namespace Barotrauma if (!convAction.TargetClients.Contains(sender)) { #if DEBUG || UNSTABLE - DebugConsole.ThrowError($"Client \"{sender.Name}\" tried to respond to a ConversationAction that was not targeted to them ({convAction.Text})."); + if (!isIgnore) + { + DebugConsole.ThrowError($"Client \"{sender.Name}\" tried to respond to a ConversationAction that was not targeted to them ({convAction.Text})."); + } #endif + convAction.IgnoreClient(sender, IgnoreTime); continue; } if (convAction.SelectedOption > -1) { //someone else already chose an option for this conversation: interrupt for this client + DebugConsole.Log($"Client replied to {ev.Prefab.Identifier}, but option already selected for conversation, interrupt for the client"); convAction.ServerWrite(convAction.Speaker, sender, interrupt: true); } else { - if (selectedOption == byte.MaxValue) + if (isIgnore) { - convAction.IgnoreClient(sender, 3f); + DebugConsole.NewMessage($"Client ignored ConversationAction (event {ev.Prefab.Identifier})."); + convAction.IgnoreClient(sender, IgnoreTime); + //no more target clients (the only/last target ignored the conversation action) + // -> reset the action so it can appear when some client becomes available + if (convAction.TargetClients.None()) + { + DebugConsole.NewMessage($"No target clients for event {ev.Prefab.Identifier}, retrying in " + (IgnoreTime + 1.0f)); + convAction.RetriggerAfter(IgnoreTime + 1.0f); + } } else { + DebugConsole.NewMessage($"Client selected option {selectedOption} for ConversationAction in event {ev.Prefab.Identifier}."); convAction.SelectedOption = selectedOption; if (convAction.Options.Any() && !convAction.GetEndingOptions().Contains(selectedOption)) { diff --git a/Barotrauma/BarotraumaServer/ServerSource/Events/Missions/AbandonedOutpostMission.cs b/Barotrauma/BarotraumaServer/ServerSource/Events/Missions/AbandonedOutpostMission.cs index bfa52267f..d41b78a62 100644 --- a/Barotrauma/BarotraumaServer/ServerSource/Events/Missions/AbandonedOutpostMission.cs +++ b/Barotrauma/BarotraumaServer/ServerSource/Events/Missions/AbandonedOutpostMission.cs @@ -1,7 +1,5 @@ using Barotrauma.Networking; -using System; using System.Collections.Generic; -using System.Linq; namespace Barotrauma { @@ -24,7 +22,7 @@ namespace Barotrauma character.WriteSpawnData(msg, character.ID, restrictMessageSize: false); msg.WriteBoolean(requireKill.Contains(character)); msg.WriteBoolean(requireRescue.Contains(character)); - msg.WriteUInt16((ushort)characterItems[character].Count()); + msg.WriteUInt16((ushort)characterItems[character].Count); foreach (Item item in characterItems[character]) { item.WriteSpawnData(msg, item.ID, item.ParentInventory?.Owner?.ID ?? Entity.NullEntityID, 0, item.ParentInventory?.FindIndex(item) ?? -1); diff --git a/Barotrauma/BarotraumaServer/ServerSource/Events/Missions/SalvageMission.cs b/Barotrauma/BarotraumaServer/ServerSource/Events/Missions/SalvageMission.cs index cf7fdf067..7a88ef68e 100644 --- a/Barotrauma/BarotraumaServer/ServerSource/Events/Missions/SalvageMission.cs +++ b/Barotrauma/BarotraumaServer/ServerSource/Events/Missions/SalvageMission.cs @@ -29,15 +29,26 @@ namespace Barotrauma public override void ServerWriteInitial(IWriteMessage msg, Client c) { base.ServerWriteInitial(msg, c); + + msg.WriteByte((byte)characters.Count); + foreach (Character character in characters) + { + character.WriteSpawnData(msg, character.ID, restrictMessageSize: false); + var items = characterItems[character]; + msg.WriteUInt16((ushort)items.Count); + foreach (Item item in items) + { + item.WriteSpawnData(msg, item.ID, item.ParentInventory?.Owner?.ID ?? Entity.NullEntityID, 0, item.ParentInventory?.FindIndex(item) ?? -1); + } + } foreach (var target in targets) { - bool targetFound = spawnInfo.ContainsKey(target) && target.Item != null; + bool targetFound = spawnInfo.TryGetValue(target, out SpawnInfo sInfo) && target.Item != null; msg.WriteBoolean(targetFound); if (!targetFound) { continue; } - - msg.WriteBoolean(spawnInfo[target].UsedExistingItem); - if (spawnInfo[target].UsedExistingItem) + msg.WriteBoolean(sInfo.UsedExistingItem); + if (sInfo.UsedExistingItem) { msg.WriteUInt16(target.Item.ID); } @@ -45,14 +56,14 @@ namespace Barotrauma { target.Item.WriteSpawnData(msg, target.Item.ID, - spawnInfo[target].OriginalInventoryID, - spawnInfo[target].OriginalItemContainerIndex, - spawnInfo[target].OriginalSlotIndex); + sInfo.OriginalInventoryID, + sInfo.OriginalItemContainerIndex, + sInfo.OriginalSlotIndex); msg.WriteUInt16(target.ParentTarget?.Item?.ID ?? Entity.NullEntityID); } - msg.WriteByte((byte)spawnInfo[target].ExecutedEffectIndices.Count); - foreach ((int listIndex, int effectIndex) in spawnInfo[target].ExecutedEffectIndices) + msg.WriteByte((byte)sInfo.ExecutedEffectIndices.Count); + foreach ((int listIndex, int effectIndex) in sInfo.ExecutedEffectIndices) { msg.WriteByte((byte)listIndex); msg.WriteByte((byte)effectIndex); @@ -64,9 +75,9 @@ namespace Barotrauma { base.ServerWrite(msg); msg.WriteByte((byte)targets.Count); - for (int i = 0; i < targets.Count; i++) + foreach (Target t in targets) { - msg.WriteByte((byte)targets[i].State); + msg.WriteByte((byte)t.State); } } } diff --git a/Barotrauma/BarotraumaServer/ServerSource/GameMain.cs b/Barotrauma/BarotraumaServer/ServerSource/GameMain.cs index 8f5502124..0f09fa7e5 100644 --- a/Barotrauma/BarotraumaServer/ServerSource/GameMain.cs +++ b/Barotrauma/BarotraumaServer/ServerSource/GameMain.cs @@ -167,7 +167,9 @@ namespace Barotrauma } else { - name = doc.Root.GetAttributeString(nameof(ServerSettings.Name), "Server"); + name = doc.Root.GetAttributeString(nameof(ServerSettings.ServerName), + //backwards compatibility + doc.Root.GetAttributeString("name", "Server")); port = doc.Root.GetAttributeInt(nameof(ServerSettings.Port), NetConfig.DefaultPort); queryPort = doc.Root.GetAttributeInt(nameof(ServerSettings.QueryPort), NetConfig.DefaultQueryPort); publiclyVisible = doc.Root.GetAttributeBool(nameof(ServerSettings.IsPublic), false); @@ -289,6 +291,12 @@ namespace Barotrauma } i++; break; + case "-multiclienttestmode": +#if DEBUG + CharacterCampaignData.RequireClientNameMatch = true; +#endif + i++; + break; } } } diff --git a/Barotrauma/BarotraumaServer/ServerSource/GameSession/CrewManager.cs b/Barotrauma/BarotraumaServer/ServerSource/GameSession/CrewManager.cs index c14233f30..a0366f9fd 100644 --- a/Barotrauma/BarotraumaServer/ServerSource/GameSession/CrewManager.cs +++ b/Barotrauma/BarotraumaServer/ServerSource/GameSession/CrewManager.cs @@ -1,4 +1,5 @@ -using Barotrauma.Networking; +using System; +using Barotrauma.Networking; using System.Collections.Generic; using System.Linq; using System.Xml.Linq; @@ -28,11 +29,17 @@ namespace Barotrauma public XElement SaveMultiplayer(XElement parentElement) { var element = new XElement("bots", new XAttribute("hasbots", HasBots)); - foreach (CharacterInfo info in characterInfos) + foreach (CharacterInfo info in GetCharacterInfos(includeReserveBench: true)) { if (Level.Loaded != null) { - if (!info.IsNewHire && (info.Character == null || info.Character.IsDead)) { continue; } + //new hires and reserve benched CharacterInfos should be saved even though the Character doesn't exist + if (!info.IsNewHire && !info.IsOnReserveBench) + { + //character being null either means the character has been removed, or that it hasn't spawn yet + if (info.Character == null && !info.PendingSpawnToActiveService) { continue; } + if (info.Character is { IsDead: true }) { continue; } + } } XElement characterElement = info.Save(element); @@ -63,5 +70,108 @@ namespace Barotrauma } } } + + public void ReadToggleReserveBenchMessage(IReadMessage inc, Client sender) + { + UInt16 botId = inc.ReadUInt16(); + bool pendingHire = inc.ReadBoolean(); + + if (GameMain.GameSession?.GameMode is not MultiPlayerCampaign mpCampaign) { return; } + if (!CampaignMode.AllowedToManageCampaign(sender, ClientPermissions.ManageHires)) + { + DebugConsole.NewMessage($"Client {sender.Name} is not allowed to modify the reserve bench status of bots (requires ManageHires)"); + return; + } + + if (pendingHire && mpCampaign.Map.CurrentLocation?.HireManager.PendingHires.FirstOrDefault(ci => ci.ID == botId) is CharacterInfo pendingCharacterInfo) + { + ToggleReserveBenchStatus(pendingCharacterInfo, sender, pendingHire: true); + } + else if (GameMain.GameSession.CrewManager?.GetCharacterInfos(includeReserveBench: true)?.FirstOrDefault(i => i.ID == botId) is CharacterInfo characterInfo) + { + ToggleReserveBenchStatus(characterInfo, sender); + } + } + + /// + /// Used to correctly handle (and document) transitions between the different possible statuses (BotStatus) bots might have + /// relating to the reserve bench, assigning them the correct new status and into the right CrewManager lists. + /// This will only take care of things relevant to the CrewManager (like maximum crew size), and will assume requirements + /// to hiring (money, permissions) have already been handled. + /// + /// CharacterInfo of the bot + /// Which client requested changing the reserve bench status? + /// Is the bot a pending hire? + /// Has the hire been confirmed now? This will store the bot in the CrewManager. + /// By default, the method will trigger sending updated crew data to the clients, but this may not always be useful – eg. if this method is called as part of a longer procedure that will send the update in the end anyway. + public void ToggleReserveBenchStatus(CharacterInfo characterInfo, Client client, bool pendingHire = false, bool confirmPendingHire = false, bool sendUpdate = true) + { + if (GameMain.GameSession?.GameMode is not MultiPlayerCampaign mpCampaign) { return; } + + if (confirmPendingHire && !pendingHire) + { + DebugConsole.ThrowError($"ToggleReserveBenchStatus: cannot confirm a hire that is not pending (bot {characterInfo.DisplayName})"); + } + + BotStatus currentStatus = characterInfo.BotStatus; + if (pendingHire && !confirmPendingHire) + { + if (!(mpCampaign.Map.CurrentLocation?.HireManager.PendingHires.Contains(characterInfo) ?? false)) + { + DebugConsole.ThrowError($"ToggleReserveBenchStatus: bot {characterInfo.DisplayName} is supposed to be in the pending hires list, but can't be found there"); + } + + if (currentStatus == BotStatus.PendingHireToActiveService) + { + characterInfo.BotStatus = BotStatus.PendingHireToReserveBench; + GameServer.Log($"Client \"{client.Name}\" moved the pending hire \"{characterInfo.DisplayName}\" to the reserve bench.", ServerLog.MessageType.ServerMessage); + } + else if (currentStatus == BotStatus.PendingHireToReserveBench) + { + if (GetCharacterInfos().Count() >= MaxCrewSize) + { + DebugConsole.NewMessage($"ToggleReserveBenchStatus: Tried moving pending hire {characterInfo.DisplayName} to active service, but MaxCrewSize has already been reached"); + return; + } + characterInfo.BotStatus = BotStatus.PendingHireToActiveService; + GameServer.Log($"Client \"{client.Name}\" moved the pending hire \"{characterInfo.DisplayName}\" from the reserve bench to active service.", ServerLog.MessageType.ServerMessage); + } + } + else if (GetCharacterInfos(includeReserveBench: true).Contains(characterInfo) || confirmPendingHire) + { + if (currentStatus == BotStatus.ActiveService || (confirmPendingHire && currentStatus == BotStatus.PendingHireToReserveBench)) + { + if (reserveBench.Contains(characterInfo)) + { + DebugConsole.ThrowError($"ToggleReserveBenchStatus: Tried to add the same CharacterInfo ({characterInfo.DisplayName}) to reserve bench twice"); + } + RemoveCharacterInfo(characterInfo); + characterInfo.BotStatus = BotStatus.ReserveBench; + GameServer.Log($"Client \"{client.Name}\" moved the bot \"{characterInfo.DisplayName}\" from active service to the reserve bench.", ServerLog.MessageType.ServerMessage); + reserveBench.Add(characterInfo); + } + else if (currentStatus == BotStatus.ReserveBench || (confirmPendingHire && currentStatus == BotStatus.PendingHireToActiveService)) + { + if (GetCharacterInfos().Count() >= MaxCrewSize) + { + DebugConsole.NewMessage($"ToggleReserveBenchStatus: Tried moving {characterInfo.DisplayName} to active service, but MaxCrewSize has already been reached"); + return; + } + RemoveCharacterInfo(characterInfo); + characterInfo.BotStatus = BotStatus.ActiveService; + GameServer.Log($"Client \"{client.Name}\" moved the bot \"{characterInfo.DisplayName}\" from the reserve bench to active service.", ServerLog.MessageType.ServerMessage); + AddCharacterInfo(characterInfo); + } + } + else + { + DebugConsole.ThrowError($"ToggleReserveBenchStatus: bot {characterInfo.DisplayName} not found from CrewManager"); + } + + if (sendUpdate) + { + mpCampaign.SendCrewState(); + } + } } } diff --git a/Barotrauma/BarotraumaServer/ServerSource/GameSession/GameModes/CharacterCampaignData.cs b/Barotrauma/BarotraumaServer/ServerSource/GameSession/GameModes/CharacterCampaignData.cs index 0a0d45a07..737b1718e 100644 --- a/Barotrauma/BarotraumaServer/ServerSource/GameSession/GameModes/CharacterCampaignData.cs +++ b/Barotrauma/BarotraumaServer/ServerSource/GameSession/GameModes/CharacterCampaignData.cs @@ -15,6 +15,12 @@ namespace Barotrauma #endif public bool HasSpawned; + + /// + /// Respawning via shuttle has been blocked from permanently dead characters, but it should be possible when the player + /// chooses a bot from the reserve bench and shuttles are enabled in the campaign. + /// + public bool ChosenNewBotViaShuttle; public bool HasItemData { @@ -77,6 +83,7 @@ namespace Barotrauma string accountIdStr = element.GetAttributeString("accountid", null) ?? element.GetAttributeString("steamid", ""); AccountId = Networking.AccountId.Parse(accountIdStr); + ChosenNewBotViaShuttle = element.GetAttributeBool("waitingforshuttle", false); foreach (XElement subElement in element.Elements()) { @@ -180,7 +187,8 @@ namespace Barotrauma XElement element = new XElement("CharacterCampaignData", new XAttribute("name", Name), new XAttribute("address", ClientAddress), - new XAttribute("accountid", AccountId.TryUnwrap(out var accountId) ? accountId.StringRepresentation : "")); + new XAttribute("accountid", AccountId.TryUnwrap(out var accountId) ? accountId.StringRepresentation : ""), + new XAttribute("waitingforshuttle", ChosenNewBotViaShuttle)); CharacterInfo?.Save(element); if (itemData != null) { element.Add(itemData); } if (healthData != null) { element.Add(healthData); } diff --git a/Barotrauma/BarotraumaServer/ServerSource/GameSession/GameModes/MultiPlayerCampaign.cs b/Barotrauma/BarotraumaServer/ServerSource/GameSession/GameModes/MultiPlayerCampaign.cs index 95d6df05c..da7fa0676 100644 --- a/Barotrauma/BarotraumaServer/ServerSource/GameSession/GameModes/MultiPlayerCampaign.cs +++ b/Barotrauma/BarotraumaServer/ServerSource/GameSession/GameModes/MultiPlayerCampaign.cs @@ -243,6 +243,12 @@ namespace Barotrauma savedExperiencePoints.RemoveAll(s => client.AccountId == s.AccountId || client.Connection.Endpoint.Address == s.Address); } + public void RefreshCharacterCampaignData(Character character, bool refreshHealthData) + { + var matchingData = characterData.FirstOrDefault(c => c.CharacterInfo == character.Info); + matchingData?.Refresh(character, refreshHealthData: refreshHealthData); + } + public void SavePlayers() { //refresh the character data of clients who are still in the server @@ -390,7 +396,7 @@ namespace Barotrauma } } // Event history must be registered before ending the round or it will be cleared - GameMain.GameSession.EventManager.RegisterEventHistory(); + GameMain.GameSession.EventManager.StoreEventDataAtRoundEnd(); } //store the currently active missions at this point so we can communicate their states to clients, they're cleared in EndRound @@ -639,6 +645,7 @@ namespace Barotrauma msg.WriteBoolean(IsFirstRound); msg.WriteByte(CampaignID); + msg.WriteByte(RoundID); msg.WriteUInt16(lastSaveID); msg.WriteString(map.Seed); @@ -1209,18 +1216,22 @@ namespace Barotrauma public void ServerReadCrew(IReadMessage msg, Client sender) { UInt16[] pendingHires = null; + bool[] pendingToReserveBench = null; + Dictionary existingBotsClient = null; bool updatePending = msg.ReadBoolean(); if (updatePending) { ushort pendingHireLength = msg.ReadUInt16(); pendingHires = new UInt16[pendingHireLength]; + pendingToReserveBench = new bool[pendingHireLength]; for (int i = 0; i < pendingHireLength; i++) { pendingHires[i] = msg.ReadUInt16(); + pendingToReserveBench[i] = msg.ReadBoolean(); } } - + bool validateHires = msg.ReadBoolean(); bool renameCharacter = msg.ReadBoolean(); @@ -1232,7 +1243,7 @@ namespace Barotrauma renamedIdentifier = msg.ReadUInt16(); newName = Client.SanitizeName(msg.ReadString()); existingCrewMember = msg.ReadBoolean(); - if (!GameMain.Server.IsNameValid(sender, newName)) + if (!GameMain.Server.IsNameValid(sender, newName, clientRenamingSelf: renamedIdentifier == sender.CharacterInfo?.ID)) { renameCharacter = false; } @@ -1250,7 +1261,7 @@ namespace Barotrauma { if (fireCharacter && AllowedToManageCampaign(sender, ClientPermissions.ManageHires)) { - firedCharacter = CrewManager.GetCharacterInfos().FirstOrDefault(info => info.ID == firedIdentifier); + firedCharacter = CrewManager.GetCharacterInfos(includeReserveBench: true).FirstOrDefault(info => info.ID == firedIdentifier); if (firedCharacter != null && (firedCharacter.Character?.IsBot ?? true)) { CrewManager.FireCharacter(firedCharacter); @@ -1268,7 +1279,7 @@ namespace Barotrauma { if (existingCrewMember && CrewManager != null) { - characterInfo = CrewManager.GetCharacterInfos().FirstOrDefault(info => info.ID == renamedIdentifier); + characterInfo = CrewManager.GetCharacterInfos(includeReserveBench: true).FirstOrDefault(info => info.ID == renamedIdentifier); } else if (!existingCrewMember && location.HireManager != null) { @@ -1319,6 +1330,7 @@ namespace Barotrauma if (updatePending) { List pendingHireInfos = new List(); + int i = 0; foreach (UInt16 identifier in pendingHires) { CharacterInfo match = location.GetHireableCharacters().FirstOrDefault(info => info.ID == identifier); @@ -1327,12 +1339,15 @@ namespace Barotrauma DebugConsole.ThrowError($"Tried to add a character that doesn't exist ({identifier}) to pending hires"); continue; } + + match.BotStatus = pendingToReserveBench[i++] ? BotStatus.PendingHireToReserveBench : BotStatus.PendingHireToActiveService; + if (match.BotStatus == BotStatus.PendingHireToActiveService) + { + //can't add more bots to active service is max has been reached + if (pendingHireInfos.Count(ci => ci.BotStatus == BotStatus.PendingHireToActiveService) + CrewManager.GetCharacterInfos().Count() >= CrewManager.MaxCrewSize) { continue; } + } pendingHireInfos.Add(match); - if (pendingHireInfos.Count + CrewManager.GetCharacterInfos().Count() >= CrewManager.MaxCrewSize) - { - break; - } } location.HireManager.PendingHires = pendingHireInfos; } @@ -1397,14 +1412,21 @@ namespace Barotrauma foreach (CharacterInfo pendingHire in pendingHires) { msg.WriteUInt16(pendingHire.ID); + msg.WriteBoolean(pendingHire.BotStatus == BotStatus.PendingHireToReserveBench); } - var hiredCharacters = CrewManager.GetCharacterInfos().Where(ci => ci.IsNewHire); - msg.WriteUInt16((ushort)hiredCharacters.Count()); - foreach (CharacterInfo info in hiredCharacters) + var crewManager = CrewManager.GetCharacterInfos(); + msg.WriteUInt16((ushort)crewManager.Count()); + foreach (CharacterInfo info in crewManager) + { + info.ServerWrite(msg); + } + + var reserveBench = CrewManager.GetReserveBenchInfos(); + msg.WriteUInt16((ushort)reserveBench.Count()); + foreach (CharacterInfo info in reserveBench) { info.ServerWrite(msg); - msg.WriteInt32(info.Salary); } bool validRenaming = renamedCrewMember.id > 0 && !string.IsNullOrEmpty(renamedCrewMember.newName); diff --git a/Barotrauma/BarotraumaServer/ServerSource/Items/Components/Machines/Steering.cs b/Barotrauma/BarotraumaServer/ServerSource/Items/Components/Machines/Steering.cs index 3ca6dae01..2d80c54ff 100644 --- a/Barotrauma/BarotraumaServer/ServerSource/Items/Components/Machines/Steering.cs +++ b/Barotrauma/BarotraumaServer/ServerSource/Items/Components/Machines/Steering.cs @@ -14,11 +14,15 @@ namespace Barotrauma.Items.Components DockingButtonClicked = dockingButtonClicked; } } - - // TODO: an enumeration would be much cleaner - public bool MaintainPos; - public bool LevelStartSelected; - public bool LevelEndSelected; + + [Serialize(defaultValue: false, isSaveable: IsPropertySaveable.Yes, AlwaysUseInstanceValues = true)] + public bool MaintainPos { get; set; } + + [Serialize(defaultValue: false, isSaveable: IsPropertySaveable.Yes, AlwaysUseInstanceValues = true)] + public bool LevelStartSelected { get; set; } + + [Serialize(defaultValue: false, isSaveable: IsPropertySaveable.Yes, AlwaysUseInstanceValues = true)] + public bool LevelEndSelected { get; set; } public bool UnsentChanges { diff --git a/Barotrauma/BarotraumaServer/ServerSource/Items/Components/Signal/WifiComponent.cs b/Barotrauma/BarotraumaServer/ServerSource/Items/Components/Signal/WifiComponent.cs index a42adf967..12105db73 100644 --- a/Barotrauma/BarotraumaServer/ServerSource/Items/Components/Signal/WifiComponent.cs +++ b/Barotrauma/BarotraumaServer/ServerSource/Items/Components/Signal/WifiComponent.cs @@ -6,7 +6,15 @@ namespace Barotrauma.Items.Components { public void ServerEventWrite(IWriteMessage msg, Client c, NetEntityEvent.IData extraData = null) { - msg.WriteRangedInteger(Channel, MinChannel, MaxChannel); + SharedEventWrite(msg); + } + + public void ServerEventRead(IReadMessage msg, Client c) + { + SharedEventRead(msg); + + // Create an event to notify other clients about the changes + item.CreateServerEvent(this); } } } diff --git a/Barotrauma/BarotraumaServer/ServerSource/Networking/BanList.cs b/Barotrauma/BarotraumaServer/ServerSource/Networking/BanList.cs index 300853f29..c243d607f 100644 --- a/Barotrauma/BarotraumaServer/ServerSource/Networking/BanList.cs +++ b/Barotrauma/BarotraumaServer/ServerSource/Networking/BanList.cs @@ -195,7 +195,11 @@ namespace Barotrauma.Networking public void BanPlayer(string name, Either addressOrAccountId, string reason, TimeSpan? duration) { - if (addressOrAccountId.TryGet(out Address address) && address.IsLocalHost) { return; } + if (addressOrAccountId.TryGet(out Address address) && address.IsLocalHost) + { + DebugConsole.AddWarning($"Cannot ban localhost ({address.StringRepresentation})"); + return; + } var existingBan = bannedPlayers.Find(bp => bp.AddressOrAccountId == addressOrAccountId); if (existingBan != null) { bannedPlayers.Remove(existingBan); } diff --git a/Barotrauma/BarotraumaServer/ServerSource/Networking/Client.cs b/Barotrauma/BarotraumaServer/ServerSource/Networking/Client.cs index 2765dc531..145571884 100644 --- a/Barotrauma/BarotraumaServer/ServerSource/Networking/Client.cs +++ b/Barotrauma/BarotraumaServer/ServerSource/Networking/Client.cs @@ -84,7 +84,11 @@ namespace Barotrauma.Networking set { if (characterInfo == value) { return; } - characterInfo?.Remove(); + if (characterInfo is { Character: null }) + { + //if a character hasn't spawned for this characterInfo, we can remove the info and free the sprites and such + characterInfo.Remove(); + } characterInfo = value; } } @@ -94,6 +98,7 @@ namespace Barotrauma.Networking public NetworkConnection Connection { get; set; } public bool SpectateOnly; + public bool AFK; public bool? WaitForNextRoundRespawn; public int KarmaKickCount; @@ -335,10 +340,21 @@ namespace Barotrauma.Networking { //the bot has spawned, but the new CharacterCampaignData technically hasn't, because we just created it characterData.HasSpawned = true; + mpCampaign.IncrementLastUpdateIdForFlag(MultiPlayerCampaign.NetFlags.CharacterInfo); } SpectateOnly = false; return true; } + + public void ResetSync() + { + NeedsMidRoundSync = false; + PendingPositionUpdates.Clear(); + EntityEventLastSent.Clear(); + LastSentEntityEventID = 0; + LastRecvEntityEventID = 0; + UnreceivedEntityEventCount = 0; + } } } diff --git a/Barotrauma/BarotraumaServer/ServerSource/Networking/GameServer.cs b/Barotrauma/BarotraumaServer/ServerSource/Networking/GameServer.cs index 3de093d12..f74d35c2b 100644 --- a/Barotrauma/BarotraumaServer/ServerSource/Networking/GameServer.cs +++ b/Barotrauma/BarotraumaServer/ServerSource/Networking/GameServer.cs @@ -223,7 +223,14 @@ namespace Barotrauma.Networking serverPeer = new LidgrenServerPeer(ownerKey, ServerSettings, callbacks); if (registerToServerList) { - registeredToSteamMaster = SteamManager.CreateServer(this, ServerSettings.IsPublic); + try + { + registeredToSteamMaster = SteamManager.CreateServer(this, ServerSettings.IsPublic); + } + catch (Exception e) + { + DebugConsole.NewMessage($"Steam registering skipped due to error (and probably more of it was printed above): {e.Message}"); + } Eos.EosSessionManager.UpdateOwnedSession(Option.None, ServerSettings); } } @@ -423,12 +430,15 @@ namespace Barotrauma.Networking for (int i = Character.CharacterList.Count - 1; i >= 0; i--) { Character character = Character.CharacterList[i]; - if (!character.ClientDisconnected) { continue; } Client owner = connectedClients.Find(c => (c.Character == null || c.Character == character) && character.IsClientOwner(c)); + bool spectating = owner is { SpectateOnly: true } && ServerSettings.AllowSpectating; + + if (!character.ClientDisconnected && !spectating) { continue; } + bool canOwnerTakeControl = owner != null && owner.InGame && !owner.NeedsMidRoundSync && - (!ServerSettings.AllowSpectating || !owner.SpectateOnly || + (!spectating || (permadeathMode && (!character.IsDead || character.CauseOfDeath?.Type == CauseOfDeathType.Disconnected))); if (!character.IsDead) { @@ -438,8 +448,17 @@ namespace Barotrauma.Networking character.SetStun(1.0f); } + float killTime = permadeathMode ? ServerSettings.DespawnDisconnectedPermadeathTime : ServerSettings.KillDisconnectedTime; + //owner decided to spectate -> kill the character immediately, + //it's no longer needed and should not be considered the character this client is controlling + //the client can still regain control, because the character can be revived in the block below if the client rejoins as a non-spectator + if (spectating) + { + killTime = 0.0f; + } + if ((OwnerConnection == null || owner?.Connection != OwnerConnection) && - character.KillDisconnectedTimer > (permadeathMode ? ServerSettings.DespawnDisconnectedPermadeathTime : ServerSettings.KillDisconnectedTime)) + character.KillDisconnectedTimer > killTime) { character.Kill(CauseOfDeathType.Disconnected, null); continue; @@ -618,8 +637,9 @@ namespace Barotrauma.Networking } else if (ServerSettings.StartWhenClientsReady) { - int clientsReady = connectedClients.Count(c => c.GetVote(VoteType.StartRound)); - if (clientsReady / (float)connectedClients.Count >= ServerSettings.StartWhenClientsReadyRatio) + var startVoteEligibleClients = connectedClients.Where(c => Voting.CanVoteToStartRound(c)); + int clientsReady = startVoteEligibleClients.Count(c => c.GetVote(VoteType.StartRound)); + if (clientsReady / (float)startVoteEligibleClients.Count() >= ServerSettings.StartWhenClientsReadyRatio) { readyToStartAutomatically = true; } @@ -649,7 +669,8 @@ namespace Barotrauma.Networking c.ChatSpamSpeed = Math.Max(0.0f, c.ChatSpamSpeed - deltaTime); //constantly increase AFK timer if the client is controlling a character (gets reset to zero every time an input is received) - if (GameStarted && c.Character != null && !c.Character.IsDead && !c.Character.IsIncapacitated) + if (GameStarted && c.Character != null && !c.Character.IsDead && !c.Character.IsIncapacitated && + (!c.AFK || !ServerSettings.AllowAFK)) { if (c.Connection != OwnerConnection && c.Permissions != ClientPermissions.All) { c.KickAFKTimer += deltaTime; } } @@ -835,6 +856,7 @@ namespace Barotrauma.Networking if (connectedClient != null) { connectedClient.ReadyToStart = inc.ReadBoolean(); + connectedClient.AFK = inc.ReadBoolean(); UpdateCharacterInfo(inc, connectedClient); //game already started -> send start message immediately @@ -970,6 +992,10 @@ namespace Barotrauma.Networking case ClientPacketHeader.SERVER_COMMAND: ClientReadServerCommand(inc); break; + case ClientPacketHeader.ENDROUND_SELF: + connectedClient.InGame = false; + connectedClient.ResetSync(); + break; case ClientPacketHeader.CREW: ReadCrewMessage(inc, connectedClient); break; @@ -997,6 +1023,9 @@ namespace Barotrauma.Networking case ClientPacketHeader.TAKEOVERBOT: ReadTakeOverBotMessage(inc, connectedClient); break; + case ClientPacketHeader.TOGGLE_RESERVE_BENCH: + GameMain.GameSession?.CrewManager?.ReadToggleReserveBenchMessage(inc, connectedClient); + break; case ClientPacketHeader.FILE_REQUEST: if (ServerSettings.AllowFileTransfers) { @@ -1233,6 +1262,7 @@ namespace Barotrauma.Networking } c.LastRecvChatMsgID = NetIdUtils.Clamp(inc.ReadUInt16(), c.LastRecvChatMsgID, c.LastChatMsgQueueID); c.LastRecvClientListUpdate = NetIdUtils.Clamp(inc.ReadUInt16(), c.LastRecvClientListUpdate, LastClientListUpdateID); + c.AFK = inc.ReadBoolean(); ReadClientNameChange(c, inc); @@ -1298,6 +1328,7 @@ namespace Barotrauma.Networking //check if midround syncing is needed due to missed unique events if (!midroundSyncingDone) { entityEventManager.InitClientMidRoundSync(c); } MissionAction.NotifyMissionsUnlockedThisRound(c); + UnlockPathAction.NotifyPathsUnlockedThisRound(c); if (GameMain.GameSession.GameMode is PvPMode) { @@ -1316,6 +1347,7 @@ namespace Barotrauma.Networking c.TeamID = CharacterTeamType.Team1; } c.InGame = true; + c.AFK = false; } } @@ -1558,17 +1590,23 @@ namespace Barotrauma.Networking } else { - CharacterInfo botInfo = GameMain.GameSession.CrewManager?.GetCharacterInfos()?.FirstOrDefault(i => i.ID == botId); + CharacterInfo botInfo = GameMain.GameSession.CrewManager?.GetCharacterInfos(includeReserveBench: true)?.FirstOrDefault(i => i.ID == botId); - if (botInfo is { IsNewHire: true, Character: null }) + if (botInfo is { Character: null } && (botInfo.IsNewHire || botInfo.IsOnReserveBench)) { - SpawnAndTakeOverBot(campaign, botInfo, sender); + if (IsUsingRespawnShuttle()) + { + SpawnAndTakeOverBotInShuttle(campaign, botInfo, sender); + } + else + { + SpawnAndTakeOverBot(campaign, botInfo, sender); + } } else if (botInfo?.Character == null || !botInfo.Character.IsBot) { SendConsoleMessage($"Could not find a bot with the id {botId}.", sender, Color.Red); DebugConsole.ThrowError($"Client {sender.Name} failed to take over a bot (Could not find a bot with the id {botId})."); - return; } else if (ServerSettings.AllowBotTakeoverOnPermadeath) { @@ -1584,8 +1622,22 @@ namespace Barotrauma.Networking private static void SpawnAndTakeOverBot(CampaignMode campaign, CharacterInfo botInfo, Client client) { - var mainSubSpawnpoint = WayPoint.SelectCrewSpawnPoints(botInfo.ToEnumerable().ToList(), Submarine.MainSub).FirstOrDefault(); - var spawnWaypoint = campaign.CrewManager.GetOutpostSpawnpoints()?.FirstOrDefault() ?? mainSubSpawnpoint; + WayPoint mainSubSpawnpoint = WayPoint.SelectCrewSpawnPoints(botInfo.ToEnumerable().ToList(), Submarine.MainSub).FirstOrDefault(); + WayPoint outpostWaypoint = campaign.CrewManager.GetOutpostSpawnpoints()?.FirstOrDefault(); + WayPoint spawnWaypoint; + + //give the bot the same salary the player had + TransferPreviousSalaryToBot(campaign, botInfo, client); + + if (botInfo.IsOnReserveBench) + { + spawnWaypoint = mainSubSpawnpoint ?? outpostWaypoint; + } + else + { + spawnWaypoint = outpostWaypoint ?? mainSubSpawnpoint; + } + if (spawnWaypoint == null) { DebugConsole.ThrowError("SpawnAndTakeOverBot: Unable to find any spawn waypoints inside the sub"); @@ -1598,13 +1650,59 @@ namespace Barotrauma.Networking DebugConsole.ThrowError("SpawnAndTakeOverBot: newCharacter is null somehow"); return; } - // No longer show the hired character in the HR list of current hires - campaign.CrewManager.RemoveCharacterInfo(botInfo); + + if (botInfo.IsOnReserveBench) + { + campaign.CrewManager.ToggleReserveBenchStatus(botInfo, client); + } + newCharacter.TeamID = CharacterTeamType.Team1; campaign.CrewManager.InitializeCharacter(newCharacter, mainSubSpawnpoint, spawnWaypoint); client.TryTakeOverBot(newCharacter); + Log($"Client \"{client.Name}\" took over the bot \"{botInfo.DisplayName}\".", ServerLog.MessageType.ServerMessage); }); } + + private static void SpawnAndTakeOverBotInShuttle(CampaignMode campaign, CharacterInfo botInfo, Client client) + { + if (botInfo.IsOnReserveBench && campaign is MultiPlayerCampaign mpCampaign) + { + //give the bot the same salary the player had + TransferPreviousSalaryToBot(campaign, botInfo, client); + + // Bring the bot from the reserve bench to active service + mpCampaign.CrewManager.ToggleReserveBenchStatus(botInfo, client); + Debug.Assert(botInfo.BotStatus == BotStatus.ActiveService); + + Log($"Client \"{client.Name}\" chose to spawn as the bot \"{botInfo.DisplayName}\" in the next respawn shuttle.", ServerLog.MessageType.ServerMessage); + + // Note: The following does what ServerSource/Networking/Client.cs:TryTakeOverBot() would do, but here we have + // to do it without a Character (before the Character has spawned), to get them on the respawn shuttle + + // Now that the old permanently killed character will be replaced, we can fully discard it + mpCampaign.DiscardClientCharacterData(client); + + client.CharacterInfo = botInfo; + client.CharacterInfo.RenamingEnabled = true; // Grant one opportunity to rename a taken over bot + client.CharacterInfo.IsNewHire = false; + client.SpectateOnly = false; + client.WaitForNextRoundRespawn = false; // =respawn asap + + // Generate a new, less dead CharacterCampaignData for the client + if (mpCampaign.SetClientCharacterData(client) is CharacterCampaignData characterData) + { + //the bot has spawned, but the new CharacterCampaignData technically hasn't, because we just created it + characterData.HasSpawned = true; + characterData.ChosenNewBotViaShuttle = true; + } + } + } + + private static void TransferPreviousSalaryToBot(CampaignMode campaign, CharacterInfo botInfo, Client client) + { + //give the bot the same salary the player had + botInfo.LastRewardDistribution = Option.Some(client?.Character?.Wallet.RewardDistribution ?? campaign.Bank.RewardDistribution); + } private void ClientReadServerCommand(IReadMessage inc) { @@ -1956,6 +2054,7 @@ namespace Barotrauma.Networking outmsg.WriteBoolean(GameStarted); outmsg.WriteBoolean(ServerSettings.AllowSpectating); + outmsg.WriteBoolean(ServerSettings.AllowAFK); outmsg.WriteBoolean(ServerSettings.RespawnMode == RespawnMode.Permadeath); outmsg.WriteBoolean(ServerSettings.IronmanMode); @@ -2288,6 +2387,7 @@ namespace Barotrauma.Networking outmsg.WriteBoolean(ServerSettings.VoiceChatEnabled); outmsg.WriteBoolean(ServerSettings.AllowSpectating); + outmsg.WriteBoolean(ServerSettings.AllowAFK); outmsg.WriteSingle(ServerSettings.TraitorProbability); outmsg.WriteRangedInteger(ServerSettings.TraitorDangerLevel, TraitorEventPrefab.MinDangerLevel, TraitorEventPrefab.MaxDangerLevel); @@ -2671,7 +2771,7 @@ namespace Barotrauma.Networking //give the clients a few seconds to request missing sub/shuttle files before starting the round float waitForResponseTimer = 5.0f; - while (connectedClients.Any(c => !c.ReadyToStart) && waitForResponseTimer > 0.0f) + while (connectedClients.Any(c => !c.ReadyToStart && !c.AFK) && waitForResponseTimer > 0.0f) { waitForResponseTimer -= CoroutineManager.DeltaTime; yield return CoroutineStatus.Running; @@ -2780,7 +2880,7 @@ namespace Barotrauma.Networking } yield return CoroutineStatus.Failure; } - + campaign.RoundID++; SendStartMessage(roundStartSeed, campaign.NextLevel.Seed, GameMain.GameSession, connectedClients, includesFinalize: false); GameMain.GameSession.StartRound(campaign.NextLevel, startOutpost: campaign.GetPredefinedStartOutpost(), mirrorLevel: campaign.MirrorLevel); SubmarineSwitchLoad = false; @@ -2821,6 +2921,7 @@ namespace Barotrauma.Networking campaign.CargoManager.CreatePurchasedItems(); //midround-joining clients need to be informed of pending/new hires at outposts if (isOutpost) { campaign.SendCrewState(); } + //campaign.SendCrewState(); // pending/new hires, reserve bench } if (GameMain.GameSession.Missions.None(m => !m.Prefab.AllowOutpostNPCs)) @@ -2881,13 +2982,7 @@ namespace Barotrauma.Networking List characterInfos = new List(); foreach (Client client in teamClients) { - client.NeedsMidRoundSync = false; - - client.PendingPositionUpdates.Clear(); - client.EntityEventLastSent.Clear(); - client.LastSentEntityEventID = 0; - client.LastRecvEntityEventID = 0; - client.UnreceivedEntityEventCount = 0; + client.ResetSync(); if (client.CharacterInfo == null) { @@ -2928,19 +3023,11 @@ namespace Barotrauma.Networking hadBots = false; } - List spawnWaypoints = null; - List mainSubWaypoints = teamSub != null ? WayPoint.SelectCrewSpawnPoints(characterInfos, Submarine.MainSubs[n]).ToList() : null; + WayPoint[] spawnWaypoints = null; + WayPoint[] mainSubWaypoints = teamSub != null ? WayPoint.SelectCrewSpawnPoints(characterInfos, Submarine.MainSubs[n]) : null; if (Level.Loaded != null && Level.Loaded.ShouldSpawnCrewInsideOutpost()) { - spawnWaypoints = WayPoint.GetOutpostSpawnPoints(teamID); - while (spawnWaypoints.Count > characterInfos.Count) - { - spawnWaypoints.RemoveAt(Rand.Int(spawnWaypoints.Count)); - } - while (spawnWaypoints.Any() && spawnWaypoints.Count < characterInfos.Count) - { - spawnWaypoints.Add(spawnWaypoints[Rand.Int(spawnWaypoints.Count)]); - } + spawnWaypoints = WayPoint.SelectOutpostSpawnPoints(characterInfos, teamID); } if (teamSub != null) { @@ -2948,9 +3035,10 @@ namespace Barotrauma.Networking { spawnWaypoints = mainSubWaypoints; } - Debug.Assert(spawnWaypoints.Count == mainSubWaypoints.Count); + Debug.Assert(spawnWaypoints.Length == mainSubWaypoints.Length); } - + + // Spawn players for (int i = 0; i < teamClients.Count; i++) { //if there's a main sub waypoint available (= the spawnpoint the character would've spawned at, if they'd spawned in the main sub instead of the outpost), @@ -3003,7 +3091,8 @@ namespace Barotrauma.Networking spawnedCharacter.SetOwnerClient(teamClients[i]); AddCharacterToList(teamID, spawnedCharacter); } - + + // Spawn bots for (int i = teamClients.Count; i < teamClients.Count + bots.Count; i++) { WayPoint jobItemSpawnPoint = mainSubWaypoints != null ? mainSubWaypoints[i] : spawnWaypoints[i]; @@ -3167,6 +3256,7 @@ namespace Barotrauma.Networking int nextLocationIndex = campaign.Map.Locations.FindIndex(l => l.LevelData == campaign.NextLevel); int nextConnectionIndex = campaign.Map.Connections.FindIndex(c => c.LevelData == campaign.NextLevel); msg.WriteByte(campaign.CampaignID); + msg.WriteByte(campaign == null ? (byte)0 : campaign.RoundID); msg.WriteUInt16(campaign.LastSaveID); msg.WriteInt32(nextLocationIndex); msg.WriteInt32(nextConnectionIndex); @@ -3225,6 +3315,7 @@ namespace Barotrauma.Networking { msg.WriteString(contentFile.Path.Value); } + msg.WriteByte((GameMain.GameSession.Campaign as MultiPlayerCampaign)?.RoundID ?? 0); msg.WriteInt32(Submarine.MainSub?.Info.EqualityCheckVal ?? 0); msg.WriteByte((byte)GameMain.GameSession.Missions.Count()); foreach (Mission mission in GameMain.GameSession.Missions) @@ -3293,9 +3384,7 @@ namespace Barotrauma.Networking entityEventManager.Clear(); foreach (Client c in connectedClients) { - c.EntityEventLastSent.Clear(); - c.PendingPositionUpdates.Clear(); - c.PositionUpdateLastSent.Clear(); + c.ResetSync(); } if (GameStarted) @@ -3418,13 +3507,13 @@ namespace Barotrauma.Networking return result.Value; } - return TryChangeClientName(c, newName); + return TryChangeClientName(c, newName, clientRenamingSelf: true); } - public bool TryChangeClientName(Client c, string newName) + public bool TryChangeClientName(Client c, string newName, bool clientRenamingSelf = false) { newName = Client.SanitizeName(newName); - if (newName != c.Name && !string.IsNullOrEmpty(newName) && IsNameValid(c, newName)) + if (newName != c.Name && !string.IsNullOrEmpty(newName) && IsNameValid(c, newName, clientRenamingSelf)) { c.LastNameChangeTime = DateTime.Now; string oldName = c.Name; @@ -3443,7 +3532,7 @@ namespace Barotrauma.Networking } } - public bool IsNameValid(Client c, string newName) + public bool IsNameValid(Client c, string newName, bool clientRenamingSelf = false) { if (c.Connection != OwnerConnection) { @@ -3465,18 +3554,23 @@ namespace Barotrauma.Networking } } - Client nameTakenByClient = ConnectedClients.Find(c2 => c != c2 && Homoglyphs.Compare(c2.Name.ToLower(), newName.ToLower())); + Client nameTakenByClient = ConnectedClients.Find(c2 => + !(clientRenamingSelf && c == c2) && // only allow renaming one's own client with a similar name + Homoglyphs.Compare(c2.Name.ToLower(), newName.ToLower())); if (nameTakenByClient != null) { SendDirectChatMessage($"ServerMessage.NameChangeFailedClientTooSimilar~[newname]={newName}~[takenname]={nameTakenByClient.Name}", c, ChatMessageType.ServerMessageBox); return false; } - - Character nameTakenByCharacter = - GameSession.GetSessionCrewCharacters(CharacterType.Both).FirstOrDefault(c2 => c2 != c.Character && Homoglyphs.Compare(c2.Name.ToLower(), newName.ToLower())); - if (nameTakenByCharacter != null) + + string existingTooSimilarName = GameMain.GameSession?.CrewManager? + .GetCharacterInfos(includeReserveBench: true) + .FirstOrDefault(ci => + (!clientRenamingSelf || ci.ID != c.Character?.ID) && + Homoglyphs.Compare(ci.Name.ToLower(), newName.ToLower()))?.Name; + if (!existingTooSimilarName.IsNullOrEmpty()) { - SendDirectChatMessage($"ServerMessage.NameChangeFailedClientTooSimilar~[newname]={newName}~[takenname]={nameTakenByCharacter.Name}", c, ChatMessageType.ServerMessageBox); + SendDirectChatMessage($"ServerMessage.NameChangeFailedTooSimilar~[newname]={newName}~[takenname]={existingTooSimilarName}", c, ChatMessageType.ServerMessageBox); return false; } return true; @@ -4029,10 +4123,11 @@ namespace Barotrauma.Networking SendVoteStatus(connectedClients); - int endVoteCount = ConnectedClients.Count(c => c.HasSpawned && c.GetVote(VoteType.EndRound)); - int endVoteMax = GameMain.Server.ConnectedClients.Count(c => c.HasSpawned); + var endVoteEligibleClients = connectedClients.Where(c => Voting.CanVoteToEndRound(c)); + int endVoteCount = endVoteEligibleClients.Count(c => c.GetVote(VoteType.EndRound)); + int endVoteMax = endVoteEligibleClients.Count(); if (ServerSettings.AllowEndVoting && endVoteMax > 0 && - ((float)endVoteCount / (float)endVoteMax) >= ServerSettings.EndVoteRequiredRatio) + (endVoteCount / (float)endVoteMax) >= ServerSettings.EndVoteRequiredRatio) { Log("Ending round by votes (" + endVoteCount + "/" + (endVoteMax - endVoteCount) + ")", ServerLog.MessageType.ServerMessage); EndGame(wasSaved: false); @@ -4269,13 +4364,17 @@ namespace Barotrauma.Networking private void UpdateCharacterInfo(IReadMessage message, Client sender) { bool spectateOnly = message.ReadBoolean(); + bool characterDiscarded = message.ReadBoolean(); + bool readInfo = message.ReadBoolean(); message.ReadPadBits(); sender.SpectateOnly = spectateOnly && (ServerSettings.AllowSpectating || sender.Connection == OwnerConnection); - if (sender.SpectateOnly) { return; } + + if (!readInfo) { return; } var netInfo = INetSerializableStruct.Read(message); + if (sender.SpectateOnly) { return; } if (charInfoRateLimiter.IsLimitReached(sender)) { return; } string newName = netInfo.NewName; @@ -4286,7 +4385,7 @@ namespace Barotrauma.Networking else { newName = Client.SanitizeName(newName); - if (!IsNameValid(sender, newName)) + if (!IsNameValid(sender, newName, clientRenamingSelf: true)) { newName = sender.Name; } @@ -4297,11 +4396,16 @@ namespace Barotrauma.Networking } // If a CharacterInfo for this Client already exists on the server, make sure it is used, and prevent the Client from replacing it - var existingCampaignData = (GameMain.GameSession?.Campaign as MultiPlayerCampaign)?.GetClientCharacterData(sender); - if (existingCampaignData != null) + if (GameMain.GameSession?.Campaign is MultiPlayerCampaign mpCampaign) { - sender.CharacterInfo = existingCampaignData.CharacterInfo; - return; + if (characterDiscarded) { mpCampaign.DiscardClientCharacterData(sender); } + var existingCampaignData = mpCampaign.GetClientCharacterData(sender); + if (existingCampaignData != null) + { + DebugConsole.NewMessage("Client attempted to modify their CharacterInfo, but they already have an existing campaign character. Ignoring the modifications."); + sender.CharacterInfo = existingCampaignData.CharacterInfo; + return; + } } sender.CharacterInfo = new CharacterInfo(CharacterPrefab.HumanSpeciesName, newName); @@ -4687,7 +4791,7 @@ namespace Barotrauma.Networking private List GetPlayingClients() { - List playingClients = new List(connectedClients); + List playingClients = new List(connectedClients.Where(c => !c.AFK || !ServerSettings.AllowAFK)); if (ServerSettings.AllowSpectating) { playingClients.RemoveAll(static c => c.SpectateOnly); diff --git a/Barotrauma/BarotraumaServer/ServerSource/Networking/Primitives/Peers/Server/LidgrenServerPeer.cs b/Barotrauma/BarotraumaServer/ServerSource/Networking/Primitives/Peers/Server/LidgrenServerPeer.cs index 0677e7775..047f7d29d 100644 --- a/Barotrauma/BarotraumaServer/ServerSource/Networking/Primitives/Peers/Server/LidgrenServerPeer.cs +++ b/Barotrauma/BarotraumaServer/ServerSource/Networking/Primitives/Peers/Server/LidgrenServerPeer.cs @@ -210,11 +210,11 @@ namespace Barotrauma.Networking } PendingClient? pendingClient = pendingClients.Find(c => c.Connection.NetConnection == inc.SenderConnection); - if (pendingClient is null) { pendingClient = new PendingClient(new LidgrenConnection(inc.SenderConnection)); pendingClients.Add(pendingClient); + GameServer.Log($"Incoming connection from {pendingClient.Connection.NetConnection?.RemoteEndPoint?.ToString() ?? "null"}.", ServerLog.MessageType.ServerMessage); } inc.SenderConnection.Approve(); @@ -228,7 +228,25 @@ namespace Barotrauma.Networking IReadMessage inc = lidgrenMsg.ToReadMessage(); - var (_, packetHeader, initialization) = INetSerializableStruct.Read(inc); + PeerPacketHeaders peerPacketHeaders = default; + try + { + peerPacketHeaders = INetSerializableStruct.Read(inc); + } + catch + { + if (pendingClient != null) + { + //pending (= not yet authenticated) client sent malformed data, immediately ban them so they can't use this for spamming + GameServer.Log($"Received an invalid connection attempt from {pendingClient.Connection.NetConnection?.RemoteEndPoint?.ToString() ?? "null"}. Banning the IP.", ServerLog.MessageType.DoSProtection); + serverSettings.BanList.BanPlayer(name: "Unknown", endpoint: pendingClient.Connection.Endpoint, reason: "Invalid connection attempt", duration: null); + } + else + { + throw; + } + } + var (_, packetHeader, initialization) = peerPacketHeaders; if (packetHeader.IsConnectionInitializationStep() && pendingClient != null && initialization.HasValue) { diff --git a/Barotrauma/BarotraumaServer/ServerSource/Networking/Primitives/Peers/Server/ServerPeer.cs b/Barotrauma/BarotraumaServer/ServerSource/Networking/Primitives/Peers/Server/ServerPeer.cs index e766896bb..09f69ca4f 100644 --- a/Barotrauma/BarotraumaServer/ServerSource/Networking/Primitives/Peers/Server/ServerPeer.cs +++ b/Barotrauma/BarotraumaServer/ServerSource/Networking/Primitives/Peers/Server/ServerPeer.cs @@ -41,7 +41,7 @@ namespace Barotrauma.Networking public ConnectionInitialization InitializationStep; public double UpdateTime; public double TimeOut; - public int Retries; + public int PasswordRetries; public Int32? PasswordSalt; public bool AuthSessionStarted; @@ -52,7 +52,7 @@ namespace Barotrauma.Networking OwnerKey = Option.None; Connection = conn; InitializationStep = ConnectionInitialization.AuthInfoAndVersion; - Retries = 0; + PasswordRetries = 0; PasswordSalt = null; UpdateTime = Timing.TotalTime + Timing.Step * 3.0; TimeOut = NetworkConnection.TimeoutThreshold; @@ -156,8 +156,8 @@ namespace Barotrauma.Networking } else { - pendingClient.Retries++; - if (serverSettings.BanAfterWrongPassword && pendingClient.Retries > serverSettings.MaxPasswordRetriesBeforeBan) + pendingClient.PasswordRetries++; + if (serverSettings.BanAfterWrongPassword && pendingClient.PasswordRetries > serverSettings.MaxPasswordRetriesBeforeBan) { const string banMsg = "Failed to enter correct password too many times"; BanPendingClient(pendingClient, banMsg, null); @@ -286,7 +286,7 @@ namespace Barotrauma.Networking structToSend = new ServerPeerPasswordPacket { Salt = GetSalt(pendingClient), - RetriesLeft = Option.Some(pendingClient.Retries) + RetriesLeft = Option.Some(pendingClient.PasswordRetries) }; static Option GetSalt(PendingClient client) diff --git a/Barotrauma/BarotraumaServer/ServerSource/Networking/RespawnManager.cs b/Barotrauma/BarotraumaServer/ServerSource/Networking/RespawnManager.cs index c6724222f..b83376d66 100644 --- a/Barotrauma/BarotraumaServer/ServerSource/Networking/RespawnManager.cs +++ b/Barotrauma/BarotraumaServer/ServerSource/Networking/RespawnManager.cs @@ -38,8 +38,9 @@ namespace Barotrauma.Networking continue; } - // Respawning might also be needed in permadeath mode for disconnected characters, but never for permanently dead ones + // Respawning can still happen in permadeath mode (disconnected characters, reserve bench...), but never for permanently dead ones if (GameMain.NetworkMember?.ServerSettings is { RespawnMode: RespawnMode.Permadeath } && + matchingData is not { ChosenNewBotViaShuttle: true } && // respawning as a bot that should respawn the usual way via shuttle (matchingData?.CharacterInfo is { PermanentlyDead: true } || c.Character is { IsDead: true })) { continue; @@ -47,7 +48,7 @@ namespace Barotrauma.Networking if (campaign != null) { - if (matchingData != null && matchingData.HasSpawned) + if (matchingData != null && matchingData.HasSpawned && !matchingData.ChosenNewBotViaShuttle) { //in the campaign mode, wait for the client to choose whether they want to spawn if (!c.WaitForNextRoundRespawn.HasValue || c.WaitForNextRoundRespawn.Value) { continue; } @@ -66,7 +67,7 @@ namespace Barotrauma.Networking if (c.SpectateOnly && (GameMain.Server.ServerSettings.AllowSpectating || GameMain.Server.OwnerConnection == c.Connection)) { return false; } if (c.Character != null && !c.Character.IsDead) { return false; } - var matchingData = campaign.GetClientCharacterData(c); + CharacterCampaignData matchingData = campaign.GetClientCharacterData(c); if (matchingData != null && matchingData.HasSpawned) { if (Character.CharacterList.Any(c => @@ -83,6 +84,16 @@ namespace Barotrauma.Networking return false; } + private static bool ClientHasChosenNewBotViaShuttle(Client c) + { + if (GameMain.GameSession.GameMode is MultiPlayerCampaign mpCampaign && + mpCampaign.GetClientCharacterData(c) is CharacterCampaignData matchingData) + { + return matchingData.ChosenNewBotViaShuttle; + } + return false; + } + private static List GetBotsToRespawn(CharacterTeamType teamId) { //this works under the assumption that GetCharacterInfos only returns bots in MP @@ -152,7 +163,8 @@ namespace Barotrauma.Networking private static int GetMinCharactersToRespawn() { - return Math.Max((int)(GameMain.Server.ConnectedClients.Count * GameMain.Server.ServerSettings.MinRespawnRatio), 1); + int respawnableClientCount = GameMain.Server.ConnectedClients.Count(c => c.InGame && (!c.AFK || !GameMain.Server.ServerSettings.AllowAFK)); + return Math.Max((int)(respawnableClientCount * GameMain.Server.ServerSettings.MinRespawnRatio), 1); } private bool ShouldStartRespawnCountdown(int characterToRespawnCount) @@ -237,6 +249,7 @@ namespace Barotrauma.Networking teamSpecificState.CurrentState = State.Transporting; } GameMain.Server.CreateEntityEvent(this); + SetShuttleBodyType(teamSpecificState.TeamID, FarseerPhysics.BodyType.Dynamic); } else { @@ -429,15 +442,10 @@ namespace Barotrauma.Networking ItemPrefab batteryPrefab = ItemPrefab.Find(null, "batterycell".ToIdentifier()); //the spawnpoints where the characters will spawn - var selectedSpawnPoints = WayPoint.SelectCrewSpawnPoints(characterInfos, respawnSub); - if (isPvPMode && Level.Loaded != null && Level.Loaded.ShouldSpawnCrewInsideOutpost()) - { - var spawnWaypoints = WayPoint.GetOutpostSpawnPoints(teamID); - for (int i = 0; i < characterInfos.Count; i++) - { - selectedSpawnPoints[i] = spawnWaypoints.GetRandomUnsynced(); - } - } + var selectedSpawnPoints = + isPvPMode && Level.Loaded != null && Level.Loaded.ShouldSpawnCrewInsideOutpost() ? + WayPoint.SelectOutpostSpawnPoints(characterInfos, teamID) : + WayPoint.SelectCrewSpawnPoints(characterInfos, respawnSub); //the spawnpoints where they would spawn if they were spawned inside the main sub //(in order to give them appropriate ID card tags) @@ -459,7 +467,7 @@ namespace Barotrauma.Networking //when the character spawns, set the client's name to match if (clients[i].PendingName == characterInfo.Name) { - GameMain.Server?.TryChangeClientName(clients[i], clients[i].PendingName); + GameMain.Server?.TryChangeClientName(clients[i], clients[i].PendingName, clientRenamingSelf: true); clients[i].PendingName = null; } @@ -678,6 +686,7 @@ namespace Barotrauma.Networking msg.WriteUInt16((ushort)teamSpecificState.PendingRespawnCount); msg.WriteUInt16((ushort)teamSpecificState.RequiredRespawnCount); msg.WriteBoolean(IsRespawnDecisionPendingForClient(c)); + msg.WriteBoolean(ClientHasChosenNewBotViaShuttle(c)); msg.WriteBoolean(teamSpecificState.RespawnCountdownStarted); msg.WriteSingle((float)(teamSpecificState.RespawnTime - DateTime.Now).TotalSeconds); break; diff --git a/Barotrauma/BarotraumaServer/ServerSource/Networking/ServerSettings.cs b/Barotrauma/BarotraumaServer/ServerSource/Networking/ServerSettings.cs index bc4232848..4645a7a53 100644 --- a/Barotrauma/BarotraumaServer/ServerSource/Networking/ServerSettings.cs +++ b/Barotrauma/BarotraumaServer/ServerSource/Networking/ServerSettings.cs @@ -268,7 +268,6 @@ namespace Barotrauma.Networking { XDocument doc = new XDocument(new XElement("serversettings")); - doc.Root.SetAttributeValue("name", ServerName); doc.Root.SetAttributeValue("port", Port); if (QueryPort != 0) @@ -280,8 +279,6 @@ namespace Barotrauma.Networking doc.Root.SetAttributeValue("enableupnp", EnableUPnP); doc.Root.SetAttributeValue("autorestart", autoRestart); - doc.Root.SetAttributeValue("ServerMessage", ServerMessageText); - doc.Root.SetAttributeValue("HiddenSubs", string.Join(",", HiddenSubs)); doc.Root.SetAttributeValue("AllowedRandomMissionTypes", string.Join(",", AllowedRandomMissionTypes)); @@ -325,6 +322,11 @@ namespace Barotrauma.Networking SerializableProperties = SerializableProperty.DeserializeProperties(this, doc.Root); + //backwards compatibility + if (serverName.IsNullOrEmpty()) { ServerName = doc.Root.GetAttributeString("name", ""); } + if (ServerName.Length > NetConfig.ServerNameMaxLength) { ServerName = ServerName.Substring(0, NetConfig.ServerNameMaxLength); } + if (ServerMessageText.IsNullOrEmpty()) { ServerMessageText = doc.Root.GetAttributeString("ServerMessage", ""); } + if (string.IsNullOrEmpty(doc.Root.GetAttributeString("losmode", ""))) { LosMode = GameSettings.CurrentConfig.Graphics.LosMode; @@ -409,10 +411,6 @@ namespace Barotrauma.Networking AllowedRandomMissionTypes = doc.Root.GetAttributeIdentifierArray( "AllowedRandomMissionTypes", MissionPrefab.GetAllMultiplayerSelectableMissionTypes().ToArray()).ToList(); - ServerName = doc.Root.GetAttributeString("name", ""); - if (ServerName.Length > NetConfig.ServerNameMaxLength) { ServerName = ServerName.Substring(0, NetConfig.ServerNameMaxLength); } - ServerMessageText = doc.Root.GetAttributeString("ServerMessage", ""); - GameMain.NetLobbyScreen.SelectedModeIdentifier = GameModeIdentifier; if (AllowedRandomMissionTypes.Contains(Tags.MissionTypeAll)) { diff --git a/Barotrauma/BarotraumaServer/ServerSource/Networking/Voting.cs b/Barotrauma/BarotraumaServer/ServerSource/Networking/Voting.cs index 9979d522a..cc69609ce 100644 --- a/Barotrauma/BarotraumaServer/ServerSource/Networking/Voting.cs +++ b/Barotrauma/BarotraumaServer/ServerSource/Networking/Voting.cs @@ -168,6 +168,16 @@ namespace Barotrauma } } + public bool CanVoteToStartRound(Client client) + { + return !client.AFK || !GameMain.Server.ServerSettings.AllowAFK; + } + + public bool CanVoteToEndRound(Client client) + { + return client.HasSpawned && client.InGame; + } + private bool ShouldRejectVote(Client sender, VoteType voteType) { if (rejectedVoteTimes.ContainsKey(sender)) @@ -415,8 +425,8 @@ namespace Barotrauma msg.WriteBoolean(GameMain.Server.ServerSettings.AllowEndVoting); if (GameMain.Server.ServerSettings.AllowEndVoting) { - msg.WriteByte((byte)GameMain.Server.ConnectedClients.Count(c => c.HasSpawned && c.GetVote(VoteType.EndRound))); - msg.WriteByte((byte)GameMain.Server.ConnectedClients.Count(c => c.HasSpawned)); + msg.WriteByte((byte)GameMain.Server.ConnectedClients.Count(c => CanVoteToEndRound(c) && c.GetVote(VoteType.EndRound))); + msg.WriteByte((byte)GameMain.Server.ConnectedClients.Count(c => CanVoteToEndRound(c))); } msg.WriteBoolean(GameMain.Server.ServerSettings.AllowVoteKick); diff --git a/Barotrauma/BarotraumaServer/ServerSource/Steam/SteamManager.cs b/Barotrauma/BarotraumaServer/ServerSource/Steam/SteamManager.cs index e8af9e679..b546f50a3 100644 --- a/Barotrauma/BarotraumaServer/ServerSource/Steam/SteamManager.cs +++ b/Barotrauma/BarotraumaServer/ServerSource/Steam/SteamManager.cs @@ -1,4 +1,6 @@ -using System.Collections.Generic; +using System; +using System.Collections.Generic; +using System.Linq; using Barotrauma.Networking; namespace Barotrauma.Steam @@ -64,9 +66,24 @@ namespace Barotrauma.Steam case bool hasPassword when key == "HasPassword": Steamworks.SteamServer.Passworded = hasPassword; return; + case string serverMessage when key == "message": + int maxValueLength = 255; + int totalMaxLength = 2000; + int chunkIndex = 0; + for (int charIndex = 0; charIndex < serverMessage.Length && charIndex < totalMaxLength; charIndex += maxValueLength) + { + Steamworks.SteamServer.SetKey( + $"message{chunkIndex}", + serverMessage.Substring(charIndex, Math.Min(maxValueLength, serverMessage.Length - charIndex))); + chunkIndex++; + } + return; case IEnumerable contentPackages: + //a2s seems to break if too much data is added (seems to be related to MTU?) + //let's restrict the number of packages to 10, clients can use packagecount to tell when the list has been truncated + const int MaxPackagesToList = 10; int index = 0; - foreach (var contentPackage in contentPackages) + foreach (var contentPackage in contentPackages.Take(MaxPackagesToList)) { Steamworks.SteamServer.SetKey( $"contentpackage{index}", diff --git a/Barotrauma/BarotraumaServer/WindowsServer.csproj b/Barotrauma/BarotraumaServer/WindowsServer.csproj index ce7eb135e..ded6d514f 100644 --- a/Barotrauma/BarotraumaServer/WindowsServer.csproj +++ b/Barotrauma/BarotraumaServer/WindowsServer.csproj @@ -6,7 +6,7 @@ Barotrauma FakeFish, Undertow Games Barotrauma Dedicated Server - 1.7.7.0 + 1.8.6.2 Copyright © FakeFish 2018-2023 AnyCPU;x64 DedicatedServer diff --git a/Barotrauma/BarotraumaShared/Data/clientpermissions.xml b/Barotrauma/BarotraumaShared/Data/clientpermissions.xml deleted file mode 100644 index ccc105b28..000000000 --- a/Barotrauma/BarotraumaShared/Data/clientpermissions.xml +++ /dev/null @@ -1,3 +0,0 @@ - - - \ No newline at end of file diff --git a/Barotrauma/BarotraumaShared/Data/permissionpresets.xml b/Barotrauma/BarotraumaShared/Data/permissionpresets.xml index 68be436bb..19f31cec3 100644 --- a/Barotrauma/BarotraumaShared/Data/permissionpresets.xml +++ b/Barotrauma/BarotraumaShared/Data/permissionpresets.xml @@ -1,4 +1,6 @@  + + + + + + \ No newline at end of file diff --git a/Barotrauma/BarotraumaShared/LocalMods/[DebugOnlyTest]Character override and variant tests/README.txt b/Barotrauma/BarotraumaShared/LocalMods/[DebugOnlyTest]Character override and variant tests/README.txt index 300ba9923..415f50330 100644 --- a/Barotrauma/BarotraumaShared/LocalMods/[DebugOnlyTest]Character override and variant tests/README.txt +++ b/Barotrauma/BarotraumaShared/LocalMods/[DebugOnlyTest]Character override and variant tests/README.txt @@ -21,6 +21,11 @@ - Note that the character is configured incorrectly: it's defined to be an override, but there's no character (Testcyborgworm_m) it'd override. It works regardless, so this can be used as a test case for checking that these incorrectly defined characters still load. +- Testcrawlerhatchling: overrides crawler hatchling with an identical version. + - Expected behavior: crawler hatchling looks normal, the same way as in vanilla game. + - This has previously caused issues, because we incorrectly tried to fetch the texture path from the root element instead + of the element under it. + - Spineling_morbusine_m: adds a variant of Spineling_morbusine (identical to the normal Spineling_morbusine). - Expected behavior: Spineling_morbusine_m looks identical to Spineling_morbusine. - This has previously caused issues, because Spineling_morbusine defines the ragdoll slightly differently than other monsters diff --git a/Barotrauma/BarotraumaShared/LocalMods/[DebugOnlyTest]Character override and variant tests/Testcrawlerhatchling.xml b/Barotrauma/BarotraumaShared/LocalMods/[DebugOnlyTest]Character override and variant tests/Testcrawlerhatchling.xml new file mode 100644 index 000000000..590c10edd --- /dev/null +++ b/Barotrauma/BarotraumaShared/LocalMods/[DebugOnlyTest]Character override and variant tests/Testcrawlerhatchling.xml @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/Barotrauma/BarotraumaShared/LocalMods/[DebugOnlyTest]Character override and variant tests/filelist.xml b/Barotrauma/BarotraumaShared/LocalMods/[DebugOnlyTest]Character override and variant tests/filelist.xml index 1073ed60d..671772446 100644 --- a/Barotrauma/BarotraumaShared/LocalMods/[DebugOnlyTest]Character override and variant tests/filelist.xml +++ b/Barotrauma/BarotraumaShared/LocalMods/[DebugOnlyTest]Character override and variant tests/filelist.xml @@ -3,6 +3,7 @@ + \ No newline at end of file diff --git a/Barotrauma/BarotraumaShared/LocalMods/[DebugOnlyTest]RotationAndFlippingTests/RotationAndFlippingTests.sub b/Barotrauma/BarotraumaShared/LocalMods/[DebugOnlyTest]RotationAndFlippingTests/RotationAndFlippingTests.sub new file mode 100644 index 000000000..be65f40e5 Binary files /dev/null and b/Barotrauma/BarotraumaShared/LocalMods/[DebugOnlyTest]RotationAndFlippingTests/RotationAndFlippingTests.sub differ diff --git a/Barotrauma/BarotraumaShared/LocalMods/[DebugOnlyTest]RotationAndFlippingTests/filelist.xml b/Barotrauma/BarotraumaShared/LocalMods/[DebugOnlyTest]RotationAndFlippingTests/filelist.xml new file mode 100644 index 000000000..fb1fd016e --- /dev/null +++ b/Barotrauma/BarotraumaShared/LocalMods/[DebugOnlyTest]RotationAndFlippingTests/filelist.xml @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/Barotrauma/BarotraumaShared/LocalMods/[DebugOnlyTest]Testhuman/Characters/Testhuman/Animations/HumanRunDivingSuit.xml b/Barotrauma/BarotraumaShared/LocalMods/[DebugOnlyTest]Testhuman/Characters/Testhuman/Animations/HumanRunDivingSuit.xml new file mode 100644 index 000000000..956472e89 --- /dev/null +++ b/Barotrauma/BarotraumaShared/LocalMods/[DebugOnlyTest]Testhuman/Characters/Testhuman/Animations/HumanRunDivingSuit.xml @@ -0,0 +1,40 @@ + \ No newline at end of file diff --git a/Barotrauma/BarotraumaShared/LocalMods/[DebugOnlyTest]Testhuman/Characters/Testhuman/Animations/HumanWalkDivingSuit.xml b/Barotrauma/BarotraumaShared/LocalMods/[DebugOnlyTest]Testhuman/Characters/Testhuman/Animations/HumanWalkDivingSuit.xml new file mode 100644 index 000000000..41f3c448e --- /dev/null +++ b/Barotrauma/BarotraumaShared/LocalMods/[DebugOnlyTest]Testhuman/Characters/Testhuman/Animations/HumanWalkDivingSuit.xml @@ -0,0 +1,40 @@ + \ No newline at end of file diff --git a/Barotrauma/BarotraumaShared/LocalMods/[DebugOnlyTest]Testhuman/Characters/Testhuman/Animations/TesthumanCrouch.xml b/Barotrauma/BarotraumaShared/LocalMods/[DebugOnlyTest]Testhuman/Characters/Testhuman/Animations/TesthumanCrouch.xml new file mode 100644 index 000000000..a9034ab43 --- /dev/null +++ b/Barotrauma/BarotraumaShared/LocalMods/[DebugOnlyTest]Testhuman/Characters/Testhuman/Animations/TesthumanCrouch.xml @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/Barotrauma/BarotraumaShared/LocalMods/[DebugOnlyTest]Testhuman/Characters/Testhuman/Animations/TesthumanRun.xml b/Barotrauma/BarotraumaShared/LocalMods/[DebugOnlyTest]Testhuman/Characters/Testhuman/Animations/TesthumanRun.xml new file mode 100644 index 000000000..ddf101355 --- /dev/null +++ b/Barotrauma/BarotraumaShared/LocalMods/[DebugOnlyTest]Testhuman/Characters/Testhuman/Animations/TesthumanRun.xml @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/Barotrauma/BarotraumaShared/LocalMods/[DebugOnlyTest]Testhuman/Characters/Testhuman/Animations/TesthumanSwimFast.xml b/Barotrauma/BarotraumaShared/LocalMods/[DebugOnlyTest]Testhuman/Characters/Testhuman/Animations/TesthumanSwimFast.xml new file mode 100644 index 000000000..acdeffb4d --- /dev/null +++ b/Barotrauma/BarotraumaShared/LocalMods/[DebugOnlyTest]Testhuman/Characters/Testhuman/Animations/TesthumanSwimFast.xml @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/Barotrauma/BarotraumaShared/LocalMods/[DebugOnlyTest]Testhuman/Characters/Testhuman/Animations/TesthumanSwimSlow.xml b/Barotrauma/BarotraumaShared/LocalMods/[DebugOnlyTest]Testhuman/Characters/Testhuman/Animations/TesthumanSwimSlow.xml new file mode 100644 index 000000000..24db372ed --- /dev/null +++ b/Barotrauma/BarotraumaShared/LocalMods/[DebugOnlyTest]Testhuman/Characters/Testhuman/Animations/TesthumanSwimSlow.xml @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/Barotrauma/BarotraumaShared/LocalMods/[DebugOnlyTest]Testhuman/Characters/Testhuman/Animations/TesthumanWalk.xml b/Barotrauma/BarotraumaShared/LocalMods/[DebugOnlyTest]Testhuman/Characters/Testhuman/Animations/TesthumanWalk.xml new file mode 100644 index 000000000..38e2eca21 --- /dev/null +++ b/Barotrauma/BarotraumaShared/LocalMods/[DebugOnlyTest]Testhuman/Characters/Testhuman/Animations/TesthumanWalk.xml @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/Barotrauma/BarotraumaShared/LocalMods/[DebugOnlyTest]Testhuman/Characters/Testhuman/Ragdolls/TesthumanDefaultRagdoll.xml b/Barotrauma/BarotraumaShared/LocalMods/[DebugOnlyTest]Testhuman/Characters/Testhuman/Ragdolls/TesthumanDefaultRagdoll.xml new file mode 100644 index 000000000..e7a177202 --- /dev/null +++ b/Barotrauma/BarotraumaShared/LocalMods/[DebugOnlyTest]Testhuman/Characters/Testhuman/Ragdolls/TesthumanDefaultRagdoll.xml @@ -0,0 +1,130 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/Barotrauma/BarotraumaShared/LocalMods/[DebugOnlyTest]Testhuman/Characters/Testhuman/Testhuman.xml b/Barotrauma/BarotraumaShared/LocalMods/[DebugOnlyTest]Testhuman/Characters/Testhuman/Testhuman.xml new file mode 100644 index 000000000..776307e83 --- /dev/null +++ b/Barotrauma/BarotraumaShared/LocalMods/[DebugOnlyTest]Testhuman/Characters/Testhuman/Testhuman.xml @@ -0,0 +1,308 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/Barotrauma/BarotraumaShared/LocalMods/[DebugOnlyTest]Testhuman/README.txt b/Barotrauma/BarotraumaShared/LocalMods/[DebugOnlyTest]Testhuman/README.txt new file mode 100644 index 000000000..94c01bad7 --- /dev/null +++ b/Barotrauma/BarotraumaShared/LocalMods/[DebugOnlyTest]Testhuman/README.txt @@ -0,0 +1,8 @@ +This mod includes a non-human character copied from a human. +The mod can be used to test whether some normally human-only features work on a non-human character, for example: +- Giving the character a job, name and portrait. +- Giving talents to the character. +- Adding the character to the crew. +- Using human AI on a non-human. + +When spawned, the character should behave and look the same way as a human character (depending on the team, it can either befriendly or hostile). \ No newline at end of file diff --git a/Barotrauma/BarotraumaShared/LocalMods/[DebugOnlyTest]Testhuman/filelist.xml b/Barotrauma/BarotraumaShared/LocalMods/[DebugOnlyTest]Testhuman/filelist.xml new file mode 100644 index 000000000..bf21e44ee --- /dev/null +++ b/Barotrauma/BarotraumaShared/LocalMods/[DebugOnlyTest]Testhuman/filelist.xml @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/Barotrauma/BarotraumaShared/SharedSource/AchievementManager.cs b/Barotrauma/BarotraumaShared/SharedSource/AchievementManager.cs index 419e6c1cb..0e03c7356 100644 --- a/Barotrauma/BarotraumaShared/SharedSource/AchievementManager.cs +++ b/Barotrauma/BarotraumaShared/SharedSource/AchievementManager.cs @@ -500,7 +500,8 @@ namespace Barotrauma } #if SERVER - if (GameMain.Server?.ServerSettings?.RespawnMode == RespawnMode.Permadeath) + if (GameMain.Server?.ServerSettings?.RespawnMode == RespawnMode.Permadeath && + causeOfDeath.Type != CauseOfDeathType.Disconnected) { UnlockAchievement(character, "abyssbeckons".ToIdentifier()); } diff --git a/Barotrauma/BarotraumaShared/SharedSource/Characters/AI/AIController.cs b/Barotrauma/BarotraumaShared/SharedSource/Characters/AI/AIController.cs index aa47e8d51..31db3cded 100644 --- a/Barotrauma/BarotraumaShared/SharedSource/Characters/AI/AIController.cs +++ b/Barotrauma/BarotraumaShared/SharedSource/Characters/AI/AIController.cs @@ -213,7 +213,8 @@ namespace Barotrauma if (targetHull == null) { return false; } if (maxDistance > 0) { - if (Vector2.DistanceSquared(Character.WorldPosition, targetWorldPos) > maxDistance * maxDistance) { return false; } + // Too far from the gap. + if (Vector2.DistanceSquared(Character.WorldPosition, gap.WorldPosition) > maxDistance * maxDistance) { return false; } } if (SteeringManager is IndoorsSteeringManager pathSteering) { @@ -325,7 +326,7 @@ namespace Barotrauma } if (dropOtherIfCannotMove) { - if (otherItem.Prefab.Identifier == item.Prefab.Identifier || otherItem.HasIdentifierOrTags(targetTags)) + if (otherItem.Prefab.Identifier == item.Prefab.Identifier || (targetTags != null && otherItem.HasIdentifierOrTags(targetTags))) { bool switchingToBetterSuit = targetTags != null && @@ -358,13 +359,23 @@ namespace Barotrauma } } - public void UnequipEmptyItems(Item parentItem, bool avoidDroppingInSea = true) => UnequipEmptyItems(Character, parentItem, avoidDroppingInSea); + /// When enabled, items are first put in the inventory and dropped only if that fails, unless the character is inside a friendly submarine. + /// Allows destroying of the items when unequipped (instead of dropping them). Used only with infinite spawns. + public void UnequipEmptyItems(Item parentItem, bool avoidDroppingInSea = true, bool allowDestroying = false) => UnequipEmptyItems(Character, parentItem, avoidDroppingInSea, allowDestroying); - public void UnequipContainedItems(Item parentItem, Func predicate = null, bool avoidDroppingInSea = true, int? unequipMax = null) => UnequipContainedItems(Character, parentItem, predicate, avoidDroppingInSea, unequipMax); + /// When enabled, items are first put in the inventory and dropped only if that fails, unless the character is inside a friendly submarine. + /// Allows destroying of the items when unequipped (instead of dropping them). Used only with infinite spawns. + /// Optional max amount for items to be unequipped. + public void UnequipContainedItems(Item parentItem, Func predicate = null, bool avoidDroppingInSea = true, bool allowDestroying = false, int? unequipMax = null) => UnequipContainedItems(Character, parentItem, predicate, avoidDroppingInSea, allowDestroying, unequipMax); - public static void UnequipEmptyItems(Character character, Item parentItem, bool avoidDroppingInSea = true) => UnequipContainedItems(character, parentItem, it => it.Condition <= 0, avoidDroppingInSea); - - public static void UnequipContainedItems(Character character, Item parentItem, Func predicate, bool avoidDroppingInSea = true, int? unequipMax = null) + /// When enabled, items are first put in the inventory and dropped only if that fails, unless the character is inside a friendly submarine. + /// Allows destroying of the items when unequipped (instead of dropping them). Used only with infinite spawns. + public static void UnequipEmptyItems(Character character, Item parentItem, bool avoidDroppingInSea = true, bool allowDestroying = false) => UnequipContainedItems(character, parentItem, it => it.Condition <= 0, avoidDroppingInSea, allowDestroying); + + /// When enabled, items are first put in the inventory and dropped only if that fails, unless the character is inside a friendly submarine. + /// Allows destroying of the items when unequipped (instead of dropping them). Used only with infinite spawns. + /// Optional max amount for items to be unequipped. + public static void UnequipContainedItems(Character character, Item parentItem, Func predicate, bool avoidDroppingInSea = true, bool allowDestroying = false, int? unequipMax = null) { var inventory = parentItem.OwnInventory; if (inventory == null || !inventory.Container.DrawInventory) { return; } @@ -376,21 +387,36 @@ namespace Barotrauma if (containedItem == null) { continue; } if (predicate == null || predicate(containedItem)) { - if (avoidDroppingInSea && !character.IsInFriendlySub) + if (allowDestroying && GameMain.NetworkMember is not { IsClient: true } && character.AIController.HasInfiniteItemSpawns(containedItem.Prefab.Identifier)) { - // If we are not inside a friendly sub (= same team), try to put the item in the inventory instead dropping it. - if (character.Inventory.TryPutItem(containedItem, character, CharacterInventory.AnySlot)) - { - if (unequipMax.HasValue && ++removed >= unequipMax) { return; } - continue; - } + Entity.Spawner?.AddItemToRemoveQueue(containedItem); + } + else + { + if (avoidDroppingInSea && !character.IsInFriendlySub) + { + // If we are not inside a friendly sub (= same team), try to put the item in the inventory instead dropping it. + if (character.Inventory.TryPutItem(containedItem, character, CharacterInventory.AnySlot)) + { + if (unequipMax.HasValue && ++removed >= unequipMax) { return; } + continue; + } + } + containedItem.Drop(character); } - containedItem.Drop(character); if (unequipMax.HasValue && ++removed >= unequipMax) { return; } } } } } + + public bool HasInfiniteItemSpawns(IEnumerable itemIdentifiers) + => (Character.HumanPrefab?.InfiniteItems.Any(it => itemIdentifiers.Contains(it.Identifier) || it.Tags.Any(itemIdentifiers.Contains)) ?? false) + || (Character.Info?.Job?.HasJobItem(jobItem => jobItem.Infinite && itemIdentifiers.Contains(jobItem.GetItemIdentifier(Character.TeamID, isPvPMode: GameMain.GameSession.GameMode is PvPMode))) ?? false); + + public bool HasInfiniteItemSpawns(Identifier itemIdentifier) + => (Character.HumanPrefab?.InfiniteItems.Any(it => it.Identifier == itemIdentifier || it.Tags.Contains(itemIdentifier)) ?? false) + || (Character.Info?.Job?.HasJobItem(jobItem => jobItem.Infinite && jobItem.GetItemIdentifier(Character.TeamID, isPvPMode: GameMain.GameSession.GameMode is PvPMode) == itemIdentifier) ?? false); public void ReequipUnequipped() { diff --git a/Barotrauma/BarotraumaShared/SharedSource/Characters/AI/EnemyAIController.cs b/Barotrauma/BarotraumaShared/SharedSource/Characters/AI/EnemyAIController.cs index 0bc42e627..5e7fd8e8c 100644 --- a/Barotrauma/BarotraumaShared/SharedSource/Characters/AI/EnemyAIController.cs +++ b/Barotrauma/BarotraumaShared/SharedSource/Characters/AI/EnemyAIController.cs @@ -1128,9 +1128,9 @@ namespace Barotrauma searchingNewHull = false; } } - if (patrolTarget != null && pathSteering.CurrentPath != null && !pathSteering.CurrentPath.Finished && !pathSteering.CurrentPath.Unreachable) + if (patrolTarget != null && pathSteering.CurrentPath is { Finished: false, Unreachable: false }) { - PathSteering.SteeringSeek(Character.GetRelativeSimPosition(patrolTarget), weight: 1, minGapWidth: minGapSize * 1.5f, nodeFilter: n => PatrolNodeFilter(n)); + pathSteering.SteeringSeek(Character.GetRelativeSimPosition(patrolTarget), weight: 1, minGapWidth: minGapSize * 1.5f, nodeFilter: PatrolNodeFilter); return; } } @@ -1570,22 +1570,22 @@ namespace Barotrauma Vector2 toTarget = attackWorldPos - attackLimbPos; Vector2 toTargetOffset = toTarget; // Add a margin when the target is moving away, because otherwise it might be difficult to reach it if the attack takes some time to execute - if (wallTarget != null && Character.Submarine == null) + if (Character.Submarine == null) { - if (wallTarget.Structure.Submarine != null) + if (wallTarget != null) { - Vector2 margin = CalculateMargin(wallTarget.Structure.Submarine.Velocity); + if (wallTarget.Structure.Submarine != null) + { + Vector2 margin = CalculateMargin(wallTarget.Structure.Submarine.Velocity); + toTargetOffset += margin; + } + } + else if (targetCharacter != null) + { + Vector2 margin = CalculateMargin(targetCharacter.AnimController.Collider.LinearVelocity); toTargetOffset += margin; } - } - else if (targetCharacter != null) - { - Vector2 margin = CalculateMargin(targetCharacter.AnimController.Collider.LinearVelocity); - toTargetOffset += margin; - } - else if (SelectedAiTarget.Entity is MapEntity e) - { - if (e.Submarine != null) + else if (SelectedAiTarget.Entity is MapEntity { Submarine: not null } e) { Vector2 margin = CalculateMargin(e.Submarine.Velocity); toTargetOffset += margin; @@ -1666,12 +1666,15 @@ namespace Barotrauma // Crouch if the target is down (only humanoids), so that we can reach it. if (Character.AnimController is HumanoidAnimController humanoidAnimController && distance < AttackLimb.attack.Range * 2) { - if (Math.Abs(toTarget.Y) > AttackLimb.attack.Range / 2 && Math.Abs(toTarget.X) <= AttackLimb.attack.Range) + if (targetCharacter?.CurrentHull is Hull targetHull && targetHull == Character.CurrentHull && toTarget.Y < 0) { - humanoidAnimController.Crouch(); + if (Math.Abs(toTarget.Y) > AttackLimb.attack.Range / 2 && Math.Abs(toTarget.X) <= AttackLimb.attack.Range) + { + humanoidAnimController.Crouch(); + } } } - + if (canAttack) { if (AttackLimb.attack.Ranged) @@ -1706,6 +1709,10 @@ namespace Barotrauma } } } + else if (wallTarget == null && Character.CurrentHull != null && targetCharacter != null) + { + canAttack = Submarine.PickBody(SimPosition, attackSimPos, collisionCategory: Physics.CollisionWall) == null; + } } } Limb steeringLimb = canAttack && !AttackLimb.attack.Ranged ? AttackLimb : null; @@ -1720,6 +1727,8 @@ namespace Barotrauma State = AIState.Idle; return; } + // Note that this returns null when we don't currently use IndoorsSteeringManager, even if we are able to path! + // -> Don't use PathSteering, because that returns the reference even if we currently don't use it. var pathSteering = SteeringManager as IndoorsSteeringManager; if (AttackLimb != null && AttackLimb.attack.Retreat) { @@ -1727,58 +1736,71 @@ namespace Barotrauma } else { - Vector2 steerPos = attackSimPos; - if (!Character.AnimController.SimplePhysicsEnabled) - { - // Offset so that we don't overshoot the movement - Vector2 offset = Character.SimPosition - steeringLimb.SimPosition; - steerPos += offset; - } if (pathSteering != null) { - if (pathSteering.CurrentPath != null) + // Attack doors + if (canAttackDoors && HasValidPath()) { - // Attack doors - if (canAttackDoors) + // If the target is in the same hull, there shouldn't be any doors blocking the path + if (targetCharacter == null || targetCharacter.CurrentHull != Character.CurrentHull) { - // If the target is in the same hull, there shouldn't be any doors blocking the path - if (targetCharacter == null || targetCharacter.CurrentHull != Character.CurrentHull) + var door = pathSteering.CurrentPath.CurrentNode?.ConnectedDoor ?? pathSteering.CurrentPath.NextNode?.ConnectedDoor; + if (door is { CanBeTraversed: false } && (!Character.IsInFriendlySub || !door.HasAccess(Character))) { - var door = pathSteering.CurrentPath.CurrentNode?.ConnectedDoor ?? pathSteering.CurrentPath.NextNode?.ConnectedDoor; - if (door is { CanBeTraversed: false } && (!Character.IsInFriendlySub || !door.HasAccess(Character))) + if (door.Item.AiTarget != null && SelectedAiTarget != door.Item.AiTarget) { - if (door.Item.AiTarget != null && SelectedAiTarget != door.Item.AiTarget) - { - SelectTarget(door.Item.AiTarget, currentTargetMemory.Priority); - State = AIState.Attack; - return; - } + SelectTarget(door.Item.AiTarget, currentTargetMemory.Priority); + State = AIState.Attack; + return; } } } - // When pursuing, we don't want to pursue too close - float max = 300; - float margin = AttackLimb != null ? Math.Min(AttackLimb.attack.Range * 0.9f, max) : max; - if (!canAttack || distance > margin) + } + // When pursuing, we don't want to pursue too close + float max = 300; + float margin = AttackLimb != null ? Math.Min(AttackLimb.attack.Range * 0.9f, max) : max; + if ((!canAttack || distance > margin) && !IsTryingToSteerThroughGap) + { + // Steer towards the target if in the same room and swimming + // Ruins have walls/pillars inside hulls and therefore we should navigate around them using the path steering. + if (Character.CurrentHull != null && + Character.Submarine != null && !Character.Submarine.Info.IsRuin && + (Character.AnimController.InWater || pursue || !Character.AnimController.CanWalk) && + targetCharacter != null && VisibleHulls.Contains(targetCharacter.CurrentHull)) { - // Steer towards the target if in the same room and swimming - // Ruins have walls/pillars inside hulls and therefore we should navigate around them using the path steering. - if (Character.CurrentHull != null && - Character.Submarine != null && !Character.Submarine.Info.IsRuin && - (Character.AnimController.InWater || pursue || !Character.AnimController.CanWalk) && - targetCharacter != null && VisibleHulls.Contains(targetCharacter.CurrentHull)) + Vector2 myPos = Character.AnimController.SimplePhysicsEnabled ? Character.SimPosition : steeringLimb.SimPosition; + SteeringManager.SteeringManual(deltaTime, Vector2.Normalize(attackSimPos - myPos)); + } + else + { + Func nodeFilter = null; + float outsideNodePenalty = 0; + if (Character.CurrentHull != null && Character.IsInPlayerSub) { - Vector2 myPos = Character.AnimController.SimplePhysicsEnabled ? Character.SimPosition : steeringLimb.SimPosition; - SteeringManager.SteeringManual(deltaTime, Vector2.Normalize(steerPos - myPos)); + // Prefer not to path back outside, if we are inside player sub. + // Fixes monsters sometimes pathing from the airlock to another airlock on the other side of the sub, because the path is technically cheaper than the path through the interiors. + outsideNodePenalty = 50; } - else - { - pathSteering.SteeringSeek(steerPos, weight: 2, - minGapWidth: minGapSize, - startNodeFilter: n => (n.Waypoint.CurrentHull == null) == (Character.CurrentHull == null), - checkVisiblity: true); + pathSteering.SteeringSeek(Character.GetRelativeSimPosition(SelectedAiTarget.Entity), weight: 2, + minGapWidth: minGapSize, + startNodeFilter: n => (n.Waypoint.CurrentHull == null) == (Character.CurrentHull == null), + nodeFilter: nodeFilter, + checkVisiblity: true, + outsideNodePenalty: outsideNodePenalty); - if (!pathSteering.IsPathDirty && pathSteering.CurrentPath.Unreachable) + if (pathSteering.CurrentPath != null) + { + if (pathSteering.IsPathDirty) + { + if (Character.CurrentHull is Hull hull && hull.ConnectedGaps.Any(static g => !g.IsRoomToRoom && g.Open >= 1.0f && g.ConnectedDoor != null)) + { + // Reset in the airlock, because otherwise the character may be too slow to change the steering and keep moving outside. + SteeringManager.Reset(); + } + // Steer towards the target + SteeringManager.SteeringManual(deltaTime, Vector2.Normalize(SelectedAiTarget.Entity.WorldPosition - Character.WorldPosition)); + } + else if (pathSteering.CurrentPath.Unreachable) { State = AIState.Idle; IgnoreTarget(SelectedAiTarget); @@ -1787,39 +1809,42 @@ namespace Barotrauma } } } - else if (!IsTryingToSteerThroughGap) + } + else if (!IsTryingToSteerThroughGap) + { + if (AttackLimb.attack.Ranged) { - if (AttackLimb.attack.Ranged) + float dir = Character.AnimController.Dir; + if (dir > 0 && attackWorldPos.X > AttackLimb.WorldPosition.X + margin || dir < 0 && attackWorldPos.X < AttackLimb.WorldPosition.X - margin) { - float dir = Character.AnimController.Dir; - if (dir > 0 && attackWorldPos.X > AttackLimb.WorldPosition.X + margin || dir < 0 && attackWorldPos.X < AttackLimb.WorldPosition.X - margin) - { - SteeringManager.Reset(); - } - else - { - // Too close - UpdateFallBack(attackWorldPos, deltaTime, followThrough: false); - } + SteeringManager.Reset(); } else { - // Close enough - SteeringManager.Reset(); + // Too close + UpdateFallBack(attackWorldPos, deltaTime, followThrough: false); } } else { - SteeringManager.SteeringManual(deltaTime, Vector2.Normalize(SelectedAiTarget.Entity.WorldPosition - Character.WorldPosition)); + // Close enough + SteeringManager.Reset(); } } else { - pathSteering.SteeringSeek(steerPos, weight: 5, minGapWidth: minGapSize); + SteeringManager.SteeringManual(deltaTime, Vector2.Normalize(SelectedAiTarget.Entity.WorldPosition - Character.WorldPosition)); } } else { + Vector2 steerPos = attackSimPos; + if (!Character.AnimController.SimplePhysicsEnabled) + { + // Offset so that we don't overshoot the movement + Vector2 offset = Character.SimPosition - steeringLimb.SimPosition; + steerPos += offset; + } // Sweeping and circling doesn't work well inside if (Character.CurrentHull == null) { @@ -2232,7 +2257,7 @@ namespace Barotrauma // Don't allow root motion attacks, if we are not on the same level with the target, because it can cause warping. switch (target) { - case Character targetCharacter when Character.CurrentHull != targetCharacter.CurrentHull || targetCharacter.IsKnockedDown: + case Character targetCharacter when Character.CurrentHull != targetCharacter.CurrentHull || targetCharacter.IsKnockedDownOrRagdolled: case Item targetItem when Character.CurrentHull != targetItem.CurrentHull: return false; } diff --git a/Barotrauma/BarotraumaShared/SharedSource/Characters/AI/HumanAIController.cs b/Barotrauma/BarotraumaShared/SharedSource/Characters/AI/HumanAIController.cs index fbf0b0b94..c4df6028a 100644 --- a/Barotrauma/BarotraumaShared/SharedSource/Characters/AI/HumanAIController.cs +++ b/Barotrauma/BarotraumaShared/SharedSource/Characters/AI/HumanAIController.cs @@ -348,7 +348,7 @@ namespace Barotrauma enemyCheckTimer -= deltaTime; if (enemyCheckTimer < 0) { - CheckEnemies(); + SpotEnemies(); enemyCheckTimer = enemyCheckInterval * Rand.Range(0.75f, 1.25f); } } @@ -443,7 +443,6 @@ namespace Barotrauma if (Character.Submarine != null && (Character.Submarine.TeamID == Character.TeamID || Character.Submarine.TeamID == Character.OriginalTeamID || Character.IsEscorted) && !Character.Submarine.Info.IsWreck) { ReportProblems(); - } else { @@ -467,31 +466,7 @@ namespace Barotrauma bool run = !currentObjective.ForceWalk && (currentObjective.ForceRun || objectiveManager.GetCurrentPriority() > AIObjectiveManager.RunPriority); if (currentObjective is AIObjectiveGoTo goTo) { - if (run && goTo == objectiveManager.ForcedOrder && goTo.IsWaitOrder && !Character.IsOnPlayerTeam) - { - // NPCs with a wait order don't run. - run = false; - } - else if (goTo.Target != null) - { - if (Character.CurrentHull == null) - { - run = Vector2.DistanceSquared(Character.WorldPosition, goTo.Target.WorldPosition) > 300 * 300; - } - else - { - float yDiff = goTo.Target.WorldPosition.Y - Character.WorldPosition.Y; - if (Math.Abs(yDiff) > 100) - { - run = true; - } - else - { - float xDiff = goTo.Target.WorldPosition.X - Character.WorldPosition.X; - run = Math.Abs(xDiff) > 500; - } - } - } + run = goTo.ShouldRun(run); } //if someone is grabbing the bot and the bot isn't trying to run anywhere, let them keep dragging and "control" the bot @@ -564,13 +539,15 @@ namespace Barotrauma ShipCommandManager?.Update(deltaTime); } - private void CheckEnemies() + private void SpotEnemies() { //already in combat, no need to check if (objectiveManager.IsCurrentObjective()) { return; } + if (objectiveManager.HasActiveObjective()) { return; } float closestDistance = 0; Character closestEnemy = null; + bool shouldActOffensively = ObjectiveManager.HasObjectiveOrOrder(); foreach (Character c in Character.CharacterList) { if (c.Submarine != Character.Submarine) { continue; } @@ -596,8 +573,8 @@ namespace Barotrauma } if (closestEnemy != null) { - AddCombatObjective(AIObjectiveCombat.CombatMode.Defensive, closestEnemy); - } + AddCombatObjective(shouldActOffensively ? AIObjectiveCombat.CombatMode.Offensive : AIObjectiveCombat.CombatMode.Defensive, closestEnemy); + } } private void UnequipUnnecessaryItems() @@ -606,7 +583,7 @@ namespace Barotrauma if (ObjectiveManager.CurrentObjective == null) { return; } if (Character.CurrentHull == null) { return; } bool shouldActOnSuffocation = Character.IsLowInOxygen && !Character.AnimController.HeadInWater && HasDivingSuit(Character, requireOxygenTank: false) && !HasItem(Character, Tags.OxygenSource, out _, conditionPercentage: 1); - bool isCarrying = ObjectiveManager.HasActiveObjective() || ObjectiveManager.HasActiveObjective(); + bool isCarrying = ObjectiveManager.HasActiveObjective() || ObjectiveManager.HasActiveObjective(); bool NeedsDivingGearOnPath(AIObjectiveGoTo gotoObjective) { @@ -621,8 +598,8 @@ namespace Barotrauma { if (findItemState != FindItemState.OtherItem) { - var decontain = ObjectiveManager.GetActiveObjectives().LastOrDefault(); - if (decontain != null && decontain.TargetItem != null && decontain.TargetItem.HasTag(Tags.HeavyDivingGear) && + var moveItemObjective = ObjectiveManager.GetLastActiveObjective(); + if (moveItemObjective is { TargetItem: not null } && moveItemObjective.TargetItem.HasTag(Tags.HeavyDivingGear) && ObjectiveManager.GetActiveObjective() is AIObjectiveGoTo gotoObjective && NeedsDivingGearOnPath(gotoObjective)) { // Don't try to put the diving suit in a locker if the suit would be needed in any hull in the path to the locker. @@ -728,17 +705,17 @@ namespace Barotrauma itemIndex = 0; if (targetContainer != null) { - var decontainObjective = new AIObjectiveDecontainItem(Character, divingSuit, ObjectiveManager, targetContainer: targetContainer.GetComponent()) + var moveItemObjective = new AIObjectiveMoveItem(Character, divingSuit, ObjectiveManager, targetContainer: targetContainer.GetComponent()) { DropIfFails = false }; - decontainObjective.Abandoned += () => + moveItemObjective.Abandoned += () => { ReequipUnequipped(); IgnoredItems.Add(targetContainer); }; - decontainObjective.Completed += () => ReequipUnequipped(); - ObjectiveManager.CurrentObjective.AddSubObjective(decontainObjective, addFirst: true); + moveItemObjective.Completed += () => ReequipUnequipped(); + ObjectiveManager.CurrentObjective.AddSubObjective(moveItemObjective, addFirst: true); return; } else @@ -764,7 +741,7 @@ namespace Barotrauma HandleRelocation(mask); ReequipUnequipped(); } - else if (findItemState == FindItemState.None || findItemState == FindItemState.DivingMask) + else if (findItemState is FindItemState.None or FindItemState.DivingMask) { findItemState = FindItemState.DivingMask; if (FindSuitableContainer(mask, out Item targetContainer)) @@ -773,14 +750,14 @@ namespace Barotrauma itemIndex = 0; if (targetContainer != null) { - var decontainObjective = new AIObjectiveDecontainItem(Character, mask, ObjectiveManager, targetContainer: targetContainer.GetComponent()); - decontainObjective.Abandoned += () => + var moveItemObjective = new AIObjectiveMoveItem(Character, mask, ObjectiveManager, targetContainer: targetContainer.GetComponent()); + moveItemObjective.Abandoned += () => { ReequipUnequipped(); IgnoredItems.Add(targetContainer); }; - decontainObjective.Completed += () => ReequipUnequipped(); - ObjectiveManager.CurrentObjective.AddSubObjective(decontainObjective, addFirst: true); + moveItemObjective.Completed += ReequipUnequipped; + ObjectiveManager.CurrentObjective.AddSubObjective(moveItemObjective, addFirst: true); return; } else @@ -814,9 +791,9 @@ namespace Barotrauma if (Character.TryPutItemInBag(item)) { continue; } if (item.HasTag(Tags.Weapon)) { - // Don't decontain weapons, because it could be that we are holding a weapon that cannot be placed on back (if we have a toolbelt) nor in the any slot, such as an HMG. + // Don't store weapons in containers, because it could be that we are holding a weapon that cannot be placed on back (if we have a toolbelt) nor in any slot, such as an HMG. // Could check that we only ignore weapons when we've had an order to find a weapon, but it could also be that we picked the weapon for self-defence, on ad-hoc basis. - // And I don't think it would make sense to decontain those weapons either. + // And I don't think it would make sense to move those weapons in containers either. continue; } findItemState = FindItemState.OtherItem; @@ -826,13 +803,13 @@ namespace Barotrauma itemIndex = 0; if (targetContainer != null) { - var decontainObjective = new AIObjectiveDecontainItem(Character, item, ObjectiveManager, targetContainer: targetContainer.GetComponent()); - decontainObjective.Abandoned += () => + var moveItemObjective = new AIObjectiveMoveItem(Character, item, ObjectiveManager, targetContainer: targetContainer.GetComponent()); + moveItemObjective.Abandoned += () => { ReequipUnequipped(); IgnoredItems.Add(targetContainer); }; - ObjectiveManager.CurrentObjective.AddSubObjective(decontainObjective, addFirst: true); + ObjectiveManager.CurrentObjective.AddSubObjective(moveItemObjective, addFirst: true); return; } else @@ -1046,12 +1023,12 @@ namespace Barotrauma foreach (Character target in Character.CharacterList) { if (target.CurrentHull != hull || !target.Enabled || target.InDetectable) { continue; } - if (AIObjectiveFightIntruders.IsValidTarget(target, Character, false)) + if (AIObjectiveFightIntruders.IsValidTarget(target, Character, targetCharactersInOtherSubs: false)) { - if (!target.IsHandcuffed && AddTargets(Character, target) && newOrder == null) + if (AddTargets(Character, target) && newOrder == null) { var orderPrefab = OrderPrefab.Prefabs["reportintruders"]; - newOrder = new Order(orderPrefab, hull, null, orderGiver: Character); + newOrder = new Order(orderPrefab, hull, targetItem: null, orderGiver: Character); targetHull = hull; if (target.IsEscorted) { @@ -1069,6 +1046,12 @@ namespace Barotrauma } } } + if (Character.CombatAction == null && !isFighting) + { + // Immediately react to enemies when they are spotted. AIObjectiveFightIntruders and AIObjectiveFindSafety would make the bot react to the threats, + // but the reaction is delayed (and doesn't necessarily target this enemy), and in many cases the reaction would come only when the enemy attacks and triggers AIObjectiveCombat. + AddCombatObjective(ObjectiveManager.HasObjectiveOrOrder() ? AIObjectiveCombat.CombatMode.Offensive : AIObjectiveCombat.CombatMode.Defensive, target); + } } } if (AIObjectiveExtinguishFires.IsValidTarget(hull, Character)) @@ -1076,14 +1059,14 @@ namespace Barotrauma if (AddTargets(Character, hull) && newOrder == null) { var orderPrefab = OrderPrefab.Prefabs["reportfire"]; - newOrder = new Order(orderPrefab, hull, null, orderGiver: Character); + newOrder = new Order(orderPrefab, hull, targetItem: null, orderGiver: Character); targetHull = hull; } } if (IsBallastFloraNoticeable(Character, hull) && newOrder == null) { var orderPrefab = OrderPrefab.Prefabs["reportballastflora"]; - newOrder = new Order(orderPrefab, hull, null, orderGiver: Character); + newOrder = new Order(orderPrefab, hull, targetItem: null, orderGiver: Character); targetHull = hull; } if (!isFighting) @@ -1095,7 +1078,7 @@ namespace Barotrauma if (AddTargets(Character, gap) && newOrder == null && !gap.IsRoomToRoom) { var orderPrefab = OrderPrefab.Prefabs["reportbreach"]; - newOrder = new Order(orderPrefab, hull, null, orderGiver: Character); + newOrder = new Order(orderPrefab, hull, targetItem: null, orderGiver: Character); targetHull = hull; } } @@ -1110,7 +1093,7 @@ namespace Barotrauma if (AddTargets(Character, target) && newOrder == null && (!Character.IsMedic || Character == target) && !ObjectiveManager.HasActiveObjective()) { var orderPrefab = OrderPrefab.Prefabs["requestfirstaid"]; - newOrder = new Order(orderPrefab, hull, null, orderGiver: Character); + newOrder = new Order(orderPrefab, hull, targetItem: null, orderGiver: Character); targetHull = hull; } } @@ -1135,20 +1118,19 @@ namespace Barotrauma } if (newOrder != null && speak) { + string msg = newOrder.GetChatMessage(string.Empty, targetHull?.DisplayName?.Value ?? string.Empty, givingOrderToSelf: false); if (Character.TeamID == CharacterTeamType.FriendlyNPC) { - Character.Speak(newOrder.GetChatMessage("", targetHull?.DisplayName?.Value ?? "", givingOrderToSelf: false), ChatMessageType.Default, - identifier: $"{newOrder.Prefab.Identifier}{targetHull?.RoomName ?? "null"}".ToIdentifier(), - minDurationBetweenSimilar: 60.0f); + Character.Speak(msg, ChatMessageType.Default, identifier: $"{newOrder.Prefab.Identifier}{targetHull?.RoomName ?? "null"}".ToIdentifier(), minDurationBetweenSimilar: 60f); } else if (Character.IsOnPlayerTeam && GameMain.GameSession?.CrewManager != null && GameMain.GameSession.CrewManager.AddOrder(newOrder, newOrder.FadeOutTime)) { - Character.Speak(newOrder.GetChatMessage("", targetHull?.DisplayName?.Value ?? "", givingOrderToSelf: false), ChatMessageType.Order); + Character.Speak(msg, messageType: ChatMessageType.Order); #if SERVER GameMain.Server.SendOrderChatMessage(new OrderChatMessage(newOrder .WithManualPriority(CharacterInfo.HighestManualOrderPriority) .WithTargetEntity(targetHull) - .WithOrderGiver(Character), "", null, Character)); + .WithOrderGiver(Character), msg, targetCharacter: null, sender: Character)); #endif } } @@ -1172,8 +1154,19 @@ namespace Barotrauma public static void ReportProblem(Character reporter, Order order, Hull targetHull = null) { if (reporter == null || order == null) { return; } - var visibleHulls = targetHull is null ? new List(reporter.GetVisibleHulls()) : new List { targetHull }; - foreach (var hull in visibleHulls) + if (targetHull == null) + { + foreach (var hull in reporter.GetVisibleHulls()) + { + Report(hull); + } + } + else + { + Report(targetHull); + } + + void Report(Hull hull) { PropagateHullSafety(reporter, hull); RefreshTargets(reporter, order, hull); @@ -1353,7 +1346,7 @@ namespace Barotrauma // Inform other NPCs if (isAttackerInfected || cumulativeDamage > minorDamageThreshold || totalDamage > minorDamageThreshold) { - if (GameMain.IsMultiplayer || !attacker.IsPlayer || Character.TeamID != attacker.TeamID) + if (!attacker.IsPlayer || Character.TeamID != attacker.TeamID) { InformOtherNPCs(cumulativeDamage); } @@ -1421,7 +1414,12 @@ namespace Barotrauma if (otherCharacter.IsPlayer) { continue; } if (otherCharacter.AIController is not HumanAIController otherHumanAI) { continue; } if (!otherHumanAI.IsFriendly(Character)) { continue; } - if (attacker.AIController is EnemyAIController enemyAI && otherHumanAI.IsFriendly(attacker)) + if (otherHumanAI.objectiveManager.IsCurrentObjective() || otherHumanAI.objectiveManager.HasActiveObjective()) + { + // Already in combat, don't change target (because we are not attacked by the enemy) + return; + } + if (attacker.AIController is EnemyAIController && otherHumanAI.IsFriendly(attacker)) { // Don't react to friendly enemy AI attacking other characters. E.g. husks attacking someone when whe are a cultist. continue; @@ -1432,15 +1430,33 @@ namespace Barotrauma otherCharacter.CanSeeTarget(attacker, seeThroughWindows: true); if (!isWitnessing) { - //if the other character did not witness the attack, and the character is not within report range (or capable of reporting) - //don't react to the attack - if (Character.IsDead || Character.IsUnconscious || otherCharacter.TeamID != Character.TeamID || !CheckReportRange(Character, otherCharacter, ReportRange)) + if (Character.IsDead || Character.IsUnconscious || otherCharacter.TeamID != Character.TeamID) { + // Dead or in different team -> cannot report. continue; - } + } + if (otherHumanAI.objectiveManager.HasOrders()) + { + // Unless witnessing the attack, don't react, if have been ordered to do something. + // The combat objective would take a higher prio than the order. + continue; + } + if (!CheckReportRange(Character, otherCharacter, ReportRange)) + { + // Not inside report range -> cannot report. + continue; + } } var combatMode = DetermineCombatMode(otherCharacter, cumulativeDamage, isWitnessing); - float delay = isWitnessing ? GetReactionTime() : Rand.Range(2.0f, 5.0f, Rand.RandSync.Unsynced); + if (!isWitnessing) + { + if (combatMode is AIObjectiveCombat.CombatMode.Defensive or AIObjectiveCombat.CombatMode.Retreat) + { + // Ignore defensive and retreating behavior, unless witnessing the attack. + continue; + } + } + float delay = isWitnessing ? GetReactionTime() : Rand.Range(2.0f, 3.0f, Rand.RandSync.Unsynced); otherHumanAI.AddCombatObjective(combatMode, attacker, delay); } } @@ -1476,12 +1492,8 @@ namespace Barotrauma } if (attacker.IsPlayer && c.TeamID == attacker.TeamID) { - if (GameMain.IsSingleplayer || c.TeamID != attacker.TeamID) - { - // Bots in the player team never act aggressively in single player when attacked by the player - // In multiplayer, they react only to players attacking them or other crew members - return Character == c && cumulativeDamage > minorDamageThreshold ? AIObjectiveCombat.CombatMode.Retreat : AIObjectiveCombat.CombatMode.None; - } + // Bots in the player team never act aggressively when attacked by the player + return Character == c && cumulativeDamage > minorDamageThreshold ? AIObjectiveCombat.CombatMode.Retreat : AIObjectiveCombat.CombatMode.None; } if (c.Submarine == null || !c.Submarine.GetConnectedSubs().Contains(attacker.Submarine)) { @@ -1526,16 +1538,18 @@ namespace Barotrauma // Already targeting the attacker -> treat as a more serious threat. cumulativeDamage *= 2; currentCombatObjective.AllowHoldFire = false; - c.IsCriminal = true; + attacker.IsCriminal = true; + attacker.IsActingOffensively = true; } - if (c.IsCriminal) + if (attacker.IsCriminal) { // Always react if the attacker has been misbehaving earlier. cumulativeDamage = Math.Max(cumulativeDamage, minorDamageThreshold); } if (cumulativeDamage > majorDamageThreshold) { - c.IsCriminal = true; + attacker.IsCriminal = true; + attacker.IsActingOffensively = true; if (c.IsSecurity) { return AIObjectiveCombat.CombatMode.Offensive; @@ -1547,6 +1561,7 @@ namespace Barotrauma } else if (cumulativeDamage > minorDamageThreshold) { + attacker.IsActingOffensively = true; return c.IsSecurity ? AIObjectiveCombat.CombatMode.Arrest : AIObjectiveCombat.CombatMode.Retreat; } else @@ -1611,7 +1626,6 @@ namespace Barotrauma { var objective = new AIObjectiveCombat(Character, target, mode, objectiveManager) { - HoldPosition = Character.Info?.Job?.Prefab.Identifier == "watchman", AbortCondition = abortCondition, AllowHoldFire = allowHoldFire, SpeakWarnings = speakWarnings @@ -1718,8 +1732,9 @@ namespace Barotrauma } return true; } - - public bool NeedsDivingGear(Hull hull, out bool needsSuit) + + /// Used for checking the objective. + public bool NeedsDivingGear(Hull hull, out bool needsSuit, AIObjectiveManager objectiveManager = null) { needsSuit = false; bool needsAir = Character.NeedsAir && Character.CharacterHealth.OxygenLowResistance < 1; @@ -1728,7 +1743,11 @@ namespace Barotrauma hull.LethalPressure > 0 || hull.ConnectedGaps.Any(gap => !gap.IsRoomToRoom && gap.Open > 0.9f)) { - needsSuit = (hull == null || hull.LethalPressure > 0) && !Character.IsImmuneToPressure; + if (!Character.IsImmuneToPressure) + { + // Always require a diving suit when operating an item in a flooding room. + needsSuit = hull == null || hull.LethalPressure > 0 || objectiveManager?.CurrentOrder is AIObjectiveOperateItem operateItem && operateItem.GetTarget().Item.CurrentHull == hull; + } return needsAir || needsSuit; } if (hull.WaterPercentage > 60 || (hull.IsWetRoom && hull.WaterPercentage > 10) || hull.OxygenPercentage < HULL_LOW_OXYGEN_PERCENTAGE + 1) @@ -1863,6 +1882,7 @@ namespace Barotrauma if (combatMode == AIObjectiveCombat.CombatMode.Offensive) { character.IsCriminal = true; + character.IsActingOffensively = true; } if (!TriggerSecurity(otherHumanAI, combatMode)) { @@ -1903,6 +1923,11 @@ namespace Barotrauma public static void ItemTaken(Item item, Character thief) { if (item == null || thief == null || item.GetComponent() != null) { return; } + if (thief.IsBot && item.HasTag(AIObjectiveGetItem.AllowedItemsToTake)) + { + // Bots are allowed to take diving gear or extinguishers, when they need them, without it being considered as stealing. + return; + } bool someoneSpoke = false; if (item.Illegitimate && item.GetRootInventoryOwner() is Character itemOwner && itemOwner != thief && itemOwner.TeamID == thief.TeamID) { @@ -2016,16 +2041,16 @@ namespace Barotrauma /// The safety levels need to be calculated for each bot individually, because the formula takes into account things like current orders. /// There's now a cached value per each hull, which should prevent too frequent calculations. /// - public static void PropagateHullSafety(Character character, Hull hull) + private static void PropagateHullSafety(Character character, Hull hull) { - DoForEachBot(character, (humanAi) => humanAi.RefreshHullSafety(hull)); + DoForEachBot(character, humanAi => humanAi.RefreshHullSafety(hull)); } public void AskToRecalculateHullSafety(Hull hull) => dirtyHullSafetyCalculations.Add(hull); private void RefreshHullSafety(Hull hull) { - var visibleHulls = dirtyHullSafetyCalculations.Contains(hull) ? hull.GetConnectedHulls(includingThis: true, searchDepth: 1) : VisibleHulls; + var visibleHulls = dirtyHullSafetyCalculations.Contains(hull) ? hull.GetConnectedHulls(includingThis: true, searchDepth: 1) : null; float hullSafety = GetHullSafety(hull, Character, visibleHulls); if (hullSafety > HULL_SAFETY_THRESHOLD) { @@ -2037,7 +2062,7 @@ namespace Barotrauma } } - public static void RefreshTargets(Character character, Order order, Hull hull) + private static void RefreshTargets(Character character, Order order, Hull hull) { switch (order.Identifier.Value.ToLowerInvariant()) { @@ -2068,7 +2093,7 @@ namespace Barotrauma foreach (var enemy in Character.CharacterList) { if (enemy.CurrentHull != hull) { continue; } - if (AIObjectiveFightIntruders.IsValidTarget(enemy, character, false)) + if (AIObjectiveFightIntruders.IsValidTarget(enemy, character, targetCharactersInOtherSubs: false)) { AddTargets(character, enemy); } @@ -2147,7 +2172,7 @@ namespace Barotrauma // Use the cached visible hulls visibleHulls = VisibleHulls; } - bool ignoreFire = objectiveManager.CurrentOrder is AIObjectiveExtinguishFires extinguishOrder && extinguishOrder.Priority > 0 || objectiveManager.HasActiveObjective(); + bool ignoreFire = objectiveManager.CurrentOrder is AIObjectiveExtinguishFires { Priority: > 0 } || objectiveManager.HasActiveObjective(); bool ignoreOxygen = HasDivingGear(character); bool ignoreEnemies = ObjectiveManager.HasObjectiveOrOrder(); float safety = CalculateHullSafety(hull, visibleHulls, character, ignoreWater: false, ignoreOxygen, ignoreFire, ignoreEnemies); @@ -2157,12 +2182,26 @@ namespace Barotrauma } return safety; } + + /// + /// Returns hull safety for the character without ignoring any threats. + /// Useful for example, when we need to calculate a safety value of the hull regardless of the protective equipment or buffs of the character. + /// No caching involved (always recalculated). + /// + public static float CalculateObjectiveHullSafety(Character character) => CalculateHullSafety( + hull: character.CurrentHull, + visibleHulls: character.AIController?.VisibleHulls ?? character.GetVisibleHulls(), + character, + ignoreEnemies: false, ignoreFire: false, ignoreWater: false, ignoreOxygen: false, ignorePressureProtection: true); - private static float CalculateHullSafety(Hull hull, IEnumerable visibleHulls, Character character, bool ignoreWater = false, bool ignoreOxygen = false, bool ignoreFire = false, bool ignoreEnemies = false) + private static float CalculateHullSafety(Hull hull, IEnumerable visibleHulls, Character character, bool ignoreWater = false, bool ignoreOxygen = false, bool ignoreFire = false, bool ignoreEnemies = false, bool ignorePressureProtection = false) { - bool isProtectedFromPressure = character.IsProtectedFromPressure; - if (hull == null) { return isProtectedFromPressure ? 100 : 0; } - if (hull.LethalPressure > 0 && !isProtectedFromPressure) { return 0; } + if (!ignorePressureProtection) + { + bool isProtectedFromPressure = character.IsProtectedFromPressure; + if (hull == null) { return isProtectedFromPressure ? 100 : 0; } + if (hull.LethalPressure > 0 && !isProtectedFromPressure) { return 0; } + } // Oxygen factor should be 1 with 70% oxygen or more and 0.1 when the oxygen level is 30% or lower. // With insufficient oxygen, the safety of the hull should be 39, all the other factors aside. So, just below the HULL_SAFETY_THRESHOLD. float oxygenFactor = ignoreOxygen ? 1 : MathHelper.Lerp((HULL_SAFETY_THRESHOLD - 1) / 100, 1, MathUtils.InverseLerp(HULL_LOW_OXYGEN_PERCENTAGE, 100 - HULL_LOW_OXYGEN_PERCENTAGE, hull.OxygenPercentage)); @@ -2181,42 +2220,56 @@ namespace Barotrauma waterFactor = MathHelper.Lerp(1, HULL_SAFETY_THRESHOLD / 2 / 100, relativeWaterVolume); } } - if (!character.NeedsOxygen || character.CharacterHealth.OxygenLowResistance >= 1) + if (!ignoreOxygen) { - oxygenFactor = 1; - } - if (isProtectedFromPressure) - { - waterFactor = 1; + if (!character.NeedsOxygen || character.CharacterHealth.OxygenLowResistance >= 1) + { + oxygenFactor = 1; + } } float fireFactor = 1; if (!ignoreFire) { - static float CalculateFire(Hull h) => h.FireSources.Count * 0.5f + h.FireSources.Sum(fs => fs.DamageRange) / h.Size.X; - // Even the smallest fire reduces the safety by 50% - float fire = visibleHulls?.Sum(CalculateFire) ?? CalculateFire(hull); + float fire = CalculateFire(hull) + hull.linkedTo.Sum(e => CalculateFire(e as Hull)); fireFactor = MathHelper.Lerp(1, 0, MathHelper.Clamp(fire, 0, 1)); + + float CalculateFire(Hull h) + { + if (h is not Hull) { return 0; } + bool isInDamageRange = h.FireSources.Any(fs => fs.IsInDamageRange(character, fs.DamageRange)); + if (isInDamageRange) { return 1; } + // Even the smallest fire reduces the safety by 50% + return h.FireSources.Count * 0.5f + h.FireSources.Sum(fs => fs.DamageRange) / h.Size.X; + } } float enemyFactor = 1; if (!ignoreEnemies) { - int enemyCount = 0; + float enemyCount = 0; foreach (Character c in Character.CharacterList) { + float countModifier = 1.0f; + if (c.CurrentHull == null) { continue; } if (visibleHulls == null) { - if (c.CurrentHull != hull) { continue; } + if (c.CurrentHull != hull && !c.CurrentHull.linkedTo.Contains(hull)) { continue; } } else { if (!visibleHulls.Contains(c.CurrentHull)) { continue; } + if (c.CurrentHull != hull && !c.CurrentHull.linkedTo.Contains(hull)) + { + // Enemy in a visible room, but not in the same room -> a lower threat + countModifier = 0.25f; + } } if (IsActive(c) && !IsFriendly(character, c) && !c.IsHandcuffed) { - enemyCount++; + enemyCount += countModifier; } } - // The hull safety decreases 90% per enemy up to 100% (TODO: test smaller percentages) + // The hull safety decreases 90% per enemy up to 100%, + // and 22.5% per each enemy in the visible, adjacent rooms enemyFactor = MathHelper.Lerp(1, 0, MathHelper.Clamp(enemyCount * 0.9f, 0, 1)); } float dangerousItemsFactor = 1f; @@ -2236,7 +2289,7 @@ namespace Barotrauma { if (hull == null) { - return CalculateHullSafety(hull, character, visibleHulls); + return CalculateHullSafety(null, character, visibleHulls); } if (!knownHulls.TryGetValue(hull, out HullSafety hullSafety)) { @@ -2254,7 +2307,7 @@ namespace Barotrauma { if (hull == null) { - return CalculateHullSafety(hull, visibleHulls, character, ignoreWater, ignoreOxygen, ignoreFire, ignoreEnemies); + return CalculateHullSafety(null, visibleHulls, character, ignoreWater, ignoreOxygen, ignoreFire, ignoreEnemies); } HullSafety hullSafety; if (character.AIController is HumanAIController controller) @@ -2279,11 +2332,14 @@ namespace Barotrauma return hullSafety.safety; } - public static bool IsFriendly(Character me, Character other, bool onlySameTeam = false) + public static bool IsFriendly(Character me, Character other, bool onlySameTeam = false, bool ignoreHuskDisguising = false) { - if (other.IsHusk && !onlySameTeam) + if (onlySameTeam) + { + ignoreHuskDisguising = true; + } + if (other.IsHusk && !ignoreHuskDisguising) { - // Disguised as husk return me.IsDisguisedAsHusk; } else @@ -2435,7 +2491,7 @@ namespace Barotrauma private static void DoForEachBot(Character character, Action action, float range = float.PositiveInfinity) { if (character == null) { return; } - foreach (var c in Character.CharacterList) + foreach (Character c in Character.CharacterList) { if (IsBotInTheCrew(character, c) && CheckReportRange(character, c, range)) { diff --git a/Barotrauma/BarotraumaShared/SharedSource/Characters/AI/IndoorsSteeringManager.cs b/Barotrauma/BarotraumaShared/SharedSource/Characters/AI/IndoorsSteeringManager.cs index b9c0d75f3..60ad0799d 100644 --- a/Barotrauma/BarotraumaShared/SharedSource/Characters/AI/IndoorsSteeringManager.cs +++ b/Barotrauma/BarotraumaShared/SharedSource/Characters/AI/IndoorsSteeringManager.cs @@ -119,10 +119,13 @@ namespace Barotrauma steering += base.DoSteeringSeek(targetSimPos, weight); } - public void SteeringSeek(Vector2 target, float weight, float minGapWidth = 0, Func startNodeFilter = null, Func endNodeFilter = null, Func nodeFilter = null, bool checkVisiblity = true) + /// + /// + /// Additional cost applied to outside nodes. When the character is inside an extra cost of 100 is also automatically added for outside nodes, unless the character is protected from the pressure. Used for example to prevent monsters from preferring outside nodes when they already are inside. + public void SteeringSeek(Vector2 target, float weight, float minGapWidth = 0, Func startNodeFilter = null, Func endNodeFilter = null, Func nodeFilter = null, bool checkVisiblity = true, float outsideNodePenalty = 0) { // Have to use a variable here or resetting doesn't work. - Vector2 addition = CalculateSteeringSeek(target, weight, minGapWidth, startNodeFilter, endNodeFilter, nodeFilter, checkVisiblity); + Vector2 addition = CalculateSteeringSeek(target, weight, minGapWidth, startNodeFilter, endNodeFilter, nodeFilter, checkVisiblity, outsideNodePenalty); steering += addition; } @@ -139,9 +142,9 @@ namespace Barotrauma return null; } - private Vector2 CalculateSteeringSeek(Vector2 target, float weight, float minGapSize = 0, Func startNodeFilter = null, Func endNodeFilter = null, Func nodeFilter = null, bool checkVisibility = true) + private Vector2 CalculateSteeringSeek(Vector2 target, float weight, float minGapSize = 0, Func startNodeFilter = null, Func endNodeFilter = null, Func nodeFilter = null, bool checkVisibility = true, float outsideNodePenalty = 0) { - bool needsNewPath = currentPath == null || currentPath.Unreachable || currentPath.Finished || currentPath.CurrentNode == null; + bool needsNewPath = currentPath == null || currentPath.Unreachable || currentPath.Finished || currentPath.IsAtEndNode || currentPath.CurrentNode == null; if (!needsNewPath && character.Submarine != null && character.Params.PathFinderPriority > 0.5f) { // If the target has moved, we need a new path. @@ -182,7 +185,7 @@ namespace Barotrauma Vector2 currentPos = host.SimPosition; pathFinder.InsideSubmarine = character.Submarine != null && !character.Submarine.Info.IsRuin; pathFinder.ApplyPenaltyToOutsideNodes = character.Submarine != null && !character.IsProtectedFromPressure; - var newPath = pathFinder.FindPath(currentPos, target, character.Submarine, "(Character: " + character.Name + ")", minGapSize, startNodeFilter, endNodeFilter, nodeFilter, checkVisibility: checkVisibility); + var newPath = pathFinder.FindPath(currentPos, target, character.Submarine, "(Character: " + character.Name + ")", minGapSize, startNodeFilter, endNodeFilter, nodeFilter, checkVisibility: checkVisibility, outsideNodePenalty); bool useNewPath = needsNewPath; if (!useNewPath && currentPath?.CurrentNode != null && newPath.Nodes.Any() && !newPath.Unreachable) { @@ -792,7 +795,7 @@ namespace Barotrauma float? penalty = GetSingleNodePenalty(nextNode); if (penalty == null) { return null; } bool nextNodeAboveWaterLevel = nextNode.Waypoint.CurrentHull != null && nextNode.Waypoint.CurrentHull.Surface < nextNode.Waypoint.Position.Y; - if (!character.CanClimb) + if (!character.CanClimb && node.Waypoint.Stairs == null && nextNode.Waypoint.Stairs == null) { if (node.Waypoint.Ladders != null && nextNode.Waypoint.Ladders != null && (!nextNode.Waypoint.Ladders.Item.IsInteractable(character) || character.LockHands) || (nextNode.Position.Y - node.Position.Y > 1.0f && //more than one sim unit to climb up @@ -888,6 +891,7 @@ namespace Barotrauma return penalty; } + // TODO: Long and complex. Consider refactoring. public static float smallRoomSize = 500; public void Wander(float deltaTime, float wallAvoidDistance = 150, bool stayStillInTightSpace = true) { @@ -915,36 +919,92 @@ namespace Barotrauma } else { - float leftDist = character.Position.X - currentHull.Rect.X; - float rightDist = currentHull.Rect.Right - character.Position.X; - if (leftDist < wallAvoidDistance && rightDist < wallAvoidDistance) + bool isVerySmallRoom = roomWidth < smallRoomSize; + Hull nextRoom = null; + if (!stayStillInTightSpace && isVerySmallRoom) { - if (Math.Abs(rightDist - leftDist) > wallAvoidDistance / 2) + float closestDistance = 0; + // Try to steer to the next room + foreach (Gap gap in currentHull.ConnectedGaps) { - SteeringManual(deltaTime, Vector2.UnitX * Math.Sign(rightDist - leftDist)); - return; - } - else if (stayStillInTightSpace) - { - Reset(); - return; + if (gap.Open < 1.0f) { continue; } + float feetPos = ConvertUnits.ToDisplayUnits(character.AnimController.FloorY); + float gapTop = gap.Rect.Y; + float gapBottom = gap.Rect.Y - gap.Rect.Height; + const float margin = 25; + if (character.Position.Y > gapTop || feetPos < gapBottom - margin) + { + // If the character is above or below the gap, they can't walk through it. + continue; + } + Hull room = null; + foreach (var entity in gap.linkedTo) + { + if (entity is not Hull hull) { continue; } + if (hull.Submarine != character.Submarine) { continue; } + if (hull.Rect.Width < smallRoomSize) { continue; } + if (hull != currentHull) + { + room = hull; + break; + } + } + if (room == null) { continue; } + Vector2 toGap = gap.Position - character.Position; + float distance = Math.Abs(toGap.X); + if (nextRoom == null || distance < closestDistance) + { + closestDistance = distance; + nextRoom = room; + } } } - if (leftDist < wallAvoidDistance) + if (nextRoom != null) { - float speed = (wallAvoidDistance - leftDist) / wallAvoidDistance; - SteeringManual(deltaTime, Vector2.UnitX * MathHelper.Clamp(speed, 0.25f, 1)); + float toNextRoom = nextRoom.Position.X - character.Position.X; + SteeringManual(deltaTime, Vector2.UnitX * Math.Sign(toNextRoom)); WanderAngle = 0.0f; } - else if (rightDist < wallAvoidDistance) - { - float speed = (wallAvoidDistance - rightDist) / wallAvoidDistance; - SteeringManual(deltaTime, -Vector2.UnitX * MathHelper.Clamp(speed, 0.25f, 1)); - WanderAngle = MathHelper.Pi; - } else { - wander = true; + if (!stayStillInTightSpace && isVerySmallRoom) + { + // Reset regardless, because moving inside the room would look glitchy. + Reset(); + return; + } + float leftDist = character.Position.X - currentHull.Rect.X; + float rightDist = currentHull.Rect.Right - character.Position.X; + if (leftDist < wallAvoidDistance && rightDist < wallAvoidDistance) + { + float diff = rightDist - leftDist; + if (Math.Abs(diff) > wallAvoidDistance / 2) + { + SteeringManual(deltaTime, Vector2.UnitX * Math.Sign(diff)); + return; + } + else if (stayStillInTightSpace) + { + Reset(); + return; + } + } + if (leftDist < wallAvoidDistance) + { + float speed = (wallAvoidDistance - leftDist) / wallAvoidDistance; + SteeringManual(deltaTime, Vector2.UnitX * MathHelper.Clamp(speed, 0.25f, 1)); + WanderAngle = 0.0f; + } + else if (rightDist < wallAvoidDistance) + { + float speed = (wallAvoidDistance - rightDist) / wallAvoidDistance; + SteeringManual(deltaTime, -Vector2.UnitX * MathHelper.Clamp(speed, 0.25f, 1)); + WanderAngle = MathHelper.Pi; + } + else + { + wander = true; + } } } } diff --git a/Barotrauma/BarotraumaShared/SharedSource/Characters/AI/Objectives/AIObjective.cs b/Barotrauma/BarotraumaShared/SharedSource/Characters/AI/Objectives/AIObjective.cs index a4bf9270a..fea80a297 100644 --- a/Barotrauma/BarotraumaShared/SharedSource/Characters/AI/Objectives/AIObjective.cs +++ b/Barotrauma/BarotraumaShared/SharedSource/Characters/AI/Objectives/AIObjective.cs @@ -274,7 +274,7 @@ namespace Barotrauma public bool IsIgnoredAtOutpost() { if (!IgnoreAtOutpost) { return false; } - if (!Level.IsLoadedFriendlyOutpost) { return false; } + if (!Level.IsLoadedFriendlyOutpost && GameMain.GameSession.GameMode is not TestGameMode) { return false; } if (!character.IsOnPlayerTeam || character.IsFriendlyNPCTurnedHostile) { return false; } if (character.Submarine?.Info == null) { return false; } return character.Submarine.Info.IsOutpost && character.Submarine.TeamID == CharacterTeamType.FriendlyNPC; diff --git a/Barotrauma/BarotraumaShared/SharedSource/Characters/AI/Objectives/AIObjectiveCheckStolenItems.cs b/Barotrauma/BarotraumaShared/SharedSource/Characters/AI/Objectives/AIObjectiveCheckStolenItems.cs index a9ba2eae0..eb95ccebc 100644 --- a/Barotrauma/BarotraumaShared/SharedSource/Characters/AI/Objectives/AIObjectiveCheckStolenItems.cs +++ b/Barotrauma/BarotraumaShared/SharedSource/Characters/AI/Objectives/AIObjectiveCheckStolenItems.cs @@ -48,19 +48,22 @@ namespace Barotrauma protected override bool CheckObjectiveState() => false; protected override float GetPriority() - { - if (character.IsClimbing) - { - // Target is climbing -> stop following the objective (soft abandon, without ignoring the target). - Priority = 0; - } - else if (!Abandon && !IsCompleted && objectiveManager.IsOrder(this)) + { + if (!Abandon && !IsCompleted && objectiveManager.IsOrder(this)) { Priority = objectiveManager.GetOrderPriority(this); } else { - Priority = AIObjectiveManager.LowestOrderPriority - 1; + if (HumanAIController.CurrentHullSafety < HumanAIController.HULL_SAFETY_THRESHOLD || HumanAIController.CalculateObjectiveHullSafety(Target) < HumanAIController.HULL_SAFETY_THRESHOLD) + { + // Don't do inspections in unsafe hulls, because under a threat, bots are allowed to wear diving gear or hold fire extinguishers etc. Even if they are "stolen". + Priority = 0; + } + else + { + Priority = AIObjectiveManager.LowestOrderPriority - 1; + } } return Priority; } @@ -86,18 +89,18 @@ namespace Barotrauma onCompleted: () => { RemoveSubObjective(ref goToObjective); - if (character.IsClimbing) + if (character.IsClimbing || HumanAIController.CurrentHullSafety < HumanAIController.HULL_SAFETY_THRESHOLD || HumanAIController.CalculateObjectiveHullSafety(Target) < HumanAIController.HULL_SAFETY_THRESHOLD) { - // Shouldn't start inspecting characters when they climb, nor get here, because the priority should be 0, - // but if this still happens, we'll have to abandon the objective - // because it's not currently possible to hold to characters and ladders at the same time. + // Don't do inspections in unsafe hulls, because under a threat, bots are allowed to wear diving gear or hold fire extinguishers etc. Even if they are "stolen". + // Shouldn't start inspecting characters when they climb, but we can still get here, if they start climbing while we are moving at them. + // If that happens, let's abandon the objective, because it's not currently possible to hold to characters and ladders at the same time. Abandon = true; } else { currentState = State.Inspect; stolenItems.Clear(); - Target.Inventory.FindAllItems(it => it.Illegitimate, recursive: true, stolenItems); + Target.Inventory.FindAllItems(it => IsItemIllegitimate(Target, it), recursive: true, stolenItems); character.Speak(TextManager.Get(Target.IsCriminal ? "dialogcheckstolenitems.criminal" : "dialogcheckstolenitems").Value); } }, @@ -190,11 +193,23 @@ namespace Barotrauma var stolenItemsOnCharacter = stolenItems.Where(it => it.GetRootInventoryOwner() == Target); if (stolenItemsOnCharacter.Any()) { - character.Speak(TextManager.Get(character.IsCriminal ? "dialogcheckstolenitems.arrest.criminal" : "dialogcheckstolenitems.arrest").Value); - Arrest(abortWhenItemsDropped: true, allowHoldFire: true); - foreach (var stolenItem in stolenItemsOnCharacter) + if (Target.IsBot) { - HumanAIController.ApplyStealingReputationLoss(stolenItem); + // Bots automatically comply and drop stolen items when being inspected. + foreach (Item item in stolenItemsOnCharacter) + { + item.Drop(Target); + } + character.Speak(TextManager.Get("dialogcheckstolenitems.comply").Value); + } + else + { + character.Speak(TextManager.Get(character.IsCriminal ? "dialogcheckstolenitems.arrest.criminal" : "dialogcheckstolenitems.arrest").Value); + Arrest(abortWhenItemsDropped: true, allowHoldFire: true); + foreach (var stolenItem in stolenItemsOnCharacter) + { + HumanAIController.ApplyStealingReputationLoss(stolenItem); + } } } else @@ -242,5 +257,10 @@ namespace Barotrauma currentWarnDelay = Target.IsCriminal ? CriminalWarnDelay : NormalWarnDelay; warnTimer = currentWarnDelay; } + + /// + /// Checks for illegitimate item, ignoring handcuffs equipped on the owner. + /// + public static bool IsItemIllegitimate(Character owner, Item item) => item.Illegitimate && (!item.HasTag(Tags.HandLockerItem) || !owner.HasEquippedItem(item)); } } diff --git a/Barotrauma/BarotraumaShared/SharedSource/Characters/AI/Objectives/AIObjectiveCleanupItem.cs b/Barotrauma/BarotraumaShared/SharedSource/Characters/AI/Objectives/AIObjectiveCleanupItem.cs index 05b059ddf..e110e20c1 100644 --- a/Barotrauma/BarotraumaShared/SharedSource/Characters/AI/Objectives/AIObjectiveCleanupItem.cs +++ b/Barotrauma/BarotraumaShared/SharedSource/Characters/AI/Objectives/AIObjectiveCleanupItem.cs @@ -18,11 +18,11 @@ namespace Barotrauma public bool IsPriority { get; set; } private readonly List ignoredContainers = new List(); - private AIObjectiveDecontainItem decontainObjective; + private AIObjectiveMoveItem moveItemObjective; private int itemIndex = 0; /// - /// Allows decontainObjective to be interrupted if this objective gets abandoned (e.g. due to the item no longer being eligible for cleanup) + /// Allows to be interrupted if this objective gets abandoned (e.g. due to the item no longer being eligible for cleanup) /// protected override bool ConcurrentObjectives => true; @@ -53,9 +53,9 @@ namespace Barotrauma float reduction = IsPriority ? 1 : isSelected ? 2 : 3; float max = AIObjectiveManager.LowestOrderPriority - reduction; Priority = MathHelper.Lerp(0, max, MathHelper.Clamp(devotion + (distanceFactor * PriorityModifier), 0, 1)); - if (decontainObjective == null) + if (moveItemObjective == null) { - // Halve the priority until there's a decontain objective (a valid container was found). + // Halve the priority until there's a moveItemObjective (a valid container was found). Priority /= 2; } } @@ -79,7 +79,7 @@ namespace Barotrauma s == InvSlotType.OuterClothes || s == InvSlotType.HealthInterface); - TryAddSubObjective(ref decontainObjective, () => new AIObjectiveDecontainItem(character, item, objectiveManager, targetContainer: suitableContainer.GetComponent()) + TryAddSubObjective(ref moveItemObjective, () => new AIObjectiveMoveItem(character, item, objectiveManager, targetContainer: suitableContainer.GetComponent()) { Equip = equip, TakeWholeStack = true, @@ -99,7 +99,7 @@ namespace Barotrauma { HumanAIController.ReequipUnequipped(); } - if (decontainObjective != null && decontainObjective.ContainObjective != null && decontainObjective.ContainObjective.CanBeCompleted) + if (moveItemObjective is { ContainObjective.CanBeCompleted: true }) { ignoredContainers.Add(suitableContainer); } @@ -144,7 +144,7 @@ namespace Barotrauma base.Reset(); ignoredContainers.Clear(); itemIndex = 0; - decontainObjective = null; + moveItemObjective = null; } public void DropTarget() diff --git a/Barotrauma/BarotraumaShared/SharedSource/Characters/AI/Objectives/AIObjectiveCombat.cs b/Barotrauma/BarotraumaShared/SharedSource/Characters/AI/Objectives/AIObjectiveCombat.cs index 762297c86..59d930560 100644 --- a/Barotrauma/BarotraumaShared/SharedSource/Characters/AI/Objectives/AIObjectiveCombat.cs +++ b/Barotrauma/BarotraumaShared/SharedSource/Characters/AI/Objectives/AIObjectiveCombat.cs @@ -4,15 +4,18 @@ using Microsoft.Xna.Framework; using System; using System.Collections.Generic; using System.Linq; -using FarseerPhysics.Dynamics; using static Barotrauma.AIObjectiveFindSafety; using System.Collections.Immutable; +using System.Diagnostics; +using FarseerPhysics; namespace Barotrauma { class AIObjectiveCombat : AIObjective { public override Identifier Identifier { get; set; } = "combat".ToIdentifier(); + + public override string DebugTag => $"{Identifier} ({Mode})"; public override bool KeepDivingGearOn => true; public override bool IgnoreUnsafeHulls => true; @@ -35,10 +38,9 @@ namespace Barotrauma private bool allowCooldown; public Character Enemy { get; private set; } - public bool HoldPosition { get; set; } - + private Item _weapon; - private Item Weapon + public Item Weapon { get { return _weapon; } set @@ -48,6 +50,7 @@ namespace Barotrauma } } private ItemComponent _weaponComponent; + private bool hasValidRangedWeapon; private ItemComponent WeaponComponent { get @@ -87,7 +90,9 @@ namespace Barotrauma private const float DistanceCheckInterval = 0.2f; private float distanceTimer; - private const float CloseDistanceThreshold = 300; + private const float CloseDistance = 300; + private const float MeleeDistance = 125; + private const float TooCloseToShoot = 100; private const float FloorHeightApproximate = 100; public bool AllowHoldFire; @@ -168,13 +173,7 @@ namespace Barotrauma public AIObjectiveCombat(Character character, Character enemy, CombatMode mode, AIObjectiveManager objectiveManager, float priorityModifier = 1, float coolDown = DefaultCoolDown) : base(character, objectiveManager, priorityModifier) { - if (mode == CombatMode.None) - { -#if DEBUG - DebugConsole.ThrowError("Combat mode == None"); -#endif - return; - } + Debug.Assert(mode != CombatMode.None); Enemy = enemy; coolDownTimer = coolDown; findSafety = objectiveManager.GetObjective(); @@ -229,6 +228,7 @@ namespace Barotrauma public override void Update(float deltaTime) { base.Update(deltaTime); + isAimBlocked = false; ignoreWeaponTimer -= deltaTime; checkWeaponsTimer -= deltaTime; if (reloadTimer > 0) @@ -331,68 +331,155 @@ namespace Barotrauma pathBackTimer -= deltaTime; } } + if (standUpTimer > 0) + { + standUpTimer -= deltaTime; + } + else + { + // Crouch by default so that others can shoot from behind. Disabled when the line of sight is blocked and while moving. + allowCrouching = true; + } + if (HumanAIController.DebugAI) + { + BlockedPositions ??= new List(); + BlockedPositions.Clear(); + } if (seekAmmunitionObjective == null && seekWeaponObjective == null) { if (Mode != CombatMode.Retreat && TryArm()) { OperateWeapon(deltaTime); } - if (HoldPosition) - { - SteeringManager.Reset(); - } - else if (seekAmmunitionObjective == null && seekWeaponObjective == null) + isMoving = false; + if (seekAmmunitionObjective == null && seekWeaponObjective == null) { Move(deltaTime); } } } - + + private bool isMoving; private void Move(float deltaTime) { - switch (Mode) + if (Mode == CombatMode.Retreat) { - case CombatMode.Offensive: - case CombatMode.Arrest: + Retreat(deltaTime); + } + else if (character.IsOnPlayerTeam && !Enemy.IsPlayer && objectiveManager.CurrentOrder is AIObjectiveGoTo gotoObjective) + { + if (gotoObjective.IsWaitOrder && WeaponComponent is MeleeWeapon && IsEnemyClose(CloseDistance)) + { + // Ordered to wait near the enemy with a melee weapon -> engage. Engage(deltaTime); - break; - case CombatMode.Defensive: - if (character.IsOnPlayerTeam && !Enemy.IsPlayer && objectiveManager.IsCurrentOrder()) + } + else + { + // Ordered to follow -> keep following. + if (!gotoObjective.IsCloseEnough) { - if ((character.CurrentHull == null || character.CurrentHull == Enemy.CurrentHull) && sqrDistance < 200 * 200) + isMoving = true; + } + gotoObjective.FaceTargetOnCompleted = false; + gotoObjective.ForceAct(deltaTime); + if (!character.AnimController.InWater && IsEnemyClose(CloseDistance)) + { + // If close to the enemy, face it, so that we can attack it. + HumanAIController.FaceTarget(Enemy); + HumanAIController.AutoFaceMovement = false; + if (!gotoObjective.ShouldRun(true)) { - Engage(deltaTime); + ForceWalk = true; + } + } + } + } + else + { + switch (Mode) + { + case CombatMode.Defensive: + Retreat(deltaTime); + break; + case CombatMode.Offensive when hasValidRangedWeapon && IsEnemyClose(CloseDistance): + // Too close to the enemy -> try to back off. + Hull currentHull = character.CurrentHull; + bool backOff = currentHull != null; + Vector2 escapeVel = Vector2.Zero; + if (backOff) + { + int previousEnemyDir = 0; + foreach (Character enemy in Character.CharacterList) + { + if (!HumanAIController.IsActive(enemy) || HumanAIController.IsFriendly(enemy) || enemy.IsHandcuffed) { continue; } + if (enemy.CurrentHull == null) { continue; } + if (currentHull != enemy.CurrentHull && !currentHull.linkedTo.Contains(enemy.CurrentHull)) { continue; } + Vector2 dir = character.Position - enemy.Position; + int enemyDir = Math.Sign(dir.X); + if (enemyDir == 0) + { + // Exactly at the same pos. + if (previousEnemyDir != 0) + { + // Another enemy at either side -> Ignore this enemy. + continue; + } + else + { + // Just choose either direction. + enemyDir = Rand.Value() > 0.5f ? 1 : -1; + } + } + if (previousEnemyDir != 0 && enemyDir != previousEnemyDir) + { + // Don't back off when there are enemies in different directions, because that's doomed. + backOff = false; + break; + } + previousEnemyDir = enemyDir; + // This formula is slightly modified from AIObjectiveFindSafety.UpdateSimpleEscape(). + float distMultiplier = MathHelper.Clamp(MeleeDistance / Vector2.Distance(enemy.Position, character.Position), 0.1f, 10.0f); + escapeVel += new Vector2(enemyDir * distMultiplier, !character.IsClimbing ? 0 : Math.Sign(dir.Y) * distMultiplier); + } + if (escapeVel == Vector2.Zero) + { + backOff = false; + } + if (backOff) + { + // Only move if we haven't reached the edge of the room. + float left = currentHull.Rect.X + 50; + float right = currentHull.Rect.Right - 50; + backOff = escapeVel.X < 0 && character.Position.X > left || escapeVel.X > 0 && character.Position.X < right; + } + } + if (backOff) + { + BackOff(); } else { - // Keep following the goto target - var gotoObjective = objectiveManager.GetOrder(); - if (gotoObjective != null) - { - gotoObjective.ForceAct(deltaTime); - if (!character.AnimController.InWater) - { - HumanAIController.FaceTarget(Enemy); - ForceWalk = true; - HumanAIController.AutoFaceMovement = false; - } - } - else - { - SteeringManager.Reset(); - } + Engage(deltaTime); } - } - else - { - Retreat(deltaTime); - } - break; - case CombatMode.Retreat: - Retreat(deltaTime); - break; - default: - throw new NotImplementedException(); + + void BackOff() + { + RemoveFollowTarget(); + isMoving = true; + if (!IsEnemyClose(MeleeDistance)) + { + ForceWalk = true; + } + HumanAIController.FaceTarget(Enemy); + HumanAIController.AutoFaceMovement = false; + character.ReleaseSecondaryItem(); + character.AIController.SteeringManager.SteeringManual(deltaTime, escapeVel); + } + break; + default: + Engage(deltaTime); + break; + } } } @@ -407,7 +494,8 @@ namespace Barotrauma bool isAllowedToSeekWeapons = character.IsHostileEscortee || character.IsPrisoner || // Prisoners and terrorists etc are always allowed to seek new weapons. (character.IsInFriendlySub // Other characters need to be on a friendly sub in order to "know" where the weapons are. This also prevents NPCs "stealing" player items. && IsOffensiveOrArrest // = Defensive or retreating AI shouldn't seek new weapons. - && !character.IsInstigator); // Instigators (= aggressive NPCs spawned with events) shouldn't seek new weapons, because we don't want them to grab e.g. an smg, if they spawn with a wrench or something. + && !character.IsInstigator // Instigators (= aggressive NPCs spawned with events) shouldn't seek new weapons, because we don't want them to grab e.g. an smg, if they spawn with a wrench or something. + && objectiveManager.CurrentOrder is not AIObjectiveGoTo); // if ordered to wait/follow, shouldn't go seeking new weapons. if (checkWeaponsTimer < 0) { checkWeaponsTimer = CheckWeaponsInterval; @@ -433,7 +521,12 @@ namespace Barotrauma // All good, the weapon is loaded break; } - bool seekAmmo = isAllowedToSeekWeapons && seekAmmunitionObjective == null && !IsEnemyClose(CloseDistanceThreshold); + bool seekAmmo = isAllowedToSeekWeapons && seekAmmunitionObjective == null; + if (seekAmmo) + { + // Bots set to arrest the target are always allowed to seek (or spawn) more ammo, because otherwise they might not be able to stun the target and need to use lethal weapons. + seekAmmo = Mode == CombatMode.Arrest || !IsEnemyClose(CloseDistance); + } if (Reload(seekAmmo: seekAmmo)) { // All good, we can use the weapon. @@ -479,7 +572,7 @@ namespace Barotrauma Mode = CombatMode.Retreat; } } - else if (seekAmmunitionObjective == null && (WeaponComponent == null || (WeaponComponent.CombatPriority < GoodWeaponPriority && !IsEnemyClose(CloseDistanceThreshold)))) + else if (seekAmmunitionObjective == null && (WeaponComponent == null || (WeaponComponent.CombatPriority < GoodWeaponPriority && !IsEnemyClose(CloseDistance)))) { // No weapon or only a poor weapon equipped -> try to find better. RemoveSubObjective(ref retreatObjective); @@ -488,7 +581,7 @@ namespace Barotrauma constructor: () => new AIObjectiveGetItem(character, "weapon".ToIdentifier(), objectiveManager, equip: true, checkInventory: false) { AllowStealing = HumanAIController.IsMentallyUnstable, - AbortCondition = obj => IsEnemyClose(200), + AbortCondition = _ => IsEnemyClose(CloseDistance / 2), EvaluateCombatPriority = false, // Use a custom formula instead GetItemPriority = i => { @@ -581,6 +674,12 @@ namespace Barotrauma private void OperateWeapon(float deltaTime) { + if (isMoving && character.IsClimbing) + { + // Don't climb and shoot at the same time, because it messes up the aiming. + ClearInputs(); + return; + } switch (Mode) { case CombatMode.Offensive: @@ -789,17 +888,22 @@ namespace Barotrauma return containers.None() || containers.Any(container => (container as ItemContainer)?.Inventory.AllItems.Any(i => i != null && i.HasTag(mobileBatteryTag) && i.Condition > 0.0f) ?? false); } - + private Item GetWeapon(IEnumerable weaponList, out ItemComponent weaponComponent) { + hasValidRangedWeapon = false; weaponComponent = null; float bestPriority = 0; float lethalDmg = -1; - bool prioritizeMelee = IsEnemyClose(50) || EnemyAIController.IsLatchedTo(Enemy, character); - bool isCloseToEnemy = prioritizeMelee || IsEnemyClose(CloseDistanceThreshold); - foreach (var weapon in weaponList) + bool prioritizeMelee = IsEnemyClose(TooCloseToShoot) || EnemyAIController.IsLatchedTo(Enemy, character); + bool isCloseToEnemy = prioritizeMelee || IsEnemyClose(CloseDistance); + foreach (ItemComponent weapon in weaponList) { float priority = GetWeaponPriority(weapon, prioritizeMelee, canSeekAmmo: !isCloseToEnemy, out lethalDmg); + if (priority >= GoodWeaponPriority && weapon is RangedWeapon or RepairTool) + { + hasValidRangedWeapon = true; + } if (priority > bestPriority) { weaponComponent = weapon; @@ -947,6 +1051,7 @@ namespace Barotrauma private void Retreat(float deltaTime) { + isMoving = true; if (!Enemy.IsHuman && !character.IsInFriendlySub) { // Only relevant when we are retreating from monsters and are not inside a friendly sub. @@ -1044,6 +1149,7 @@ namespace Barotrauma { if (sqrDistance > MathUtils.Pow2(meleeWeapon.Range)) { + isMoving = true; character.ReleaseSecondaryItem(); // Swim towards the target SteeringManager.Reset(); @@ -1094,7 +1200,7 @@ namespace Barotrauma } }); if (followTargetObjective == null) { return; } - if (Mode == CombatMode.Arrest && Enemy.IsKnockedDown && !arrestingRegistered) + if (Mode == CombatMode.Arrest && Enemy.IsKnockedDownOrRagdolled && !arrestingRegistered) { bool hasHandCuffs = HumanAIController.HasItem(character, Tags.HandLockerItem, out _); if (!hasHandCuffs && character.TeamID == CharacterTeamType.FriendlyNPC) @@ -1119,12 +1225,20 @@ namespace Barotrauma followTargetObjective.CloseEnough = WeaponComponent switch { - RangedWeapon => 1000, + RangedWeapon => isAimBlocked ? BlockedDistance : 1000, MeleeWeapon mw => mw.Range, RepairTool rt => rt.Range, _ => 50 }; } + if (isAimBlocked) + { + ForceWalk = true; + } + if (!followTargetObjective.IsCloseEnough) + { + isMoving = true; + } } private void RemoveFollowTarget() @@ -1142,19 +1256,23 @@ namespace Barotrauma private void OnArrestTargetReached() { - if (!Enemy.IsKnockedDown) + if (!Enemy.IsKnockedDownOrRagdolled) { RemoveFollowTarget(); return; } if (character.TeamID == CharacterTeamType.FriendlyNPC) { - // Confiscate stolen goods and all weapons foreach (var item in Enemy.Inventory.AllItemsMod) { - // Ignore handcuffs already on the target. - if (item.HasTag(Tags.HandLockerItem) && Enemy.HasEquippedItem(item)) { continue; } - if (item.Illegitimate || item.HasTag(Tags.Weapon) || item.HasTag(Tags.Poison) || GetWeaponComponent(item) is { CombatPriority: > 0 }) + AIObjectiveFindThieves.MarkTargetAsInspected(character); + bool confiscateItem = AIObjectiveCheckStolenItems.IsItemIllegitimate(Enemy, item); + if (!confiscateItem && Enemy.IsActingOffensively) + { + // Confiscate any weapons or items that can be used offensively. + confiscateItem = item.HasTag(Tags.Weapon) || item.HasTag(Tags.Poison) || GetWeaponComponent(item) is { CombatPriority: > 0 }; + } + if (confiscateItem) { item.Drop(character); character.Inventory.TryPutItem(item, character, CharacterInventory.AnySlot); @@ -1169,7 +1287,7 @@ namespace Barotrauma } if (matchingItems.Any() && - !Enemy.IsUnconscious && Enemy.IsKnockedDown && character.CanInteractWith(Enemy) && !Enemy.LockHands) + !Enemy.IsUnconscious && Enemy.IsKnockedDownOrRagdolled && character.CanInteractWith(Enemy) && !Enemy.LockHands) { var handCuffs = matchingItems.First(); if (!HumanAIController.TakeItem(handCuffs, Enemy.Inventory, equip: true, wear: true)) @@ -1202,7 +1320,8 @@ namespace Barotrauma RemoveFollowTarget(); var itemContainer = Weapon.GetComponent(); TryAddSubObjective(ref seekAmmunitionObjective, - constructor: () => new AIObjectiveContainItem(character, ammunitionIdentifiers, itemContainer, objectiveManager) + constructor: () => new AIObjectiveContainItem(character, ammunitionIdentifiers, itemContainer, objectiveManager, + spawnItemIfNotFound: !character.IsOnPlayerTeam && character.AIController.HasInfiniteItemSpawns(ammunitionIdentifiers)) { ItemCount = itemContainer.MainContainerCapacity * itemContainer.MaxStackSize, checkInventory = false, @@ -1224,10 +1343,9 @@ namespace Barotrauma /// private bool Reload(bool seekAmmo) { - if (WeaponComponent == null) { return false; } + if (WeaponComponent == null) { return false; } if (Weapon.OwnInventory == null) { return true; } - // Eject empty ammo - HumanAIController.UnequipEmptyItems(Weapon); + HumanAIController.UnequipEmptyItems(Weapon, allowDestroying: !character.IsOnPlayerTeam); ImmutableHashSet ammunitionIdentifiers = null; if (WeaponComponent.RequiredItems.ContainsKey(RelatedItem.RelationType.Contained)) { @@ -1267,7 +1385,7 @@ namespace Barotrauma { return true; } - else if (!HoldPosition && IsOffensiveOrArrest && seekAmmo && ammunitionIdentifiers != null) + else if (IsOffensiveOrArrest && seekAmmo && ammunitionIdentifiers != null) { // Inventory not drawn = it's not interactable // If the weapon is empty and the inventory is inaccessible, it can't be reloaded @@ -1277,6 +1395,20 @@ namespace Barotrauma return false; } + private bool isAimBlocked; + private float _blockedDistance; + private float BlockedDistance + { + get + { + if (_blockedDistance <= 0) + { + _blockedDistance = CloseDistance * Rand.Range(1.0f, 1.3f); + } + return _blockedDistance; + } + } + public List BlockedPositions; private void Attack(float deltaTime) { character.CursorPosition = Enemy.WorldPosition; @@ -1378,28 +1510,49 @@ namespace Barotrauma var pickedBodies = Submarine.PickBodies(Weapon.SimPosition, Submarine.GetRelativeSimPosition(from: Weapon, to: Enemy), ignoredBodies: character.AnimController.LimbBodies, Physics.CollisionCharacter); + foreach (var body in pickedBodies) { - Character target = body.UserData switch + if (body.UserData is Limb limb) { - Character c => c, - Limb limb => limb.character, - _ => null - }; - if (target != null && target != Enemy && HumanAIController.IsFriendly(target)) - { - return; + Character target = limb.character; + if (target != null && target != Enemy && HumanAIController.IsFriendly(target)) + { + // Blocked by a friendly target. + isAimBlocked = true; + if (HumanAIController.DebugAI) + { + BlockedPositions.Add(ConvertUnits.ToDisplayUnits(body.Position)); + } + // Stand up, so that we might shoot past the friendlies that are crouching. + allowCrouching = false; + standUpTimer = StandUpCooldown; + return; + } } + } UseWeapon(deltaTime); } } } + private bool allowCrouching; + private float standUpTimer; + private const float StandUpCooldown = 5; private void UseWeapon(float deltaTime) { - // Never allow friendly crew (bots) to attack with deadly weapons. - if (Mode == CombatMode.Arrest && isLethalWeapon && character.IsOnPlayerTeam && Enemy.IsOnPlayerTeam) { return; } + // Enable this to debug intentional friendly fire. + // if (isLethalWeapon && character.TeamID == Enemy.TeamID && character.IsOnPlayerTeam) + // { + // // Never allow friendly crew (bots) to attack with deadly weapons (this check should be redundant) + // Debugger.Break(); + // return; + // } + if (allowCrouching && !isMoving && !character.AnimController.InWater && WeaponComponent is not MeleeWeapon) + { + HumanAIController.AnimController.Crouch(); + } character.SetInput(InputType.Shoot, hit: false, held: true); Weapon.Use(deltaTime, user: character); SetReloadTime(WeaponComponent); @@ -1512,6 +1665,7 @@ namespace Barotrauma hasAimed = false; holdFireTimer = 0; pathBackTimer = 0; + standUpTimer = 0; isLethalWeapon = false; canSeeTarget = false; seekWeaponObjective = null; diff --git a/Barotrauma/BarotraumaShared/SharedSource/Characters/AI/Objectives/AIObjectiveContainItem.cs b/Barotrauma/BarotraumaShared/SharedSource/Characters/AI/Objectives/AIObjectiveContainItem.cs index 2d55f68a4..72d39beff 100644 --- a/Barotrauma/BarotraumaShared/SharedSource/Characters/AI/Objectives/AIObjectiveContainItem.cs +++ b/Barotrauma/BarotraumaShared/SharedSource/Characters/AI/Objectives/AIObjectiveContainItem.cs @@ -48,6 +48,8 @@ namespace Barotrauma public int? RemoveMax { get; set; } public bool MoveWholeStack { get; set; } + + public bool AllowStealing { get; set; } private int _itemCount = 1; public int ItemCount @@ -147,11 +149,11 @@ namespace Barotrauma if (RemoveExisting || (RemoveExistingWhenNecessary && !CanBePut(container.Inventory, TargetSlot, ItemToContain))) { - HumanAIController.UnequipContainedItems(container.Item, predicate: RemoveExistingPredicate, unequipMax: RemoveMax); + HumanAIController.UnequipContainedItems(container.Item, predicate: RemoveExistingPredicate, unequipMax: RemoveMax, allowDestroying: spawnItemIfNotFound); } else if (RemoveEmpty) { - HumanAIController.UnequipEmptyItems(container.Item); + HumanAIController.UnequipEmptyItems(container.Item, allowDestroying: spawnItemIfNotFound); } Inventory originalInventory = ItemToContain.ParentInventory; var slots = originalInventory?.FindIndices(ItemToContain); @@ -195,6 +197,7 @@ namespace Barotrauma { TryAddSubObjective(ref goToObjective, () => new AIObjectiveGoTo(container.Item, character, objectiveManager, getDivingGearIfNeeded: AllowToFindDivingGear) { + DialogueIdentifier = AIObjectiveGoTo.DialogCannotReachTarget, TargetName = container.Item.Name, AbortCondition = obj => container?.Item == null || container.Item.Removed || !container.Item.HasAccess(character) || @@ -231,7 +234,9 @@ namespace Barotrauma return (RemoveEmpty ? container.CanBeContained(potentialItem) : container.Inventory.CanBePut(potentialItem)) && container.ShouldBeContained(potentialItem, out _); }, ItemCount = ItemCount, - TakeWholeStack = MoveWholeStack + TakeWholeStack = MoveWholeStack, + ContainTarget = container, + AllowStealing = AllowStealing }, onAbandon: () => { Abandon = true; diff --git a/Barotrauma/BarotraumaShared/SharedSource/Characters/AI/Objectives/AIObjectiveDeconstructItem.cs b/Barotrauma/BarotraumaShared/SharedSource/Characters/AI/Objectives/AIObjectiveDeconstructItem.cs index 8a22ba597..9fc63b0dd 100644 --- a/Barotrauma/BarotraumaShared/SharedSource/Characters/AI/Objectives/AIObjectiveDeconstructItem.cs +++ b/Barotrauma/BarotraumaShared/SharedSource/Characters/AI/Objectives/AIObjectiveDeconstructItem.cs @@ -14,7 +14,7 @@ namespace Barotrauma private Deconstructor deconstructor; - private AIObjectiveDecontainItem decontainObjective; + private AIObjectiveMoveItem moveItemObjective; private AIObjectiveGoTo gotoObjective; public AIObjectiveDeconstructItem(Item item, Character character, AIObjectiveManager objectiveManager, float priorityModifier = 1) @@ -37,8 +37,8 @@ namespace Barotrauma } } - TryAddSubObjective(ref decontainObjective, - constructor: () => new AIObjectiveDecontainItem(character, Item, objectiveManager, + TryAddSubObjective(ref moveItemObjective, + constructor: () => new AIObjectiveMoveItem(character, Item, objectiveManager, sourceContainer: Item.Container?.GetComponent(), targetContainer: deconstructor.InputContainer, priorityModifier: PriorityModifier) { Equip = true, @@ -64,7 +64,7 @@ namespace Barotrauma Abandon = true; }); } - RemoveSubObjective(ref decontainObjective); + RemoveSubObjective(ref moveItemObjective); }, onAbandon: () => { @@ -125,7 +125,7 @@ namespace Barotrauma public override void Reset() { base.Reset(); - decontainObjective = null; + moveItemObjective = null; } public void DropTarget() diff --git a/Barotrauma/BarotraumaShared/SharedSource/Characters/AI/Objectives/AIObjectiveExtinguishFire.cs b/Barotrauma/BarotraumaShared/SharedSource/Characters/AI/Objectives/AIObjectiveExtinguishFire.cs index a5faf7f4e..53ec38880 100644 --- a/Barotrauma/BarotraumaShared/SharedSource/Characters/AI/Objectives/AIObjectiveExtinguishFire.cs +++ b/Barotrauma/BarotraumaShared/SharedSource/Characters/AI/Objectives/AIObjectiveExtinguishFire.cs @@ -39,40 +39,39 @@ namespace Barotrauma // Don't go into rooms with any enemies, unless it's an order Priority = 0; Abandon = true; + return Priority; } - else + // Prioritize fires that currently damage the character. + bool inDamageRange = targetHull.FireSources.Any(fs => fs.IsInDamageRange(character, fs.DamageRange)); + float severity = inDamageRange ? 1.0f : AIObjectiveExtinguishFires.GetFireSeverity(targetHull); + float characterY = character.CurrentHull?.WorldPosition.Y ?? character.WorldPosition.Y; + float distanceFactor = targetHull == character.CurrentHull ? 1.0f + : HumanAIController.VisibleHulls.Contains(targetHull) ? 0.75f : 0.0f; + + if (distanceFactor <= 0.0f) { - float characterY = character.CurrentHull?.WorldPosition.Y ?? character.WorldPosition.Y; - - float distanceFactor = 1.0f; - if (targetHull != character.CurrentHull && - !HumanAIController.VisibleHulls.Contains(targetHull)) - { - distanceFactor = - GetDistanceFactor( - new Vector2(character.WorldPosition.Y, characterY), - targetHull.WorldPosition, - verticalDistanceMultiplier: 3, - maxDistance: 5000, - factorAtMaxDistance: 0.1f); - } - float severity = AIObjectiveExtinguishFires.GetFireSeverity(targetHull); - if (severity > 0.75f && !isOrder && - targetHull.RoomName != null && - !targetHull.RoomName.Contains("reactor", StringComparison.OrdinalIgnoreCase) && - !targetHull.RoomName.Contains("engine", StringComparison.OrdinalIgnoreCase) && - !targetHull.RoomName.Contains("command", StringComparison.OrdinalIgnoreCase)) - { - // Ignore severe fires to prevent casualities unless ordered to extinguish. - Priority = 0; - Abandon = true; - } - else - { - float devotion = CumulatedDevotion / 100; - Priority = MathHelper.Lerp(0, AIObjectiveManager.MaxObjectivePriority, MathHelper.Clamp(devotion + (severity * distanceFactor * PriorityModifier), 0, 1)); - } + distanceFactor = + GetDistanceFactor( + new Vector2(character.WorldPosition.Y, characterY), + targetHull.WorldPosition, + verticalDistanceMultiplier: 3, + maxDistance: 5000, + factorAtMaxDistance: 0.1f); } + + if (!inDamageRange && severity > 0.75f && distanceFactor < 0.75f && !isOrder && character.IsOnPlayerTeam && + targetHull.RoomName != null && + !targetHull.RoomName.Contains("reactor", StringComparison.OrdinalIgnoreCase) && + !targetHull.RoomName.Contains("engine", StringComparison.OrdinalIgnoreCase) && + !targetHull.RoomName.Contains("command", StringComparison.OrdinalIgnoreCase)) + { + // Bots in the player crew ignore severe fires that are not close to the target to prevent casualties unless ordered to extinguish. + Priority = 0; + Abandon = true; + return Priority; + } + float devotion = CumulatedDevotion / 100; + Priority = MathHelper.Lerp(0, AIObjectiveManager.MaxObjectivePriority, MathHelper.Clamp(devotion + (severity * distanceFactor * PriorityModifier), 0, 1)); return Priority; } @@ -81,16 +80,16 @@ namespace Barotrauma private float sinTime; protected override void Act(float deltaTime) { - var extinguisherItem = character.Inventory.FindItemByTag("fireextinguisher".ToIdentifier()); + var extinguisherItem = character.Inventory.FindItemByTag(Tags.FireExtinguisher); if (extinguisherItem == null || extinguisherItem.Condition <= 0.0f || !character.HasEquippedItem(extinguisherItem)) { TryAddSubObjective(ref getExtinguisherObjective, () => { - if (character.IsOnPlayerTeam && !character.HasEquippedItem("fireextinguisher".ToIdentifier(), allowBroken: false)) + if (character.IsOnPlayerTeam && !character.HasEquippedItem(Tags.FireExtinguisher, allowBroken: false)) { - character.Speak(TextManager.Get("DialogFindExtinguisher").Value, null, 2.0f, "findextinguisher".ToIdentifier(), 30.0f); + character.Speak(TextManager.Get("DialogFindExtinguisher").Value, null, 2.0f, Tags.FireExtinguisher, 30.0f); } - var getItemObjective = new AIObjectiveGetItem(character, "fireextinguisher".ToIdentifier(), objectiveManager, equip: true) + var getItemObjective = new AIObjectiveGetItem(character, Tags.FireExtinguisher, objectiveManager, equip: true) { AllowStealing = true, // If the item is inside an unsafe hull, decrease the priority @@ -124,7 +123,9 @@ namespace Barotrauma break; } float xDist = Math.Abs(character.WorldPosition.X - fs.WorldPosition.X); - float yDist = Math.Abs(character.CurrentHull.WorldPosition.Y - targetHull.WorldPosition.Y); + // If fire source and the character are on the same level, it's better to ignore the y-axis (e.g. it doesn't matter if we stand or crouch), as the fire size is rectangular. + // If we'd do this while climbing, the character would often get too close to the fire. + float yDist = !character.IsClimbing && MathUtils.NearlyEqual(character.CurrentHull.WorldPosition.Y, targetHull.WorldPosition.Y) ? 0.0f : Math.Abs(character.CurrentHull.WorldPosition.Y - fs.WorldPosition.Y); float dist = xDist + yDist; bool inRange = dist < extinguisher.Range; bool isInDamageRange = fs.IsInDamageRange(character, fs.DamageRange) && character.CanSeeTarget(targetHull); @@ -153,8 +154,8 @@ namespace Barotrauma { if (TryAddSubObjective(ref gotoObjective, () => new AIObjectiveGoTo(fs, character, objectiveManager, closeEnough: extinguisher.Range * 0.8f) { - DialogueIdentifier = "dialogcannotreachfire".ToIdentifier(), - TargetName = fs.Hull.DisplayName, + DialogueIdentifier = AIObjectiveGoTo.DialogCannotReachFire, + TargetName = fs.Hull.DisplayName }, onAbandon: () => Abandon = true, onCompleted: () => RemoveSubObjective(ref gotoObjective))) diff --git a/Barotrauma/BarotraumaShared/SharedSource/Characters/AI/Objectives/AIObjectiveFightIntruders.cs b/Barotrauma/BarotraumaShared/SharedSource/Characters/AI/Objectives/AIObjectiveFightIntruders.cs index 92751fdcd..cd3996752 100644 --- a/Barotrauma/BarotraumaShared/SharedSource/Characters/AI/Objectives/AIObjectiveFightIntruders.cs +++ b/Barotrauma/BarotraumaShared/SharedSource/Characters/AI/Objectives/AIObjectiveFightIntruders.cs @@ -75,7 +75,7 @@ namespace Barotrauma } } if (target.HasAbilityFlag(AbilityFlags.IgnoredByEnemyAI)) { return false; } - if (target.IsHandcuffed && target.IsKnockedDown) { return false; } + if (target.IsHandcuffed) { return false; } if (EnemyAIController.IsLatchedToSomeoneElse(target, character)) { return false; } return true; } diff --git a/Barotrauma/BarotraumaShared/SharedSource/Characters/AI/Objectives/AIObjectiveFindDivingGear.cs b/Barotrauma/BarotraumaShared/SharedSource/Characters/AI/Objectives/AIObjectiveFindDivingGear.cs index 9a602a097..743f4506c 100644 --- a/Barotrauma/BarotraumaShared/SharedSource/Characters/AI/Objectives/AIObjectiveFindDivingGear.cs +++ b/Barotrauma/BarotraumaShared/SharedSource/Characters/AI/Objectives/AIObjectiveFindDivingGear.cs @@ -21,7 +21,7 @@ namespace Barotrauma private Item targetItem; private int? oxygenSourceSlotIndex; - public const float MIN_OXYGEN = 10; + private const float MinOxygen = 10; protected override bool CheckObjectiveState() => targetItem != null && character.HasEquippedItem(targetItem, slotType: InvSlotType.OuterClothes | InvSlotType.InnerClothes | InvSlotType.Head); @@ -154,9 +154,10 @@ namespace Barotrauma { AllowToFindDivingGear = false, AllowDangerousPressure = true, - ConditionLevel = MIN_OXYGEN, + ConditionLevel = MinOxygen, RemoveExistingWhenNecessary = true, - TargetSlot = oxygenSourceSlotIndex + TargetSlot = oxygenSourceSlotIndex, + AllowStealing = HumanAIController.NeedsDivingGear(character.CurrentHull, out _) }; if (container.HasSubContainers) { @@ -199,7 +200,7 @@ namespace Barotrauma int ReportOxygenTankCount() { if (character.Submarine != Submarine.MainSub) { return 1; } - int remainingOxygenTanks = Submarine.MainSub.GetItems(false).Count(i => i.HasTag(Tags.OxygenSource) && i.Condition > 1); + int remainingOxygenTanks = Submarine.MainSub?.GetItems(false).Count(i => i.HasTag(Tags.OxygenSource) && i.Condition > 1) ?? 0; if (remainingOxygenTanks == 0) { character.Speak(TextManager.Get("DialogOutOfOxygenTanks").Value, null, 0.0f, "outofoxygentanks".ToIdentifier(), 30.0f); @@ -227,7 +228,6 @@ namespace Barotrauma return true; } - private bool IsSuitableContainedOxygenSource(Item item) { return @@ -259,7 +259,7 @@ namespace Barotrauma // The margin helps us to survive, because we might need some oxygen before we can find more oxygen. // When we are venturing outside of our sub, let's just suppose that we have enough oxygen with us and optimize it so that we don't keep switching off half used tanks. float min = 0.01f; - float minOxygen = character.IsInFriendlySub ? MIN_OXYGEN : min; + float minOxygen = character.IsInFriendlySub ? MinOxygen : min; if (minOxygen > min && character.Inventory.AllItems.Any(i => i.HasTag(Tags.OxygenSource) && i.ConditionPercentage >= minOxygen)) { // There's a valid oxygen tank in the inventory -> no need to swap the tank too early. diff --git a/Barotrauma/BarotraumaShared/SharedSource/Characters/AI/Objectives/AIObjectiveFindSafety.cs b/Barotrauma/BarotraumaShared/SharedSource/Characters/AI/Objectives/AIObjectiveFindSafety.cs index 3d5e7ace7..400e60c76 100644 --- a/Barotrauma/BarotraumaShared/SharedSource/Characters/AI/Objectives/AIObjectiveFindSafety.cs +++ b/Barotrauma/BarotraumaShared/SharedSource/Characters/AI/Objectives/AIObjectiveFindSafety.cs @@ -42,9 +42,9 @@ namespace Barotrauma if (character.CurrentHull == null) { Priority = ( - objectiveManager.HasOrder(o => o.Priority > 0) || + objectiveManager.CurrentOrder is AIObjectiveGoTo || objectiveManager.HasActiveObjective() || - objectiveManager.Objectives.Any(o => (o is AIObjectiveCombat || o is AIObjectiveReturn) && o.Priority > 0)) + objectiveManager.Objectives.Any(o => o is AIObjectiveCombat or AIObjectiveReturn && o.Priority > 0)) && ((!character.IsLowInOxygen && character.IsImmuneToPressure)|| HumanAIController.HasDivingSuit(character)) ? 0 : AIObjectiveManager.EmergencyObjectivePriority - 10; } else @@ -71,6 +71,11 @@ namespace Barotrauma Priority = AIObjectiveManager.MaxObjectivePriority; } } + else if (objectiveManager.CurrentOrder is AIObjectiveGoTo { IsFollowOrder: true }) + { + // Ordered to follow -> Don't flee from the enemies/fires (doesn't get here if we need more oxygen). + Priority = 0; + } else if ((objectiveManager.IsCurrentOrder() || objectiveManager.IsCurrentOrder()) && character.Submarine != null && !character.IsOnFriendlyTeam(character.Submarine.TeamID)) { @@ -83,7 +88,7 @@ namespace Barotrauma Priority = 0; } Priority = MathHelper.Clamp(Priority, 0, AIObjectiveManager.MaxObjectivePriority); - if (divingGearObjective != null && !divingGearObjective.IsCompleted && divingGearObjective.CanBeCompleted) + if (divingGearObjective is { IsCompleted: false, CanBeCompleted: true, Priority: > 0f }) { // Boost the priority while seeking the diving gear Priority = Math.Max(Priority, Math.Min(AIObjectiveManager.EmergencyObjectivePriority - 1, AIObjectiveManager.MaxObjectivePriority)); @@ -149,7 +154,13 @@ namespace Barotrauma bool shouldActOnSuffocation = character.IsLowInOxygen && !character.AnimController.HeadInWater && HumanAIController.HasDivingSuit(character, requireOxygenTank: false); if (!character.LockHands && (!dangerousPressure || shouldActOnSuffocation || cannotFindSafeHull)) { - bool needsDivingGear = HumanAIController.NeedsDivingGear(currentHull, out bool needsDivingSuit); + bool needsDivingGear = HumanAIController.NeedsDivingGear(currentHull, out bool needsDivingSuit, objectiveManager); + if (character.TeamID == CharacterTeamType.FriendlyNPC && character.Submarine?.Info is { IsOutpost: true }) + { + // In outposts, the NPCs don't try to use diving suits, because otherwise there's probably not enough for those trying to fix the leaks. + // This is not a hard rule: the bots may still grab a suit, unless they find a diving mask. + needsDivingSuit = false; + } bool needsEquipment = shouldActOnSuffocation; if (needsDivingSuit) { @@ -307,10 +318,10 @@ namespace Barotrauma } } } - if (escapeVel != Vector2.Zero) + if (escapeVel != Vector2.Zero && character.CurrentHull is Hull currentHull) { - float left = character.CurrentHull.Rect.X + 50; - float right = character.CurrentHull.Rect.Right - 50; + float left = currentHull.Rect.X + 50; + float right = currentHull.Rect.Right - 50; //only move if we haven't reached the edge of the room if (escapeVel.X < 0 && character.Position.X > left || escapeVel.X > 0 && character.Position.X < right) { @@ -441,7 +452,7 @@ namespace Barotrauma hullSearchIndex = 0; #if DEBUG stopWatch.Stop(); - DebugConsole.NewMessage($"({character.DisplayName}) Sorted hulls by suitability in {stopWatch.ElapsedMilliseconds} ms", debugOnly: true); + DebugConsole.Log($"({character.DisplayName}) Sorted hulls by suitability in {stopWatch.ElapsedMilliseconds} ms"); #endif } @@ -471,12 +482,47 @@ namespace Barotrauma } else { - // Each unsafe node reduces the hull safety value. - // Ignore the current hull, because otherwise we couldn't find a path out. - int unsafeNodes = path.Nodes.Count(n => n.CurrentHull != character.CurrentHull && HumanAIController.UnsafeHulls.Contains(n.CurrentHull)); - hullSafety /= 1 + unsafeNodes; + // Check the path safety. Each unsafe node reduces the hull safety value. + Hull previousHull = null; + foreach (WayPoint node in path.Nodes) + { + Hull hull = node.CurrentHull; + if (hull == previousHull) + { + // Let's evaluate each hull only once. If we'd want to make this foolproof, we'd have to add the checked hulls to a list, + // yet in practice there shouldn't be a case where the path would get back to a hull once it has exited it. + continue; + } + previousHull = hull; + if (hull == character.CurrentHull) + { + // Ignore the current hull, because otherwise we couldn't find a path out. + continue; + } + if (HumanAIController.UnsafeHulls.Contains(hull)) + { + // Compare safety of the node hull to the current hull safety. + float nodeHullSafety = HumanAIController.GetHullSafety(hull, hull.GetConnectedHulls(true, 1), character); + if (nodeHullSafety < HumanAIController.HULL_SAFETY_THRESHOLD && nodeHullSafety < HumanAIController.CurrentHullSafety) + { + // If the node hull is considered unsafe and less safe than the current hull, let's ignore the target. + hullSafety = 0; + break; + } + else + { + // Otherwise, each unsafe hull on the path reduces the safety of the target hull by 50% of their threat value. + float hullThreat = 100 - nodeHullSafety; + hullSafety -= hullThreat / 2; + if (hullSafety <= 0) + { + break; + } + } + } + } // If the target is not inside a friendly submarine, considerably reduce the hull safety. - if (!character.Submarine.IsEntityFoundOnThisSub(potentialHull, true)) + if (!character.Submarine.IsEntityFoundOnThisSub(potentialHull, includingConnectedSubs: true)) { hullSafety /= 10; } diff --git a/Barotrauma/BarotraumaShared/SharedSource/Characters/AI/Objectives/AIObjectiveFindThieves.cs b/Barotrauma/BarotraumaShared/SharedSource/Characters/AI/Objectives/AIObjectiveFindThieves.cs index 44519a290..59601f26e 100644 --- a/Barotrauma/BarotraumaShared/SharedSource/Characters/AI/Objectives/AIObjectiveFindThieves.cs +++ b/Barotrauma/BarotraumaShared/SharedSource/Characters/AI/Objectives/AIObjectiveFindThieves.cs @@ -83,7 +83,7 @@ namespace Barotrauma { return false; } - if (!IsValidTarget(target, character)) { return false; } + if (!CheckTarget(target)) { return false; } float inspectDist = target.IsCriminal ? CriminalInspectDistance : inspectDistance; if (Vector2.DistanceSquared(target.WorldPosition, character.WorldPosition) > inspectDist * inspectDist) { return false; } if (lastInspectionTimes.TryGetValue(target, out double lastInspectionTime)) @@ -145,26 +145,31 @@ namespace Barotrauma // Might be e.g. sitting on a chair. character.SelectedSecondaryItem = null; } - foreach (var target in Character.CharacterList) + if (HumanAIController.CurrentHullSafety >= HumanAIController.HULL_SAFETY_THRESHOLD) { - if (!IsValidTarget(target, character)) { continue; } - //if we spot someone wearing or holding stolen items, immediately check them (with 100% chance of spotting the stolen items) - if (target.Inventory.AllItems.Any(it => it.Illegitimate && target.HasEquippedItem(it)) && - character.CanSeeTarget(target, seeThroughWindows: true)) + foreach (var target in Character.CharacterList) { - AIObjectiveCheckStolenItems? existingObjective = - objectiveManager.GetActiveObjectives().FirstOrDefault(o => o.Target == target); - if (existingObjective == null) + if (!CheckTarget(target)) { continue; } + //if we spot someone wearing or holding stolen items, immediately check them (with 100% chance of spotting the stolen items) + if (target.Inventory.AllItems.Any(it => target.HasEquippedItem(it) && AIObjectiveCheckStolenItems.IsItemIllegitimate(target, it)) && character.CanSeeTarget(target, seeThroughWindows: true)) { - objectiveManager.AddObjective(new AIObjectiveCheckStolenItems(character, target, objectiveManager)); - lastInspectionTimes[target] = Timing.TotalTime; + if (HumanAIController.CalculateObjectiveHullSafety(target) >= HumanAIController.HULL_SAFETY_THRESHOLD) + { + // Don't do inspections in unsafe hulls, because under a threat, bots are allowed to wear diving gear or hold fire extinguishers etc. Even if they are "stolen". + AIObjectiveCheckStolenItems? existingObjective = objectiveManager.GetActiveObjectives().FirstOrDefault(o => o.Target == target); + if (existingObjective == null) + { + objectiveManager.AddObjective(new AIObjectiveCheckStolenItems(character, target, objectiveManager)); + lastInspectionTimes[target] = Timing.TotalTime; + } + } } } } checkVisibleStolenItemsTimer = CheckVisibleStolenItemsInterval; } - private bool IsValidTarget(Character target, Character character) + private bool CheckTarget(Character target) { if (target == null || target.Removed) { return false; } if (target.IsIncapacitated) { return false; } @@ -192,6 +197,16 @@ namespace Barotrauma } protected override void OnObjectiveCompleted(AIObjective objective, Character target) + { + MarkTargetAsInspected(target); + } + + /// + /// Marks the targets as being inspected for stolen items (e.g. while arresting the character), + /// meaning characters with this objective won't attempt to trigger an inspection in a while. + /// + /// + public static void MarkTargetAsInspected(Character target) { lastInspectionTimes[target] = Timing.TotalTime; } diff --git a/Barotrauma/BarotraumaShared/SharedSource/Characters/AI/Objectives/AIObjectiveFixLeak.cs b/Barotrauma/BarotraumaShared/SharedSource/Characters/AI/Objectives/AIObjectiveFixLeak.cs index c0b1114e0..280ab7b59 100644 --- a/Barotrauma/BarotraumaShared/SharedSource/Characters/AI/Objectives/AIObjectiveFixLeak.cs +++ b/Barotrauma/BarotraumaShared/SharedSource/Characters/AI/Objectives/AIObjectiveFixLeak.cs @@ -194,7 +194,7 @@ namespace Barotrauma { UseDistanceRelativeToAimSourcePos = true, CloseEnough = reach, - DialogueIdentifier = Leak.FlowTargetHull != null ? "dialogcannotreachleak".ToIdentifier() : Identifier.Empty, + DialogueIdentifier = Leak.FlowTargetHull != null ? AIObjectiveGoTo.DialogCannotReachLeak : Identifier.Empty, TargetName = Leak.FlowTargetHull?.DisplayName, requiredCondition = () => Leak.Submarine == character.Submarine && diff --git a/Barotrauma/BarotraumaShared/SharedSource/Characters/AI/Objectives/AIObjectiveGetItem.cs b/Barotrauma/BarotraumaShared/SharedSource/Characters/AI/Objectives/AIObjectiveGetItem.cs index 1600d588f..e63a46765 100644 --- a/Barotrauma/BarotraumaShared/SharedSource/Characters/AI/Objectives/AIObjectiveGetItem.cs +++ b/Barotrauma/BarotraumaShared/SharedSource/Characters/AI/Objectives/AIObjectiveGetItem.cs @@ -31,6 +31,10 @@ namespace Barotrauma private Item targetItem; private readonly Item originalTarget; + /// + /// ItemContainer the bot is trying to put the into. Only set when the objective is a subobjective of a . + /// + public ItemContainer ContainTarget; private ISpatialEntity moveToTarget; private bool isDoneSeeking; public Item TargetItem => targetItem; @@ -76,6 +80,12 @@ namespace Barotrauma } public InvSlotType? EquipSlotType { get; set; } + + /// + /// Tags of items that bots are allowed to take from outposts, when needed. For example when there's not enough oxygen in the room, or if they need to extinguish a fire. + /// The guards won't react if these items are taken by the bots. + /// + public static readonly Identifier[] AllowedItemsToTake = { Tags.OxygenSource, Tags.FireExtinguisher, Tags.LightDivingGear, Tags.HeavyDivingGear }; public AIObjectiveGetItem(Character character, Item targetItem, AIObjectiveManager objectiveManager, bool equip = true, float priorityModifier = 1) : base(character, objectiveManager, priorityModifier) @@ -477,7 +487,17 @@ namespace Barotrauma //the item is inside an item inside an item (e.g. fuel tank in a welding tool in a cabinet -> reduce priority to prefer items that aren't inside a tool) if (ownerItem != item.Container) { - itemPriority *= 0.1f; + if (ContainTarget != null && ContainTarget.Item.Prefab.Identifier == item.Container.Prefab.Identifier) + { + // The item is identical to the item we are trying to contain the item to (e.g. trying to find an oxygen source to a mask -> allow to take oxygen sources from other masks) + // Reduce the priority just a tiny bit, so that we choose items that are not inside the items first. + // TODO: Doesn't solve the issue for items that are not the same type but that should be treated the same. E.g. diving mask and clown diving mask. + itemPriority = 0.95f; + } + else + { + itemPriority *= 0.1f; + } } } } @@ -645,6 +665,11 @@ namespace Barotrauma if (prefab is not ItemPrefab itemPrefab) { continue; } if (IdentifiersOrTags.Any(id => id == prefab.Identifier || prefab.Tags.Contains(id))) { + if (character.AIController.HasInfiniteItemSpawns(prefab.Identifier)) + { + // If an item with infinite spawns is defined, let's use it. + return itemPrefab; + } float cost = itemPrefab.DefaultPrice != null && itemPrefab.CanBeBought ? itemPrefab.DefaultPrice.Price : float.MaxValue; diff --git a/Barotrauma/BarotraumaShared/SharedSource/Characters/AI/Objectives/AIObjectiveGoTo.cs b/Barotrauma/BarotraumaShared/SharedSource/Characters/AI/Objectives/AIObjectiveGoTo.cs index d86d61052..26cae185b 100644 --- a/Barotrauma/BarotraumaShared/SharedSource/Characters/AI/Objectives/AIObjectiveGoTo.cs +++ b/Barotrauma/BarotraumaShared/SharedSource/Characters/AI/Objectives/AIObjectiveGoTo.cs @@ -95,7 +95,32 @@ namespace Barotrauma protected override bool AllowOutsideSubmarine => AllowGoingOutside; protected override bool AllowInAnySub => true; - public Identifier DialogueIdentifier { get; set; } = "dialogcannotreachtarget".ToIdentifier(); + /// + /// NPC line for when the NPC fails to find a path to a target. + /// Note that this line includes the tag [name], which needs to be replaced with the name of the target. + /// + public static readonly Identifier DialogCannotReachTarget = "dialogcannotreachtarget".ToIdentifier(); + /// + /// Generic NPC line for when the NPC fails to find a path to some place/target. + /// + public static readonly Identifier DialogCannotReachPlace = "dialogcannotreachplace".ToIdentifier(); + /// + /// NPC line for when the NPC fails to find a path to a patient they're trying to treat. + /// Note that this line includes the tag [name], which needs to be replaced with the name of the target. + /// + public static readonly Identifier DialogCannotReachPatient = "dialogcannotreachpatient".ToIdentifier(); + /// + /// NPC line for when the NPC fails to find a path to a fire they're trying to extinguish. + /// Note that this line includes the tag [name], which needs to be replaced with the name of the room the NPC is trying to get to. + /// + public static readonly Identifier DialogCannotReachFire = "dialogcannotreachfire".ToIdentifier(); + /// + /// NPC line for when the NPC fails to find a path to a leak they're trying to fix. + /// Note that this line includes the tag [name], which needs to be replaced with the name of the room the NPC is trying to get to. + /// + public static readonly Identifier DialogCannotReachLeak = "dialogcannotreachleak".ToIdentifier(); + + public Identifier DialogueIdentifier { get; set; } = DialogCannotReachPlace; private readonly Identifier ExoSuitRefuel = "dialog.exosuit.refuel".ToIdentifier(); private readonly Identifier ExoSuitOutOfFuel = "dialog.exosuit.outoffuel".ToIdentifier(); @@ -116,12 +141,12 @@ namespace Barotrauma Abandon = !isOrder; return Priority; } - if (Target == null || Target is Entity e && e.Removed) + if (Target is null or Entity { Removed: true }) { Priority = 0; Abandon = !isOrder; } - if (IgnoreIfTargetDead && Target is Character character && character.IsDead) + if (IgnoreIfTargetDead && Target is Character { IsDead: true }) { Priority = 0; Abandon = !isOrder; @@ -182,6 +207,17 @@ namespace Barotrauma if (DialogueIdentifier == null) { return; } if (!SpeakIfFails) { return; } if (SpeakCannotReachCondition != null && !SpeakCannotReachCondition()) { return; } + + if (TargetName == null && DialogueIdentifier == DialogCannotReachTarget) + { +#if DEBUG + DebugConsole.ThrowError( + $"Error in {nameof(SpeakCannotReach)}: "+ + $"attempted to use a dialog line that mentions the target (dialogue identifier: {DialogueIdentifier}), but the name of the target ({(Target?.ToString() ?? "null")}) isn't set."); +#endif + DialogueIdentifier = DialogCannotReachPlace; + } + LocalizedString msg = TargetName == null ? TextManager.Get(DialogueIdentifier) : TextManager.GetWithVariable(DialogueIdentifier, "[name]".ToIdentifier(), TargetName, formatCapitals: Target is Character ? FormatCapitals.No : FormatCapitals.Yes); @@ -342,34 +378,43 @@ namespace Barotrauma } } if (Abandon) { return; } - if (getDivingGearIfNeeded) + bool needsDivingSuit = (!isInside || hasOutdoorNodes) && !character.IsImmuneToPressure; + bool tryToGetDivingGear = needsDivingSuit || HumanAIController.NeedsDivingGear(targetHull, out needsDivingSuit); + bool tryToGetDivingSuit = needsDivingSuit; + Character followTarget = Target as Character; + if (Mimic && !character.IsImmuneToPressure) { - Character followTarget = Target as Character; - bool needsDivingSuit = (!isInside || hasOutdoorNodes) && !character.IsImmuneToPressure; - bool tryToGetDivingGear = needsDivingSuit || HumanAIController.NeedsDivingGear(targetHull, out needsDivingSuit); - bool tryToGetDivingSuit = needsDivingSuit; - if (Mimic && !character.IsImmuneToPressure) + if (HumanAIController.HasDivingSuit(followTarget)) { - if (HumanAIController.HasDivingSuit(followTarget)) - { - tryToGetDivingGear = true; - tryToGetDivingSuit = true; - } - else if (HumanAIController.HasDivingMask(followTarget) && character.CharacterHealth.OxygenLowResistance < 1) - { - tryToGetDivingGear = true; - } + tryToGetDivingGear = true; + tryToGetDivingSuit = true; } - bool needsEquipment = false; - float minOxygen = AIObjectiveFindDivingGear.GetMinOxygen(character); - if (tryToGetDivingSuit) + else if (HumanAIController.HasDivingMask(followTarget) && character.CharacterHealth.OxygenLowResistance < 1) { - needsEquipment = !HumanAIController.HasDivingSuit(character, minOxygen, requireSuitablePressureProtection: !objectiveManager.FailedToFindDivingGearForDepth); + tryToGetDivingGear = true; } - else if (tryToGetDivingGear) + } + bool needsEquipment = false; + float minOxygen = AIObjectiveFindDivingGear.GetMinOxygen(character); + if (tryToGetDivingSuit) + { + needsEquipment = !HumanAIController.HasDivingSuit(character, minOxygen, requireSuitablePressureProtection: !objectiveManager.FailedToFindDivingGearForDepth); + } + else if (tryToGetDivingGear) + { + needsEquipment = !HumanAIController.HasDivingGear(character, minOxygen); + } + if (!getDivingGearIfNeeded) + { + if (needsEquipment) { - needsEquipment = !HumanAIController.HasDivingGear(character, minOxygen); + // Don't try to reach the target without proper equipment. + Abandon = true; + return; } + } + else + { if (character.LockHands) { cantFindDivingGear = true; @@ -794,6 +839,11 @@ namespace Barotrauma // Going through a hatch return false; } + if (Target is Item targetItem && targetItem.GetComponent() == null) + { + // Targeting a static item, such as a reactor or a controller -> Don't complete, until we are no longer climbing. + return false; + } } } if (!AlwaysUseEuclideanDistance && !character.AnimController.InWater) @@ -893,5 +943,35 @@ namespace Barotrauma pathSteering.ResetPath(); } } + + public bool ShouldRun(bool run) + { + if (run && objectiveManager.ForcedOrder == this && IsWaitOrder && !character.IsOnPlayerTeam) + { + // NPCs with a wait order don't run. + run = false; + } + else if (Target != null) + { + if (character.CurrentHull == null) + { + run = Vector2.DistanceSquared(character.WorldPosition, Target.WorldPosition) > 300 * 300; + } + else + { + float yDiff = Target.WorldPosition.Y - character.WorldPosition.Y; + if (Math.Abs(yDiff) > 100) + { + run = true; + } + else + { + float xDiff = Target.WorldPosition.X - character.WorldPosition.X; + run = Math.Abs(xDiff) > 500; + } + } + } + return run; + } } } diff --git a/Barotrauma/BarotraumaShared/SharedSource/Characters/AI/Objectives/AIObjectiveIdle.cs b/Barotrauma/BarotraumaShared/SharedSource/Characters/AI/Objectives/AIObjectiveIdle.cs index 6f8df0133..efa495d6c 100644 --- a/Barotrauma/BarotraumaShared/SharedSource/Characters/AI/Objectives/AIObjectiveIdle.cs +++ b/Barotrauma/BarotraumaShared/SharedSource/Characters/AI/Objectives/AIObjectiveIdle.cs @@ -185,135 +185,162 @@ namespace Barotrauma IsForbidden(currentTarget) || (PathSteering.CurrentPath != null && PathSteering.CurrentPath.Nodes.Any(n => HumanAIController.UnsafeHulls.Contains(n.CurrentHull))); - if (behavior == BehaviorType.StayInHull && TargetHull != null && !IsForbidden(TargetHull) && !currentTargetIsInvalid && !HumanAIController.UnsafeHulls.Contains(TargetHull)) + if (behavior == BehaviorType.StayInHull && TargetHull != null && !currentTargetIsInvalid && !IsForbidden(TargetHull)) { - currentTarget = TargetHull; - bool stayInHull = character.CurrentHull == currentTarget && IsSteeringFinished() && !character.IsClimbing; - if (stayInHull) + if (HumanAIController.UnsafeHulls.Contains(TargetHull)) { - Wander(deltaTime); - } - else if (currentTarget != null) - { - PathSteering.SteeringSeek(character.GetRelativeSimPosition(currentTarget), weight: 1, nodeFilter: node => node.Waypoint.CurrentHull != null); + // Ask to refresh, because otherwise we can't get back to the hull. + HumanAIController.AskToRecalculateHullSafety(TargetHull); } else { - PathSteering.ResetPath(); - PathSteering.Reset(); + currentTarget = TargetHull; + NavigateTo(currentTarget); + return; } } + if (currentTarget != null && !currentTargetIsInvalid) + { + if (character.TeamID == CharacterTeamType.FriendlyNPC && !character.IsEscorted) + { + if (currentTarget.Submarine.TeamID != character.TeamID) + { + currentTargetIsInvalid = true; + } + } + else + { + if (currentTarget.Submarine != character.Submarine) + { + currentTargetIsInvalid = true; + } + } + } + + if (currentTargetIsInvalid || currentTarget == null || IsForbidden(character.CurrentHull) && IsSteeringFinished()) + { + if (newTargetTimer > timerMargin) + { + //don't reset to zero, otherwise the character will keep calling FindTargetHulls + //almost constantly when there's a small number of potential hulls to move to + SetTargetTimerLow(); + } + } + else if (character.IsClimbing) + { + if (currentTarget == null) + { + SetTargetTimerLow(); + } + else if (Math.Abs(character.AnimController.TargetMovement.Y) > 0.9f) + { + // Don't allow new targets when climbing straight up or down + SetTargetTimerHigh(); + } + } + else if (character.AnimController.InWater) + { + if (currentTarget == null) + { + SetTargetTimerLow(); + } + } + if (newTargetTimer <= 0.0f) + { + if (!searchingNewHull) + { + //find all available hulls first + searchingNewHull = true; + FindTargetHulls(); + } + else if (targetHulls.Any()) + { + //choose a random available hull + currentTarget = ToolBox.SelectWeightedRandom(targetHulls, hullWeights, Rand.RandSync.Unsynced); + bool isInWrongSub = (character.TeamID == CharacterTeamType.FriendlyNPC && !character.IsEscorted) && character.Submarine.TeamID != character.TeamID; + bool isCurrentHullAllowed = !isInWrongSub && !IsForbidden(character.CurrentHull); + Vector2 targetPos = character.GetRelativeSimPosition(currentTarget); + var path = PathSteering.PathFinder.FindPath(character.SimPosition, targetPos, character.Submarine, nodeFilter: node => + { + if (node.Waypoint.CurrentHull == null) { return false; } + // Check that there is no unsafe hulls on the way to the target + if (node.Waypoint.CurrentHull != character.CurrentHull && HumanAIController.UnsafeHulls.Contains(node.Waypoint.CurrentHull)) { return false; } + return true; + //don't stop at ladders when idling + }, endNodeFilter: node => node.Waypoint.Stairs == null && node.Waypoint.Ladders == null && (!isCurrentHullAllowed || !IsForbidden(node.Waypoint.CurrentHull))); + if (path.Unreachable) + { + //can't go to this room, remove it from the list and try another room + int index = targetHulls.IndexOf(currentTarget); + targetHulls.RemoveAt(index); + hullWeights.RemoveAt(index); + PathSteering.Reset(); + currentTarget = null; + SetTargetTimerLow(); + return; + } + character.AIController.SelectTarget(currentTarget.AiTarget); + PathSteering.SetPath(targetPos, path); + SetTargetTimerNormal(); + searchingNewHull = false; + } + else + { + // Couldn't find a valid hull + SetTargetTimerHigh(); + searchingNewHull = false; + } + } + newTargetTimer -= deltaTime; + if (currentTarget == null || PathSteering.CurrentPath == null) + { + Wander(deltaTime); + } else { - if (currentTarget != null && !currentTargetIsInvalid) + NavigateTo(currentTarget); + } + + void NavigateTo(Hull target) + { + bool isAtTarget = character.CurrentHull == target && IsSteeringFinished(); + if (isAtTarget) { - if (character.TeamID == CharacterTeamType.FriendlyNPC && !character.IsEscorted) + if (character.IsClimbing) { - if (currentTarget.Submarine.TeamID != character.TeamID) + StopMoving(); + if (character.AnimController.GetHeightFromFloor() < character.AnimController.ImpactTolerance / 2) { - currentTargetIsInvalid = true; + character.StopClimbing(); } } else { - if (currentTarget.Submarine != character.Submarine) - { - currentTargetIsInvalid = true; - } + Wander(deltaTime); } } - - if (currentTargetIsInvalid || currentTarget == null || IsForbidden(character.CurrentHull) && IsSteeringFinished()) + else if (target != null) { - if (newTargetTimer > timerMargin) - { - //don't reset to zero, otherwise the character will keep calling FindTargetHulls - //almost constantly when there's a small number of potential hulls to move to - SetTargetTimerLow(); - } - } - else if (character.IsClimbing) - { - if (currentTarget == null) - { - SetTargetTimerLow(); - } - else if (Math.Abs(character.AnimController.TargetMovement.Y) > 0.9f) - { - // Don't allow new targets when climbing straight up or down - SetTargetTimerHigh(); - } - } - else if (character.AnimController.InWater) - { - if (currentTarget == null) - { - SetTargetTimerLow(); - } - } - if (newTargetTimer <= 0.0f) - { - if (!searchingNewHull) - { - //find all available hulls first - searchingNewHull = true; - FindTargetHulls(); - } - else if (targetHulls.Any()) - { - //choose a random available hull - currentTarget = ToolBox.SelectWeightedRandom(targetHulls, hullWeights, Rand.RandSync.Unsynced); - bool isInWrongSub = (character.TeamID == CharacterTeamType.FriendlyNPC && !character.IsEscorted) && character.Submarine.TeamID != character.TeamID; - bool isCurrentHullAllowed = !isInWrongSub && !IsForbidden(character.CurrentHull); - Vector2 targetPos = character.GetRelativeSimPosition(currentTarget); - var path = PathSteering.PathFinder.FindPath(character.SimPosition, targetPos, character.Submarine, nodeFilter: node => - { - if (node.Waypoint.CurrentHull == null) { return false; } - // Check that there is no unsafe hulls on the way to the target - if (node.Waypoint.CurrentHull != character.CurrentHull && HumanAIController.UnsafeHulls.Contains(node.Waypoint.CurrentHull)) { return false; } - return true; - //don't stop at ladders when idling - }, endNodeFilter: node => node.Waypoint.Stairs == null && node.Waypoint.Ladders == null && (!isCurrentHullAllowed || !IsForbidden(node.Waypoint.CurrentHull))); - if (path.Unreachable) - { - //can't go to this room, remove it from the list and try another room - int index = targetHulls.IndexOf(currentTarget); - targetHulls.RemoveAt(index); - hullWeights.RemoveAt(index); - PathSteering.Reset(); - currentTarget = null; - SetTargetTimerLow(); - return; - } - character.AIController.SelectTarget(currentTarget.AiTarget); - PathSteering.SetPath(targetPos, path); - SetTargetTimerNormal(); - searchingNewHull = false; - } - else - { - // Couldn't find a valid hull - SetTargetTimerHigh(); - searchingNewHull = false; - } - } - newTargetTimer -= deltaTime; - if (!character.IsClimbing && (PathSteering == null || PathSteering.CurrentPath == null || IsSteeringFinished())) - { - Wander(deltaTime); - } - else if (currentTarget != null) - { - PathSteering.SteeringSeek(character.GetRelativeSimPosition(currentTarget), weight: 1, - nodeFilter: node => node.Waypoint.CurrentHull != null, - endNodeFilter: node => node.Waypoint.Ladders == null && node.Waypoint.Stairs == null); + PathTo(target); } else { - PathSteering.ResetPath(); - PathSteering.Reset(); + StopMoving(); } } + + void StopMoving() + { + SteeringManager.Reset(); + PathSteering.ResetPath(); + } + + void PathTo(ISpatialEntity target) + { + PathSteering.SteeringSeek(character.GetRelativeSimPosition(target), weight: 1, + nodeFilter: node => node.Waypoint.CurrentHull != null, + endNodeFilter: node => node.Waypoint.Ladders == null && node.Waypoint.Stairs == null); + } } public void Wander(float deltaTime) diff --git a/Barotrauma/BarotraumaShared/SharedSource/Characters/AI/Objectives/AIObjectiveLoadItem.cs b/Barotrauma/BarotraumaShared/SharedSource/Characters/AI/Objectives/AIObjectiveLoadItem.cs index 0b3b5c52e..2ebc14886 100644 --- a/Barotrauma/BarotraumaShared/SharedSource/Characters/AI/Objectives/AIObjectiveLoadItem.cs +++ b/Barotrauma/BarotraumaShared/SharedSource/Characters/AI/Objectives/AIObjectiveLoadItem.cs @@ -22,7 +22,7 @@ namespace Barotrauma private static Dictionary> AllValidContainableItemIdentifiers { get; } = new Dictionary>(); private int itemIndex; - private AIObjectiveDecontainItem decontainObjective; + private AIObjectiveMoveItem moveItemObjective; private readonly HashSet ignoredItems = new HashSet(); private Item targetItem; private readonly string abandonGetItemDialogueIdentifier = "dialogcannotfindloadable"; @@ -196,17 +196,17 @@ namespace Barotrauma float devotion = (CumulatedDevotion + (hasContainable ? 100 - MaxDevotion : 0)) / 100; float max = AIObjectiveManager.LowestOrderPriority - (hasContainable ? 1 : 2); Priority = MathHelper.Lerp(0, max, MathHelper.Clamp(devotion + (distanceFactor * PriorityModifier), 0, 1)); - if (decontainObjective != null && targetItem.Container != Container) + if (moveItemObjective != null && targetItem.Container != Container) { if (!IsValidContainable(targetItem)) { // Target is not valid anymore, abandon the objective - decontainObjective.Abandon = true; + moveItemObjective.Abandon = true; } else if (!ItemContainer.Inventory.CanBePut(targetItem) && ItemContainer.Inventory.AllItems.None(i => AIObjectiveLoadItems.ItemMatchesTargetCondition(i, TargetItemCondition))) { // The container is full and there's no item that should be removed, abandon the objective - decontainObjective.Abandon = true; + moveItemObjective.Abandon = true; } } if (ItemContainer.Inventory.IsFull()) @@ -257,26 +257,27 @@ namespace Barotrauma } else { - if(decontainObjective == null && !IsValidContainable(targetItem)) + if(moveItemObjective == null && !IsValidContainable(targetItem)) { IgnoreTargetItem(); Reset(); return; } - TryAddSubObjective(ref decontainObjective, - constructor: () => new AIObjectiveDecontainItem(character, targetItem, objectiveManager, targetContainer: ItemContainer, priorityModifier: PriorityModifier) + TryAddSubObjective(ref moveItemObjective, + constructor: () => new AIObjectiveMoveItem(character, targetItem, objectiveManager, targetContainer: ItemContainer, priorityModifier: PriorityModifier) { AbandonGetItemDialogueCondition = () => IsValidContainable(targetItem), AbandonGetItemDialogueIdentifier = abandonGetItemDialogueIdentifier, Equip = true, RemoveExistingWhenNecessary = true, RemoveExistingPredicate = (i) => !ValidContainableItemIdentifiers.Contains(i.Prefab.Identifier) || AIObjectiveLoadItems.ItemMatchesTargetCondition(i, TargetItemCondition), - RemoveExistingMax = 1 + RemoveExistingMax = 1, + AllowToFindDivingGear = objectiveManager.HasOrder() }, onCompleted: () => { IsCompleted = true; - RemoveSubObjective(ref decontainObjective); + RemoveSubObjective(ref moveItemObjective); }, onAbandon: () => { @@ -324,7 +325,7 @@ namespace Barotrauma { base.Reset(); // Don't reset the target item when resetting the objective because it affects priority calculations - decontainObjective = null; + moveItemObjective = null; itemIndex = 0; } diff --git a/Barotrauma/BarotraumaShared/SharedSource/Characters/AI/Objectives/AIObjectiveManager.cs b/Barotrauma/BarotraumaShared/SharedSource/Characters/AI/Objectives/AIObjectiveManager.cs index 764b79e96..f373e9e5e 100644 --- a/Barotrauma/BarotraumaShared/SharedSource/Characters/AI/Objectives/AIObjectiveManager.cs +++ b/Barotrauma/BarotraumaShared/SharedSource/Characters/AI/Objectives/AIObjectiveManager.cs @@ -35,7 +35,7 @@ namespace Barotrauma /// public const float HighestOrderPriority = 70; /// - /// Maximum priority of an order given to the character (rightmost order in the crew list) + /// Minimum priority of an order given to the character (rightmost order in the crew list) /// public const float LowestOrderPriority = 60; /// @@ -485,7 +485,7 @@ namespace Barotrauma IgnoreIfTargetDead = true, IsFollowOrder = true, Mimic = character.IsOnPlayerTeam, - DialogueIdentifier = "dialogcannotreachplace".ToIdentifier() + DialogueIdentifier = AIObjectiveGoTo.DialogCannotReachPlace }; break; case "wait": @@ -724,6 +724,11 @@ namespace Barotrauma /// public bool HasObjectiveOrOrder() where T : AIObjective => Objectives.Any(o => o is T) || HasOrder(); + /// + /// Returns the current objective or its currently active subobjective (first in chain), regadless of the type. + /// Note: Not recursive, and thus doesn't work for deeper hierarchy! + /// For seeking objectives of specific type and in a deep hierarchy, use or with looping objectives + /// public AIObjective GetActiveObjective() => CurrentObjective?.GetActiveObjective(); /// @@ -740,7 +745,8 @@ namespace Barotrauma /// Returns the last active objective of the specified objective type. /// Should generally be used to get the active objective (or subobjective) of objectives that don't sort their subobjectives by priority (see . /// - /// The last active objective of the specified type if found. + /// + /// The last active objective of the specified type if found. /// public T GetLastActiveObjective() where T : AIObjective => CurrentObjective?.GetSubObjectivesRecursive(includingSelf: true).LastOrDefault(so => so is T) as T; @@ -763,7 +769,12 @@ namespace Barotrauma if (CurrentObjective == null) { return Enumerable.Empty(); } return CurrentObjective.GetSubObjectivesRecursive(includingSelf: true).OfType(); } - + + /// + /// Is the current objective or any of its subobjectives of the given type? + /// Useful for checking whether the bot has a certain type of objective active in the hierarchy. + /// + /// False for objectives and orders that are not currently active. public bool HasActiveObjective() where T : AIObjective => CurrentObjective is T || CurrentObjective != null && CurrentObjective.GetSubObjectivesRecursive().Any(so => so is T); public bool IsOrder(AIObjective objective) diff --git a/Barotrauma/BarotraumaShared/SharedSource/Characters/AI/Objectives/AIObjectiveDecontainItem.cs b/Barotrauma/BarotraumaShared/SharedSource/Characters/AI/Objectives/AIObjectiveMoveItem.cs similarity index 74% rename from Barotrauma/BarotraumaShared/SharedSource/Characters/AI/Objectives/AIObjectiveDecontainItem.cs rename to Barotrauma/BarotraumaShared/SharedSource/Characters/AI/Objectives/AIObjectiveMoveItem.cs index 5a81839a8..3075be429 100644 --- a/Barotrauma/BarotraumaShared/SharedSource/Characters/AI/Objectives/AIObjectiveDecontainItem.cs +++ b/Barotrauma/BarotraumaShared/SharedSource/Characters/AI/Objectives/AIObjectiveMoveItem.cs @@ -6,9 +6,9 @@ using System.Linq; namespace Barotrauma { - class AIObjectiveDecontainItem : AIObjective + class AIObjectiveMoveItem : AIObjective { - public override Identifier Identifier { get; set; } = "decontain item".ToIdentifier(); + public override Identifier Identifier { get; set; } = "move item".ToIdentifier(); protected override bool AllowWhileHandcuffed => false; public Func GetItemPriority; @@ -47,8 +47,15 @@ namespace Barotrauma public int? RemoveExistingMax { get; set; } public string AbandonGetItemDialogueIdentifier { get; set; } public Func AbandonGetItemDialogueCondition { get; set; } + + /// + /// By default, finding diving gear is not allowed here, because it can cause unexpected behavior in most use cases. + /// E.g. bots equipping diving suits to clean up some items in flooded rooms. + /// Sometimes, at least when used in orders, we might want to allow this. See . + /// + public bool AllowToFindDivingGear { get; set; } - public AIObjectiveDecontainItem(Character character, Item targetItem, AIObjectiveManager objectiveManager, ItemContainer sourceContainer = null, ItemContainer targetContainer = null, float priorityModifier = 1) + public AIObjectiveMoveItem(Character character, Item targetItem, AIObjectiveManager objectiveManager, ItemContainer sourceContainer = null, ItemContainer targetContainer = null, float priorityModifier = 1) : base(character, objectiveManager, priorityModifier) { this.targetItem = targetItem; @@ -56,10 +63,10 @@ namespace Barotrauma this.targetContainer = targetContainer; } - public AIObjectiveDecontainItem(Character character, Identifier itemIdentifier, AIObjectiveManager objectiveManager, ItemContainer sourceContainer, ItemContainer targetContainer = null, float priorityModifier = 1) + public AIObjectiveMoveItem(Character character, Identifier itemIdentifier, AIObjectiveManager objectiveManager, ItemContainer sourceContainer, ItemContainer targetContainer = null, float priorityModifier = 1) : this(character, new Identifier[] { itemIdentifier }, objectiveManager, sourceContainer, targetContainer, priorityModifier) { } - public AIObjectiveDecontainItem(Character character, Identifier[] itemIdentifiers, AIObjectiveManager objectiveManager, ItemContainer sourceContainer, ItemContainer targetContainer = null, float priorityModifier = 1) + public AIObjectiveMoveItem(Character character, Identifier[] itemIdentifiers, AIObjectiveManager objectiveManager, ItemContainer sourceContainer, ItemContainer targetContainer = null, float priorityModifier = 1) : base(character, objectiveManager, priorityModifier) { this.itemIdentifiers = itemIdentifiers; @@ -75,16 +82,16 @@ namespace Barotrauma protected override void Act(float deltaTime) { - Item itemToDecontain = + Item itemToMove = targetItem ?? sourceContainer.Inventory.FindItem(i => itemIdentifiers.Any(id => i.Prefab.Identifier == id || i.HasTag(id) && !i.IgnoreByAI(character)), recursive: false); - if (itemToDecontain == null) + if (itemToMove == null) { Abandon = true; return; } - if (itemToDecontain.IgnoreByAI(character)) + if (itemToMove.IgnoreByAI(character)) { Abandon = true; return; @@ -96,19 +103,19 @@ namespace Barotrauma Abandon = true; return; } - if (itemToDecontain.Container != sourceContainer.Item) + if (itemToMove.Container != sourceContainer.Item) { - itemToDecontain.Drop(character); + itemToMove.Drop(character); IsCompleted = true; return; } } - else if (targetContainer.Inventory.Contains(itemToDecontain)) + else if (targetContainer.Inventory.Contains(itemToMove)) { IsCompleted = true; return; } - if (getItemObjective == null && !itemToDecontain.IsOwnedBy(character)) + if (getItemObjective == null && !itemToMove.IsOwnedBy(character)) { TryAddSubObjective(ref getItemObjective, constructor: () => new AIObjectiveGetItem(character, targetItem, objectiveManager, Equip) @@ -116,7 +123,8 @@ namespace Barotrauma CannotFindDialogueCondition = AbandonGetItemDialogueCondition, CannotFindDialogueIdentifierOverride = AbandonGetItemDialogueIdentifier, SpeakIfFails = AbandonGetItemDialogueIdentifier != null, - TakeWholeStack = this.TakeWholeStack + TakeWholeStack = TakeWholeStack, + AllowToFindDivingGear = AllowToFindDivingGear }, onAbandon: () => Abandon = true); return; @@ -124,7 +132,7 @@ namespace Barotrauma if (targetContainer != null) { TryAddSubObjective(ref containObjective, - constructor: () => new AIObjectiveContainItem(character, itemToDecontain, targetContainer, objectiveManager) + constructor: () => new AIObjectiveContainItem(character, itemToMove, targetContainer, objectiveManager) { MoveWholeStack = TakeWholeStack, Equip = Equip, @@ -133,14 +141,15 @@ namespace Barotrauma RemoveExistingPredicate = RemoveExistingPredicate, RemoveMax = RemoveExistingMax, GetItemPriority = GetItemPriority, - ignoredContainerIdentifiers = sourceContainer?.Item.Prefab.Identifier.ToEnumerable().ToImmutableHashSet() + ignoredContainerIdentifiers = sourceContainer?.Item.Prefab.Identifier.ToEnumerable().ToImmutableHashSet(), + AllowToFindDivingGear = AllowToFindDivingGear }, onCompleted: () => IsCompleted = true, onAbandon: () => Abandon = true); } else { - itemToDecontain.Drop(character); + itemToMove.Drop(character); IsCompleted = true; } } diff --git a/Barotrauma/BarotraumaShared/SharedSource/Characters/AI/Objectives/AIObjectiveOperateItem.cs b/Barotrauma/BarotraumaShared/SharedSource/Characters/AI/Objectives/AIObjectiveOperateItem.cs index c98721a8f..3c024b878 100644 --- a/Barotrauma/BarotraumaShared/SharedSource/Characters/AI/Objectives/AIObjectiveOperateItem.cs +++ b/Barotrauma/BarotraumaShared/SharedSource/Characters/AI/Objectives/AIObjectiveOperateItem.cs @@ -1,5 +1,5 @@ -using Barotrauma.Extensions; -using Barotrauma.Items.Components; +using Barotrauma.Items.Components; +using Barotrauma.Extensions; using Microsoft.Xna.Framework; using System; using System.Collections.Generic; @@ -89,6 +89,17 @@ namespace Barotrauma Priority = 0; return Priority; } + Hull targetHull = targetItem.CurrentHull; + if (HumanAIController.UnsafeHulls.Contains(targetHull)) + { + // Ignore the objective, if the target hull is dangerous. + Priority = 0; + if (isOrder && this == objectiveManager.CurrentObjective && character.IsOnPlayerTeam) + { + character.Speak(TextManager.GetWithVariable("dialogoperatetargetroomisunsafe", "[item]", targetItem.Name).Value, delay: 1.0f, identifier: "dialogoperatetargetroomisunsafe".ToIdentifier(), minDurationBetweenSimilar: 5.0f); + } + return Priority; + } var reactor = component.Item.GetComponent(); if (reactor != null) { @@ -137,10 +148,8 @@ namespace Barotrauma } if (targetItem.CurrentHull == null || targetItem.Submarine != character.Submarine && !isOrder || - targetItem.CurrentHull.FireSources.Any() || IsItemOperatedByAnother(target) || - Character.CharacterList.Any(c => c.CurrentHull == targetItem.CurrentHull && !HumanAIController.IsFriendly(c) && HumanAIController.IsActive(c)) - || component.Item.IgnoreByAI(character) || useController && controller.Item.IgnoreByAI(character)) + component.Item.IgnoreByAI(character) || useController && controller.Item.IgnoreByAI(character)) { Priority = 0; } @@ -246,6 +255,7 @@ namespace Barotrauma { TryAddSubObjective(ref goToObjective, () => new AIObjectiveGoTo(target.Item, character, objectiveManager, closeEnough: 50) { + DialogueIdentifier = AIObjectiveGoTo.DialogCannotReachTarget, TargetName = target.Item.Name, endNodeFilter = EndNodeFilter ?? AIObjectiveGetItem.CreateEndNodeFilter(target.Item) }, diff --git a/Barotrauma/BarotraumaShared/SharedSource/Characters/AI/Objectives/AIObjectiveRepairItem.cs b/Barotrauma/BarotraumaShared/SharedSource/Characters/AI/Objectives/AIObjectiveRepairItem.cs index 58d30395c..b5b6efdd2 100644 --- a/Barotrauma/BarotraumaShared/SharedSource/Characters/AI/Objectives/AIObjectiveRepairItem.cs +++ b/Barotrauma/BarotraumaShared/SharedSource/Characters/AI/Objectives/AIObjectiveRepairItem.cs @@ -234,6 +234,7 @@ namespace Barotrauma { var objective = new AIObjectiveGoTo(Item, character, objectiveManager) { + DialogueIdentifier = AIObjectiveGoTo.DialogCannotReachTarget, TargetName = Item.Name, SpeakCannotReachCondition = () => isPriority }; diff --git a/Barotrauma/BarotraumaShared/SharedSource/Characters/AI/Objectives/AIObjectiveRescue.cs b/Barotrauma/BarotraumaShared/SharedSource/Characters/AI/Objectives/AIObjectiveRescue.cs index da75de89f..e5242161d 100644 --- a/Barotrauma/BarotraumaShared/SharedSource/Characters/AI/Objectives/AIObjectiveRescue.cs +++ b/Barotrauma/BarotraumaShared/SharedSource/Characters/AI/Objectives/AIObjectiveRescue.cs @@ -161,7 +161,7 @@ namespace Barotrauma TryAddSubObjective(ref goToObjective, () => new AIObjectiveGoTo(Target, character, objectiveManager) { CloseEnough = CloseEnoughToTreat, - DialogueIdentifier = "dialogcannotreachpatient".ToIdentifier(), + DialogueIdentifier = AIObjectiveGoTo.DialogCannotReachPatient, TargetName = Target.DisplayName }, onCompleted: () => RemoveSubObjective(ref goToObjective), @@ -197,13 +197,16 @@ namespace Barotrauma { RemoveSubObjective(ref replaceOxygenObjective); RemoveSubObjective(ref goToObjective); - TryAddSubObjective(ref goToObjective, () => new AIObjectiveGoTo(safeHull, character, objectiveManager), - onCompleted: () => RemoveSubObjective(ref goToObjective), - onAbandon: () => - { - RemoveSubObjective(ref goToObjective); - safeHull = character.CurrentHull; - }); + TryAddSubObjective(ref goToObjective, () => new AIObjectiveGoTo(safeHull, character, objectiveManager) + { + DialogueIdentifier = AIObjectiveGoTo.DialogCannotReachPlace + }, + onCompleted: () => RemoveSubObjective(ref goToObjective), + onAbandon: () => + { + RemoveSubObjective(ref goToObjective); + safeHull = character.CurrentHull; + }); } } } @@ -221,7 +224,7 @@ namespace Barotrauma TryAddSubObjective(ref goToObjective, () => new AIObjectiveGoTo(Target, character, objectiveManager) { CloseEnough = CloseEnoughToTreat, - DialogueIdentifier = "dialogcannotreachpatient".ToIdentifier(), + DialogueIdentifier = AIObjectiveGoTo.DialogCannotReachPatient, TargetName = Target.DisplayName }, onCompleted: () => RemoveSubObjective(ref goToObjective), diff --git a/Barotrauma/BarotraumaShared/SharedSource/Characters/AI/Objectives/AIObjectiveReturn.cs b/Barotrauma/BarotraumaShared/SharedSource/Characters/AI/Objectives/AIObjectiveReturn.cs index 749515fce..ed6bdb017 100644 --- a/Barotrauma/BarotraumaShared/SharedSource/Characters/AI/Objectives/AIObjectiveReturn.cs +++ b/Barotrauma/BarotraumaShared/SharedSource/Characters/AI/Objectives/AIObjectiveReturn.cs @@ -1,4 +1,4 @@ -using Barotrauma.Extensions; +using Barotrauma.Extensions; using Microsoft.Xna.Framework; using System.Collections.Generic; @@ -20,7 +20,7 @@ namespace Barotrauma Target = GetReturnTarget(Submarine.MainSubs) ?? GetReturnTarget(Submarine.Loaded); if (Target == null) { - if (GameMain.GameSession.GameMode is not TestGameMode) + if (GameMain.GameSession?.GameMode is not TestGameMode) { DebugConsole.AddWarning($"({character.DisplayName}) No suitable return target found. Cannot return back to the main sub."); } diff --git a/Barotrauma/BarotraumaShared/SharedSource/Characters/AI/PathFinder.cs b/Barotrauma/BarotraumaShared/SharedSource/Characters/AI/PathFinder.cs index 338568707..2b43cbefb 100644 --- a/Barotrauma/BarotraumaShared/SharedSource/Characters/AI/PathFinder.cs +++ b/Barotrauma/BarotraumaShared/SharedSource/Characters/AI/PathFinder.cs @@ -170,7 +170,7 @@ namespace Barotrauma private readonly List sortedNodes; - public SteeringPath FindPath(Vector2 start, Vector2 end, Submarine hostSub = null, string errorMsgStr = null, float minGapSize = 0, Func startNodeFilter = null, Func endNodeFilter = null, Func nodeFilter = null, bool checkVisibility = true) + public SteeringPath FindPath(Vector2 start, Vector2 end, Submarine hostSub = null, string errorMsgStr = null, float minGapSize = 0, Func startNodeFilter = null, Func endNodeFilter = null, Func nodeFilter = null, bool checkVisibility = true, float outsideNodePenalty = 0) { foreach (PathNode node in nodes) { @@ -325,7 +325,12 @@ namespace Barotrauma #endif return new SteeringPath(true); } - return FindPath(startNode, endNode, nodeFilter, errorMsgStr, minGapSize); + float outsideNodeCostPenalty = outsideNodePenalty; + if (ApplyPenaltyToOutsideNodes) + { + outsideNodeCostPenalty += 100; + } + return FindPath(startNode, endNode, nodeFilter, errorMsgStr, minGapSize, outsideNodeCostPenalty); bool IsValidStartNode(PathNode node) => IsValidNode(node, (isCharacter, start), startNodeFilter); @@ -356,7 +361,7 @@ namespace Barotrauma } } - private SteeringPath FindPath(PathNode start, PathNode end, Func filter = null, string errorMsgStr = "", float minGapSize = 0) + private SteeringPath FindPath(PathNode start, PathNode end, Func filter = null, string errorMsgStr = "", float minGapSize = 0f, float outsideNodePenalty = 0f) { if (start == end) { @@ -398,50 +403,65 @@ namespace Barotrauma for (int i = 0; i < currNode.connections.Count; i++) { PathNode nextNode = currNode.connections[i]; - - //a node that hasn't been searched yet - if (nextNode.state == 0) - { - nextNode.H = Vector2.Distance(nextNode.Position, end.Position); - float penalty = 0.0f; - if (GetNodePenalty != null) + switch (nextNode.state) + { + //a node that hasn't been searched yet + case 0: { - float? nodePenalty = GetNodePenalty(currNode, nextNode); - if (nodePenalty == null) + nextNode.H = Vector2.Distance(nextNode.Position, end.Position); + float cost = CalculateNodeCost(); + if (cost < float.PositiveInfinity) { - nextNode.state = -1; - continue; + nextNode.G = cost; + nextNode.F = nextNode.G + nextNode.H; + nextNode.Parent = currNode; + nextNode.state = 1; } - penalty = nodePenalty.Value; + else + { + // Set searched and invalid. + nextNode.state = -1; + } + break; + } + //node that has been searched + case 1 or -1: + { + float tempG = CalculateNodeCost(); + //only use if this new route is better than the + //route the node was a part of + if (tempG < nextNode.G) + { + nextNode.G = tempG; + nextNode.F = nextNode.G + nextNode.H; + nextNode.Parent = currNode; + nextNode.state = 1; + } + break; } - - nextNode.G = currNode.G + currNode.distances[i] + penalty; - nextNode.F = nextNode.G + nextNode.H; - nextNode.Parent = currNode; - nextNode.state = 1; } - //node that has been searched - else if (nextNode.state == 1 || nextNode.state == -1) + + float CalculateNodeCost() { - float tempG = currNode.G + currNode.distances[i]; - + float penalty = 0f; if (GetNodePenalty != null) { float? nodePenalty = GetNodePenalty(currNode, nextNode); - if (nodePenalty == null) { continue; } - tempG += nodePenalty.Value; + if (nodePenalty.HasValue) + { + penalty += nodePenalty.Value; + } + else + { + return float.PositiveInfinity; + } } - - //only use if this new route is better than the - //route the node was a part of - if (tempG < nextNode.G) + if (currNode.Waypoint.CurrentHull == null) { - nextNode.G = tempG; - nextNode.F = nextNode.G + nextNode.H; - nextNode.Parent = currNode; - nextNode.state = 1; + penalty += outsideNodePenalty; } + return currNode.G + currNode.distances[i] + penalty; } } } diff --git a/Barotrauma/BarotraumaShared/SharedSource/Characters/AI/PetBehavior.cs b/Barotrauma/BarotraumaShared/SharedSource/Characters/AI/PetBehavior.cs index 6484ee5cb..db7b774b7 100644 --- a/Barotrauma/BarotraumaShared/SharedSource/Characters/AI/PetBehavior.cs +++ b/Barotrauma/BarotraumaShared/SharedSource/Characters/AI/PetBehavior.cs @@ -1,4 +1,4 @@ -using Barotrauma.Extensions; +using Barotrauma.Extensions; using Barotrauma.Items.Components; using Barotrauma.Networking; using Microsoft.Xna.Framework; @@ -160,7 +160,8 @@ namespace Barotrauma aggregate += Items[i].Commonness; if (aggregate >= r && Items[i].Prefab != null) { - GameAnalyticsManager.AddDesignEvent("MicroInteraction:" + (GameMain.GameSession?.GameMode?.Preset.Identifier.Value ?? "null") + ":PetProducedItem:" + pet.AIController.Character.SpeciesName + ":" + Items[i].Prefab.Identifier); + //disabled to reduce the amount of data we collect through GA + //GameAnalyticsManager.AddDesignEvent("MicroInteraction:" + (GameMain.GameSession?.GameMode?.Preset.Identifier.Value ?? "null") + ":PetProducedItem:" + pet.AIController.Character.SpeciesName + ":" + Items[i].Prefab.Identifier); Entity.Spawner?.AddItemToSpawnQueue(Items[i].Prefab, pet.AIController.Character.WorldPosition); break; } @@ -293,10 +294,15 @@ namespace Barotrauma return false; } + public bool CanPlayWith(Character player) + { + return AIController.Character.IsOnFriendlyTeam(player); + } + public void Play(Character player) { if (PlayTimer > 0.0f) { return; } - if (!AIController.Character.IsFriendly(player)) { return; } + if (!CanPlayWith(player)) { return; } if (ToggleOwner) { Owner = Owner == player ? null : player; diff --git a/Barotrauma/BarotraumaShared/SharedSource/Characters/Animation/AnimController.cs b/Barotrauma/BarotraumaShared/SharedSource/Characters/Animation/AnimController.cs index 723963e50..9cfaaf315 100644 --- a/Barotrauma/BarotraumaShared/SharedSource/Characters/Animation/AnimController.cs +++ b/Barotrauma/BarotraumaShared/SharedSource/Characters/Animation/AnimController.cs @@ -608,7 +608,7 @@ namespace Barotrauma { if (!character.Inventory.IsInLimbSlot(item, i == 0 ? InvSlotType.RightHand : InvSlotType.LeftHand)) { continue; } #if DEBUG - if (handlePos[i].LengthSquared() > ArmLength) + if (ArmLength > 0 && handlePos[i].LengthSquared() > ArmLength) { DebugConsole.AddWarning($"Aim position for the item {item.Name} may be incorrect (further than the length of the character's arm)", item.Prefab.ContentPackage); @@ -1041,8 +1041,16 @@ namespace Barotrauma Limb rightHand = GetLimb(LimbType.RightHand); if (rightHand == null) { return; } - rightShoulder = GetJointBetweenLimbs(LimbType.Torso, LimbType.RightArm) ?? GetJointBetweenLimbs(LimbType.Head, LimbType.RightArm) ?? GetJoint(LimbType.RightArm, new LimbType[] { LimbType.RightHand, LimbType.RightForearm }); - leftShoulder = GetJointBetweenLimbs(LimbType.Torso, LimbType.LeftArm) ?? GetJointBetweenLimbs(LimbType.Head, LimbType.LeftArm) ?? GetJoint(LimbType.LeftArm, new LimbType[] { LimbType.LeftHand, LimbType.LeftForearm }); + rightShoulder = + GetJointBetweenLimbs(LimbType.Torso, LimbType.RightArm) ?? + GetJointBetweenLimbs(LimbType.Head, LimbType.RightArm) ?? + GetJoint(LimbType.RightArm, new LimbType[] { LimbType.RightHand, LimbType.RightForearm }) ?? + GetJointBetweenLimbs(LimbType.Torso, LimbType.RightHand); + leftShoulder = + GetJointBetweenLimbs(LimbType.Torso, LimbType.LeftArm) ?? + GetJointBetweenLimbs(LimbType.Head, LimbType.LeftArm) ?? + GetJoint(LimbType.LeftArm, new LimbType[] { LimbType.LeftHand, LimbType.LeftForearm }) ?? + GetJointBetweenLimbs(LimbType.Torso, LimbType.LeftHand); Vector2 localAnchorShoulder = Vector2.Zero; Vector2 localAnchorElbow = Vector2.Zero; diff --git a/Barotrauma/BarotraumaShared/SharedSource/Characters/Animation/HumanoidAnimController.cs b/Barotrauma/BarotraumaShared/SharedSource/Characters/Animation/HumanoidAnimController.cs index 6669a0262..5c5fde8a8 100644 --- a/Barotrauma/BarotraumaShared/SharedSource/Characters/Animation/HumanoidAnimController.cs +++ b/Barotrauma/BarotraumaShared/SharedSource/Characters/Animation/HumanoidAnimController.cs @@ -1561,7 +1561,7 @@ namespace Barotrauma string errorMsg = $"Attempted to move the anchor B of a limb's pull joint extremely far from the limb in {nameof(DragCharacter)}. " + $"Character in sub: {character.Submarine != null}, target in sub: {target.Submarine != null}."; - GameAnalyticsManager.AddErrorEventOnce("DragCharacter:PullJointTooFar", GameAnalyticsManager.ErrorSeverity.Error, errorMsg); + GameAnalyticsManager.AddErrorEventOnce("DragCharacter:PullJointTooFar", GameAnalyticsManager.ErrorSeverity.Warning, errorMsg); #if DEBUG DebugConsole.ThrowError(errorMsg); #endif diff --git a/Barotrauma/BarotraumaShared/SharedSource/Characters/Animation/Ragdoll.cs b/Barotrauma/BarotraumaShared/SharedSource/Characters/Animation/Ragdoll.cs index a28f2d25c..9c899d04b 100644 --- a/Barotrauma/BarotraumaShared/SharedSource/Characters/Animation/Ragdoll.cs +++ b/Barotrauma/BarotraumaShared/SharedSource/Characters/Animation/Ragdoll.cs @@ -831,9 +831,10 @@ namespace Barotrauma { if (character.DisableImpactDamageTimer > 0.0f) { return; } - if (f2.Body?.UserData is Item) + if (f2.Body?.UserData is Item && + f2.Body.BodyType != BodyType.Static) { - //no impact damage from items + //no impact damage from items with a non-static body //items that can impact characters (melee weapons, projectiles) should handle the damage themselves return; } @@ -1092,7 +1093,7 @@ namespace Barotrauma Vector2 moveDir = hullDiff.LengthSquared() < 0.001f ? Vector2.UnitY : Vector2.Normalize(hullDiff); //find a position 32 units away from the hull - if (MathUtils.GetLineRectangleIntersection( + if (MathUtils.GetLineWorldRectangleIntersection( newHull.WorldPosition, newHull.WorldPosition + moveDir * Math.Max(newHull.Rect.Width, newHull.Rect.Height), new Rectangle(newHull.WorldRect.X - 32, newHull.WorldRect.Y + 32, newHull.WorldRect.Width + 64, newHull.Rect.Height + 64), diff --git a/Barotrauma/BarotraumaShared/SharedSource/Characters/Attack.cs b/Barotrauma/BarotraumaShared/SharedSource/Characters/Attack.cs index d7fa0e0de..899984f8c 100644 --- a/Barotrauma/BarotraumaShared/SharedSource/Characters/Attack.cs +++ b/Barotrauma/BarotraumaShared/SharedSource/Characters/Attack.cs @@ -225,7 +225,12 @@ namespace Barotrauma public float ImpactMultiplier { get; set; } = 1; [Serialize(0.0f, IsPropertySaveable.Yes, description: "How much damage the attack does to level walls."), Editable(MinValueFloat = 0.0f, MaxValueFloat = 1000.0f)] - public float LevelWallDamage { get; set; } + public float LevelWallDamage + { + get => _levelWallDamage * DamageMultiplier; + set => _levelWallDamage = value; + } + private float _levelWallDamage; [Serialize(false, IsPropertySaveable.Yes, description: "Sets whether or not the attack is ranged or not."), Editable] public bool Ranged { get; set; } @@ -439,10 +444,9 @@ namespace Barotrauma } //if level wall damage is not defined, default to the structure damage - if (element.GetAttribute("LevelWallDamage") == null && - element.GetAttribute("levelwalldamage") == null) + if (element.GetAttribute("LevelWallDamage") == null) { - LevelWallDamage = StructureDamage; + LevelWallDamage = _structureDamage; } InitProjSpecific(element); diff --git a/Barotrauma/BarotraumaShared/SharedSource/Characters/Character.cs b/Barotrauma/BarotraumaShared/SharedSource/Characters/Character.cs index db424ffb8..930a18961 100644 --- a/Barotrauma/BarotraumaShared/SharedSource/Characters/Character.cs +++ b/Barotrauma/BarotraumaShared/SharedSource/Characters/Character.cs @@ -147,13 +147,23 @@ namespace Barotrauma public bool IsRemotePlayer { get; set; } public bool IsLocalPlayer => Controlled == this; - public bool IsPlayer => Controlled == this || IsRemotePlayer; + + public bool IsPlayer => IsLocalPlayer || IsRemotePlayer; /// /// Is the character player or does it have an active ship command manager (an AI controlled sub)? Bots in the player team are not treated as commanders. /// public bool IsCommanding => IsPlayer || AIController is HumanAIController { ShipCommandManager.Active: true }; + + /// + /// Is the character actively controlled by a human AI? + /// public bool IsBot => !IsPlayer && AIController is HumanAIController { Enabled: true }; + + /// + /// Is the character actively controlled by an AI? + /// + public bool IsAIControlled => !IsPlayer && AIController is { Enabled: true }; public bool IsEscorted { get; set; } public Identifier JobIdentifier => Info?.Job?.Prefab.Identifier ?? Identifier.Empty; @@ -381,11 +391,11 @@ namespace Barotrauma public bool IsOnPlayerTeam => teamID == CharacterTeamType.Team1 || - (teamID == CharacterTeamType.Team2 && !IsFriendlyNPCTurnedHostile); + (teamID == CharacterTeamType.Team2 && !IsFriendlyNPCTurnedHostile); // Some events use Team2 as a hostile team for NPCs, so we shouldn't treat those to be in any player team. Normally Team2 means a player team in PvP. - public bool IsOriginallyOnPlayerTeam => originalTeamID == CharacterTeamType.Team1 || originalTeamID == CharacterTeamType.Team2; + public bool IsOriginallyOnPlayerTeam => originalTeamID is CharacterTeamType.Team1 or CharacterTeamType.Team2; - public bool IsFriendlyNPCTurnedHostile => originalTeamID == CharacterTeamType.FriendlyNPC && (teamID == CharacterTeamType.Team2 || teamID == CharacterTeamType.None); + public bool IsFriendlyNPCTurnedHostile => originalTeamID == CharacterTeamType.FriendlyNPC && teamID is CharacterTeamType.Team2 or CharacterTeamType.None; public bool IsInstigator => CombatAction is { IsInstigator: true }; @@ -398,6 +408,12 @@ namespace Barotrauma /// public bool IsCriminal; + /// + /// A flag for the guards to remember that the character has used weapons or tools offensively, so that they know to confiscate those. + /// Intentionally not set from stealing or fleeing. + /// + public bool IsActingOffensively; + /// /// Set true only, if the character is turned hostile from an escort mission (See ). /// @@ -752,11 +768,24 @@ namespace Barotrauma set { if (value == selectedCharacter) { return; } - if (selectedCharacter != null) { selectedCharacter.selectedBy = null; } + if (selectedCharacter != null) { selectedCharacter.selectedBy = null; } selectedCharacter = value; - if (selectedCharacter != null) {selectedCharacter.selectedBy = this; } + if (selectedCharacter != null) { selectedCharacter.selectedBy = this; } #if CLIENT CharacterHealth.SetHealthBarVisibility(value == null); + + if (IsLocalPlayer && !GUI.IsUltrawide && GUI.IsHUDScaled) + { + if (value != null) + { + // Scaled HUD on non-ultra-wide -> hide the chatbox, so that it doesn't overlap with the inventory. + ChatBox.AutoHideChatBox(); + } + else + { + ChatBox.ResetChatBoxOpenState(); + } + } #endif bool isServerOrSingleplayer = GameMain.IsSingleplayer || GameMain.NetworkMember is { IsServer: true }; CheckTalents(AbilityEffectType.OnLootCharacter, new AbilityCharacterLoot(value)); @@ -937,10 +966,7 @@ namespace Barotrauma get { return IsHuman && HasEquippedItem(Tags.HandLockerItem); } } - public bool IsPet - { - get { return AIController is EnemyAIController enemyController && enemyController.PetBehavior != null; } - } + public bool IsPet => Params.IsPet; public float Oxygen { @@ -1085,18 +1111,18 @@ namespace Barotrauma } #if CLIENT HintManager.OnSetSelectedItem(this, prevSelectedItem, _selectedItem); - if (Controlled == this) + if (IsLocalPlayer) { _selectedItem?.GetComponent()?.RefreshSelectedItem(); - if (_selectedItem == null) - { - GameMain.GameSession?.CrewManager?.ResetCrewList(); - } - else if (!_selectedItem.IsLadder) + if (_selectedItem != null) { GameMain.GameSession?.CrewManager?.AutoHideCrewList(); } + else + { + GameMain.GameSession?.CrewManager?.ResetCrewListOpenState(); + } _selectedItem?.GetComponent()?.OnViewUpdateProjSpecific(); } @@ -1199,7 +1225,7 @@ namespace Barotrauma { get { - return (SelectedItem == null || SelectedItem.GetComponent() is { AllowAiming: true }) && !IsKnockedDown && (!IsRagdolled || AnimController.IsHoldingToRope); + return (SelectedItem == null || SelectedItem.GetComponent() is { AllowAiming: true }) && !IsKnockedDownOrRagdolled && (!IsRagdolled || AnimController.IsHoldingToRope); } } @@ -1430,12 +1456,9 @@ namespace Barotrauma //no longer a new hire after spawning (only displayed as a new hire at the end of the outpost round, when the character hasn't spawned yet) Info.IsNewHire = false; } - if (characterInfo?.HumanPrefabIds is { } prefabIds && - prefabIds.NpcSetIdentifier != default && prefabIds.NpcIdentifier != default) + if (characterInfo?.HumanPrefabIds is { NpcSetIdentifier.IsEmpty: false, NpcIdentifier.IsEmpty: false }) { - HumanPrefab = NPCSet.Get( - characterInfo.HumanPrefabIds.NpcSetIdentifier, - characterInfo.HumanPrefabIds.NpcIdentifier); + HumanPrefab = characterInfo.HumanPrefab; } keys = new Key[Enum.GetNames(typeof(InputType)).Length]; @@ -1831,12 +1854,12 @@ namespace Barotrauma if (info == null) { return; } if (info.HumanPrefabIds != default) { - var humanPrefab = NPCSet.Get(info.HumanPrefabIds.NpcSetIdentifier, info.HumanPrefabIds.NpcIdentifier); - if (humanPrefab == null) + var prefab = info.HumanPrefab; + if (prefab == null) { DebugConsole.ThrowError($"Failed to give job items for the character \"{Name}\" - could not find human prefab with the id \"{info.HumanPrefabIds.NpcIdentifier}\" from \"{info.HumanPrefabIds.NpcSetIdentifier}\"."); } - else if (humanPrefab.GiveItems(this, spawnPoint?.Submarine ?? Submarine, spawnPoint)) + else if (prefab.GiveItems(this, spawnPoint?.Submarine ?? Submarine, spawnPoint)) { return; } @@ -1905,23 +1928,9 @@ namespace Barotrauma if (skillIdentifier != null) { - foreach (Item item in Inventory.AllItems) + if (wearableSkillModifiers.TryGetValue(skillIdentifier, out float skillValue)) { - if (item?.GetComponent() is Wearable wearable && - !Inventory.IsInLimbSlot(item, InvSlotType.Any)) - { - foreach (var allowedSlot in wearable.AllowedSlots) - { - if (allowedSlot == InvSlotType.Any) { continue; } - if (!Inventory.IsInLimbSlot(item, allowedSlot)) { continue; } - if (wearable.SkillModifiers.TryGetValue(skillIdentifier, out float skillValue)) - { - skillLevel += skillValue; - break; - } - } - - } + skillLevel += skillValue; } } @@ -2667,7 +2676,7 @@ namespace Barotrauma public bool CanBeDraggedBy(Character character) { if (!IsDraggable) { return false; } - return IsKnockedDown || LockHands || (IsPet && character.IsFriendly(this)) || (IsBot && character.TeamID == TeamID); + return IsKnockedDownOrRagdolled || LockHands || (IsPet && character.IsOnFriendlyTeam(this)) || (IsBot && character.TeamID == TeamID); } /// @@ -3051,12 +3060,7 @@ namespace Barotrauma public void DoInteractionUpdate(float deltaTime, Vector2 mouseSimPos) { - bool isLocalPlayer = Controlled == this; - - if (!isLocalPlayer && (this is AICharacter && !IsRemotePlayer)) - { - return; - } + if (IsAIControlled) { return; } if (DisableInteract) { @@ -3077,7 +3081,7 @@ namespace Barotrauma } #if CLIENT - if (isLocalPlayer) + if (IsLocalPlayer) { if (!IsMouseOnUI && (ViewTarget == null || ViewTarget == this) && !DisableFocusingOnEntities) { @@ -3597,23 +3601,25 @@ namespace Barotrauma humanAnimController.Crouching = false; } //ragdolling manually makes the character go through platforms - //EXCEPT for clients, they rely on the server telling whether platforms should be ignored or not - if (IsRagdolled && GameMain.NetworkMember is not { IsClient: true }) + //EXCEPT if the character is controlled by the server (i.e. remote player or bot), + //in that case the server decides whether platforms should be ignored or not + bool isControlledByRemotelyByServer = GameMain.NetworkMember is { IsClient: true } && IsRemotelyControlled; + if (IsRagdolled && + !isControlledByRemotelyByServer) { AnimController.IgnorePlatforms = true; } AnimController.ResetPullJoints(); SelectedItem = SelectedSecondaryItem = null; + SelectedCharacter = null; return; } //AI and control stuff Control(deltaTime, cam); - - bool isNotControlled = Controlled != this; - - if (isNotControlled && (!(this is AICharacter) || IsRemotePlayer)) + + if (IsRemotePlayer) { Vector2 mouseSimPos = ConvertUnits.ToSimUnits(cursorPosition); DoInteractionUpdate(deltaTime, mouseSimPos); @@ -3880,7 +3886,9 @@ namespace Barotrauma //don't spawn duffel bags in PvP modes that include respawning, because it can lead to a ton of accumulated items in the sub/outpost bool pvpWithRespawning = GameMain.GameSession?.GameMode is PvPMode && GameMain.NetworkMember?.RespawnManager != null; - if (!despawnContainerId.IsEmpty && !pvpWithRespawning) + if (!despawnContainerId.IsEmpty && !pvpWithRespawning && + //don't duffelbag disconnected character's items (the items should disappear with the character, and reappear if they rejoin later) + CauseOfDeath?.Type != CauseOfDeathType.Disconnected) { var containerPrefab = MapEntityPrefab.FindByIdentifier(despawnContainerId) as ItemPrefab ?? @@ -3928,6 +3936,16 @@ namespace Barotrauma } else { +#if SERVER + if (CauseOfDeath?.Type == CauseOfDeathType.Disconnected) + { + if (GameMain.GameSession?.GameMode is MultiPlayerCampaign mpCampaign) + { + //refresh campaign data so the disconnected player gets to keep the items that were in their inventory + mpCampaign.RefreshCharacterCampaignData(this, refreshHealthData: false); + } + } +#endif Spawner.AddEntityToRemoveQueue(this); } } @@ -4720,17 +4738,24 @@ namespace Barotrauma healer.Info?.ApplySkillGain(Tags.MedicalItem, medicalGain * SkillSettings.Current.SkillIncreasePerFriendlyHealed); } } - + + public bool IsKnockedDownOrRagdolled => (IsRagdolled && !AnimController.IsHangingWithRope) || IsKnockedDown; + /// /// Is the character knocked down regardless whether the technical state is dead, unconcious, paralyzed, or stunned. - /// With stunning, the parameter uses an one second delay before the character is treated as knocked down. The purpose of this is to ignore minor stunning. If you don't want to to ignore any stun, use the Stun property. + /// With stunning, the parameter uses a one-second delay before the character is treated as knocked down. The purpose of this is to ignore minor stunning. If you don't want to to ignore any stun, use the Stun property. /// - public bool IsKnockedDown => (IsRagdolled && !AnimController.IsHangingWithRope) || CharacterHealth.StunTimer > 1.0f || IsIncapacitated; + public bool IsKnockedDown => CharacterHealth.StunTimer > 1.0f || IsIncapacitated; public void SetStun(float newStun, bool allowStunDecrease = false, bool isNetworkMessage = false) { if (GameMain.NetworkMember != null && GameMain.NetworkMember.IsClient && !isNetworkMessage) { return; } if (Screen.Selected != GameMain.GameScreen) { return; } + if (GodMode) + { + CharacterHealth.Stun = 0; + return; + } if (newStun > 0 && Params.Health.StunImmunity) { if (EmpVulnerability <= 0 || CharacterHealth.GetAfflictionStrengthByType(AfflictionPrefab.EMPType, allowLimbAfflictions: false) <= 0) @@ -5008,6 +5033,8 @@ namespace Barotrauma { characterInfo.PermanentlyDead = true; } + + GameMain.GameSession.RefreshAnyOpenPlayerInfo(); #endif #if SERVER @@ -5016,8 +5043,10 @@ namespace Barotrauma Info.LastRewardDistribution = Option.Some(Wallet.RewardDistribution); } #endif - - if (GameAnalyticsManager.SendUserStatistics && Prefab?.ContentPackage == ContentPackageManager.VanillaCorePackage) + //we don't need info of every kill, we can get a good sample size just by logging 5% + if (GameAnalyticsManager.SendUserStatistics && + Prefab?.ContentPackage == ContentPackageManager.VanillaCorePackage && + GameAnalyticsManager.ShouldLogRandomSample()) { string causeOfDeathStr = causeOfDeathAffliction == null ? causeOfDeath.ToString() : causeOfDeathAffliction.Prefab.Identifier.Value.Replace(" ", ""); @@ -5297,6 +5326,7 @@ namespace Barotrauma } #if SERVER newItem.GetComponent()?.SyncHistory(); + if (newItem.GetComponent() is WifiComponent wifiComponent) { newItem.CreateServerEvent(wifiComponent); } if (newItem.GetComponent() is GeneticMaterial geneticMaterial) { newItem.CreateServerEvent(geneticMaterial); } SyncInGameEditables(newItem); #endif @@ -5511,7 +5541,7 @@ namespace Barotrauma /// public bool IsProtectedFromPressure => IsImmuneToPressure || PressureProtection >= (Level.Loaded?.GetRealWorldDepth(WorldPosition.Y) ?? 1.0f); - public bool IsImmuneToPressure => !NeedsAir || HasAbilityFlag(AbilityFlags.ImmuneToPressure); + public bool IsImmuneToPressure => !NeedsAir || HasAbilityFlag(AbilityFlags.ImmuneToPressure) || GodMode; #region Talents private readonly List characterTalents = new List(); @@ -5772,9 +5802,14 @@ namespace Barotrauma /// private readonly Dictionary wearableStatValues = new Dictionary(); + /// + /// A dictionary with temporary values, updated when the character equips/unequips wearables. Used to reduce unnecessary inventory checking. + /// + private readonly Dictionary wearableSkillModifiers = new Dictionary(); + public float GetStatValue(StatTypes statType, bool includeSaved = true) { - if (!IsHuman) { return 0f; } + if (Info == null) { return 0f; } float statValue = 0f; if (statValues.TryGetValue(statType, out float value)) @@ -5785,7 +5820,7 @@ namespace Barotrauma { statValue += CharacterHealth.GetStatValue(statType); } - if (Info != null && includeSaved) + if (includeSaved) { // could be optimized by instead updating the Character.cs statvalues dictionary whenever the CharacterInfo.cs values change statValue += Info.GetSavedStatValue(statType); @@ -5807,12 +5842,17 @@ namespace Barotrauma public void OnWearablesChanged() { + HashSet handledWearables = new HashSet(); wearableStatValues.Clear(); + wearableSkillModifiers.Clear(); for (int i = 0; i < Inventory.Capacity; i++) { if (Inventory.SlotTypes[i] != InvSlotType.Any && Inventory.SlotTypes[i] != InvSlotType.LeftHand && Inventory.SlotTypes[i] != InvSlotType.RightHand && Inventory.GetItemAt(i)?.GetComponent() is Wearable wearable) { + if (handledWearables.Contains(wearable)) { continue; } + handledWearables.Add(wearable); + foreach (var statValuePair in wearable.WearableStatValues) { if (wearableStatValues.ContainsKey(statValuePair.Key)) @@ -5824,6 +5864,17 @@ namespace Barotrauma wearableStatValues.Add(statValuePair.Key, statValuePair.Value); } } + foreach (var skillModifier in wearable.SkillModifiers) + { + if (wearableSkillModifiers.ContainsKey(skillModifier.Key)) + { + wearableSkillModifiers[skillModifier.Key] += skillModifier.Value; + } + else + { + wearableSkillModifiers.Add(skillModifier.Key, skillModifier.Value); + } + } } } } diff --git a/Barotrauma/BarotraumaShared/SharedSource/Characters/CharacterInfo.cs b/Barotrauma/BarotraumaShared/SharedSource/Characters/CharacterInfo.cs index a96d2e805..377e5894e 100644 --- a/Barotrauma/BarotraumaShared/SharedSource/Characters/CharacterInfo.cs +++ b/Barotrauma/BarotraumaShared/SharedSource/Characters/CharacterInfo.cs @@ -301,6 +301,25 @@ namespace Barotrauma public bool PermanentlyDead; public bool RenamingEnabled = false; + private BotStatus botStatus = BotStatus.ActiveService; + + public BotStatus BotStatus + { + get => botStatus; + set + { + botStatus = value; + if (botStatus == BotStatus.ActiveService && character == null) + { + //no character yet -> spawn is pending + PendingSpawnToActiveService = true; + } + } + } + + public bool IsOnReserveBench => BotStatus == BotStatus.ReserveBench; + public bool PendingSpawnToActiveService; + private static ushort idCounter = 1; private const string disguiseName = "???"; @@ -312,7 +331,18 @@ namespace Barotrauma public LocalizedString Title; public (Identifier NpcSetIdentifier, Identifier NpcIdentifier) HumanPrefabIds; - + + private HumanPrefab _humanPrefab; + public HumanPrefab HumanPrefab + { + get + { + if (HumanPrefabIds == default) { return null; } + _humanPrefab ??= NPCSet.Get(HumanPrefabIds.NpcSetIdentifier, HumanPrefabIds.NpcIdentifier); + return _humanPrefab; + } + } + public string DisplayName { get @@ -340,10 +370,24 @@ namespace Barotrauma public Identifier SpeciesName { get; } + private Character character; /// /// Note: Can be null. /// - public Character Character; + public Character Character + { + get => character; + set + { + character = value; + if (character != null) + { + //character spawned -> spawn no longer pending + PendingSpawnToActiveService = false; + } + } + + } public Job Job; @@ -768,6 +812,13 @@ namespace Barotrauma return name; } + public void SetNameBasedOnJob() + { + if (Job == null) { return; } + Name = Job.Name.Value; + OriginalName = Name; + } + public static Color SelectRandomColor(in ImmutableArray<(Color Color, float Commonness)> array, Rand.RandSync randSync) => ToolBox.SelectWeightedRandom(array, array.Select(p => p.Commonness).ToArray(), randSync) .Color; @@ -830,6 +881,7 @@ namespace Barotrauma LoadTagsBackwardsCompatibility(infoElement, tags); SpeciesName = infoElement.GetAttributeIdentifier("speciesname", ""); PermanentlyDead = infoElement.GetAttributeBool("permanentlydead", false); + BotStatus = infoElement.GetAttributeBool(nameof(IsOnReserveBench), false) ? BotStatus.ReserveBench : BotStatus.ActiveService; RenamingEnabled = infoElement.GetAttributeBool("renamingenabled", false); ContentXElement element; if (!SpeciesName.IsEmpty) @@ -1570,6 +1622,7 @@ namespace Barotrauma new XAttribute("refundpoints", TalentRefundPoints), new XAttribute("lastrewarddistribution", LastRewardDistribution.Match(some: value => value, none: () => -1).ToString()), new XAttribute("permanentlydead", PermanentlyDead), + new XAttribute(nameof(IsOnReserveBench), IsOnReserveBench), new XAttribute("renamingenabled", RenamingEnabled) ); diff --git a/Barotrauma/BarotraumaShared/SharedSource/Characters/Health/Afflictions/Affliction.cs b/Barotrauma/BarotraumaShared/SharedSource/Characters/Health/Afflictions/Affliction.cs index 53e3857b4..2a8102078 100644 --- a/Barotrauma/BarotraumaShared/SharedSource/Characters/Health/Afflictions/Affliction.cs +++ b/Barotrauma/BarotraumaShared/SharedSource/Characters/Health/Afflictions/Affliction.cs @@ -54,6 +54,11 @@ namespace Barotrauma activeEffectDirty = true; } } + + /// + /// Armor penetration for status effects. Normally defined per attack, but that doesn't work on status effects. + /// + public float Penetration { get; set; } private float _nonClampedStrength = -1; public float NonClampedStrength => _nonClampedStrength > 0 ? _nonClampedStrength : _strength; @@ -64,7 +69,7 @@ namespace Barotrauma [Serialize(1.0f, IsPropertySaveable.Yes, description: "The probability for the affliction to be applied."), Editable(minValue: 0f, maxValue: 1f)] public float Probability { get; set; } = 1.0f; - [Serialize(true, IsPropertySaveable.Yes, description: "Explosion damage is applied per each affected limb. Should this affliction damage be divided by the count of affected limbs (1-15) or applied in full? Default: true. Only affects explosions."), Editable] + [Serialize(true, IsPropertySaveable.Yes, description: "Explosion damage is applied per each affected limb. Should this affliction damage be divided by the count of affected limbs (1-15) or applied in full? Default: true. Only affects status effects and explosions."), Editable] public bool DivideByLimbCount { get; set; } [Serialize(false, IsPropertySaveable.Yes, description: "Is the damage relative to the max vitality (percentage) or absolute (normal)"), Editable] @@ -120,6 +125,7 @@ namespace Barotrauma Probability = source.Probability; DivideByLimbCount = source.DivideByLimbCount; MultiplyByMaxVitality = source.MultiplyByMaxVitality; + Penetration = source.Penetration; } public void Serialize(XElement element) @@ -408,6 +414,8 @@ namespace Barotrauma } else { + //force an update when a periodic effect triggers to get it to trigger client-side + characterHealth.Character.healthUpdateTimer = 0.0f; foreach (StatusEffect statusEffect in periodicEffect.StatusEffects) { ApplyStatusEffect(ActionType.OnActive, statusEffect, 1.0f, characterHealth, targetLimb); @@ -447,6 +455,17 @@ namespace Barotrauma ApplyStatusEffect(ActionType.OnActive, statusEffect, deltaTime, characterHealth, targetLimb); } + if (currentEffect.ConvulseAmount > 0f) + { + foreach (Limb limb in characterHealth.Character.AnimController.Limbs) + { + if (limb.IsSevered) { continue; } + if (limb.Hidden) { continue; } + float force = Rand.Value() * limb.Mass * currentEffect.ConvulseAmount; + limb.body.ApplyLinearImpulse(Rand.Vector(force), maxVelocity: Networking.NetConfig.MaxPhysicsBodyVelocity * 0.5f); + } + } + float amount = deltaTime; if (Prefab.GrainBurst > 0) { diff --git a/Barotrauma/BarotraumaShared/SharedSource/Characters/Health/Afflictions/AfflictionHusk.cs b/Barotrauma/BarotraumaShared/SharedSource/Characters/Health/Afflictions/AfflictionHusk.cs index 21d039f0f..541814c6a 100644 --- a/Barotrauma/BarotraumaShared/SharedSource/Characters/Health/Afflictions/AfflictionHusk.cs +++ b/Barotrauma/BarotraumaShared/SharedSource/Characters/Health/Afflictions/AfflictionHusk.cs @@ -321,6 +321,10 @@ namespace Barotrauma } var husk = Character.Create(huskedSpeciesName, character.WorldPosition, ToolBox.RandomSeed(8), huskCharacterInfo, isRemotePlayer: false, hasAi: true); + if (character.HasAbilityFlag(AbilityFlags.IgnoredByEnemyAI)) + { + husk.AddAbilityFlag(AbilityFlags.IgnoredByEnemyAI); + } if (husk.Info != null) { husk.Info.Character = husk; @@ -395,13 +399,13 @@ namespace Barotrauma public static List AttachHuskAppendage(Character character, AfflictionPrefabHusk matchingAffliction, Identifier huskedSpeciesName, ContentXElement appendageDefinition = null, Ragdoll ragdoll = null) { - var appendage = new List(); + var appendageLimbs = new List(); CharacterPrefab huskPrefab = CharacterPrefab.FindBySpeciesName(huskedSpeciesName); if (huskPrefab?.ConfigElement == null) { DebugConsole.ThrowError($"Failed to find the config file for the husk infected species with the species name '{huskedSpeciesName}'!", contentPackage: matchingAffliction.ContentPackage); - return appendage; + return appendageLimbs; } var mainElement = huskPrefab.ConfigElement; var element = appendageDefinition; @@ -413,11 +417,11 @@ namespace Barotrauma { DebugConsole.ThrowError($"Error in '{huskPrefab.FilePath}': Failed to find a huskappendage that matches the affliction with an identifier '{matchingAffliction.Identifier}'!", contentPackage: matchingAffliction.ContentPackage); - return appendage; + return appendageLimbs; } ContentPath pathToAppendage = element.GetAttributeContentPath("path") ?? ContentPath.Empty; XDocument doc = XMLExtensions.TryLoadXml(pathToAppendage); - if (doc == null) { return appendage; } + if (doc == null) { return appendageLimbs; } ragdoll ??= character.AnimController; if (ragdoll.Dir < 1.0f) { @@ -425,13 +429,13 @@ namespace Barotrauma } var root = doc.Root.FromPackage(pathToAppendage.ContentPackage); - var limbElements = root.GetChildElements("limb").ToDictionary(e => e.GetAttributeString("id", null), e => e); + var limbElements = root.GetChildElements("limb").ToDictionary(e => e.GetAttributeInt("id", -1), e => e); //the IDs may need to be offset if the character has other extra appendages (e.g. from gene splicing) //that take up the IDs of this appendage - int idOffset = 0; + int? idOffset = null; foreach (var jointElement in root.GetChildElements("joint")) { - if (!limbElements.TryGetValue(jointElement.GetAttributeString("limb2", null), out ContentXElement limbElement)) { continue; } + if (!limbElements.TryGetValue(jointElement.GetAttributeInt("limb2", -1), out ContentXElement limbElement)) { continue; } var jointParams = new RagdollParams.JointParams(jointElement, ragdoll.RagdollParams); Limb attachLimb = null; @@ -453,28 +457,32 @@ namespace Barotrauma } if (attachLimb != null) { - jointParams.Limb1 = attachLimb.Params.ID; - //the joint attaches to a limb outside the character's normal limb count = to another part of the appendage - // -> if the appendage's IDs have been offset, we need to take that into account to attach to the correct limb - if (jointParams.Limb1 >= ragdoll.RagdollParams.Limbs.Count) - { - jointParams.Limb1 += idOffset; - } var appendageLimbParams = new RagdollParams.LimbParams(limbElement, ragdoll.RagdollParams); - if (idOffset == 0) + idOffset ??= ragdoll.Limbs.Length - appendageLimbParams.ID; + jointParams.Limb1 = attachLimb.Params.ID; + //the joint attaches to one of the limbs we're creating = to another part of the appendage + // -> if the appendage's IDs have been offset, we need to take that into account to attach to the correct limb + if (limbElements.ContainsKey(jointParams.Limb1)) { - idOffset = ragdoll.Limbs.Length - appendageLimbParams.ID; + jointParams.Limb1 += idOffset.Value; } - jointParams.Limb2 = appendageLimbParams.ID = ragdoll.Limbs.Length; - Limb huskAppendage = new Limb(ragdoll, character, appendageLimbParams); + if (limbElements.ContainsKey(jointParams.Limb2)) + { + jointParams.Limb2 += idOffset.Value; + } + Limb huskAppendage = + //check if this joint is supposed to attach to a limb we already created + appendageLimbs.Find(limb => limb.Params.ID == appendageLimbParams.ID) ?? + //if not, create a new limb + new Limb(ragdoll, character, appendageLimbParams); huskAppendage.body.Submarine = character.Submarine; huskAppendage.body.SetTransform(attachLimb.SimPosition, attachLimb.Rotation); ragdoll.AddLimb(huskAppendage); ragdoll.AddJoint(jointParams); - appendage.Add(huskAppendage); + appendageLimbs.Add(huskAppendage); } } - return appendage; + return appendageLimbs; } public static Identifier GetHuskedSpeciesName(CharacterParams character, AfflictionPrefabHusk prefab) diff --git a/Barotrauma/BarotraumaShared/SharedSource/Characters/Health/Afflictions/AfflictionPrefab.cs b/Barotrauma/BarotraumaShared/SharedSource/Characters/Health/Afflictions/AfflictionPrefab.cs index 4bc7de603..725e12478 100644 --- a/Barotrauma/BarotraumaShared/SharedSource/Characters/Health/Afflictions/AfflictionPrefab.cs +++ b/Barotrauma/BarotraumaShared/SharedSource/Characters/Health/Afflictions/AfflictionPrefab.cs @@ -372,6 +372,10 @@ namespace Barotrauma description: $"Color of the \"thermal goggles overlay\" enabled by the affliction. Only has an effect if {nameof(ThermalOverlayRange)} is larger than 0.")] public Color ThermalOverlayColor { get; private set; } + [Serialize(0f, IsPropertySaveable.No, + description: "Multiplier for the convulsion/seizure effect on the character's ragdoll when this effect is active.")] + public float ConvulseAmount { get; private set; } + /// /// StatType that will be applied to the affected character when the effect is active that is proportional to the effect's strength. /// @@ -641,6 +645,7 @@ namespace Barotrauma public static AfflictionPrefab Stun => Prefabs[StunType]; public static AfflictionPrefab RadiationSickness => Prefabs["radiationsickness"]; public static AfflictionPrefab HuskInfection => Prefabs["huskinfection"]; + public static AfflictionPrefab JovianRadiation => Prefabs["jovianradiation"]; public static readonly PrefabCollection Prefabs = new PrefabCollection(); @@ -657,6 +662,11 @@ namespace Barotrauma private readonly LocalizedString defaultDescription; public readonly ImmutableList Descriptions; + /// + /// Should the affliction's description be included in the tooltips on the affliction icons above the health bar? + /// + public readonly bool ShowDescriptionInTooltip; + /// /// Arbitrary string that is used to identify the type of the affliction. /// @@ -902,6 +912,8 @@ namespace Barotrauma { defaultDescription = defaultDescription.Fallback(fallbackDescription); } + ShowDescriptionInTooltip = element.GetAttributeBool(nameof(ShowDescriptionInTooltip), true); + IsBuff = element.GetAttributeBool(nameof(IsBuff), false); AffectMachines = element.GetAttributeBool(nameof(AffectMachines), true); @@ -939,6 +951,12 @@ namespace Barotrauma HideIconAfterDelay = element.GetAttributeBool(nameof(HideIconAfterDelay), false); ActivationThreshold = element.GetAttributeFloat(nameof(ActivationThreshold), 0.0f); + if (Identifier == StunType && ActivationThreshold > 0.0f) + { + ActivationThreshold = 0.0f; + DebugConsole.AddWarning($"Error in affliction prefab {Identifier}: activation threshold of the stun affliction must be 0, because the strength of the affliction represents the length of the stun and any amount of stun has an effect."); + } + ShowIconThreshold = element.GetAttributeFloat(nameof(ShowIconThreshold), Math.Max(ActivationThreshold, 0.05f)); ShowIconToOthersThreshold = element.GetAttributeFloat(nameof(ShowIconToOthersThreshold), ShowIconThreshold); MaxStrength = element.GetAttributeFloat(nameof(MaxStrength), 100.0f); diff --git a/Barotrauma/BarotraumaShared/SharedSource/Characters/Health/CharacterHealth.cs b/Barotrauma/BarotraumaShared/SharedSource/Characters/Health/CharacterHealth.cs index 82cc348f8..03d63b845 100644 --- a/Barotrauma/BarotraumaShared/SharedSource/Characters/Health/CharacterHealth.cs +++ b/Barotrauma/BarotraumaShared/SharedSource/Characters/Health/CharacterHealth.cs @@ -736,6 +736,8 @@ namespace Barotrauma afflictionsToRemove.AddRange(afflictions.Keys.Where(a => !irremovableAfflictions.Contains(a))); foreach (var affliction in afflictionsToRemove) { + //set strength to 0 in case the affliction needs to react to becoming inactive + affliction.Strength = 0.0f; afflictions.Remove(affliction); } foreach (Affliction affliction in irremovableAfflictions) @@ -902,6 +904,8 @@ namespace Barotrauma affliction.Duration -= deltaTime; if (affliction.Duration <= 0.0f) { + //set strength to 0 in case the affliction needs to react to becoming inactive + affliction.Strength = 0.0f; afflictionsToRemove.Add(affliction); continue; } diff --git a/Barotrauma/BarotraumaShared/SharedSource/Characters/HumanPrefab.cs b/Barotrauma/BarotraumaShared/SharedSource/Characters/HumanPrefab.cs index c6148d1ed..eaf739333 100644 --- a/Barotrauma/BarotraumaShared/SharedSource/Characters/HumanPrefab.cs +++ b/Barotrauma/BarotraumaShared/SharedSource/Characters/HumanPrefab.cs @@ -2,6 +2,7 @@ using Barotrauma.Extensions; using Barotrauma.Items.Components; using System.Collections.Generic; +using System.Collections.Immutable; using System.Linq; using System.Xml.Linq; @@ -251,7 +252,7 @@ namespace Barotrauma foreach (var skill in characterInfo.Job.GetSkills()) { float newSkill = skill.Level * SkillMultiplier; - skill.IncreaseSkill(newSkill - skill.Level, increasePastMax: false); + skill.IncreaseSkill(newSkill - skill.Level, canIncreasePastDefaultMaximumSkill: false); } } characterInfo.Salary = characterInfo.CalculateSalary(BaseSalary, SalaryMultiplier); @@ -259,6 +260,12 @@ namespace Barotrauma characterInfo.GiveExperience(ExperiencePoints); return characterInfo; } + + /// + /// Items marked to be spawned infinitely (by NPCs). + /// + private readonly Dictionary infiniteItems = new(); + public IReadOnlyCollection InfiniteItems => infiniteItems.Values; public static void InitializeItem(Character character, ContentXElement itemElement, Submarine submarine, HumanPrefab humanPrefab, WayPoint spawnPoint = null, Item parentItem = null, bool createNetworkEvents = true) { @@ -323,9 +330,13 @@ namespace Barotrauma wifiComponent.TeamID = character.TeamID; } parentItem?.Combine(item, user: null); + if (itemElement.GetAttributeBool(nameof(JobPrefab.JobItem.Infinite), false)) + { + humanPrefab.infiniteItems.TryAdd(itemPrefab.Identifier, itemPrefab); + } foreach (ContentXElement childItemElement in itemElement.Elements()) { - int amount = childItemElement.GetAttributeInt("amount", 1); + int amount = childItemElement.GetAttributeInt(nameof(JobPrefab.JobItem.Amount), 1); for (int i = 0; i < amount; i++) { InitializeItem(character, childItemElement, submarine, humanPrefab, spawnPoint, item, createNetworkEvents); diff --git a/Barotrauma/BarotraumaShared/SharedSource/Characters/Jobs/Job.cs b/Barotrauma/BarotraumaShared/SharedSource/Characters/Jobs/Job.cs index 820f18fdb..d3d4bfc0c 100644 --- a/Barotrauma/BarotraumaShared/SharedSource/Characters/Jobs/Job.cs +++ b/Barotrauma/BarotraumaShared/SharedSource/Characters/Jobs/Job.cs @@ -1,4 +1,5 @@ -using Barotrauma.Items.Components; +using System; +using Barotrauma.Items.Components; using System.Collections.Generic; using System.Linq; using System.Xml.Linq; @@ -127,6 +128,11 @@ namespace Barotrauma new Skill(skillIdentifier, increase)); } } + + /// + /// Note: Does not automatically filter items by team or by game mode. See + /// + public bool HasJobItem(Func predicate) => prefab.HasJobItem(Variant, predicate); public void GiveJobItems(Character character, bool isPvPMode, WayPoint spawnPoint = null) { diff --git a/Barotrauma/BarotraumaShared/SharedSource/Characters/Jobs/JobPrefab.cs b/Barotrauma/BarotraumaShared/SharedSource/Characters/Jobs/JobPrefab.cs index 5356afa30..eabd43f15 100644 --- a/Barotrauma/BarotraumaShared/SharedSource/Characters/Jobs/JobPrefab.cs +++ b/Barotrauma/BarotraumaShared/SharedSource/Characters/Jobs/JobPrefab.cs @@ -94,6 +94,18 @@ namespace Barotrauma return null; } } + + /// + /// Note: Does not automatically filter items by team or by game mode. See + /// + public IEnumerable GetJobItems(int jobVariant, Func predicate) + => JobItems.TryGetValue(jobVariant, out ImmutableArray items) ? items.Where(predicate) : Enumerable.Empty(); + + /// + /// Note: Does not automatically filter items by team or by game mode. See + /// + public bool HasJobItem(int jobVariant, Func predicate) + => JobItems.TryGetValue(jobVariant, out ImmutableArray items) && items.Any(predicate); public class JobItem { @@ -107,7 +119,8 @@ namespace Barotrauma public readonly bool ShowPreview; public readonly bool Equip; public readonly bool Outfit; - public readonly int Amount = 1; + public readonly int Amount; + public readonly bool Infinite; public readonly JobItem ParentItem; @@ -117,14 +130,15 @@ namespace Barotrauma { ItemIdentifier = element.GetAttributeIdentifier("identifier", Identifier.Empty); ItemIdentifierTeam2 = element.GetAttributeIdentifier("identifierteam2", Identifier.Empty); - ShowPreview = element.GetAttributeBool("showpreview", true); - GameMode = element.GetAttributeEnum("gamemode", parentItem?.GameMode ?? GameModeType.Any); - Amount = element.GetAttributeInt("amount", 1); - Equip = element.GetAttributeBool("equip", false); - Outfit = element.GetAttributeBool("outfit", false); + ShowPreview = element.GetAttributeBool(nameof(ShowPreview), true); + GameMode = element.GetAttributeEnum(nameof(GameMode), parentItem?.GameMode ?? GameModeType.Any); + Amount = element.GetAttributeInt(nameof(Amount), 1); + Equip = element.GetAttributeBool(nameof(Equip), false); + Outfit = element.GetAttributeBool(nameof(Outfit), false); + Infinite = element.GetAttributeBool(nameof(Infinite), false); ParentItem = parentItem; } - + public Identifier GetItemIdentifier(CharacterTeamType team, bool isPvPMode) { switch (GameMode) @@ -136,11 +150,10 @@ namespace Barotrauma if (isPvPMode) { return Identifier.Empty; } break; } - return team == CharacterTeamType.Team2 && !ItemIdentifierTeam2.IsEmpty ? - ItemIdentifierTeam2 : - ItemIdentifier; + ItemIdentifierTeam2 : + ItemIdentifier; } } diff --git a/Barotrauma/BarotraumaShared/SharedSource/Characters/Jobs/Skill.cs b/Barotrauma/BarotraumaShared/SharedSource/Characters/Jobs/Skill.cs index 4fe173b6e..d62b018df 100644 --- a/Barotrauma/BarotraumaShared/SharedSource/Characters/Jobs/Skill.cs +++ b/Barotrauma/BarotraumaShared/SharedSource/Characters/Jobs/Skill.cs @@ -6,7 +6,10 @@ namespace Barotrauma { public readonly Identifier Identifier; - public const float MaximumSkill = 100.0f; + /// + /// The "normal" maximum skill level. It's possible to go above this with certain talents, see . + /// + public const float DefaultMaximumSkill = 100.0f; private float level; @@ -27,9 +30,21 @@ namespace Barotrauma public LocalizedString DisplayName { get; private set; } - public void IncreaseSkill(float value, bool increasePastMax) + /// + /// Increase the skill level by a value. Handles clamping the level above the maximum. + /// Note that if the skill level is already above maximum (if it for example has been set by console commands), it's allowed to stay at that level, but not to increase further. + /// + /// How much to increase the skill. + /// Can the skill level increase above , or can it go all the way to ? + public void IncreaseSkill(float value, bool canIncreasePastDefaultMaximumSkill) { - Level = MathHelper.Clamp(level + value, 0.0f, increasePastMax ? SkillSettings.Current.MaximumSkillWithTalents : MaximumSkill); + float currentMaximum = canIncreasePastDefaultMaximumSkill ? SkillSettings.Current.MaximumSkillWithTalents : DefaultMaximumSkill; + if (Level > currentMaximum && value > 0) + { + //level above max already (set with console commands?), don't allow increasing it further and don't clamp it below max either + return; + } + Level = MathHelper.Clamp(level + value, 0.0f, currentMaximum); } private readonly Identifier iconJobId; diff --git a/Barotrauma/BarotraumaShared/SharedSource/Characters/Params/Animation/AnimationParams.cs b/Barotrauma/BarotraumaShared/SharedSource/Characters/Params/Animation/AnimationParams.cs index 3a2562d48..9954efb09 100644 --- a/Barotrauma/BarotraumaShared/SharedSource/Characters/Params/Animation/AnimationParams.cs +++ b/Barotrauma/BarotraumaShared/SharedSource/Characters/Params/Animation/AnimationParams.cs @@ -182,7 +182,7 @@ namespace Barotrauma public float HandIKStrength { get; set; } public static string GetDefaultFileName(Identifier speciesName, AnimationType animType) => $"{speciesName.Value.CapitaliseFirstInvariant()}{animType}"; - public static string GetDefaultFile(Identifier speciesName, AnimationType animType) => Barotrauma.IO.Path.Combine(GetFolder(speciesName), $"{GetDefaultFileName(speciesName, animType)}.xml"); + public static string GetDefaultFilePath(Identifier speciesName, AnimationType animType) => Barotrauma.IO.Path.Combine(GetFolder(speciesName), $"{GetDefaultFileName(speciesName, animType)}.xml"); public static string GetFolder(Identifier speciesName) { @@ -314,25 +314,27 @@ namespace Barotrauma else if (string.IsNullOrEmpty(fileName)) { // Files found, but none specified -> Get a matching animation from the specified folder. - // First try to find a file that matches the default file name. If that fails, just take any file. + // First try to find a file that matches the default file name. If that fails, just take any file of the matching type. string defaultFileName = GetDefaultFileName(animSpecies, animType); selectedFile = filteredFiles.FirstOrDefault(path => PathMatchesFile(path, defaultFileName)) ?? filteredFiles.First(); } else { + // Try to get the specified file. If that fails, just take any file of the matching type. selectedFile = filteredFiles.FirstOrDefault(path => PathMatchesFile(path, fileName)); if (selectedFile == null) { - errorMessages.Add($"[AnimationParams] Could not find an animation file that matches the name {fileName} and the animation type {animType}. Using the default animations."); + errorMessages.Add($"[AnimationParams] Could not find an animation file that matches the name {fileName} and the animation type {animType}. Using the first file of the matching type."); + selectedFile = filteredFiles.First(); } - } + } } } else { errorMessages.Add($"[AnimationParams] Invalid directory: {folder}. Using the default animation."); } - selectedFile ??= GetDefaultFile(fallbackSpecies, animType); + selectedFile ??= GetDefaultFilePath(fallbackSpecies, animType); Debug.Assert(selectedFile != null); if (errorMessages.None()) { diff --git a/Barotrauma/BarotraumaShared/SharedSource/Characters/Params/CharacterParams.cs b/Barotrauma/BarotraumaShared/SharedSource/Characters/Params/CharacterParams.cs index 44c4f6a8f..fbef6a4f5 100644 --- a/Barotrauma/BarotraumaShared/SharedSource/Characters/Params/CharacterParams.cs +++ b/Barotrauma/BarotraumaShared/SharedSource/Characters/Params/CharacterParams.cs @@ -147,7 +147,17 @@ namespace Barotrauma [Serialize("", IsPropertySaveable.Yes, description: "Identifier or tag of the item the character's items are placed inside when the character despawns."), Editable] public Identifier DespawnContainer { get; private set; } + [Serialize("monster", IsPropertySaveable.Yes, description: "If changed, this character will try to play a custom music track with the specified identifier when encountered."), Editable] + public Identifier MusicType { get; private set; } + + [Serialize(1.0f, IsPropertySaveable.Yes, description: "The commonness of this character's music when a random track will be chosen."), Editable] + public float MusicCommonness { get; private set; } + + [Serialize(1.0f, IsPropertySaveable.Yes, description: "The multiplier of the minimum distance required between this character and the player/submarine before the music starts playing. The default distance is twice the length of the submarine, or a minimum of 50 meters."), Editable] + public float MusicRangeMultiplier { get; private set; } + public readonly CharacterFile File; + public bool IsPet => AI?.IsPet ?? false; public XDocument VariantFile { get; private set; } @@ -754,6 +764,8 @@ namespace Barotrauma [Serialize(0f, IsPropertySaveable.Yes, "How likely it is that the creature plays dead (= ragdolls) while idling? Only allowed inside a sub (not in the open waters). Evaluated once, when the creature spawns."), Editable] public float PlayDeadProbability { get; set; } + + public readonly bool IsPet; public IEnumerable Targets => targets; private readonly List targets = new List(); @@ -763,6 +775,7 @@ namespace Barotrauma if (element == null) { return; } element.GetChildElements("target").ForEach(t => AddTarget(t)); element.GetChildElements("targetpriority").ForEach(t => AddTarget(t)); + IsPet = element.GetChildElement("petbehavior") != null; } /// diff --git a/Barotrauma/BarotraumaShared/SharedSource/Characters/SkillSettings.cs b/Barotrauma/BarotraumaShared/SharedSource/Characters/SkillSettings.cs index a28080a0f..ec001fd45 100644 --- a/Barotrauma/BarotraumaShared/SharedSource/Characters/SkillSettings.cs +++ b/Barotrauma/BarotraumaShared/SharedSource/Characters/SkillSettings.cs @@ -93,7 +93,7 @@ namespace Barotrauma set; } - [Serialize(200.0f, IsPropertySaveable.Yes)] + [Serialize(200.0f, IsPropertySaveable.Yes, description: "The \"absolute\" maximum skill level with talents that increase the default maximum.")] public float MaximumSkillWithTalents { get; diff --git a/Barotrauma/BarotraumaShared/SharedSource/Characters/Talents/Abilities/AbilityConditionals/AbilityCondition.cs b/Barotrauma/BarotraumaShared/SharedSource/Characters/Talents/Abilities/AbilityConditionals/AbilityCondition.cs index 5b74ede1e..5c352384b 100644 --- a/Barotrauma/BarotraumaShared/SharedSource/Characters/Talents/Abilities/AbilityConditionals/AbilityCondition.cs +++ b/Barotrauma/BarotraumaShared/SharedSource/Characters/Talents/Abilities/AbilityConditionals/AbilityCondition.cs @@ -69,21 +69,20 @@ namespace Barotrauma.Abilities switch (targetType) { case TargetType.Enemy: - return !HumanAIController.IsFriendly(character, targetCharacter); + return !HumanAIController.IsFriendly(character, targetCharacter, onlySameTeam: false); case TargetType.Ally: - return HumanAIController.IsFriendly(character, targetCharacter); + return HumanAIController.IsFriendly(character, targetCharacter, onlySameTeam: true); case TargetType.NotSelf: return targetCharacter != character; case TargetType.Alive: return !targetCharacter.IsDead; case TargetType.Monster: - return !targetCharacter.IsHuman; + return !targetCharacter.IsHuman && !targetCharacter.IsPet; case TargetType.InFriendlySubmarine: return targetCharacter.Submarine != null && targetCharacter.Submarine.TeamID == character.TeamID; default: return true; } } - } } diff --git a/Barotrauma/BarotraumaShared/SharedSource/Characters/Talents/Abilities/CharacterAbilityApplyStatusEffects.cs b/Barotrauma/BarotraumaShared/SharedSource/Characters/Talents/Abilities/CharacterAbilityApplyStatusEffects.cs index 210a852f9..4ef07d6e2 100644 --- a/Barotrauma/BarotraumaShared/SharedSource/Characters/Talents/Abilities/CharacterAbilityApplyStatusEffects.cs +++ b/Barotrauma/BarotraumaShared/SharedSource/Characters/Talents/Abilities/CharacterAbilityApplyStatusEffects.cs @@ -65,7 +65,7 @@ namespace Barotrauma.Abilities } if (!nearbyCharactersAppliesToAllies) { - targets.RemoveAll(c => c is Character otherCharacter && HumanAIController.IsFriendly(otherCharacter, Character)); + targets.RemoveAll(c => c is Character otherCharacter && HumanAIController.IsFriendly(otherCharacter, Character, onlySameTeam: true)); } if (!nearbyCharactersAppliesToEnemies) { diff --git a/Barotrauma/BarotraumaShared/SharedSource/CircuitBox/CircuitBoxWire.cs b/Barotrauma/BarotraumaShared/SharedSource/CircuitBox/CircuitBoxWire.cs index ba2e8e480..e94807c83 100644 --- a/Barotrauma/BarotraumaShared/SharedSource/CircuitBox/CircuitBoxWire.cs +++ b/Barotrauma/BarotraumaShared/SharedSource/CircuitBox/CircuitBoxWire.cs @@ -1,4 +1,4 @@ -#nullable enable +#nullable enable using System.Xml.Linq; using Barotrauma.Items.Components; @@ -182,7 +182,10 @@ namespace Barotrauma To.Connection.DisconnectWire(wire); } // if EntitySpawner is not available - wireItem.Remove(); + if (!wireItem.Removed) + { + wireItem.Remove(); + } } public static ItemPrefab DefaultWirePrefab => ItemPrefab.Prefabs[Tags.RedWire]; diff --git a/Barotrauma/BarotraumaShared/SharedSource/ContentManagement/ContentFile/BackgroundCreaturePrefabsFile.cs b/Barotrauma/BarotraumaShared/SharedSource/ContentManagement/ContentFile/BackgroundCreaturePrefabsFile.cs index f9c370c8e..0ebe3ade3 100644 --- a/Barotrauma/BarotraumaShared/SharedSource/ContentManagement/ContentFile/BackgroundCreaturePrefabsFile.cs +++ b/Barotrauma/BarotraumaShared/SharedSource/ContentManagement/ContentFile/BackgroundCreaturePrefabsFile.cs @@ -1,9 +1,28 @@ -namespace Barotrauma +namespace Barotrauma { - sealed class BackgroundCreaturePrefabsFile : OtherFile +#if CLIENT + [NotSyncedInMultiplayer] + sealed class BackgroundCreaturePrefabsFile : GenericPrefabFile { public BackgroundCreaturePrefabsFile(ContentPackage contentPackage, ContentPath path) : base(contentPackage, path) { } - //this content type only comes into play when a level is generated, so LoadFile and UnloadFile don't have anything to do + protected override bool MatchesSingular(Identifier identifier) => !MatchesPlural(identifier); + protected override bool MatchesPlural(Identifier identifier) => identifier == "backgroundcreatures"; + protected override PrefabCollection Prefabs => BackgroundCreaturePrefab.Prefabs; + protected override BackgroundCreaturePrefab CreatePrefab(ContentXElement element) + { + return new BackgroundCreaturePrefab(element, this); + } + + public sealed override Md5Hash CalculateHash() => Md5Hash.Blank; } +#else + [NotSyncedInMultiplayer] + sealed class BackgroundCreaturePrefabsFile : OtherFile + { + public BackgroundCreaturePrefabsFile(ContentPackage contentPackage, ContentPath path) : base(contentPackage, path) + { + } + } +#endif } \ No newline at end of file diff --git a/Barotrauma/BarotraumaShared/SharedSource/ContentManagement/ContentPackage/ContentPackage.cs b/Barotrauma/BarotraumaShared/SharedSource/ContentManagement/ContentPackage/ContentPackage.cs index 4bf5b73a5..2bee7b3c1 100644 --- a/Barotrauma/BarotraumaShared/SharedSource/ContentManagement/ContentPackage/ContentPackage.cs +++ b/Barotrauma/BarotraumaShared/SharedSource/ContentManagement/ContentPackage/ContentPackage.cs @@ -11,6 +11,7 @@ using System.Threading.Tasks; using System.Xml.Linq; using System.Xml; using Barotrauma.IO; +using Steamworks.Data; namespace Barotrauma { @@ -44,6 +45,27 @@ namespace Barotrauma public readonly Version GameVersion; public readonly string ModVersion; + + public enum UgcStatus + { + NotFetched = 0, + Fetching = 1, + Fetched = 2, + Unavailable = 3 + } + + public UgcStatus UgcItemStatus { get; private set; } + + /// + /// Ugc item (workshop item) data that this content package corresponds to. Needs to be fetched with . + /// You can also check to see if the item is available or not. + /// + public Option UgcItem + { + get; + private set; + } + public Md5Hash Hash { get; private set; } public readonly Option InstallTime; @@ -65,8 +87,15 @@ namespace Barotrauma /// public Option EnableError { get; private set; } = Option.None; - - public bool HasAnyErrors => FatalLoadErrors.Length > 0 || EnableError.IsSome(); + + private readonly HashSet missingDependencies = new HashSet(); + /// + /// An error caused by missing dependencies (Workshop items required by the package). + /// Can be safe to ignore. + /// + public IEnumerable MissingDependencies => missingDependencies; + + public bool HasAnyErrors => FatalLoadErrors.Length > 0 || EnableError.IsSome() || missingDependencies.Any(); public async Task IsUpToDate() { @@ -232,6 +261,16 @@ namespace Barotrauma } } + public void AddMissingDependency(PublishedFileId missingItemID) + { + missingDependencies.Add(missingItemID); + } + + public void ClearMissingDependencies() + { + missingDependencies.Clear(); + } + public void LoadFilesOfType() where T : ContentFile { Files.Where(f => f is T).ForEach(f => f.LoadFile()); @@ -328,6 +367,76 @@ namespace Barotrauma errorCatcher.Dispose(); } + public void TryFetchUgcDescription(Action onFinished) + { + TryFetchUgcItem((Steamworks.Ugc.Item? item) => + { + onFinished?.Invoke(item?.Description ?? string.Empty); + }); + } + + public void TryFetchUgcChildren(Action onFinished) + { + TryFetchUgcItem((Steamworks.Ugc.Item? item) => + { + onFinished?.Invoke(item?.Children ?? Array.Empty()); + }); + } + + private void TryFetchUgcItem(Action onFinished) + { + switch (UgcItemStatus) + { + case UgcStatus.NotFetched: + TryFetchUgcItem(onFinished: () => + { + if (UgcItemStatus == UgcStatus.Fetched && + UgcItem.TryUnwrap(out var cachedItem)) + { + onFinished?.Invoke(cachedItem); + } + }); + break; + case UgcStatus.Fetched when UgcItem.TryUnwrap(out var cachedItem): + onFinished?.Invoke(cachedItem); + break; + default: + onFinished?.Invoke(null); + break; + } + } + + /// + /// Attempts to fetch the UgcItem (workshop item) data from Steamworks, and if successful, caches it in . + /// + /// Triggers when the query finishes or fails (or immediately if the item has been already cached) + public void TryFetchUgcItem(Action onFinished) + { + if (UgcItemStatus != UgcStatus.NotFetched) + { + onFinished?.Invoke(); + } + if (!UgcId.TryUnwrap(out var ugcId) || ugcId is not SteamWorkshopId workshopId) + { + UgcItemStatus = UgcStatus.Unavailable; + return; + } + + UgcItemStatus = UgcStatus.Fetching; + TaskPool.Add($"PrepareToShow{UgcId}Info", SteamManager.Workshop.GetItem(workshopId.Value), + task => + { + if (!task.TryGetResult(out Option itemOption) || !itemOption.TryUnwrap(out var item)) + { + UgcItemStatus = UgcStatus.Unavailable; + return; + } + UgcItem = Option.Some(item); + UgcItemStatus = UgcStatus.Fetched; + onFinished?.Invoke(); + }); + } + public void UnloadContent() { Files.ForEach(f => f.UnloadFile()); diff --git a/Barotrauma/BarotraumaShared/SharedSource/ContentManagement/ContentPackageManager.cs b/Barotrauma/BarotraumaShared/SharedSource/ContentManagement/ContentPackageManager.cs index b14e575b2..79ce7be3f 100644 --- a/Barotrauma/BarotraumaShared/SharedSource/ContentManagement/ContentPackageManager.cs +++ b/Barotrauma/BarotraumaShared/SharedSource/ContentManagement/ContentPackageManager.cs @@ -1,4 +1,4 @@ -#nullable enable +#nullable enable using Barotrauma.Extensions; using System; using System.Collections; @@ -569,6 +569,26 @@ namespace Barotrauma yield return LoadProgress.Progress(1.0f); } + public static void CheckMissingDependencies() + { + foreach (var enabledPackage in EnabledPackages.All) + { + enabledPackage.ClearMissingDependencies(); + enabledPackage.TryFetchUgcChildren((Steamworks.Data.PublishedFileId[]? children) => + { + if (children == null) { return; } + var missingChildren = children + .Where(childUgcItemId => + EnabledPackages.All.None(package => + package.UgcId.TryUnwrap(out var ugcId) && ugcId is SteamWorkshopId workshopId && workshopId.Value == childUgcItemId.Value)); + foreach (var missingChild in missingChildren) + { + enabledPackage.AddMissingDependency(missingChild); + } + }); + } + } + public static void LogEnabledRegularPackageErrors() { foreach (var p in EnabledPackages.Regular) diff --git a/Barotrauma/BarotraumaShared/SharedSource/DebugConsole.cs b/Barotrauma/BarotraumaShared/SharedSource/DebugConsole.cs index c7a35c13a..a0eaf316c 100644 --- a/Barotrauma/BarotraumaShared/SharedSource/DebugConsole.cs +++ b/Barotrauma/BarotraumaShared/SharedSource/DebugConsole.cs @@ -296,6 +296,19 @@ namespace Barotrauma }; }, isCheat: true)); + commands.Add(new Command("spawnnpc", "spawnnpc [any/npcsetidentifier] [npcidentifier] [near/inside/outside/cursor] [team (0-3)] [add to crew (true/false)]: Spawns an pre-configured NPC at a random spawnpoint. (Use the third parameter to select a specific set of spawnpoints.)", onExecute: null, + getValidArgs: () => + { + return new string[][] + { + "any".ToEnumerable().Union(NPCSet.Sets.Select(p => p.Identifier.Value).OrderBy(s => s)).ToArray(), // NPC Sets + NPCSet.Sets.SelectMany(set => set.Humans).Select(p => p.Identifier.Value).OrderBy(s => s).ToArray(), // NPCs + new string[] { "near", "inside", "outside", "cursor" }, + Enum.GetValues().Select(v => v.ToString()).ToArray(), + new string[] { "true", "false" } + }; + }, isCheat: true)); + commands.Add(new Command("spawnitem", "spawnitem [itemname/itemidentifier] [cursor/inventory/cargo/random/[name]] [amount] [condition]: Spawn an item at the position of the cursor, in the inventory of the controlled character, in the inventory of the client with the given name, or at a random spawnpoint if the location parameter is omitted or \"random\".", (string[] args) => { @@ -570,12 +583,10 @@ namespace Barotrauma onExecute: null, getValidArgs:() => { - var characterList = Character.Controlled != null ? new[] { "Me" } : Array.Empty(); - var subList = Submarine.MainSub != null ? new[] { "mainsub" } : Array.Empty(); return new string[][] { - characterList.Concat(ListCharacterNames()).ToArray(), - subList.Concat(ListAvailableLocations()).ToArray() + ListCharacterNames(includeMeArgument: Character.Controlled != null, includeCrewArgument: true), + ListAvailableLocations() }; }, isCheat: true)); @@ -647,16 +658,19 @@ namespace Barotrauma commands.Add(new Command("godmode", "godmode [character name]: Toggle character godmode. Makes the targeted character invulnerable to damage. If the name parameter is omitted, the controlled character will receive godmode.", (string[] args) => { - Character targetCharacter = (args.Length == 0) ? Character.Controlled : FindMatchingCharacter(args, false); - - if (targetCharacter == null) { return; } - - targetCharacter.GodMode = !targetCharacter.GodMode; - NewMessage((targetCharacter.GodMode ? "Enabled godmode on " : "Disabled godmode on ") + targetCharacter.Name, Color.White); + bool? godmodeStateOnFirstCharacter = null; + HandleCommandForCrewOrSingleCharacter(args, ToggleGodMode); + void ToggleGodMode(Character targetCharacter) + { + targetCharacter.GodMode = godmodeStateOnFirstCharacter ?? !targetCharacter.GodMode; + godmodeStateOnFirstCharacter = targetCharacter.GodMode; + NewMessage((targetCharacter.GodMode ? "Enabled godmode on " : "Disabled godmode on ") + targetCharacter.Name, + targetCharacter.GodMode ? Color.LimeGreen : Color.Gray); + } }, () => { - return new string[][] { ListCharacterNames() }; + return new string[][] { ListCharacterNames(includeMeArgument: Character.Controlled != null, includeCrewArgument: true) }; }, isCheat: true)); commands.Add(new Command("godmode_mainsub", "godmode_mainsub: Toggle submarine godmode. Makes the main submarine invulnerable to damage.", (string[] args) => @@ -813,17 +827,13 @@ namespace Barotrauma commands.Add(new Command("heal", "heal [character name] [all]: Restore the specified character to full health. If the name parameter is omitted, the controlled character will be healed. By default only heals common afflictions such as physical damage and blood loss: use the \"all\" argument to heal everything, including poisonings/addictions/etc.", (string[] args) => { bool healAll = args.Length > 1 && args[1].Equals("all", StringComparison.OrdinalIgnoreCase); - Character healedCharacter = (args.Length == 0) ? Character.Controlled : FindMatchingCharacter(healAll ? args.Take(args.Length - 1).ToArray() : args); - if (healedCharacter != null) - { - HealCharacter(healedCharacter, healAll); - } + HandleCommandForCrewOrSingleCharacter(args, (Character targetCharacter) => HealCharacter(targetCharacter, healAll)); }, () => { return new string[][] { - Character.CharacterList.Select(c => c.Name).Distinct().OrderBy(n => n).ToArray(), + ListCharacterNames(includeMeArgument: true, includeCrewArgument: true), new string[] { "all" } }; }, isCheat: true)); @@ -1858,6 +1868,15 @@ namespace Barotrauma } }, null, isCheat: true)); + commands.Add(new Command("killall", "killall: Immediately kills all characters in the level.", args => + { + foreach (Character c in Character.CharacterList) + { + c.Kill(CauseOfDeathType.Unknown, null); + NewMessage($"Killed {c.DisplayName}."); + } + }, null, isCheat: true)); + commands.Add(new Command("despawnnow", "despawnnow [character]: Immediately despawns the specified dead character. If the character argument is omitted, all dead characters are despawned.", (string[] args) => { if (args.Length == 0) @@ -2285,7 +2304,7 @@ namespace Barotrauma commands.Sort((c1, c2) => c1.Names.First().CompareTo(c2.Names.First())); } - private static void HealCharacter(Character healedCharacter, bool healAll) + private static void HealCharacter(Character healedCharacter, bool healAll, Client targetClient = null) { healedCharacter.SetAllDamage(0.0f, 0.0f, 0.0f); healedCharacter.Oxygen = 100.0f; @@ -2299,6 +2318,12 @@ namespace Barotrauma string characterNameText = healedCharacter == Character.Controlled ? $"{healedCharacter.Name} (you)" : healedCharacter.Name; string text = healAll ? $"Healed {characterNameText}: all afflictions" : $"Healed {characterNameText}: damage and common afflictions"; NewMessage(text, Color.Yellow); +#if SERVER + if (targetClient != null) + { + GameMain.Server.SendConsoleMessage(text, targetClient); + } +#endif } public static string AutoComplete(string command, int increment = 1) @@ -2479,7 +2504,7 @@ namespace Barotrauma private static string[] ListAvailableLocations() { List locationNames = new(); - foreach(var submarine in Submarine.Loaded) + foreach (var submarine in Submarine.Loaded) { locationNames.Add(submarine.Info.Name); } @@ -2498,7 +2523,10 @@ namespace Barotrauma locationNames.Add($"{caveName}_{index}"); } } - + + if (Submarine.MainSub != null) { locationNames.Add("mainsub"); } + locationNames.Add("cursor"); + return locationNames.ToArray(); } @@ -2663,9 +2691,17 @@ namespace Barotrauma private static IOrderedEnumerable SortSpawnedSpecies(IEnumerable characterList) => characterList.OrderBy(c => c.IsDead).ThenByDescending(c => c.IsHuman).ThenBy(c => c.Name); - private static string[] ListCharacterNames() => GetCharacterNames(); + private static string[] ListCharacterNames(bool includeMeArgument = false, bool includeCrewArgument = false) => + GetCharacterNames(includeMeArgument, includeCrewArgument); - private static string[] GetCharacterNames() => SortSpawnedSpecies(Character.CharacterList).Select(c => c.Name).Distinct().ToArray(); + private static string[] GetCharacterNames(bool includeMeArgument = false, bool includeCrewArgument = false) + { + var characterNames = new List(); + if (includeMeArgument) { characterNames.Add("/me"); } + if (includeCrewArgument) { characterNames.Add("/crew"); } + characterNames.AddRange(SortSpawnedSpecies(Character.CharacterList).Select(c => c.Name)); + return characterNames.ToArray(); + } private static string[] GetSpawnedSpeciesNames() => SortSpawnedSpecies(Character.CharacterList).Select(c => c.SpeciesName.Value).Distinct().ToArray(); @@ -2678,6 +2714,28 @@ namespace Barotrauma private static IEnumerable FindMatchingSpecies(string speciesName) => Character.CharacterList.FindAll(c => c.SpeciesName.Value.Equals(speciesName, StringComparison.OrdinalIgnoreCase)); + /// + /// Checks if the arguments specify a specific character, or if they target the crew, and executes the specified action on them. + /// + private static void HandleCommandForCrewOrSingleCharacter(string[] args, Action action, Client targetClient = null) + { + if (args.Length > 0 && args.First() == "/crew") + { + foreach (var crewCharacter in GameSession.GetSessionCrewCharacters(CharacterType.Both)) + { + action(crewCharacter); + } + } + else + { + Character targetCharacter = (args.Length == 0 || args.First() == "/me") ? + targetClient?.Character ?? Character.Controlled : + FindMatchingCharacter(args, false); + if (targetCharacter == null) { return; } + action(targetCharacter); + } + } + private static Character FindMatchingCharacter(string[] args, bool ignoreRemotePlayers = false, Client allowedRemotePlayer = null, bool botsOnly = false) { if (args.Length == 0) { return null; } @@ -2687,7 +2745,11 @@ namespace Barotrauma int characterIndex = -1; foreach (string arg in args) { - // First try to parse the character name from all the arguments. + if (arg == "/me") + { + return allowedRemotePlayer?.Character ?? Character.Controlled; + } + // try to parse the character name from all the arguments. if (matchingCharacters == null || matchingCharacters.None()) { string possibleCharacterName = arg?.ToLowerInvariant(); @@ -2753,15 +2815,14 @@ namespace Barotrauma Character targetCharacter = controlledCharacter; Vector2 worldPosition = cursorWorldPos; string locationNameArgument = ""; + string firstArgument = args.FirstOrDefault()?.ToLowerInvariant() ?? string.Empty; if (args.Length > 0) { - string firstArgument = args.First().ToLowerInvariant(); string lastArgument = args.Last(); // First seek the matching character. - if (firstArgument != "me") + if (firstArgument is not ("/me" or "/crew")) { - var availableLocations = Submarine.MainSub != null ? new[] { "mainsub", "cursor" } : Array.Empty(); - availableLocations = availableLocations.Concat(ListAvailableLocations()).ToArray(); + var availableLocations = ListAvailableLocations(); if (args.Length > 1 || availableLocations.None(locationName => string.Equals(locationName, lastArgument, StringComparison.OrdinalIgnoreCase))) { // Try to find a matching character, if there's more than one argument or if the last argument is not a valid location argument. @@ -2770,12 +2831,31 @@ namespace Barotrauma } } // Then seek the possible location argument. - if (targetCharacter == null || !targetCharacter.Name.Equals(lastArgument, StringComparison.OrdinalIgnoreCase) && !int.TryParse(lastArgument, out _)) + if (args.Count() > 1) { - locationNameArgument = lastArgument; + if (targetCharacter == null || !targetCharacter.Name.Equals(lastArgument, StringComparison.OrdinalIgnoreCase) && + !int.TryParse(lastArgument, out _)) + { + locationNameArgument = lastArgument; + } } } - + if (firstArgument == "/crew") + { + foreach (var crewCharacter in GameSession.GetSessionCrewCharacters(CharacterType.Both)) + { + TeleportSpecificCharacter(crewCharacter, locationNameArgument, worldPosition); + } + } + else + { + TeleportSpecificCharacter(targetCharacter, locationNameArgument, worldPosition); + } + } + + private static void TeleportSpecificCharacter(Character targetCharacter, string locationNameArgument, Vector2 defaultWorldPosition) + { + Vector2 worldPosition = defaultWorldPosition; if (!string.IsNullOrWhiteSpace(locationNameArgument) && !string.Equals(locationNameArgument, "cursor", comparisonType: StringComparison.InvariantCultureIgnoreCase)) { if (TryFindTeleportPosition(locationNameArgument, out Vector2 teleportPosition)) @@ -2788,7 +2868,7 @@ namespace Barotrauma return; } } - + if (targetCharacter != null) { targetCharacter.TeleportTo(worldPosition); @@ -2800,133 +2880,172 @@ namespace Barotrauma } } - public static void SpawnCharacter(string[] args, Vector2 cursorWorldPos, out string errorMsg) + /// Should we spawn a preconfigured NPC from an ? If so, the first 2 arguments are expected to be the identifier of the NPC set and the identifier of the NPC. + private static void SpawnCharacter(string[] args, Vector2 cursorWorldPos, bool usePreConfiguredNPC = false) { - errorMsg = ""; - if (args.Length == 0) { return; } + int characterArgumentCount = 1; + if (usePreConfiguredNPC) + { + //two arguments required for NPCs, identifier of the NPC set and identifier of the NPC. + characterArgumentCount = 2; + } - Character spawnedCharacter = null; + if (args.Length < characterArgumentCount) { return; } + for (int i = 0; i < characterArgumentCount; i++) + { + if (string.IsNullOrWhiteSpace(args[i])) { return; } + } - Vector2 spawnPosition = Vector2.Zero; - WayPoint spawnPoint = null; - - string characterLowerCase = args[0].ToLowerInvariant(); JobPrefab job = null; - if (!JobPrefab.Prefabs.ContainsKey(characterLowerCase)) + bool isHuman = true; + if (!usePreConfiguredNPC) { - job = JobPrefab.Prefabs.Find(jp => jp.Name != null && jp.Name.Equals(characterLowerCase, StringComparison.OrdinalIgnoreCase)); - } - else - { - job = JobPrefab.Prefabs[characterLowerCase]; - } - bool isHuman = job != null || characterLowerCase == CharacterPrefab.HumanSpeciesName; - if (args.Length > 1) - { - switch (args[1].ToLowerInvariant()) + string characterLowerCase = args[0].ToLowerInvariant(); + if (!JobPrefab.Prefabs.ContainsKey(characterLowerCase)) { - case "inside": - spawnPoint = WayPoint.GetRandom(SpawnType.Human, job, Submarine.MainSub) ?? WayPoint.GetRandom(SpawnType.Human, assignedJob: null, Submarine.MainSub); - break; - case "outside": - spawnPoint = WayPoint.GetRandom(SpawnType.Enemy); - break; - case "near": - case "close": - float closestDist = -1.0f; - foreach (WayPoint wp in WayPoint.WayPointList) - { - if (wp.Submarine != null) continue; - - //don't spawn inside hulls - if (Hull.FindHull(wp.WorldPosition, null) != null) continue; - - float dist = Vector2.Distance(wp.WorldPosition, GameMain.GameScreen.Cam.WorldViewCenter); - - if (closestDist < 0.0f || dist < closestDist) - { - spawnPoint = wp; - closestDist = dist; - } - } - break; - case "cursor": - spawnPosition = cursorWorldPos; - break; - default: - spawnPoint = WayPoint.GetRandom(isHuman ? SpawnType.Human : SpawnType.Enemy); - break; + job = JobPrefab.Prefabs.Find(jp => jp.Name != null && jp.Name.Equals(characterLowerCase, StringComparison.OrdinalIgnoreCase)); } - } - else - { - spawnPoint = WayPoint.GetRandom(isHuman ? SpawnType.Human : SpawnType.Enemy); - } - - if (string.IsNullOrWhiteSpace(args[0])) { return; } - - CharacterTeamType defaultTeamType = isHuman ? CharacterTeamType.Team1 : CharacterTeamType.None; - CharacterTeamType teamType = defaultTeamType; - if (args.Length > 2) - { - string team = args[2]; - if (!Enum.TryParse(team, ignoreCase: true, out teamType) && !string.IsNullOrWhiteSpace(team)) + else { - try - { - teamType = (CharacterTeamType)int.Parse(team); - } - catch - { - ThrowError($"\"{team}\" is not a valid team id."); - } + job = JobPrefab.Prefabs[characterLowerCase]; } + isHuman = job != null || characterLowerCase == CharacterPrefab.HumanSpeciesName; } - - bool addToCrew = isHuman && teamType == defaultTeamType; - if (args.Length > 3) - { - addToCrew = args[3].Equals("true", StringComparison.OrdinalIgnoreCase); - } - - if (spawnPoint != null) { spawnPosition = spawnPoint.WorldPosition; } - if (isHuman) + ParseOptionalArgs(out Vector2 spawnPosition, out WayPoint spawnPoint, out CharacterTeamType teamType, out bool addToCrew); + + if (usePreConfiguredNPC) { - var variant = job != null ? Rand.Range(0, job.Variants, Rand.RandSync.ServerAndClient) : 0; - CharacterInfo characterInfo = new CharacterInfo(CharacterPrefab.HumanSpeciesName, jobOrJobPrefab: job, variant: variant); - spawnedCharacter = Character.Create(characterInfo, spawnPosition, ToolBox.RandomSeed(8)); - } - else - { - CharacterPrefab prefab = CharacterPrefab.FindBySpeciesName(args[0].ToIdentifier()); - if (prefab != null) + Identifier npcSetIdentifier = args[0].ToIdentifier(); + Identifier humanPrefabIdentifier = args[1].ToIdentifier(); + HumanPrefab humanPrefab = + npcSetIdentifier == "any" ? + NPCSet.Sets.SelectMany(set => set.Humans).FirstOrDefault(human => human.Identifier == humanPrefabIdentifier) : + NPCSet.Get(npcSetIdentifier, humanPrefabIdentifier); + if (humanPrefab != null) { - CharacterInfo characterInfo = null; - if (prefab.HasCharacterInfo) + Entity.Spawner.AddCharacterToSpawnQueue(CharacterPrefab.HumanSpeciesName, spawnPosition, humanPrefab.CreateCharacterInfo(), onSpawn: newCharacter => { - characterInfo = new CharacterInfo(prefab.Identifier); - } - spawnedCharacter = Character.Create(args[0], spawnPosition, ToolBox.RandomSeed(8), characterInfo); - } - } - if (spawnedCharacter == null) - { - DebugConsole.ThrowError("Failed to spawn a character with the provided arguments!"); - return; - } - spawnedCharacter.TeamID = teamType; -#if CLIENT - if (addToCrew && GameMain.GameSession?.CrewManager is CrewManager crewManager) - { - crewManager.AddCharacter(spawnedCharacter); - } + newCharacter.HumanPrefab = humanPrefab; + AddToCrew(newCharacter); + humanPrefab.GiveItems(newCharacter, newCharacter.Submarine, spawnPoint); + humanPrefab.InitializeCharacter(newCharacter); +#if SERVER + newCharacter.LoadTalents(); + GameMain.NetworkMember.CreateEntityEvent(newCharacter, new Character.UpdateTalentsEventData()); #endif - if (isHuman) + }); + } + } + else if (isHuman) { - spawnedCharacter.GiveJobItems(isPvPMode: GameMain.GameSession?.GameMode is PvPMode, spawnPoint); - spawnedCharacter.GiveIdCardTags(spawnPoint); - spawnedCharacter.Info.StartItemsGiven = true; + int variant = job != null ? Rand.Range(0, job.Variants, Rand.RandSync.ServerAndClient) : 0; + CharacterInfo characterInfo = new CharacterInfo(CharacterPrefab.HumanSpeciesName, jobOrJobPrefab: job, variant: variant); + Entity.Spawner.AddCharacterToSpawnQueue(CharacterPrefab.HumanSpeciesName, spawnPosition, characterInfo, onSpawn: newCharacter => + { + AddToCrew(newCharacter); + newCharacter.GiveJobItems(isPvPMode: GameMain.GameSession?.GameMode is PvPMode, spawnPoint); + newCharacter.GiveIdCardTags(spawnPoint); + newCharacter.Info.StartItemsGiven = true; + }); + } + else if (CharacterPrefab.FindBySpeciesName(args[0].ToIdentifier()) is { } prefab) + { + Entity.Spawner.AddCharacterToSpawnQueue(args[0].ToIdentifier(), spawnPosition, prefab.HasCharacterInfo ? new CharacterInfo(prefab.Identifier) : null); + } + + void AddToCrew(Character newCharacter) + { + newCharacter.TeamID = teamType; + if (addToCrew) + { + GameMain.GameSession?.CrewManager.AddCharacter(newCharacter); + } + } + + void ParseOptionalArgs(out Vector2 spawnPosition, out WayPoint spawnPoint, out CharacterTeamType teamType, out bool addToCrew) + { + spawnPosition = Vector2.Zero; + spawnPoint = null; + + int argIndex = characterArgumentCount; + if (args.Length > argIndex) + { + switch (args[argIndex].ToLowerInvariant()) + { + case "inside": + spawnPoint = WayPoint.GetRandom(SpawnType.Human, job, Submarine.MainSub); + break; + case "outside": + spawnPoint = WayPoint.GetRandom(SpawnType.Enemy); + break; + case "near": + case "close": + float closestDist = -1f; + foreach (WayPoint wp in WayPoint.WayPointList) + { + if (wp.Submarine != null) { continue; } + + // Don't spawn inside hulls + if (Hull.FindHull(wp.WorldPosition, null) != null) { continue; } + + float dist = Vector2.Distance(wp.WorldPosition, GameMain.GameScreen.Cam.WorldViewCenter); + + if (closestDist < 0f || dist < closestDist) + { + spawnPoint = wp; + closestDist = dist; + } + } + break; + case "cursor": + spawnPosition = cursorWorldPos; + break; + default: + spawnPoint = WayPoint.GetRandom(isHuman ? SpawnType.Human : SpawnType.Enemy); + break; + } + } + else + { + spawnPoint = WayPoint.GetRandom(isHuman ? SpawnType.Human : SpawnType.Enemy); + } + if (spawnPoint != null) + { + spawnPosition = spawnPoint.WorldPosition; + } + + argIndex++; + if (args.Length > argIndex) + { + if (int.TryParse(args[argIndex], out int teamID) && teamID is >= 0 and <= 3) + { + teamType = (CharacterTeamType)teamID; + } + else if (!Enum.TryParse(args[argIndex], ignoreCase: true, out teamType)) + { + teamType = Character.Controlled != null ? Character.Controlled.TeamID : CharacterTeamType.Team1; + ThrowError($"\"{args[argIndex]}\" is not a valid team id. Defaulting to {teamType}."); + } + } + else + { + teamType = Character.Controlled != null ? Character.Controlled.TeamID : CharacterTeamType.Team1; + } + + argIndex++; + addToCrew = isHuman; + if (args.Length > argIndex) + { + if (bool.TryParse(args[argIndex], out bool result)) + { + addToCrew = result; + } + else + { + ThrowError($"Could not parse the \"add to crew\" argument ({args[argIndex]}). Defaulting to {addToCrew}."); + } + } } } @@ -3350,7 +3469,7 @@ namespace Barotrauma #if CLIENT private static IEnumerable CreateMessageBox(string errorMsg) { - new GUIMessageBox(TextManager.Get("Error"), errorMsg); + new GUIMessageBox(TextManager.Get("Error"), errorMsg, minSize: new Point(GUI.IntScale(700), GUI.IntScale(500))); yield return CoroutineStatus.Success; } #endif diff --git a/Barotrauma/BarotraumaShared/SharedSource/DisembarkPerks/DisembarkPerkPrefab.cs b/Barotrauma/BarotraumaShared/SharedSource/DisembarkPerks/DisembarkPerkPrefab.cs index 6c713fcc6..657e7fc97 100644 --- a/Barotrauma/BarotraumaShared/SharedSource/DisembarkPerks/DisembarkPerkPrefab.cs +++ b/Barotrauma/BarotraumaShared/SharedSource/DisembarkPerks/DisembarkPerkPrefab.cs @@ -39,7 +39,7 @@ namespace Barotrauma SortCategory = element.GetAttributeIdentifier("sortcategory", Identifier); Prerequisite = element.GetAttributeIdentifier("prerequisite", Identifier.Empty); MutuallyExclusivePerks = element.GetAttributeIdentifierImmutableHashSet("mutuallyexclusiveperks", ImmutableHashSet.Empty); - SortKey = element.GetAttributeInt("sortkey", ToolBox.IdentifierToInt(Identifier)); + SortKey = element.GetAttributeInt("sortkey", 0); var builder = ImmutableArray.CreateBuilder(); foreach (var child in element.Elements()) diff --git a/Barotrauma/BarotraumaShared/SharedSource/Enums.cs b/Barotrauma/BarotraumaShared/SharedSource/Enums.cs index 5efea9ec1..bc7e4df7f 100644 --- a/Barotrauma/BarotraumaShared/SharedSource/Enums.cs +++ b/Barotrauma/BarotraumaShared/SharedSource/Enums.cs @@ -760,4 +760,12 @@ namespace Barotrauma PlayerPreference, PlayerChoice, } + + public enum BotStatus + { + PendingHireToActiveService, + PendingHireToReserveBench, + ActiveService, + ReserveBench + } } \ No newline at end of file diff --git a/Barotrauma/BarotraumaShared/SharedSource/Events/EventActions/CheckConditionalAction.cs b/Barotrauma/BarotraumaShared/SharedSource/Events/EventActions/CheckConditionalAction.cs index 85e65cd65..04d517c2f 100644 --- a/Barotrauma/BarotraumaShared/SharedSource/Events/EventActions/CheckConditionalAction.cs +++ b/Barotrauma/BarotraumaShared/SharedSource/Events/EventActions/CheckConditionalAction.cs @@ -25,6 +25,9 @@ namespace Barotrauma [Serialize("", IsPropertySaveable.Yes, description: "A tag to apply to the hull the target is currently in when the check succeeds.")] public Identifier ApplyTagToHull { get; set; } + + [Serialize("", IsPropertySaveable.Yes, description: "Tag to apply to the target (or all targets if there's multiple) when the check succeeds.")] + public Identifier ApplyTagToTarget { get; set; } public CheckConditionalAction(ScriptedEvent parentEvent, ContentXElement element) : base(parentEvent, element) { @@ -84,7 +87,7 @@ namespace Barotrauma { foreach (var target in targets) { - ApplyTagsToHulls(target as Entity, ApplyTagToHull, ApplyTagToLinkedHulls); + ApplyTagsToTarget(target); } return true; } @@ -95,12 +98,21 @@ namespace Barotrauma { if (ConditionalsMatch(target)) { + ApplyTagsToTarget(target); success = true; - ApplyTagsToHulls(target as Entity, ApplyTagToHull, ApplyTagToLinkedHulls); } } return success; } + + void ApplyTagsToTarget(ISerializableEntity target) + { + if (!ApplyTagToTarget.IsEmpty) + { + ParentEvent.AddTarget(ApplyTagToTarget, target as Entity); + } + ApplyTagsToHulls(target as Entity, ApplyTagToHull, ApplyTagToLinkedHulls); + } } private bool ConditionalsMatch(ISerializableEntity target) diff --git a/Barotrauma/BarotraumaShared/SharedSource/Events/EventActions/ConversationAction.cs b/Barotrauma/BarotraumaShared/SharedSource/Events/EventActions/ConversationAction.cs index 8e765c3d1..f6ff09a03 100644 --- a/Barotrauma/BarotraumaShared/SharedSource/Events/EventActions/ConversationAction.cs +++ b/Barotrauma/BarotraumaShared/SharedSource/Events/EventActions/ConversationAction.cs @@ -84,6 +84,8 @@ namespace Barotrauma //an identifier the server uses to identify which ConversationAction a client is responding to public readonly UInt16 Identifier; + private float startDelay; + private int selectedOption = -1; private bool dialogOpened = false; @@ -167,7 +169,11 @@ namespace Barotrauma #else foreach (Client c in GameMain.Server.ConnectedClients) { - if (c.InGame && c.Character != null) { ServerWrite(Speaker, c, interrupt); } + if (c.InGame && c.Character != null) + { + DebugConsole.Log($"Conversation {ParentEvent.Prefab.Identifier} finished, communicating to clients..."); + ServerWrite(Speaker, c, interrupt); + } } #endif ResetSpeaker(); @@ -211,6 +217,16 @@ namespace Barotrauma Speaker = null; } + /// + /// Retriggers the conversation after the specified delay. + /// + public void RetriggerAfter(float delay) + { + startDelay = delay; + dialogOpened = false; + selectedOption = -1; + } + public override bool SetGoToTarget(string goTo) { selectedOption = -1; @@ -264,6 +280,9 @@ namespace Barotrauma public override void Update(float deltaTime) { + startDelay -= deltaTime; + if (startDelay > 0) { return; } + if (interrupt) { Interrupted?.Update(deltaTime); @@ -400,9 +419,22 @@ namespace Barotrauma { targets = ParentEvent.GetTargets(TargetTag).Where(e => IsValidTarget(e)); if (!targets.Any() || IsBlockedByAnotherConversation(targets, BlockOtherConversationsDuration)) { return; } + //some specific character tried to start the convo, but not included in the targets for this conversation -> disallow + if (targetCharacter != null && !targets.Contains(targetCharacter)) { return; } + } + else + { +#if SERVER + if (GameMain.NetworkMember != null) + { + //conversation targeted to everyone, but no-one present yet who could potentially hear it -> don't start yet + UpdateIgnoredClients(); + if (GameMain.NetworkMember.ConnectedClients.None(c => CanClientReceive(c))) { return; } + } +#endif + if (IsBlockedByAnotherConversation(targetCharacter?.ToEnumerable(), BlockOtherConversationsDuration)) { return; } } - if (IsBlockedByAnotherConversation(targetCharacter?.ToEnumerable(), BlockOtherConversationsDuration)) { return; } if (speaker?.AIController is HumanAIController humanAI) { diff --git a/Barotrauma/BarotraumaShared/SharedSource/Events/EventActions/NPCChangeTeamAction.cs b/Barotrauma/BarotraumaShared/SharedSource/Events/EventActions/NPCChangeTeamAction.cs index 8be03f728..675f07bf5 100644 --- a/Barotrauma/BarotraumaShared/SharedSource/Events/EventActions/NPCChangeTeamAction.cs +++ b/Barotrauma/BarotraumaShared/SharedSource/Events/EventActions/NPCChangeTeamAction.cs @@ -77,7 +77,7 @@ namespace Barotrauma ChangeItemTeam(Submarine.MainSub ?? Submarine.Loaded.FirstOrDefault(s => s.TeamID == TeamID), allowStealing: true); if (GameMain.NetworkMember != null && GameMain.NetworkMember.IsServer) { - GameMain.NetworkMember.CreateEntityEvent(npc, new Character.AddToCrewEventData(TeamID, npc.Inventory.AllItems)); + GameMain.NetworkMember.CreateEntityEvent(npc, new Character.AddToCrewEventData(TeamID, npc.Inventory.FindAllItems(recursive: true))); } } else if (RemoveFromCrew && npc.TeamID is CharacterTeamType.Team1 or CharacterTeamType.Team2) @@ -95,7 +95,7 @@ namespace Barotrauma ChangeItemTeam(sub, false); if (GameMain.NetworkMember != null && GameMain.NetworkMember.IsServer) { - GameMain.NetworkMember.CreateEntityEvent(npc, new Character.RemoveFromCrewEventData(TeamID, npc.Inventory.AllItems)); + GameMain.NetworkMember.CreateEntityEvent(npc, new Character.RemoveFromCrewEventData(TeamID, npc.Inventory.FindAllItems(recursive: true))); } } } diff --git a/Barotrauma/BarotraumaShared/SharedSource/Events/EventActions/NPCFollowAction.cs b/Barotrauma/BarotraumaShared/SharedSource/Events/EventActions/NPCFollowAction.cs index 2b01611ae..968e8c988 100644 --- a/Barotrauma/BarotraumaShared/SharedSource/Events/EventActions/NPCFollowAction.cs +++ b/Barotrauma/BarotraumaShared/SharedSource/Events/EventActions/NPCFollowAction.cs @@ -1,3 +1,4 @@ +using System; using System.Collections.Generic; using System.Linq; @@ -22,6 +23,20 @@ namespace Barotrauma [Serialize(true, IsPropertySaveable.Yes, description: "The event actions reset when a GoTo action makes the event jump to a different point. Should the NPC stop following the target when the event resets?")] public bool AbandonOnReset { get; set; } + + [Serialize(AIObjectiveManager.MaxObjectivePriority, IsPropertySaveable.Yes, description: "AI priority for the action. Uses 100 by default, which is the absolute maximum for any objectives, " + + "meaning nothing can be prioritized over it, including the emergency objectives, such as find safety and combat." + + "Setting the priority to 70 would function like a regular order, but with the highest priority." + + "A priority of 60 would make the objective work like a lowest priority order." + + "So, if we'll want the character to follow, but still be able to find safety, defend themselves when attacked, or flee from dangers," + + "it's better to use e.g. 70 instead of 100.")] + public float Priority + { + get => _priority; + set => _priority = Math.Clamp(value, AIObjectiveManager.LowestOrderPriority, AIObjectiveManager.MaxObjectivePriority); + } + + private float _priority; private bool isFinished = false; @@ -39,7 +54,7 @@ namespace Barotrauma if (target == null) { return; } int targetCount = 0; - affectedNpcs = ParentEvent.GetTargets(NPCTag).Where(c => c is Character).Select(c => c as Character); + affectedNpcs = ParentEvent.GetTargets(NPCTag).OfType(); foreach (var npc in affectedNpcs) { if (npc.Removed) { continue; } @@ -49,7 +64,7 @@ namespace Barotrauma { var newObjective = new AIObjectiveGoTo(target, npc, humanAiController.ObjectiveManager, repeat: true) { - OverridePriority = 100.0f, + OverridePriority = Priority, IsFollowOrder = true }; humanAiController.ObjectiveManager.AddObjective(newObjective); diff --git a/Barotrauma/BarotraumaShared/SharedSource/Events/EventActions/NPCOperateItemAction.cs b/Barotrauma/BarotraumaShared/SharedSource/Events/EventActions/NPCOperateItemAction.cs index a6488bf28..95824dff4 100644 --- a/Barotrauma/BarotraumaShared/SharedSource/Events/EventActions/NPCOperateItemAction.cs +++ b/Barotrauma/BarotraumaShared/SharedSource/Events/EventActions/NPCOperateItemAction.cs @@ -2,6 +2,7 @@ using Barotrauma.Items.Components; using System.Collections.Generic; using System.Linq; +using System; namespace Barotrauma { @@ -31,8 +32,19 @@ namespace Barotrauma [Serialize(-1, IsPropertySaveable.Yes, description: "Maximum number of NPCs the action can target. For example, you could only make a specific number of security officers man a periscope.")] public int MaxTargets { get; set; } - [Serialize(100, IsPropertySaveable.Yes, description: "Priority of operating the item (0-100). Higher values will make the AI prefer operating the item over other orders (priority 60-70) or e.g. reacting to emergencies (priority 90).")] - public int Priority { get; set; } + + [Serialize(AIObjectiveManager.MaxObjectivePriority, IsPropertySaveable.Yes, description: "AI priority for the action. Uses 100 by default, which is the absolute maximum for any objectives, " + + "meaning nothing can be prioritized over it, including the emergency objectives, such as find safety and combat." + + "Setting the priority to 70 would function like a regular order, but with the highest priority." + + "A priority of 60 would make the objective work like a lowest priority order." + + "So, if we'll want the character to operate the item, but still be able to find safety, defend themselves when attacked, or flee from dangers," + + "it's better to use e.g. 70 instead of 100.")] + public float Priority + { + get => _priority; + set => _priority = Math.Clamp(value, AIObjectiveManager.LowestOrderPriority, AIObjectiveManager.MaxObjectivePriority); + } + private float _priority; [Serialize(true, IsPropertySaveable.Yes, description: "The event actions reset when a GoTo action makes the event jump to a different point. Should the NPC stop operating the item when the event resets?")] public bool AbandonOnReset { get; set; } diff --git a/Barotrauma/BarotraumaShared/SharedSource/Events/EventActions/NPCWaitAction.cs b/Barotrauma/BarotraumaShared/SharedSource/Events/EventActions/NPCWaitAction.cs index 8a12868f5..487e06ca9 100644 --- a/Barotrauma/BarotraumaShared/SharedSource/Events/EventActions/NPCWaitAction.cs +++ b/Barotrauma/BarotraumaShared/SharedSource/Events/EventActions/NPCWaitAction.cs @@ -1,3 +1,4 @@ +using System; using System.Collections.Generic; using System.Linq; @@ -13,6 +14,20 @@ namespace Barotrauma [Serialize(true, IsPropertySaveable.Yes, description: "Should the NPC start or stop waiting?")] public bool Wait { get; set; } + + [Serialize(AIObjectiveManager.MaxObjectivePriority, IsPropertySaveable.Yes, description: "AI priority for the action. Uses 100 by default, which is the absolute maximum for any objectives, " + + "meaning nothing can be prioritized over it, including the emergency objectives, such as find safety and combat." + + "Setting the priority to 70 would function like a regular order, but with the highest priority." + + "A priority of 60 would make the objective work like a lowest priority order." + + "So, if we'll want the character to wait, but still be able to find safety, defend themselves when attacked, or flee from dangers," + + "it's better to use e.g. 70 instead of 100.")] + public float Priority + { + get => _priority; + set => _priority = Math.Clamp(value, AIObjectiveManager.LowestOrderPriority, AIObjectiveManager.MaxObjectivePriority); + } + + private float _priority; private bool isFinished = false; @@ -25,7 +40,7 @@ namespace Barotrauma { if (isFinished) { return; } - affectedNpcs = ParentEvent.GetTargets(NPCTag).Where(c => c is Character).Select(c => c as Character); + affectedNpcs = ParentEvent.GetTargets(NPCTag).OfType(); foreach (var npc in affectedNpcs) { @@ -38,7 +53,7 @@ namespace Barotrauma AIObjectiveGoTo.GetTargetHull(npc) as ISpatialEntity ?? npc, npc, humanAiController.ObjectiveManager, repeat: true) { FaceTargetOnCompleted = false, - OverridePriority = 100.0f, + OverridePriority = Priority, SourceEventAction = this, IsWaitOrder = true, CloseEnough = 100 diff --git a/Barotrauma/BarotraumaShared/SharedSource/Events/EventActions/TriggerAction.cs b/Barotrauma/BarotraumaShared/SharedSource/Events/EventActions/TriggerAction.cs index 071325db2..35e5901db 100644 --- a/Barotrauma/BarotraumaShared/SharedSource/Events/EventActions/TriggerAction.cs +++ b/Barotrauma/BarotraumaShared/SharedSource/Events/EventActions/TriggerAction.cs @@ -212,13 +212,24 @@ namespace Barotrauma { npc.CampaignInteractionType = CampaignMode.InteractionType.Examine; npc.RequireConsciousnessForCustomInteract = DisableIfTargetIncapacitated; -#if CLIENT npc.SetCustomInteract( - (speaker, player) => { if (e1 == speaker) { Trigger(speaker, player); } else { Trigger(player, speaker); } }, + (Character npc, Character interactor) => + { + //the first character in the CustomInteract callback is always the NPC and the 2nd the character who interacted with it + //but the TriggerAction can configure the 1st and 2nd entity in either order, + //let's make sure we pass the NPC and the interactor in the intended order + if (e1 == npc && ParentEvent.GetTargets(Target2Tag).Contains(interactor)) + { + Trigger(npc, interactor); + } + else if (ParentEvent.GetTargets(Target1Tag).Contains(interactor) && e2 == npc) + { + Trigger(interactor, npc); + } + }, +#if CLIENT TextManager.GetWithVariable("CampaignInteraction.Examine", "[key]", GameSettings.CurrentConfig.KeyMap.KeyBindText(InputType.Use))); #else - npc.SetCustomInteract( - (speaker, player) => { if (e1 == speaker) { Trigger(speaker, player); } else { Trigger(player, speaker); } }, TextManager.Get("CampaignInteraction.Talk")); GameMain.NetworkMember.CreateEntityEvent(npc, new Character.AssignCampaignInteractionEventData()); #endif diff --git a/Barotrauma/BarotraumaShared/SharedSource/Events/EventActions/UnlockPathAction.cs b/Barotrauma/BarotraumaShared/SharedSource/Events/EventActions/UnlockPathAction.cs index eaefc7169..bdd9bb191 100644 --- a/Barotrauma/BarotraumaShared/SharedSource/Events/EventActions/UnlockPathAction.cs +++ b/Barotrauma/BarotraumaShared/SharedSource/Events/EventActions/UnlockPathAction.cs @@ -1,6 +1,7 @@ -using Barotrauma.Networking; +using Barotrauma.Networking; using Microsoft.Xna.Framework; using System; +using System.Collections.Generic; namespace Barotrauma { @@ -9,6 +10,13 @@ namespace Barotrauma /// class UnlockPathAction : EventAction { + private static readonly HashSet pathsUnlockedThisRound = new HashSet(); + + public static void ResetPathsUnlockedThisRound() + { + pathsUnlockedThisRound.Clear(); + } + public UnlockPathAction(ScriptedEvent parentEvent, ContentXElement element) : base(parentEvent, element) { } private bool isFinished = false; @@ -32,6 +40,7 @@ namespace Barotrauma { if (!connection.Locked) { continue; } connection.Locked = false; + pathsUnlockedThisRound.Add(connection); #if SERVER NotifyUnlock(connection); #else @@ -50,17 +59,30 @@ namespace Barotrauma } #if SERVER - private void NotifyUnlock(LocationConnection connection) + public static void NotifyPathsUnlockedThisRound(Client client) + { + foreach (LocationConnection connection in pathsUnlockedThisRound) + { + NotifyUnlock(connection, client); + } + } + + private static void NotifyUnlock(LocationConnection connection) { foreach (Client client in GameMain.Server.ConnectedClients) { - IWriteMessage outmsg = new WriteOnlyMessage(); - outmsg.WriteByte((byte)ServerPacketHeader.EVENTACTION); - outmsg.WriteByte((byte)EventManager.NetworkEventType.UNLOCKPATH); - outmsg.WriteUInt16((UInt16)GameMain.GameSession.Map.Connections.IndexOf(connection)); - GameMain.Server.ServerPeer.Send(outmsg, client.Connection, DeliveryMethod.Reliable); + NotifyUnlock(connection, client); } } + + private static void NotifyUnlock(LocationConnection connection, Client client) + { + IWriteMessage outmsg = new WriteOnlyMessage(); + outmsg.WriteByte((byte)ServerPacketHeader.EVENTACTION); + outmsg.WriteByte((byte)EventManager.NetworkEventType.UNLOCKPATH); + outmsg.WriteUInt16((UInt16)GameMain.GameSession.Map.Connections.IndexOf(connection)); + GameMain.Server.ServerPeer.Send(outmsg, client.Connection, DeliveryMethod.Reliable); + } #endif } } \ No newline at end of file diff --git a/Barotrauma/BarotraumaShared/SharedSource/Events/EventManager.cs b/Barotrauma/BarotraumaShared/SharedSource/Events/EventManager.cs index c0d219e39..1d1d3132d 100644 --- a/Barotrauma/BarotraumaShared/SharedSource/Events/EventManager.cs +++ b/Barotrauma/BarotraumaShared/SharedSource/Events/EventManager.cs @@ -158,6 +158,7 @@ namespace Barotrauma activeEvents.Clear(); #if SERVER MissionAction.ResetMissionsUnlockedThisRound(); + UnlockPathAction.ResetPathsUnlockedThisRound(); #endif pathFinder = new PathFinder(WayPoint.WayPointList, false); totalPathLength = 0.0f; @@ -187,6 +188,12 @@ namespace Barotrauma var selectAlwaysEventSets = GetAllowedEventSets(EventSet.Prefabs.ToList(), requireCampaignSet: playingCampaign).Where(s => s.SelectAlways); foreach (var eventSet in selectAlwaysEventSets) { + if (eventSet.GetCommonness(level) <= 0.0f) + { + //you might be wondering why an event set would be configured to SelectAlways, but have a commonness of 0: + //the set might have a non-zero commonness in some other biome or level type, but not this one + continue; + } if (eventSet.Additive) { additiveSet = eventSet; @@ -252,6 +259,22 @@ namespace Barotrauma level.StartLocation.Connections.ForEach(c => c.Locked = false); } } + if (GameMain.NetworkMember is not { IsClient: true } && level.StartOutpost != null) + { + foreach (var eventTag in level.StartOutpost.Info.TriggerOutpostMissionEvents) + { + EventPrefab eventPrefab = EventPrefab.FindEventPrefab(identifier: Identifier.Empty, tag: eventTag, level.StartOutpost.ContentPackage); + if (eventPrefab == null) + { + DebugConsole.ThrowError($"Outpost {level.StartOutpost.Info.DisplayName} failed to trigger an event (tag: {eventTag}).", contentPackage: level.StartOutpost.ContentPackage); + } + else + { + var newEvent = eventPrefab.CreateInstance(RandomSeed); + ActivateEvent(newEvent); + } + } + } } RegisterNonRepeatableChildEvents(initialEventSet); void RegisterNonRepeatableChildEvents(EventSet eventSet) @@ -450,12 +473,10 @@ namespace Barotrauma /// /// Registers the exhaustible events in the level as exhausted, and adds the current events to the event history /// - public void RegisterEventHistory(bool registerFinishedOnly = false) + public void StoreEventDataAtRoundEnd(bool registerFinishedOnly = false) { if (level?.LevelData == null) { return; } - level.LevelData.EventsExhausted = !registerFinishedOnly; - if (level.LevelData.Type == LevelData.LevelType.Outpost) { if (registerFinishedOnly) @@ -466,7 +487,7 @@ namespace Barotrauma if (parentSet == null) { continue; } if (parentSet.Exhaustible) { - level.LevelData.EventsExhausted = true; + level.LevelData.ExhaustEventSet(parentSet); } if (!level.LevelData.FinishedEvents.TryAdd(parentSet, 1)) { @@ -513,7 +534,7 @@ namespace Barotrauma selectedEvents.Remove(eventSet); if (level == null) { return; } if (level.LevelData.HasHuntingGrounds && eventSet.DisableInHuntingGrounds) { return; } - if (eventSet.Exhaustible && level.LevelData.EventsExhausted) { return; } + if (eventSet.Exhaustible && level.LevelData.IsEventSetExhausted(eventSet)) { return; } DebugConsole.NewMessage($"Loading event set {eventSet.Identifier}", Color.LightBlue, debugOnly: true); @@ -740,7 +761,7 @@ namespace Barotrauma { return level.IsAllowedDifficulty(eventSet.MinLevelDifficulty, eventSet.MaxLevelDifficulty) && - level.LevelData.Type == eventSet.LevelType && + eventSet.LevelType.HasFlag(level.LevelData.Type) && (eventSet.RequiredLayer.IsEmpty || Submarine.LayerExistsInAnySub(eventSet.RequiredLayer)) && (eventSet.RequiredSpawnPointTag.IsEmpty || WayPoint.WayPointList.Any(wp => wp.Tags.Contains(eventSet.RequiredSpawnPointTag))) && (eventSet.BiomeIdentifier.IsEmpty || eventSet.BiomeIdentifier == level.LevelData.Biome.Identifier); @@ -753,7 +774,7 @@ namespace Barotrauma { if (eventSet.Faction != location.Faction?.Prefab.Identifier && eventSet.Faction != location.SecondaryFaction?.Prefab.Identifier) { return false; } } - var locationType = location.GetLocationType(); + var locationType = location.Type; bool includeGenericEvents = level.Type == LevelData.LevelType.LocationConnection || !locationType.IgnoreGenericEvents; if (includeGenericEvents && eventSet.LocationTypeIdentifiers == null) { return true; } return eventSet.LocationTypeIdentifiers != null && eventSet.LocationTypeIdentifiers.Any(identifier => identifier == locationType.Identifier); @@ -1148,16 +1169,15 @@ namespace Barotrauma /// Get the entity that should be used in determining how far the player has progressed in the level. /// = The submarine or player character that has progressed the furthest. /// - public static ISpatialEntity GetRefEntity() + public static ISpatialEntity GetRefEntity(bool acceptRemoteControlledSubs = false) { ISpatialEntity refEntity = Submarine.MainSub; #if CLIENT if (Character.Controlled != null) { - if (Character.Controlled.Submarine != null && - Character.Controlled.Submarine.Info.Type == SubmarineType.Player) + if (Character.Controlled.Submarine is { Info.Type: SubmarineType.Player } playerSub) { - refEntity = Character.Controlled.Submarine; + GetRefSubForCharacter(Character.Controlled); } else { @@ -1170,19 +1190,39 @@ namespace Barotrauma foreach (Barotrauma.Networking.Client client in GameMain.Server.ConnectedClients) { if (client.Character == null) { continue; } - //only take the players inside a player sub into account. - //Otherwise the system could be abused by for example making a respawned player wait - //close to the destination outpost - if (client.Character.Submarine != null && - client.Character.Submarine.Info.Type == SubmarineType.Player) + GetRefSubForCharacter(client.Character); + + } +#endif + + void GetRefSubForCharacter(Character character) + { + if (character.Submarine is { Info.Type: SubmarineType.Player } playerSub) { - if (client.Character.Submarine.WorldPosition.X > refEntity.WorldPosition.X) + if (playerSub.WorldPosition.X > refEntity.WorldPosition.X) { - refEntity = client.Character.Submarine; + refEntity = playerSub; + } + } + if (acceptRemoteControlledSubs) + { + if (character.ViewTarget?.Submarine is { Info.Type: SubmarineType.Player } viewedSub) + { + if (viewedSub.WorldPosition.X > refEntity.WorldPosition.X) + { + refEntity = viewedSub; + } + } + if (character.SelectedItem?.GetComponent()?.ControlledSub is { } controlledSub) + { + if (controlledSub.WorldPosition.X > refEntity.WorldPosition.X) + { + refEntity = controlledSub; + } } } } -#endif + return refEntity; } diff --git a/Barotrauma/BarotraumaShared/SharedSource/Events/Missions/AbandonedOutpostMission.cs b/Barotrauma/BarotraumaShared/SharedSource/Events/Missions/AbandonedOutpostMission.cs index 5b0a2730f..67c18ce0d 100644 --- a/Barotrauma/BarotraumaShared/SharedSource/Events/Missions/AbandonedOutpostMission.cs +++ b/Barotrauma/BarotraumaShared/SharedSource/Events/Missions/AbandonedOutpostMission.cs @@ -1,6 +1,5 @@ using Barotrauma.Extensions; using Microsoft.Xna.Framework; -using System; using System.Collections.Generic; using System.Linq; using System.Xml.Linq; @@ -9,10 +8,6 @@ namespace Barotrauma { partial class AbandonedOutpostMission : Mission { - private readonly XElement characterConfig; - - protected readonly List characters = new List(); - private readonly Dictionary> characterItems = new Dictionary>(); protected readonly HashSet requireKill = new HashSet(); protected readonly HashSet requireRescue = new HashSet(); @@ -82,8 +77,6 @@ namespace Barotrauma public AbandonedOutpostMission(MissionPrefab prefab, Location[] locations, Submarine sub) : base(prefab, locations, sub) { - characterConfig = prefab.ConfigElement.GetChildElement("Characters"); - allowOrderingRescuees = prefab.ConfigElement.GetAttributeBool(nameof(allowOrderingRescuees), true); string msgTag = prefab.ConfigElement.GetAttributeString("hostageskilledmessage", ""); @@ -97,8 +90,6 @@ namespace Barotrauma { failed = false; endTimer = 0.0f; - characters.Clear(); - characterItems.Clear(); requireKill.Clear(); requireRescue.Clear(); items.Clear(); @@ -165,165 +156,6 @@ namespace Barotrauma } } } - - private void InitCharacters(Submarine submarine) - { - characters.Clear(); - characterItems.Clear(); - - if (characterConfig != null) - { - foreach (XElement element in characterConfig.Elements()) - { - if (GameMain.NetworkMember == null && element.GetAttributeBool("multiplayeronly", false)) { continue; } - - int defaultCount = element.GetAttributeInt("count", -1); - if (defaultCount < 0) - { - defaultCount = element.GetAttributeInt("amount", 1); - } - int min = Math.Min(element.GetAttributeInt("min", defaultCount), 255); - int max = Math.Min(Math.Max(min, element.GetAttributeInt("max", defaultCount)), 255); - int count = Rand.Range(min, max + 1); - - if (element.Attribute("identifier") != null && element.Attribute("from") != null) - { - HumanPrefab humanPrefab = GetHumanPrefabFromElement(element); - if (humanPrefab == null) - { - DebugConsole.ThrowError($"Couldn't spawn a human character for abandoned outpost mission: human prefab \"{element.GetAttributeString("identifier", string.Empty)}\" not found", - contentPackage: Prefab.ContentPackage); - continue; - } - for (int i = 0; i < count; i++) - { - LoadHuman(humanPrefab, element, submarine); - } - } - else - { - Identifier speciesName = element.GetAttributeIdentifier("character", element.GetAttributeIdentifier("identifier", Identifier.Empty)); - var characterPrefab = CharacterPrefab.FindBySpeciesName(speciesName); - if (characterPrefab == null) - { - DebugConsole.ThrowError($"Couldn't spawn a character for abandoned outpost mission: character prefab \"{speciesName}\" not found", - contentPackage: Prefab.ContentPackage); - continue; - } - for (int i = 0; i < count; i++) - { - LoadMonster(characterPrefab, element, submarine); - } - } - } - } - } - - private void LoadHuman(HumanPrefab humanPrefab, XElement element, Submarine submarine) - { - Identifier[] moduleFlags = element.GetAttributeIdentifierArray("moduleflags", null); - Identifier[] spawnPointTags = element.GetAttributeIdentifierArray("spawnpointtags", null); - var spawnPointType = element.GetAttributeEnum("spawnpointtype", SpawnType.Human); - ISpatialEntity spawnPos = SpawnAction.GetSpawnPos( - SpawnAction.SpawnLocationType.Outpost, spawnPointType, - moduleFlags ?? humanPrefab.GetModuleFlags(), - spawnPointTags ?? humanPrefab.GetSpawnPointTags(), - element.GetAttributeBool("asfaraspossible", false)); - spawnPos ??= submarine.GetHulls(alsoFromConnectedSubs: false).GetRandomUnsynced(); - - bool requiresRescue = element.GetAttributeBool("requirerescue", false); - var teamId = element.GetAttributeEnum("teamid", requiresRescue ? CharacterTeamType.FriendlyNPC : CharacterTeamType.None); - var originalTeam = Level.Loaded.StartOutpost?.TeamID ?? teamId; - Character spawnedCharacter = CreateHuman(humanPrefab, characters, characterItems, submarine, originalTeam, spawnPos); - //consider the NPC to be "originally" from the team of the outpost it spawns in, and change it to the desired (hostile) team afterwards - //that allows the NPC to fight intruders and otherwise function in the outpost if the mission is configured to spawn the hostile NPCs in a friendly outpost - if (teamId != originalTeam) - { - spawnedCharacter.SetOriginalTeamAndChangeTeam(teamId); - } - if (element.GetAttribute("color") != null) - { - spawnedCharacter.UniqueNameColor = element.GetAttributeColor("color", Color.Red); - } - if (Level.Loaded?.StartOutpost?.Info is { } outPostInfo) - { - outPostInfo.AddOutpostNPCIdentifierOrTag(spawnedCharacter, humanPrefab.Identifier); - foreach (Identifier tag in humanPrefab.GetTags()) - { - outPostInfo.AddOutpostNPCIdentifierOrTag(spawnedCharacter, tag); - } - } - - if (spawnPos is WayPoint wp) - { - spawnedCharacter.GiveIdCardTags(wp); - } - - if (requiresRescue) - { - requireRescue.Add(spawnedCharacter); -#if CLIENT - if (allowOrderingRescuees) - { - GameMain.GameSession.CrewManager.AddCharacterToCrewList(spawnedCharacter); - } -#endif - } - else if (TimesAttempted > 0 && spawnedCharacter.AIController is HumanAIController humanAi) - { - var order = OrderPrefab.Prefabs["fightintruders"] - .CreateInstance(OrderPrefab.OrderTargetType.Entity, orderGiver: spawnedCharacter) - .WithManualPriority(CharacterInfo.HighestManualOrderPriority); - spawnedCharacter.SetOrder(order, isNewOrder: true, speak: false); - } - InitCharacter(spawnedCharacter, element); - } - - private void LoadMonster(CharacterPrefab monsterPrefab, XElement element, Submarine submarine) - { - Identifier[] moduleFlags = element.GetAttributeIdentifierArray("moduleflags", null); - Identifier[] spawnPointTags = element.GetAttributeIdentifierArray("spawnpointtags", null); - ISpatialEntity spawnPos = SpawnAction.GetSpawnPos(SpawnAction.SpawnLocationType.Outpost, SpawnType.Enemy, moduleFlags, spawnPointTags, element.GetAttributeBool("asfaraspossible", false)); - spawnPos ??= submarine.GetHulls(alsoFromConnectedSubs: false).GetRandomUnsynced(); - Character spawnedCharacter = Character.Create(monsterPrefab.Identifier, spawnPos.WorldPosition, ToolBox.RandomSeed(8), createNetworkEvent: false); - characters.Add(spawnedCharacter); - if (spawnedCharacter.Inventory != null) - { - characterItems.Add(spawnedCharacter, spawnedCharacter.Inventory.FindAllItems(recursive: true)); - } - if (submarine != null && spawnedCharacter.AIController is EnemyAIController enemyAi) - { - enemyAi.UnattackableSubmarines.Add(submarine); - enemyAi.UnattackableSubmarines.Add(Submarine.MainSub); - foreach (Submarine sub in Submarine.MainSub.DockedTo) - { - enemyAi.UnattackableSubmarines.Add(sub); - } - } - InitCharacter(spawnedCharacter, element); - } - - private void InitCharacter(Character character, XElement element) - { - if (element.GetAttributeBool("requirekill", false)) - { - requireKill.Add(character); - } - float playDeadProbability = element.GetAttributeFloat("playdeadprobability", -1); - if (playDeadProbability >= 0) - { - character.EvaluatePlayDeadProbability(playDeadProbability); - } - float huskProbability = element.GetAttributeFloat("huskprobability", 0); - if (huskProbability > 0 && Rand.Value() <= huskProbability) - { - character.TurnIntoHusk(); - } - else if (element.GetAttributeBool("corpse", false)) - { - character.Kill(CauseOfDeathType.Unknown, causeOfDeathAffliction: null, log: false); - } - } protected override void UpdateMissionSpecific(float deltaTime) { @@ -341,7 +173,7 @@ namespace Barotrauma if (endTimer > EndDelay) { #if SERVER - if (!(GameMain.GameSession.GameMode is CampaignMode) && GameMain.Server != null) + if (GameMain.GameSession.GameMode is not CampaignMode && GameMain.Server != null) { GameMain.Server.EndGame(); } @@ -362,7 +194,7 @@ namespace Barotrauma break; #if SERVER case 1: - if (!(GameMain.GameSession.GameMode is CampaignMode) && GameMain.Server != null) + if (GameMain.GameSession.GameMode is not CampaignMode && GameMain.Server != null) { if (!Submarine.MainSub.AtStartExit || (wasDocked && !Submarine.MainSub.DockedTo.Contains(Level.Loaded.StartOutpost))) { @@ -385,5 +217,44 @@ namespace Barotrauma { failed = !completed && requireRescue.Any(r => r.Removed || r.IsDead); } + + protected override void InitCharacter(Character character, XElement element) + { + base.InitCharacter(character, element); + if (element.GetAttributeBool("requirekill", false)) + { + requireKill.Add(character); + } + } + + protected override Character LoadHuman(HumanPrefab humanPrefab, XElement element, Submarine submarine) + { + Character spawnedCharacter = base.LoadHuman(humanPrefab, element, submarine); + bool requiresRescue = element.GetAttributeBool("requirerescue", false); + if (requiresRescue) + { + requireRescue.Add(spawnedCharacter); +#if CLIENT + if (allowOrderingRescuees) + { + GameMain.GameSession.CrewManager?.AddCharacterToCrewList(spawnedCharacter); + } +#endif + } + else if (TimesAttempted > 0 && spawnedCharacter.AIController is HumanAIController) + { + var order = OrderPrefab.Prefabs["fightintruders"] + .CreateInstance(OrderPrefab.OrderTargetType.Entity, orderGiver: spawnedCharacter) + .WithManualPriority(CharacterInfo.HighestManualOrderPriority); + spawnedCharacter.SetOrder(order, isNewOrder: true, speak: false); + } + // Overrides the team change set in the base method. + var teamId = element.GetAttributeEnum("teamid", requiresRescue ? CharacterTeamType.FriendlyNPC : CharacterTeamType.None); + if (teamId != spawnedCharacter.TeamID) + { + spawnedCharacter.SetOriginalTeamAndChangeTeam(teamId); + } + return spawnedCharacter; + } } } \ No newline at end of file diff --git a/Barotrauma/BarotraumaShared/SharedSource/Events/Missions/EscortMission.cs b/Barotrauma/BarotraumaShared/SharedSource/Events/Missions/EscortMission.cs index 2edb4b83b..15d69334c 100644 --- a/Barotrauma/BarotraumaShared/SharedSource/Events/Missions/EscortMission.cs +++ b/Barotrauma/BarotraumaShared/SharedSource/Events/Missions/EscortMission.cs @@ -4,17 +4,13 @@ using Microsoft.Xna.Framework; using System; using System.Collections.Generic; using System.Linq; -using System.Xml.Linq; namespace Barotrauma { partial class EscortMission : Mission { - private readonly ContentXElement characterConfig; private readonly ContentXElement itemConfig; - - private readonly List characters = new List(); - private readonly Dictionary> characterItems = new Dictionary>(); + private readonly Dictionary> characterStatusEffects = new Dictionary>(); private readonly int baseEscortedCharacters; @@ -36,7 +32,6 @@ namespace Barotrauma : base(prefab, locations, sub) { missionSub = sub; - characterConfig = prefab.ConfigElement.GetChildElement("Characters"); baseEscortedCharacters = prefab.ConfigElement.GetAttributeInt("baseescortedcharacters", 1); scalingEscortedCharacters = prefab.ConfigElement.GetAttributeFloat("scalingescortedcharacters", 0); terroristChance = prefab.ConfigElement.GetAttributeFloat("terroristchance", 0); diff --git a/Barotrauma/BarotraumaShared/SharedSource/Events/Missions/Mission.cs b/Barotrauma/BarotraumaShared/SharedSource/Events/Missions/Mission.cs index 5a88ec388..e4edf96d7 100644 --- a/Barotrauma/BarotraumaShared/SharedSource/Events/Missions/Mission.cs +++ b/Barotrauma/BarotraumaShared/SharedSource/Events/Missions/Mission.cs @@ -161,6 +161,10 @@ namespace Barotrauma private readonly List delayedTriggerEvents = new List(); public Action OnMissionStateChanged; + + protected readonly ContentXElement characterConfig; + protected readonly List characters = new List(); + protected readonly Dictionary> characterItems = new Dictionary>(); public Mission(MissionPrefab prefab, Location[] locations, Submarine sub) { @@ -192,6 +196,8 @@ namespace Barotrauma messages[m] = ReplaceVariablesInMissionMessage(messages[m], sub); } Messages = messages.ToImmutableArray(); + + characterConfig = prefab.ConfigElement.GetChildElement("Characters"); } public LocalizedString ReplaceVariablesInMissionMessage(LocalizedString message, Submarine sub, bool replaceReward = true) @@ -262,6 +268,162 @@ namespace Barotrauma } return (int)Math.Round(reward); } + + /// + /// Call to load character elements to be spawned. Has to be implemented (and synced) separately per each mission. + /// + protected void InitCharacters(Submarine submarine) + { + characters.Clear(); + characterItems.Clear(); + + if (characterConfig != null) + { + foreach (XElement element in characterConfig.Elements()) + { + if (GameMain.NetworkMember == null && element.GetAttributeBool("multiplayeronly", false)) { continue; } + + int defaultCount = element.GetAttributeInt("count", -1); + if (defaultCount < 0) + { + defaultCount = element.GetAttributeInt("amount", 1); + } + int min = Math.Min(element.GetAttributeInt("min", defaultCount), 255); + int max = Math.Min(Math.Max(min, element.GetAttributeInt("max", defaultCount)), 255); + int count = Rand.Range(min, max + 1); + + if (element.Attribute("identifier") != null && element.Attribute("from") != null) + { + HumanPrefab humanPrefab = GetHumanPrefabFromElement(element); + if (humanPrefab == null) + { + DebugConsole.ThrowError($"Couldn't spawn a human character for a mission: human prefab \"{element.GetAttributeString("identifier", string.Empty)}\" not found", + contentPackage: Prefab.ContentPackage); + continue; + } + for (int i = 0; i < count; i++) + { + LoadHuman(humanPrefab, element, submarine); + } + } + else + { + Identifier speciesName = element.GetAttributeIdentifier("character", element.GetAttributeIdentifier("identifier", Identifier.Empty)); + var characterPrefab = CharacterPrefab.FindBySpeciesName(speciesName); + if (characterPrefab == null) + { + DebugConsole.ThrowError($"Couldn't spawn a character for a mission: character prefab \"{speciesName}\" not found", + contentPackage: Prefab.ContentPackage); + continue; + } + for (int i = 0; i < count; i++) + { + LoadMonster(characterPrefab, element, submarine); + } + } + } + } + } + + private SpawnAction.SpawnLocationType GetSpawnLocationTypeFromSubmarineType(Submarine sub) + { + return sub.Info.Type switch + { + SubmarineType.Outpost or SubmarineType.OutpostModule => SpawnAction.SpawnLocationType.Outpost, + SubmarineType.Wreck => SpawnAction.SpawnLocationType.Wreck, + SubmarineType.Ruin => SpawnAction.SpawnLocationType.Ruin, + SubmarineType.BeaconStation => SpawnAction.SpawnLocationType.BeaconStation, + SubmarineType.Player => SpawnAction.SpawnLocationType.MainSub, + _ => SpawnAction.SpawnLocationType.Any + }; + } + + protected virtual Character LoadHuman(HumanPrefab humanPrefab, XElement element, Submarine submarine) + { + Identifier[] moduleFlags = element.GetAttributeIdentifierArray("moduleflags", null); + Identifier[] spawnPointTags = element.GetAttributeIdentifierArray("spawnpointtags", null); + var spawnPointType = element.GetAttributeEnum("spawnpointtype", SpawnType.Human); + ISpatialEntity spawnPos = SpawnAction.GetSpawnPos( + GetSpawnLocationTypeFromSubmarineType(submarine), spawnPointType, + moduleFlags ?? humanPrefab.GetModuleFlags(), + spawnPointTags ?? humanPrefab.GetSpawnPointTags(), + element.GetAttributeBool("asfaraspossible", false)); + spawnPos ??= submarine.GetHulls(alsoFromConnectedSubs: false).GetRandomUnsynced(); + var teamId = element.GetAttributeEnum("teamid", CharacterTeamType.None); + var originalTeam = Level.Loaded.StartOutpost?.TeamID ?? teamId; + Character spawnedCharacter = CreateHuman(humanPrefab, characters, characterItems, submarine, originalTeam, spawnPos); + //consider the NPC to be "originally" from the team of the outpost it spawns in, and change it to the desired (hostile) team afterwards + //that allows the NPC to fight intruders and otherwise function in the outpost if the mission is configured to spawn the hostile NPCs in a friendly outpost + if (teamId != originalTeam) + { + spawnedCharacter.SetOriginalTeamAndChangeTeam(teamId, processImmediately: true); + } + if (element.GetAttribute("color") != null) + { + spawnedCharacter.UniqueNameColor = element.GetAttributeColor("color", Color.Red); + } + if (submarine.Info is { IsOutpost: true } outPostInfo) + { + outPostInfo.AddOutpostNPCIdentifierOrTag(spawnedCharacter, humanPrefab.Identifier); + foreach (Identifier tag in humanPrefab.GetTags()) + { + outPostInfo.AddOutpostNPCIdentifierOrTag(spawnedCharacter, tag); + } + } + if (spawnPos is WayPoint wp) + { + spawnedCharacter.GiveIdCardTags(wp); + } + InitCharacter(spawnedCharacter, element); + return spawnedCharacter; + } + + protected virtual Character LoadMonster(CharacterPrefab monsterPrefab, XElement element, Submarine submarine) + { + Identifier[] moduleFlags = element.GetAttributeIdentifierArray("moduleflags", null); + Identifier[] spawnPointTags = element.GetAttributeIdentifierArray("spawnpointtags", null); + ISpatialEntity spawnPos = SpawnAction.GetSpawnPos(SpawnAction.SpawnLocationType.Outpost, SpawnType.Enemy, moduleFlags, spawnPointTags, element.GetAttributeBool("asfaraspossible", false)); + spawnPos ??= submarine.GetHulls(alsoFromConnectedSubs: false).GetRandomUnsynced(); + Character spawnedCharacter = Character.Create(monsterPrefab.Identifier, spawnPos.WorldPosition, ToolBox.RandomSeed(8), createNetworkEvent: false); + characters.Add(spawnedCharacter); + if (spawnedCharacter.Inventory != null) + { + characterItems.Add(spawnedCharacter, spawnedCharacter.Inventory.FindAllItems(recursive: true)); + } + if (submarine != null && spawnedCharacter.AIController is EnemyAIController enemyAi) + { + enemyAi.UnattackableSubmarines.Add(submarine); + enemyAi.UnattackableSubmarines.Add(Submarine.MainSub); + foreach (Submarine sub in Submarine.MainSub.DockedTo) + { + enemyAi.UnattackableSubmarines.Add(sub); + } + } + InitCharacter(spawnedCharacter, element); + return spawnedCharacter; + } + + protected virtual void InitCharacter(Character character, XElement element) + { + if (element.GetAttributeBool(Tags.IgnoredByAI.Value, false)) + { + character.AddAbilityFlag(AbilityFlags.IgnoredByEnemyAI); + } + float playDeadProbability = element.GetAttributeFloat("playdeadprobability", -1); + if (playDeadProbability >= 0) + { + character.EvaluatePlayDeadProbability(playDeadProbability); + } + float huskProbability = element.GetAttributeFloat("huskprobability", 0); + if (huskProbability > 0 && Rand.Value() <= huskProbability) + { + character.TurnIntoHusk(); + } + else if (element.GetAttributeBool("corpse", false)) + { + character.Kill(CauseOfDeathType.Unknown, causeOfDeathAffliction: null, log: false); + } + } public void Start(Level level) { diff --git a/Barotrauma/BarotraumaShared/SharedSource/Events/Missions/PirateMission.cs b/Barotrauma/BarotraumaShared/SharedSource/Events/Missions/PirateMission.cs index a7fd4c3c7..e03515c67 100644 --- a/Barotrauma/BarotraumaShared/SharedSource/Events/Missions/PirateMission.cs +++ b/Barotrauma/BarotraumaShared/SharedSource/Events/Missions/PirateMission.cs @@ -12,7 +12,6 @@ namespace Barotrauma partial class PirateMission : Mission { private readonly ContentXElement submarineTypeConfig; - private readonly ContentXElement characterConfig; private readonly ContentXElement characterTypeConfig; private readonly float addedMissionDifficultyPerPlayer; @@ -22,8 +21,8 @@ namespace Barotrauma private Identifier factionIdentifier; private Submarine enemySub; - private readonly List characters = new List(); - private readonly Dictionary> characterItems = new Dictionary>(); + + private readonly Dictionary> characterStatusEffects = new Dictionary>(); private readonly Dictionary> characterStatusEffects = new Dictionary>(); @@ -94,7 +93,6 @@ namespace Barotrauma public PirateMission(MissionPrefab prefab, Location[] locations, Submarine sub) : base(prefab, locations, sub) { submarineTypeConfig = prefab.ConfigElement.GetChildElement("SubmarineTypes"); - characterConfig = prefab.ConfigElement.GetChildElement("Characters"); characterTypeConfig = prefab.ConfigElement.GetChildElement("CharacterTypes"); addedMissionDifficultyPerPlayer = prefab.ConfigElement.GetAttributeFloat("addedmissiondifficultyperplayer", 0); diff --git a/Barotrauma/BarotraumaShared/SharedSource/Events/Missions/SalvageMission.cs b/Barotrauma/BarotraumaShared/SharedSource/Events/Missions/SalvageMission.cs index 4b368c37b..3f958c88d 100644 --- a/Barotrauma/BarotraumaShared/SharedSource/Events/Missions/SalvageMission.cs +++ b/Barotrauma/BarotraumaShared/SharedSource/Events/Missions/SalvageMission.cs @@ -39,6 +39,12 @@ namespace Barotrauma public readonly Identifier ContainerTag; public readonly Identifier ExistingItemTag; + /// + /// If true, target location indicator points to the submarine where the target is inside when the target is not yet found. Not used, if target is not inside any submarine. + /// When enabled, the indicator is hidden when the player is inside the target submarine. + /// + public readonly bool PointToSub; + public readonly bool RemoveItem; public readonly LocalizedString SonarLabel; @@ -55,6 +61,8 @@ namespace Barotrauma public readonly RetrievalState RequiredRetrievalState; public readonly bool HideLabelAfterRetrieved; + public readonly bool HideLabelWhenFound; + public readonly bool HideLabelWhenNotFound; public bool Retrieved { @@ -115,6 +123,9 @@ namespace Barotrauma RequiredRetrievalState = element.GetAttributeEnum("requireretrieval", parentTarget?.RequiredRetrievalState ?? RetrievalState.RetrievedToSub); AllowContinueBeforeRetrieved = element.GetAttributeBool("allowcontinuebeforeretrieved", parentTarget != null); HideLabelAfterRetrieved = element.GetAttributeBool("hidelabelafterretrieved", parentTarget?.HideLabelAfterRetrieved ?? false); + HideLabelWhenFound = element.GetAttributeBool(nameof(HideLabelWhenFound), parentTarget?.HideLabelWhenFound ?? false); + HideLabelWhenNotFound = element.GetAttributeBool(nameof(HideLabelWhenNotFound), parentTarget?.HideLabelWhenNotFound ?? false); + PointToSub = element.GetAttributeBool(nameof(PointToSub), parentTarget?.PointToSub ?? false); RequireInsideOriginalContainer = element.GetAttributeBool("requireinsideoriginalcontainer", false); string sonarLabelTag = element.GetAttributeString("sonarlabel", ""); @@ -203,6 +214,8 @@ namespace Barotrauma /// What percentage of targets need to be retrieved for the mission to complete (0.0 - 1.0). Defaults to 0.98. /// private readonly float requiredDeliveryAmount; + + private LocalizedString pickedUpMessage; /// /// Message displayed when at least one of the targets is retrieved, but the mission is not complete yet. @@ -225,8 +238,26 @@ namespace Barotrauma foreach (var target in targets) { if (target.Retrieved && target.HideLabelAfterRetrieved) { continue; } - if (target.Item != null && !target.Item.Removed) + if (target.State is Target.RetrievalState.None) { + if (target.HideLabelWhenNotFound) { continue; } + } + else if (target.HideLabelWhenFound) + { + continue; + } + if (target.Item is { Removed: false }) + { + if (target.PointToSub && target.Item.Submarine is Submarine targetSub && target.State == Target.RetrievalState.None) + { + if (Character.Controlled is Character playerCharacter && playerCharacter.Submarine != targetSub) + { + // The target is not in the same sub as the player -> point to the target submarine (instead of the item position). + // When inside the target sub, don't show anything. + yield return (target.SonarLabel, targetSub.WorldPosition); + } + continue; + } if (target.Item.ParentInventory?.Owner is Item parentItem) { bool insideParentItem = false; @@ -238,7 +269,7 @@ namespace Barotrauma break; } } - //if the item is inside another target that has it's own sonar label, no need to show one on this item + //if the item is inside another target that has its own sonar label, no need to show one on this item if (insideParentItem) { continue; } } @@ -263,6 +294,7 @@ namespace Barotrauma partiallyRetrievedMessage = GetMessage(nameof(partiallyRetrievedMessage)); allRetrievedMessage = GetMessage(nameof(allRetrievedMessage)); + pickedUpMessage = GetMessage(nameof(pickedUpMessage)); foreach (ContentXElement subElement in prefab.ConfigElement.Elements()) { @@ -341,6 +373,17 @@ namespace Barotrauma #if SERVER spawnInfo.Clear(); #endif + if (!IsClient) + { + // First spawn any possible characters, so that we can use their items as targets. + Target firstTarget = targets.First(); + var submarine = Submarine.Loaded.Find(s => IsValidSubmarine(s, firstTarget.SpawnPositionType)); + if (submarine != null) + { + InitCharacters(submarine); + } + } + foreach (var target in targets) { bool usedExistingItem = false; @@ -380,39 +423,36 @@ namespace Barotrauma case Level.PositionType.Cave: case Level.PositionType.MainPath: case Level.PositionType.SidePath: + case Level.PositionType.AbyssCave: target.Item = suitableItems.FirstOrDefault(it => Vector2.DistanceSquared(it.WorldPosition, position) < 1000.0f); -#if SERVER - usedExistingItem = target.Item != null; -#endif + break; + case Level.PositionType.Abyss: + target.Item = suitableItems.FirstOrDefault(it => Level.IsPositionInAbyss(it.WorldPosition)); break; case Level.PositionType.Ruin: case Level.PositionType.Wreck: case Level.PositionType.Outpost: + case Level.PositionType.BeaconStation: foreach (Item it in suitableItems) { - if (it.Submarine?.Info == null) { continue; } - if (target.SpawnPositionType == Level.PositionType.Ruin && it.Submarine.Info.Type != SubmarineType.Ruin) { continue; } - if (target.SpawnPositionType == Level.PositionType.Wreck && it.Submarine.Info.Type != SubmarineType.Wreck) { continue; } - if (target.SpawnPositionType == Level.PositionType.Outpost && it.Submarine.Info.Type != SubmarineType.Outpost) { continue; } - Rectangle worldBorders = it.Submarine.Borders; - worldBorders.Location += it.Submarine.WorldPosition.ToPoint(); + if (it.Submarine is not Submarine sub) { continue; } + if (!IsValidSubmarine(sub, target.SpawnPositionType)) { continue; } + Rectangle worldBorders = sub.Borders; + worldBorders.Location += sub.WorldPosition.ToPoint(); if (Submarine.RectContains(worldBorders, it.WorldPosition)) { target.Item = it; -#if SERVER - usedExistingItem = true; -#endif break; } } break; default: target.Item = suitableItems.FirstOrDefault(); -#if SERVER - usedExistingItem = target.Item != null; -#endif break; } +#if SERVER + usedExistingItem = target.Item != null; +#endif } if (target.Item == null) @@ -464,19 +504,7 @@ namespace Barotrauma { if (!it.HasTag(target.ContainerTag)) { continue; } if (!it.IsPlayerTeamInteractable) { continue; } - switch (target.SpawnPositionType) - { - case Level.PositionType.Cave: - case Level.PositionType.MainPath: - if (it.Submarine != null) { continue; } - break; - case Level.PositionType.Ruin: - if (it.Submarine?.Info == null || !it.Submarine.Info.IsRuin) { continue; } - break; - case Level.PositionType.Wreck: - if (it.Submarine?.Info == null || it.Submarine.Info.Type != SubmarineType.Wreck) { continue; } - break; - } + if (!IsValidSubmarine(it.Submarine, target.SpawnPositionType)) { continue; } var itemContainer = it.GetComponent(); if (itemContainer != null && itemContainer.Inventory.CanBePut(target.Item)) { validContainers.Add(itemContainer); } } @@ -538,6 +566,26 @@ namespace Barotrauma } } } + + private static bool IsValidSubmarine(Submarine sub, Level.PositionType spawnPosType) + { + if (sub == null) + { + return spawnPosType switch + { + Level.PositionType.Ruin or Level.PositionType.Wreck or Level.PositionType.BeaconStation or Level.PositionType.Outpost => false, + _ => true + }; + } + return spawnPosType switch + { + Level.PositionType.Ruin => sub.Info.IsRuin, + Level.PositionType.Wreck => sub.Info.IsWreck, + Level.PositionType.BeaconStation => sub.Info.IsBeacon, + Level.PositionType.Outpost => sub.Info.IsOutpost, + _ => false + }; + } protected override void UpdateMissionSpecific(float deltaTime) { @@ -571,48 +619,45 @@ namespace Barotrauma switch (target.State) { case Target.RetrievalState.None: + if (target.Interacted) { - if (target.Interacted) - { - TrySetRetrievalState(Target.RetrievalState.Interact); - } - var root = target.Item?.RootContainer ?? target.Item; - if (root.ParentInventory?.Owner is Character character && character.TeamID == CharacterTeamType.Team1) - { - TrySetRetrievalState(Target.RetrievalState.PickedUp); - } - if (inPlayerSub) - { - TrySetRetrievalState(Target.RetrievalState.RetrievedToSub); - } + TrySetRetrievalState(Target.RetrievalState.Interact); + } + var root = target.Item?.RootContainer ?? target.Item; + if (root.ParentInventory?.Owner is Character { TeamID: CharacterTeamType.Team1 }) + { + TrySetRetrievalState(Target.RetrievalState.PickedUp); +#if CLIENT + TryShowPickedUpMessage(); +#endif + } + if (inPlayerSub) + { + TrySetRetrievalState(Target.RetrievalState.RetrievedToSub); } break; case Target.RetrievalState.PickedUp: case Target.RetrievalState.RetrievedToSub: + bool inPlayerInventory = false; + bool playerInFriendlySub = false; + if (rootInventoryOwner is Character { TeamID: CharacterTeamType.Team1 } character) { - - bool inPlayerInventory = false; - bool playerInFriendlySub = false; - if (rootInventoryOwner is Character character && character.TeamID == CharacterTeamType.Team1) + inPlayerInventory = true; + if (character.Submarine != null) { - inPlayerInventory = true; - if (character.Submarine != null) - { - playerInFriendlySub = - character.IsInFriendlySub || - (character.Submarine == Level.Loaded?.StartOutpost && Level.IsLoadedFriendlyOutpost && GameMain.GameSession?.Campaign.CurrentLocation is not { IsFactionHostile: true }); - } - } - - if (inPlayerSub || (inPlayerInventory && playerInFriendlySub)) - { - TrySetRetrievalState(Target.RetrievalState.RetrievedToSub); - } - else - { - target.State = Target.RetrievalState.PickedUp; + playerInFriendlySub = + character.IsInFriendlySub || + (character.Submarine == Level.Loaded?.StartOutpost && Level.IsLoadedFriendlyOutpost && GameMain.GameSession?.Campaign.CurrentLocation is not { IsFactionHostile: true }); } } + if (inPlayerSub || (inPlayerInventory && playerInFriendlySub)) + { + TrySetRetrievalState(Target.RetrievalState.RetrievedToSub); + } + else + { + target.State = Target.RetrievalState.PickedUp; + } break; } @@ -621,7 +666,7 @@ namespace Barotrauma if (retrievalState < target.State || target.State == retrievalState) { return; } bool wasRetrieved = target.Retrieved; target.State = retrievalState; - //increment the mission state if the target became retrieved + //increment the mission state if the target became retrieved if (!wasRetrieved && target.Retrieved) { State = Math.Max(i + 1, State); @@ -645,7 +690,7 @@ namespace Barotrauma { if (requiredDeliveryAmount < 1.0f) { - return targets.Count(t => IsTargetRetrieved(t)) / (float)targets.Count >= requiredDeliveryAmount; + return targets.Count(IsTargetRetrieved) / (float)targets.Count >= requiredDeliveryAmount; } else { @@ -679,7 +724,7 @@ namespace Barotrauma } foreach (var target in targetsToRemove) { - if (target.Item != null && !target.Item.Removed) + if (target.Item is { Removed: false }) { target.Item.Remove(); } diff --git a/Barotrauma/BarotraumaShared/SharedSource/Events/MonsterEvent.cs b/Barotrauma/BarotraumaShared/SharedSource/Events/MonsterEvent.cs index 12f3748c9..d9cd9964a 100644 --- a/Barotrauma/BarotraumaShared/SharedSource/Events/MonsterEvent.cs +++ b/Barotrauma/BarotraumaShared/SharedSource/Events/MonsterEvent.cs @@ -160,9 +160,9 @@ namespace Barotrauma } } - private static Submarine GetReferenceSub() + private static Submarine GetReferenceSub(bool acceptRemoteControlledSubs) { - return EventManager.GetRefEntity() as Submarine ?? Submarine.MainSub; + return EventManager.GetRefEntity(acceptRemoteControlledSubs) as Submarine ?? Submarine.MainSub; } public override IEnumerable GetFilesToPreload() @@ -223,14 +223,6 @@ namespace Barotrauma { createdCharacter.EvaluatePlayDeadProbability(overridePlayDeadProbability); } - if (GameMain.GameSession.IsCurrentLocationRadiated()) - { - AfflictionPrefab radiationPrefab = AfflictionPrefab.RadiationSickness; - Affliction affliction = new Affliction(radiationPrefab, radiationPrefab.MaxStrength); - createdCharacter?.CharacterHealth.ApplyAffliction(null, affliction); - // TODO test multiplayer - createdCharacter?.Kill(CauseOfDeathType.Affliction, affliction, log: false); - } createdCharacter.DisabledByEvent = true; monsters.Add(createdCharacter); } @@ -307,11 +299,18 @@ namespace Barotrauma disallowed = true; return; } - Submarine refSub = GetReferenceSub(); + Submarine refSub = GetReferenceSub(acceptRemoteControlledSubs: true); if (Submarine.MainSubs.Length == 2 && Submarine.MainSubs[1] != null) { refSub = Submarine.MainSubs.GetRandom(Rand.RandSync.Unsynced); } + //if the reference sub is not the main sub, e.g. a remotely controlled drone, + //there's a 50% chance that the monsters will spawn near the main sub instead + //so you can't abuse the remotely controlled subs to make monsters only spawn somewhere far away from the main sub + if (refSub != Submarine.MainSub && Rand.Range(0.0f, 1.0f) < 0.5f) + { + refSub ??= GetReferenceSub(acceptRemoteControlledSubs: false); + } float closestDist = float.PositiveInfinity; //find the closest spawnposition that isn't too close to any of the subs foreach (var position in availablePositions) @@ -745,7 +744,9 @@ namespace Barotrauma DebugConsole.NewMessage($"Spawned: {ToString()}. Strength: {StringFormatter.FormatZeroDecimal(monsters.Sum(m => m.Params.AI?.CombatStrength ?? 0))}.", Color.LightBlue, debugOnly: true); } - if (GameMain.GameSession != null) + if (GameMain.GameSession != null && + monster.ContentPackage == ContentPackageManager.VanillaCorePackage && + GameAnalyticsManager.ShouldLogRandomSample()) { GameAnalyticsManager.AddDesignEvent( $"MonsterSpawn:{GameMain.GameSession.GameMode?.Preset?.Identifier.Value ?? "none"}:{Level.Loaded?.LevelData?.Biome?.Identifier.Value ?? "none"}:{SpawnPosType}:{SpeciesName}", diff --git a/Barotrauma/BarotraumaShared/SharedSource/GameAnalytics/GameAnalyticsManager.cs b/Barotrauma/BarotraumaShared/SharedSource/GameAnalytics/GameAnalyticsManager.cs index 27ff2c57e..3abffee20 100644 --- a/Barotrauma/BarotraumaShared/SharedSource/GameAnalytics/GameAnalyticsManager.cs +++ b/Barotrauma/BarotraumaShared/SharedSource/GameAnalytics/GameAnalyticsManager.cs @@ -376,6 +376,33 @@ namespace Barotrauma #endif } + public enum DataSampleSize + { + Small, + Medium, + Large, + Full + } + + private readonly static Dictionary dataSampleSizes = new Dictionary() + { + { DataSampleSize.Small, 0.01f }, + { DataSampleSize.Medium, 0.05f }, + { DataSampleSize.Large, 0.5f }, + { DataSampleSize.Full, 1.0f } + }; + + /// + /// Should we log something into GameAnalytics if we only want a random sample of some events? + /// Essentially just randomly decides whether to log or not based on the probability + /// + /// A value between 0 and 1 + /// + public static bool ShouldLogRandomSample(DataSampleSize sampleSize = DataSampleSize.Small) + { + return Rand.Range(0.0f, 1.0f) < dataSampleSizes[sampleSize]; + } + public static void AddErrorEvent(ErrorSeverity errorSeverity, string message) { if (!SendUserStatistics) { return; } diff --git a/Barotrauma/BarotraumaShared/SharedSource/GameSession/AutoItemPlacer.cs b/Barotrauma/BarotraumaShared/SharedSource/GameSession/AutoItemPlacer.cs index 9a6228b27..b59b9a86e 100644 --- a/Barotrauma/BarotraumaShared/SharedSource/GameSession/AutoItemPlacer.cs +++ b/Barotrauma/BarotraumaShared/SharedSource/GameSession/AutoItemPlacer.cs @@ -199,12 +199,12 @@ namespace Barotrauma { var itemPrefab = prefabsItemsCanSpawnIn[i]; if (itemPrefab == null) { continue; } - SpawnItems(itemPrefab); + SpawnItems(itemPrefab, skipItemProbability); } // Spawn items that nothing can spawn in last singlePrefabs.Shuffle(Rand.RandSync.ServerAndClient); - singlePrefabs.ForEach(i => SpawnItems(i)); + singlePrefabs.ForEach(i => SpawnItems(i, skipItemProbability)); if (OutputDebugInfo) { @@ -251,7 +251,7 @@ namespace Barotrauma return itemsToSpawn; - void SpawnItems(ItemPrefab itemPrefab, float skipItemProbability = 0.0f) + void SpawnItems(ItemPrefab itemPrefab, float skipItemProbability) { if (Rand.Range(0.0f, 1.0f, Rand.RandSync.ServerAndClient) < skipItemProbability) { return; } if (itemPrefab == null) diff --git a/Barotrauma/BarotraumaShared/SharedSource/GameSession/CrewManager.cs b/Barotrauma/BarotraumaShared/SharedSource/GameSession/CrewManager.cs index e83b8bba2..cf243e4cc 100644 --- a/Barotrauma/BarotraumaShared/SharedSource/GameSession/CrewManager.cs +++ b/Barotrauma/BarotraumaShared/SharedSource/GameSession/CrewManager.cs @@ -5,9 +5,9 @@ using Barotrauma.Tutorials; using Microsoft.Xna.Framework; using System; using System.Collections.Generic; +using System.Diagnostics; using System.Linq; using System.Xml.Linq; -using Barotrauma.Networking; namespace Barotrauma { @@ -27,19 +27,29 @@ namespace Barotrauma private readonly List characterInfos = new List(); private readonly List characters = new List(); + + private readonly List reserveBench = new List(); - public IEnumerable GetCharacters() - { - return characters; - } /// - /// Note: this only returns AI characters' infos in multiplayer. The infos are used to manage hiring/firing/renaming, which only applies to AI characters. + /// NOTE: When called from client code, this method will include players, but NOT when called from server code. + /// CrewManager is used for dealing with things relevant to AI characters, like hiring, firing, renaming, and the reserve bench. + /// In single player/client code, player CharacterInfos are still stored in it but only for displaying crew listings in the GUI correctly. /// Use to get all the characters regardless if they're player or AI controlled. /// - public IEnumerable GetCharacterInfos() + /// Should characters on the reserve be included? Defaults to false. + public IEnumerable GetCharacterInfos(bool includeReserveBench = false) { + if (includeReserveBench) + { + return characterInfos.Concat(reserveBench); + } return characterInfos; } + + public IEnumerable GetReserveBenchInfos() + { + return reserveBench; + } private Character welcomeMessageNPC; @@ -174,7 +184,14 @@ namespace Barotrauma if (characterElement.GetAttributeBool("lastcontrolled", false)) { characterInfo.LastControlled = true; } characterInfo.CrewListIndex = characterElement.GetAttributeInt("crewlistindex", -1); #endif - characterInfos.Add(characterInfo); + if (characterElement.GetAttributeBool(nameof(CharacterInfo.IsOnReserveBench), false)) + { + reserveBench.Add(characterInfo); + } + else + { + characterInfos.Add(characterInfo); + } foreach (var subElement in characterElement.Elements()) { switch (subElement.Name.ToString().ToLowerInvariant()) @@ -199,7 +216,14 @@ namespace Barotrauma /// public void RemoveCharacterInfo(CharacterInfo characterInfo) { + if (characterInfo is { IsOnReserveBench: true }) + { + reserveBench.Remove(characterInfo); + } characterInfos.Remove(characterInfo); +#if CLIENT + GameMain.GameSession?.DeathPrompt?.UpdateBotList(); +#endif } public void AddCharacter(Character character) @@ -289,6 +313,15 @@ namespace Barotrauma public void AddCharacterInfo(CharacterInfo characterInfo) { + if (GameMain.GameSession?.Campaign is MultiPlayerCampaign) + { + Debug.Assert(characterInfo.BotStatus == BotStatus.ActiveService); + if (characterInfo.BotStatus != BotStatus.ActiveService) + { + DebugConsole.ThrowError($"CrewManager.AddCharacterInfo called on a bot ({characterInfo.DisplayName}) with the wrong status ({characterInfo.BotStatus})"); + } + } + if (characterInfos.Contains(characterInfo)) { DebugConsole.ThrowError("Tried to add the same character info to CrewManager twice.\n" + Environment.StackTrace.CleanupStackTrace()); @@ -296,11 +329,15 @@ namespace Barotrauma } characterInfos.Add(characterInfo); +#if CLIENT + GameMain.GameSession?.DeathPrompt?.UpdateBotList(); +#endif } public void ClearCharacterInfos() { characterInfos.Clear(); + reserveBench.Clear(); } public void InitRound() @@ -313,7 +350,7 @@ namespace Barotrauma List spawnWaypoints = null; List mainSubWaypoints = WayPoint.SelectCrewSpawnPoints(characterInfos, Submarine.MainSub).ToList(); - + if (Level.Loaded != null && Level.Loaded.ShouldSpawnCrewInsideOutpost()) { spawnWaypoints = GetOutpostSpawnpoints(); @@ -324,7 +361,7 @@ namespace Barotrauma while (spawnWaypoints.Any() && spawnWaypoints.Count < characterInfos.Count) { spawnWaypoints.Add(spawnWaypoints[Rand.Int(spawnWaypoints.Count)]); - } + } } if (spawnWaypoints == null || !spawnWaypoints.Any()) { diff --git a/Barotrauma/BarotraumaShared/SharedSource/GameSession/GameModes/CampaignMode.cs b/Barotrauma/BarotraumaShared/SharedSource/GameSession/GameModes/CampaignMode.cs index 174e3cb70..ff5a4ade7 100644 --- a/Barotrauma/BarotraumaShared/SharedSource/GameSession/GameModes/CampaignMode.cs +++ b/Barotrauma/BarotraumaShared/SharedSource/GameSession/GameModes/CampaignMode.cs @@ -177,10 +177,28 @@ namespace Barotrauma if (GameMain.NetworkMember.GameStarted) { - //allow managing if no-one with permissions is alive and in-game - return GameMain.NetworkMember.ConnectedClients.None(c => - c.InGame && c.Character is { IsIncapacitated: false, IsDead: false } && - (IsOwner(c) || c.HasPermission(permissions))); + bool someOneHasPermissions = GameMain.NetworkMember.ConnectedClients.Any(c => IsOwner(c) || c.HasPermission(permissions)); + if (someOneHasPermissions) + { + if (GameMain.GameSession != null && GameMain.GameSession.RoundDuration < 60.0f) + { + //round has been going on for less than a minute, don't allow anyone to manage just yet, + //the people with permissions might still be loading or doing something in the lobby + return false; + } + else + { + //allow managing if the round has been going on for a while, and no-one with permissions is alive and in-game + return GameMain.NetworkMember.ConnectedClients.None(c => + c.InGame && c.Character is { IsIncapacitated: false, IsDead: false } && + (IsOwner(c) || c.HasPermission(permissions))); + } + } + else + { + //no-one in the server with permissions, allow anyone to manage + return true; + } } else { @@ -963,7 +981,9 @@ namespace Barotrauma { UpdateStoreStock(); } - GameMain.GameSession.EventManager?.RegisterEventHistory(registerFinishedOnly: true); + + GameMain.GameSession.EndMissions(); + GameMain.GameSession.EventManager?.StoreEventDataAtRoundEnd(registerFinishedOnly: true); } /// @@ -1089,13 +1109,24 @@ namespace Barotrauma return false; } } - var price = buyingNewCharacter ? NewCharacterCost(characterInfo) : HireManager.GetSalaryFor(characterInfo); + int price = buyingNewCharacter ? NewCharacterCost(characterInfo) : HireManager.GetSalaryFor(characterInfo); if (takeMoney && !TryPurchase(client, price)) { return false; } characterInfo.IsNewHire = true; characterInfo.Title = null; location.RemoveHireableCharacter(characterInfo); - CrewManager.AddCharacterInfo(characterInfo); + + if (GameMain.GameSession?.Campaign is MultiPlayerCampaign) + { +#if SERVER + CrewManager.ToggleReserveBenchStatus(characterInfo, client, pendingHire: true, confirmPendingHire: true, sendUpdate: false); +#endif + } + else + { + CrewManager.AddCharacterInfo(characterInfo); + } + GameAnalyticsManager.AddMoneySpentEvent(characterInfo.Salary, GameAnalyticsManager.MoneySink.Crew, characterInfo.Job?.Prefab.Identifier.Value ?? "unknown"); return true; } diff --git a/Barotrauma/BarotraumaShared/SharedSource/GameSession/GameModes/MultiPlayerCampaign.cs b/Barotrauma/BarotraumaShared/SharedSource/GameSession/GameModes/MultiPlayerCampaign.cs index c55528ccc..80e30c2cf 100644 --- a/Barotrauma/BarotraumaShared/SharedSource/GameSession/GameModes/MultiPlayerCampaign.cs +++ b/Barotrauma/BarotraumaShared/SharedSource/GameSession/GameModes/MultiPlayerCampaign.cs @@ -92,6 +92,11 @@ namespace Barotrauma get; set; } + public byte RoundID + { + get; set; + } + private MultiPlayerCampaign(CampaignSettings settings) : base(GameModePreset.MultiPlayerCampaign, settings) { currentCampaignID++; diff --git a/Barotrauma/BarotraumaShared/SharedSource/GameSession/GameSession.cs b/Barotrauma/BarotraumaShared/SharedSource/GameSession/GameSession.cs index 9e042711e..0ba51acd6 100644 --- a/Barotrauma/BarotraumaShared/SharedSource/GameSession/GameSession.cs +++ b/Barotrauma/BarotraumaShared/SharedSource/GameSession/GameSession.cs @@ -38,7 +38,7 @@ namespace Barotrauma } } - if (Submarine.MainSubs[1] is not null) + if (Submarine.MainSubs[1] is not null && GameMain.GameSession.GameMode is PvPMode) { foreach (var team2Perk in Team2Perks) { @@ -703,70 +703,6 @@ namespace Barotrauma casualties.Clear(); - GameAnalyticsManager.AddProgressionEvent( - GameAnalyticsManager.ProgressionStatus.Start, - GameMode?.Preset?.Identifier.Value ?? "none"); - - string eventId = "StartRound:" + (GameMode?.Preset?.Identifier.Value ?? "none") + ":"; - GameAnalyticsManager.AddDesignEvent(eventId + "Submarine:" + (Submarine.MainSub?.Info?.Name ?? "none")); - GameAnalyticsManager.AddDesignEvent(eventId + "GameMode:" + (GameMode?.Preset?.Identifier.Value ?? "none")); - GameAnalyticsManager.AddDesignEvent(eventId + "CrewSize:" + (CrewManager?.GetCharacterInfos()?.Count() ?? 0)); - foreach (Mission mission in missions) - { - GameAnalyticsManager.AddDesignEvent(eventId + "MissionType:" + (mission.Prefab.Type.ToString() ?? "none") + ":" + mission.Prefab.Identifier); - } - if (Level.Loaded != null) - { - Identifier levelId = (Level.Loaded.Type == LevelData.LevelType.Outpost ? - Level.Loaded.StartOutpost?.Info?.OutpostGenerationParams?.Identifier : - Level.Loaded.GenerationParams?.Identifier) ?? "null".ToIdentifier(); - GameAnalyticsManager.AddDesignEvent(eventId + "LevelType:" + Level.Loaded.Type.ToString() + ":" + levelId); - } - GameAnalyticsManager.AddDesignEvent(eventId + "Biome:" + (Level.Loaded?.LevelData?.Biome?.Identifier.Value ?? "none")); -#if CLIENT - if (GameMode is TutorialMode tutorialMode) - { - GameAnalyticsManager.AddDesignEvent(eventId + tutorialMode.Tutorial.Identifier); - if (GameMain.IsFirstLaunch) - { - GameAnalyticsManager.AddDesignEvent("FirstLaunch:" + eventId + tutorialMode.Tutorial.Identifier); - } - } - GameAnalyticsManager.AddDesignEvent($"{eventId}HintManager:{(HintManager.Enabled ? "Enabled" : "Disabled")}"); -#endif - var campaignMode = GameMode as CampaignMode; - if (campaignMode != null) - { - GameAnalyticsManager.AddDesignEvent("CampaignSettings:RadiationEnabled:" + campaignMode.Settings.RadiationEnabled); - GameAnalyticsManager.AddDesignEvent("CampaignSettings:WorldHostility:" + campaignMode.Settings.WorldHostility); - GameAnalyticsManager.AddDesignEvent("CampaignSettings:ShowHuskWarning:" + campaignMode.Settings.ShowHuskWarning); - GameAnalyticsManager.AddDesignEvent("CampaignSettings:StartItemSet:" + campaignMode.Settings.StartItemSet); - GameAnalyticsManager.AddDesignEvent("CampaignSettings:MaxMissionCount:" + campaignMode.Settings.MaxMissionCount); - //log the multipliers as integers to reduce the number of distinct values - GameAnalyticsManager.AddDesignEvent("CampaignSettings:RepairFailMultiplier:" + (int)(campaignMode.Settings.RepairFailMultiplier * 100)); - GameAnalyticsManager.AddDesignEvent("CampaignSettings:FuelMultiplier:" + (int)(campaignMode.Settings.FuelMultiplier * 100)); - GameAnalyticsManager.AddDesignEvent("CampaignSettings:MissionRewardMultiplier:" + (int)(campaignMode.Settings.MissionRewardMultiplier * 100)); - GameAnalyticsManager.AddDesignEvent("CampaignSettings:CrewVitalityMultiplier:" + (int)(campaignMode.Settings.CrewVitalityMultiplier * 100)); - GameAnalyticsManager.AddDesignEvent("CampaignSettings:NonCrewVitalityMultiplier:" + (int)(campaignMode.Settings.NonCrewVitalityMultiplier * 100)); - GameAnalyticsManager.AddDesignEvent("CampaignSettings:OxygenMultiplier:" + (int)(campaignMode.Settings.OxygenMultiplier * 100)); - GameAnalyticsManager.AddDesignEvent("CampaignSettings:RepairFailMultiplier:" + (int)(campaignMode.Settings.RepairFailMultiplier * 100)); - GameAnalyticsManager.AddDesignEvent("CampaignSettings:ShipyardPriceMultiplier:" + (int)(campaignMode.Settings.ShipyardPriceMultiplier * 100)); - GameAnalyticsManager.AddDesignEvent("CampaignSettings:ShopPriceMultiplier:" + (int)(campaignMode.Settings.ShopPriceMultiplier * 100)); - - bool firstTimeInBiome = Map != null && !Map.Connections.Any(c => c.Passed && c.Biome == LevelData!.Biome); - if (firstTimeInBiome) - { - GameAnalyticsManager.AddDesignEvent(eventId + (Level.Loaded?.LevelData?.Biome?.Identifier.Value ?? "none") + "Discovered:Playtime", campaignMode.TotalPlayTime); - GameAnalyticsManager.AddDesignEvent(eventId + (Level.Loaded?.LevelData?.Biome?.Identifier.Value ?? "none") + "Discovered:PassedLevels", campaignMode.TotalPassedLevels); - } - if (GameMain.NetworkMember?.ServerSettings is { } serverSettings) - { - GameAnalyticsManager.AddDesignEvent("ServerSettings:RespawnMode:" + serverSettings.RespawnMode); - GameAnalyticsManager.AddDesignEvent("ServerSettings:IronmanMode:" + serverSettings.IronmanModeActive); - GameAnalyticsManager.AddDesignEvent("ServerSettings:AllowBotTakeoverOnPermadeath:" + serverSettings.AllowBotTakeoverOnPermadeath); - } - } - #if DEBUG double startDuration = (DateTime.Now - startTime).TotalSeconds; if (startDuration < MinimumLoadingTime) @@ -818,7 +754,11 @@ namespace Barotrauma GameMain.LuaCs.Hook.Call("roundStart"); EnableEventLogNotificationIcon(enabled: false); + + LogStartRoundStats(); + #endif + var campaignMode = GameMode as CampaignMode; if (campaignMode is { ItemsRelocatedToMainSub: true }) { #if SERVER @@ -1102,7 +1042,11 @@ namespace Barotrauma #if SERVER players = GameMain.Server.ConnectedClients.Select(c => c.Character).Where(c => c?.Info != null && !c.IsDead); - bots = crewManager.GetCharacters().Where(c => !c.IsRemotePlayer); + bots = crewManager.GetCharacterInfos() + //filter out players in case a player has been given control of a bot using console commands + .Where(characterInfo => GameMain.Server.ConnectedClients.None(c => c.CharacterInfo == characterInfo)) + .Select(characterInfo => characterInfo.Character) + .NotNull(); #elif CLIENT players = crewManager.GetCharacters().Where(static c => c.IsPlayer); bots = crewManager.GetCharacters().Where(static c => c.IsBot); @@ -1124,7 +1068,7 @@ namespace Barotrauma private double LastEndRoundErrorMessageTime; #endif - public void EndRound(string endMessage, CampaignMode.TransitionType transitionType = CampaignMode.TransitionType.None, TraitorManager.TraitorResults? traitorResults = null) + public void EndRound(string endMessage, CampaignMode.TransitionType transitionType = CampaignMode.TransitionType.None, TraitorManager.TraitorResults? traitorResults = null, bool createRoundSummary = true) { RoundEnding = true; @@ -1141,35 +1085,14 @@ namespace Barotrauma ImmutableHashSet crewCharacters = GetSessionCrewCharacters(CharacterType.Both); int prevMoney = GetAmountOfMoney(crewCharacters); - foreach (Mission mission in missions) - { - mission.End(); - } + + EndMissions(); foreach (Character character in crewCharacters) { character.CheckTalents(AbilityEffectType.OnRoundEnd); } - if (missions.Any()) - { - if (missions.Any(m => m.Completed)) - { - foreach (Character character in crewCharacters) - { - character.CheckTalents(AbilityEffectType.OnAnyMissionCompleted); - } - } - - if (missions.All(m => m.Completed)) - { - foreach (Character character in crewCharacters) - { - character.CheckTalents(AbilityEffectType.OnAllMissionsCompleted); - } - } - } - GameMain.LuaCs.Hook.Call("missionsEnded", missions); #if CLIENT @@ -1186,7 +1109,10 @@ namespace Barotrauma GUI.PreventPauseMenuToggle = true; - if (GameMode is not TestGameMode && Screen.Selected == GameMain.GameScreen && RoundSummary != null && transitionType != CampaignMode.TransitionType.End) + if (createRoundSummary && + GameMode is not TestGameMode && + Screen.Selected == GameMain.GameScreen && RoundSummary != null && + transitionType != CampaignMode.TransitionType.End) { GUI.ClearMessages(); GUIMessageBox.MessageBoxes.RemoveAll(mb => mb.UserData is RoundSummary); @@ -1265,6 +1191,34 @@ namespace Barotrauma } } + public void EndMissions() + { + ImmutableHashSet crewCharacters = GetSessionCrewCharacters(CharacterType.Both); + foreach (Mission mission in missions) + { + mission.End(); + } + + if (missions.Any()) + { + if (missions.Any(m => m.Completed)) + { + foreach (Character character in crewCharacters) + { + character.CheckTalents(AbilityEffectType.OnAnyMissionCompleted); + } + } + + if (missions.All(m => m.Completed)) + { + foreach (Character character in crewCharacters) + { + character.CheckTalents(AbilityEffectType.OnAllMissionsCompleted); + } + } + } + } + public static PerkCollection GetPerks() { if (GameMain.NetworkMember?.ServerSettings is not { } serverSettings) @@ -1347,8 +1301,89 @@ namespace Barotrauma return true; } + private void LogStartRoundStats() + { +#if !UNSTABLE + if (!GameAnalyticsManager.ShouldLogRandomSample()) + { + return; + } +#endif + GameAnalyticsManager.AddProgressionEvent( + GameAnalyticsManager.ProgressionStatus.Start, + GameMode?.Preset?.Identifier.Value ?? "none"); + + string eventId = "StartRound:" + (GameMode?.Preset?.Identifier.Value ?? "none") + ":"; + GameAnalyticsManager.AddDesignEvent(eventId + "Submarine:" + (Submarine.MainSub?.Info?.Name ?? "none")); + GameAnalyticsManager.AddDesignEvent(eventId + "GameMode:" + (GameMode?.Preset?.Identifier.Value ?? "none")); + GameAnalyticsManager.AddDesignEvent(eventId + "CrewSize:" + (CrewManager?.GetCharacterInfos()?.Count() ?? 0)); + foreach (Mission mission in missions) + { + GameAnalyticsManager.AddDesignEvent(eventId + "MissionType:" + (mission.Prefab.Type.ToString() ?? "none") + ":" + mission.Prefab.Identifier); + } + if (Level.Loaded != null) + { + Identifier levelId = (Level.Loaded.Type == LevelData.LevelType.Outpost ? + Level.Loaded.StartOutpost?.Info?.OutpostGenerationParams?.Identifier : + Level.Loaded.GenerationParams?.Identifier) ?? "null".ToIdentifier(); + GameAnalyticsManager.AddDesignEvent(eventId + "LevelType:" + Level.Loaded.Type.ToString() + ":" + levelId); + } + GameAnalyticsManager.AddDesignEvent(eventId + "Biome:" + (Level.Loaded?.LevelData?.Biome?.Identifier.Value ?? "none")); +#if CLIENT + if (GameMode is TutorialMode tutorialMode) + { + GameAnalyticsManager.AddDesignEvent(eventId + tutorialMode.Tutorial.Identifier); + if (GameMain.IsFirstLaunch) + { + GameAnalyticsManager.AddDesignEvent("FirstLaunch:" + eventId + tutorialMode.Tutorial.Identifier); + } + } + GameAnalyticsManager.AddDesignEvent($"{eventId}HintManager:{(HintManager.Enabled ? "Enabled" : "Disabled")}"); +#endif + var campaignMode = GameMode as CampaignMode; + if (campaignMode != null) + { + GameAnalyticsManager.AddDesignEvent("CampaignSettings:RadiationEnabled:" + campaignMode.Settings.RadiationEnabled); + GameAnalyticsManager.AddDesignEvent("CampaignSettings:WorldHostility:" + campaignMode.Settings.WorldHostility); + GameAnalyticsManager.AddDesignEvent("CampaignSettings:ShowHuskWarning:" + campaignMode.Settings.ShowHuskWarning); + GameAnalyticsManager.AddDesignEvent("CampaignSettings:StartItemSet:" + campaignMode.Settings.StartItemSet); + GameAnalyticsManager.AddDesignEvent("CampaignSettings:MaxMissionCount:" + campaignMode.Settings.MaxMissionCount); + //log the multipliers as integers to reduce the number of distinct values + GameAnalyticsManager.AddDesignEvent("CampaignSettings:RepairFailMultiplier:" + (int)(campaignMode.Settings.RepairFailMultiplier * 100)); + GameAnalyticsManager.AddDesignEvent("CampaignSettings:FuelMultiplier:" + (int)(campaignMode.Settings.FuelMultiplier * 100)); + GameAnalyticsManager.AddDesignEvent("CampaignSettings:MissionRewardMultiplier:" + (int)(campaignMode.Settings.MissionRewardMultiplier * 100)); + GameAnalyticsManager.AddDesignEvent("CampaignSettings:CrewVitalityMultiplier:" + (int)(campaignMode.Settings.CrewVitalityMultiplier * 100)); + GameAnalyticsManager.AddDesignEvent("CampaignSettings:NonCrewVitalityMultiplier:" + (int)(campaignMode.Settings.NonCrewVitalityMultiplier * 100)); + GameAnalyticsManager.AddDesignEvent("CampaignSettings:OxygenMultiplier:" + (int)(campaignMode.Settings.OxygenMultiplier * 100)); + GameAnalyticsManager.AddDesignEvent("CampaignSettings:RepairFailMultiplier:" + (int)(campaignMode.Settings.RepairFailMultiplier * 100)); + GameAnalyticsManager.AddDesignEvent("CampaignSettings:ShipyardPriceMultiplier:" + (int)(campaignMode.Settings.ShipyardPriceMultiplier * 100)); + GameAnalyticsManager.AddDesignEvent("CampaignSettings:ShopPriceMultiplier:" + (int)(campaignMode.Settings.ShopPriceMultiplier * 100)); + + bool firstTimeInBiome = Map != null && !Map.Connections.Any(c => c.Passed && c.Biome == LevelData!.Biome); + if (firstTimeInBiome) + { + GameAnalyticsManager.AddDesignEvent(eventId + (Level.Loaded?.LevelData?.Biome?.Identifier.Value ?? "none") + "Discovered:Playtime", campaignMode.TotalPlayTime); + GameAnalyticsManager.AddDesignEvent(eventId + (Level.Loaded?.LevelData?.Biome?.Identifier.Value ?? "none") + "Discovered:PassedLevels", campaignMode.TotalPassedLevels); + } + if (GameMain.NetworkMember?.ServerSettings is { } serverSettings) + { + GameAnalyticsManager.AddDesignEvent("ServerSettings:RespawnMode:" + serverSettings.RespawnMode); + GameAnalyticsManager.AddDesignEvent("ServerSettings:IronmanMode:" + serverSettings.IronmanModeActive); + GameAnalyticsManager.AddDesignEvent("ServerSettings:AllowBotTakeoverOnPermadeath:" + serverSettings.AllowBotTakeoverOnPermadeath); + } + } + + } + public void LogEndRoundStats(string eventId, TraitorManager.TraitorResults? traitorResults = null) { +#if !UNSTABLE + //only collect the stats from a random sample of round ends + if (!GameAnalyticsManager.ShouldLogRandomSample()) + { + return; + } +#endif if (Submarine.MainSub?.Info?.IsVanillaSubmarine() ?? false) { //don't log modded subs, that's a ton of extra data to collect @@ -1405,7 +1440,8 @@ namespace Barotrauma GameAnalyticsManager.AddDesignEvent($"TraitorEvent:{traitorResults.Value.TraitorEventIdentifier}:{(traitorResults.Value.VotedCorrectTraitor ? "TraitorIdentifier" : "TraitorUnidentified")}"); } - foreach (Character c in GetSessionCrewCharacters(CharacterType.Both)) + //disabled to reduce the amount of data we collect through GA + /*foreach (Character c in GetSessionCrewCharacters(CharacterType.Both)) { foreach (var itemSelectedDuration in c.ItemSelectedDurations) { @@ -1420,7 +1456,7 @@ namespace Barotrauma } GameAnalyticsManager.AddDesignEvent("TimeSpentOnDevices:" + (GameMode?.Preset?.Identifier.Value ?? "none") + ":" + characterType + ":" + (c.Info?.Job?.Prefab.Identifier.Value ?? "NoJob") + ":" + itemSelectedDuration.Key.Identifier, itemSelectedDuration.Value); } - } + }*/ #if CLIENT if (GameMode is TutorialMode tutorialMode) { @@ -1430,15 +1466,17 @@ namespace Barotrauma GameAnalyticsManager.AddDesignEvent("FirstLaunch:" + eventId + tutorialMode.Tutorial.Identifier); } } - GameAnalyticsManager.AddDesignEvent(eventId + "TimeSpentCleaning", TimeSpentCleaning); - GameAnalyticsManager.AddDesignEvent(eventId + "TimeSpentPainting", TimeSpentPainting); + //disabled to reduce the amount of data we collect through GA + //GameAnalyticsManager.AddDesignEvent(eventId + "TimeSpentCleaning", TimeSpentCleaning); + //GameAnalyticsManager.AddDesignEvent(eventId + "TimeSpentPainting", TimeSpentPainting); TimeSpentCleaning = TimeSpentPainting = 0.0; #endif } public void KillCharacter(Character character) { - if (CrewManager != null && CrewManager.GetCharacters().Contains(character)) + if (CrewManager != null && + CrewManager.GetCharacterInfos().Contains(character.Info)) { casualties.Add(character); } @@ -1542,6 +1580,8 @@ namespace Barotrauma rootElement.Add(new XAttribute("nextleveltype", campaign.NextLevel?.Type ?? LevelData?.Type ?? LevelData.LevelType.Outpost)); + rootElement.Add(new XAttribute("ismultiplayer", campaign is MultiPlayerCampaign)); + LastSaveVersion = GameMain.Version; rootElement.Add(new XAttribute("version", GameMain.Version)); if (Submarine?.Info != null && !Submarine.Removed && Campaign != null) diff --git a/Barotrauma/BarotraumaShared/SharedSource/GameSession/HireManager.cs b/Barotrauma/BarotraumaShared/SharedSource/GameSession/HireManager.cs index 33ff5e43a..5536ec2c5 100644 --- a/Barotrauma/BarotraumaShared/SharedSource/GameSession/HireManager.cs +++ b/Barotrauma/BarotraumaShared/SharedSource/GameSession/HireManager.cs @@ -58,7 +58,8 @@ namespace Barotrauma void AddCharacter(JobPrefab job) { if (job == null) { return; } - int variant = Rand.Range(0, job.Variants, Rand.RandSync.ServerAndClient); + //no need for synced rand, these only generate ones and are then included in the campaign save + int variant = Rand.Range(0, job.Variants, Rand.RandSync.Unsynced); AvailableCharacters.Add(new CharacterInfo(CharacterPrefab.HumanSpeciesName, jobOrJobPrefab: job, variant: variant)); } } @@ -73,7 +74,8 @@ namespace Barotrauma DebugConsole.ThrowError($"Couldn't create a hireable for the location: character prefab \"{character.NPCIdentifier}\" not found in the NPC set \"{character.NPCSetIdentifier}\"."); continue; } - var characterInfo = humanPrefab.CreateCharacterInfo(Rand.RandSync.ServerAndClient); + //no need for synced rand, these only generate ones and are then included in the campaign save + var characterInfo = humanPrefab.CreateCharacterInfo(Rand.RandSync.Unsynced); characterInfo.MinReputationToHire = (faction.Identifier, character.MinReputation); AvailableCharacters.Add(characterInfo); } diff --git a/Barotrauma/BarotraumaShared/SharedSource/Items/Components/EntitySpawnerComponent.cs b/Barotrauma/BarotraumaShared/SharedSource/Items/Components/EntitySpawnerComponent.cs index b3c696cbc..95cefa886 100644 --- a/Barotrauma/BarotraumaShared/SharedSource/Items/Components/EntitySpawnerComponent.cs +++ b/Barotrauma/BarotraumaShared/SharedSource/Items/Components/EntitySpawnerComponent.cs @@ -1,4 +1,4 @@ -#nullable enable +#nullable enable using System; using System.Linq; @@ -70,6 +70,19 @@ namespace Barotrauma.Items.Components [Editable, Serialize(false, IsPropertySaveable.Yes, "")] public bool PreloadCharacter { get; set; } + [Editable, Serialize(false, IsPropertySaveable.Yes, "Should the \"spawn monsters\" setting affect this item in the PvP mode?")] + public bool AffectedByPvPSpawnMonstersSetting { get; set; } + + /// + /// Implemented as a property and checked on the fly instead of disabling the component, + /// because the signals sent by the component might be necessary even if it can't spawn anything. + /// + private bool DisabledByByPvPSpawnMonstersSetting => + !SpeciesName.IsNullOrEmpty() && + AffectedByPvPSpawnMonstersSetting && + GameMain.GameSession?.GameMode is PvPMode && + GameMain.NetworkMember is { ServerSettings.PvPSpawnMonsters: false }; + private float spawnTimer; private float? spawnTimerGoal; @@ -114,15 +127,24 @@ namespace Barotrauma.Items.Components public override void Update(float deltaTime, Camera cam) { - if (PreloadCharacter && !Screen.Selected.IsEditor && !preloadInitiated) + if (DisabledByByPvPSpawnMonstersSetting) { - SpawnCharacter(Vector2.Zero, onSpawn: (Character c) => + CanSpawn = false; + //in most cases we could probably just disable the component here and return, + //but the state_out signal might be needed for something even if the spawning is disabled + } + else + { + if (PreloadCharacter && !Screen.Selected.IsEditor && !preloadInitiated) { - preloadedCharacter = c; - c.DisabledByEvent = true; - }); - preloadInitiated = true; - return; + SpawnCharacter(Vector2.Zero, onSpawn: (Character c) => + { + preloadedCharacter = c; + c.DisabledByEvent = true; + }); + preloadInitiated = true; + return; + } } base.Update(deltaTime, cam); @@ -182,7 +204,7 @@ namespace Barotrauma.Items.Components private bool CanSpawnMore() { - if (!CanSpawn) { return false; } + if (!CanSpawn || DisabledByByPvPSpawnMonstersSetting) { return false; } if (MaximumAmount > 0 && spawnedAmount >= MaximumAmount) { return false; } if (OnlySpawnWhenCrewInRange) diff --git a/Barotrauma/BarotraumaShared/SharedSource/Items/Components/GeneticMaterial.cs b/Barotrauma/BarotraumaShared/SharedSource/Items/Components/GeneticMaterial.cs index d3783d983..c8029badb 100644 --- a/Barotrauma/BarotraumaShared/SharedSource/Items/Components/GeneticMaterial.cs +++ b/Barotrauma/BarotraumaShared/SharedSource/Items/Components/GeneticMaterial.cs @@ -244,26 +244,50 @@ namespace Barotrauma.Items.Components if (!targetCharacter.HasEquippedItem(item) && (rootContainer == null || !targetCharacter.HasEquippedItem(rootContainer) || !targetCharacter.Inventory.IsInLimbSlot(rootContainer, InvSlotType.HealthInterface))) { - item.ApplyStatusEffects(ActionType.OnSevered, 1.0f, targetCharacter); + Character prevTargetCharacter = targetCharacter; + //deactivate so the material is no longer updated or considered to be "in effect" in GetCombinedEffectStrength - IsActive = false; - var affliction = targetCharacter.CharacterHealth.GetAllAfflictions().FirstOrDefault(a => a.Prefab == selectedEffect); - if (affliction != null) + Deactivate(); + if (rootContainer != null) { - affliction.Strength = GetCombinedEffectStrength(); + foreach (var otherItem in rootContainer.ContainedItems) + { + if (otherItem != item && otherItem.GetComponent() is { IsActive: true } otherGeneticMaterial) + { + //we need to deactivate other genetic materials in the container too at this point, + //otherwise their effects might get triggered by the damage done by removing the gene + otherGeneticMaterial.Deactivate(); + } + } } - var taintedAffliction = targetCharacter.CharacterHealth.GetAllAfflictions().FirstOrDefault(a => a.Prefab == selectedTaintedEffect); - if (taintedAffliction != null) - { - taintedAffliction.Strength = GetCombinedTaintedEffectStrength(); - } - - targetCharacter = null; + //do this after nullifying the effects, otherwise the damage from removing the genes could trigger the gene's own effects + item.ApplyStatusEffects(ActionType.OnSevered, 1.0f, prevTargetCharacter); } } } + private void Deactivate() + { + IsActive = false; + if (targetCharacter != null) + { + var affliction = targetCharacter.CharacterHealth.GetAllAfflictions().FirstOrDefault(a => a.Prefab == selectedEffect); + if (affliction != null) + { + affliction.Strength = GetCombinedEffectStrength(); + } + + var taintedAffliction = targetCharacter.CharacterHealth.GetAllAfflictions().FirstOrDefault(a => a.Prefab == selectedTaintedEffect); + if (taintedAffliction != null) + { + taintedAffliction.Strength = GetCombinedTaintedEffectStrength(); + } + } + NestedMaterial?.Deactivate(); + targetCharacter = null; + } + public enum CombineResult { None, diff --git a/Barotrauma/BarotraumaShared/SharedSource/Items/Components/Holdable/Holdable.cs b/Barotrauma/BarotraumaShared/SharedSource/Items/Components/Holdable/Holdable.cs index 5c77cca7f..10f02f6fc 100644 --- a/Barotrauma/BarotraumaShared/SharedSource/Items/Components/Holdable/Holdable.cs +++ b/Barotrauma/BarotraumaShared/SharedSource/Items/Components/Holdable/Holdable.cs @@ -227,6 +227,13 @@ namespace Barotrauma.Items.Components set; } + [Editable, Serialize("", IsPropertySaveable.Yes, translationTextTag: "ItemMsg", description: "A text displayed next to the item when it's been dropped on the floor (not attached to a wall).")] + public string MsgWhenDropped + { + get; + set; + } + /// /// For setting the handle positions using status effects /// @@ -733,7 +740,19 @@ namespace Barotrauma.Items.Components #endif //make the item pickable with the default pick key and with no specific tools/items when it's deattached RequiredItems.Clear(); - DisplayMsg = ""; + if (MsgWhenDropped.IsNullOrEmpty()) + { + DisplayMsg = ""; + } + else + { + DisplayMsg = TextManager.Get(MsgWhenDropped); + DisplayMsg = + DisplayMsg.Loaded ? + TextManager.ParseInputTypes(DisplayMsg) : + MsgWhenDropped; + } + PickKey = InputType.Select; #if CLIENT item.DrawDepthOffset = SpriteDepthWhenDropped - item.SpriteDepth; diff --git a/Barotrauma/BarotraumaShared/SharedSource/Items/Components/Holdable/LevelResource.cs b/Barotrauma/BarotraumaShared/SharedSource/Items/Components/Holdable/LevelResource.cs index a6495844c..5f03a6163 100644 --- a/Barotrauma/BarotraumaShared/SharedSource/Items/Components/Holdable/LevelResource.cs +++ b/Barotrauma/BarotraumaShared/SharedSource/Items/Components/Holdable/LevelResource.cs @@ -52,7 +52,11 @@ namespace Barotrauma.Items.Components { if (holdable.Attached) { - GameAnalyticsManager.AddDesignEvent("ResourceCollected:" + (GameMain.GameSession?.GameMode?.Preset.Identifier.Value ?? "none") + ":" + item.Prefab.Identifier); + //we don't need info of every collected resource, we can get a good sample size just by logging a small sample + if (GameAnalyticsManager.ShouldLogRandomSample()) + { + GameAnalyticsManager.AddDesignEvent("ResourceCollected:" + (GameMain.GameSession?.GameMode?.Preset.Identifier.Value ?? "none") + ":" + item.Prefab.Identifier); + } holdable.DeattachFromWall(); } trigger.Enabled = false; diff --git a/Barotrauma/BarotraumaShared/SharedSource/Items/Components/Holdable/MeleeWeapon.cs b/Barotrauma/BarotraumaShared/SharedSource/Items/Components/Holdable/MeleeWeapon.cs index bb800ae97..c601c551c 100644 --- a/Barotrauma/BarotraumaShared/SharedSource/Items/Components/Holdable/MeleeWeapon.cs +++ b/Barotrauma/BarotraumaShared/SharedSource/Items/Components/Holdable/MeleeWeapon.cs @@ -334,6 +334,7 @@ namespace Barotrauma.Items.Components if (targetLimb.character.IgnoreMeleeWeapons) { return false; } var targetCharacter = targetLimb.character; if (targetCharacter == picker) { return false; } + if (HitFriendlyTarget(targetCharacter)) { return false; } if (AllowHitMultiple) { if (hitTargets.Contains(targetCharacter)) { return false; } @@ -348,7 +349,7 @@ namespace Barotrauma.Items.Components { if (targetCharacter == picker || targetCharacter == User) { return false; } if (targetCharacter.IgnoreMeleeWeapons) { return false; } - targetLimb = targetCharacter.AnimController.GetLimb(LimbType.Torso); //Otherwise armor can be bypassed in strange ways + if (HitFriendlyTarget(targetCharacter)) { return false; } if (AllowHitMultiple) { if (hitTargets.Contains(targetCharacter)) { return false; } @@ -395,10 +396,22 @@ namespace Barotrauma.Items.Components { return false; } - impactQueue.Enqueue(f2); - return true; + + // Prevent bots from hitting friendly targets. + bool HitFriendlyTarget(Character target) + { + if (User.IsPlayer) { return false; } + if (User.AIController is HumanAIController { Enabled: true } humanAI) + { + if (humanAI.ObjectiveManager.CurrentObjective is AIObjectiveCombat combat && combat.Enemy != target) + { + if (humanAI.IsFriendly(target, onlySameTeam: true)) { return true; } + } + } + return false; + } } private System.Text.StringBuilder serverLogger; diff --git a/Barotrauma/BarotraumaShared/SharedSource/Items/Components/Holdable/RangedWeapon.cs b/Barotrauma/BarotraumaShared/SharedSource/Items/Components/Holdable/RangedWeapon.cs index 4c2936d4c..5a70adf2f 100644 --- a/Barotrauma/BarotraumaShared/SharedSource/Items/Components/Holdable/RangedWeapon.cs +++ b/Barotrauma/BarotraumaShared/SharedSource/Items/Components/Holdable/RangedWeapon.cs @@ -322,6 +322,7 @@ namespace Barotrauma.Items.Components } } } + projectile.Item.body.Dir = Item.body.Dir; projectile.Shoot(character, character.AnimController.AimSourceSimPos, barrelPos, rotation + spread, ignoredBodies: ignoredBodies.ToList(), createNetworkEvent: false, damageMultiplier, LaunchImpulse); projectile.Item.GetComponent()?.Attach(Item, projectile.Item); if (projectile.Item.body != null) diff --git a/Barotrauma/BarotraumaShared/SharedSource/Items/Components/Holdable/RepairTool.cs b/Barotrauma/BarotraumaShared/SharedSource/Items/Components/Holdable/RepairTool.cs index 1825bf966..00819bee9 100644 --- a/Barotrauma/BarotraumaShared/SharedSource/Items/Components/Holdable/RepairTool.cs +++ b/Barotrauma/BarotraumaShared/SharedSource/Items/Components/Holdable/RepairTool.cs @@ -248,7 +248,7 @@ namespace Barotrauma.Items.Components var barrelHull = Hull.FindHull(ConvertUnits.ToDisplayUnits(rayStartWorld), item.CurrentHull, useWorldCoordinates: true); if (barrelHull != null && barrelHull != item.CurrentHull) { - if (MathUtils.GetLineRectangleIntersection(ConvertUnits.ToDisplayUnits(sourcePos), ConvertUnits.ToDisplayUnits(rayStart), item.CurrentHull.Rect, out Vector2 hullIntersection)) + if (MathUtils.GetLineWorldRectangleIntersection(ConvertUnits.ToDisplayUnits(sourcePos), ConvertUnits.ToDisplayUnits(rayStart), item.CurrentHull.Rect, out Vector2 hullIntersection)) { if (!item.CurrentHull.ConnectedGaps.Any(g => g.Open > 0.0f && Submarine.RectContains(g.Rect, hullIntersection))) { diff --git a/Barotrauma/BarotraumaShared/SharedSource/Items/Components/ItemContainer.cs b/Barotrauma/BarotraumaShared/SharedSource/Items/Components/ItemContainer.cs index 2d696dae1..8ef56a088 100644 --- a/Barotrauma/BarotraumaShared/SharedSource/Items/Components/ItemContainer.cs +++ b/Barotrauma/BarotraumaShared/SharedSource/Items/Components/ItemContainer.cs @@ -108,6 +108,9 @@ namespace Barotrauma.Items.Components [Serialize(100, IsPropertySaveable.No, description: "How many items are placed in a row before starting a new row.")] public int ItemsPerRow { get; set; } + [Serialize(false, IsPropertySaveable.No, description: "Should items be drawn based on their position within the inventory?")] + public bool ItemsUseInventoryPlacement { get; set; } + [Serialize(true, IsPropertySaveable.No, description: "Should the inventory of this item be visible when the item is selected. Note that this does not prevent dragging and dropping items to the item.")] public bool DrawInventory { @@ -1013,6 +1016,11 @@ namespace Barotrauma.Items.Components contained.Item.CurrentHull = item.CurrentHull; contained.Item.SetContainedItemPositions(); + foreach (var lightComponent in contained.Item.GetComponents()) + { + lightComponent.SetLightSourceTransform(); + } + i++; if (Math.Abs(ItemInterval.X) > 0.001f && Math.Abs(ItemInterval.Y) > 0.001f) { @@ -1040,8 +1048,8 @@ namespace Barotrauma.Items.Components transformedItemIntervalHorizontal = new Vector2(transformedItemInterval.X, 0.0f); transformedItemIntervalVertical = new Vector2(0.0f, transformedItemInterval.Y); - flippedX = item.RootContainer?.FlippedX ?? item.FlippedX; - flippedY = item.RootContainer?.FlippedY ?? item.FlippedY; + flippedX = item.RootContainer?.FlippedX ?? (item.FlippedX && item.Prefab.CanSpriteFlipX); + flippedY = item.RootContainer?.FlippedY ?? (item.FlippedY && item.Prefab.CanSpriteFlipY); var rootBody = item.RootContainer?.body ?? item.body; bool bodyFlipped = rootBody is { Dir: -1 }; diff --git a/Barotrauma/BarotraumaShared/SharedSource/Items/Components/Machines/Fabricator.cs b/Barotrauma/BarotraumaShared/SharedSource/Items/Components/Machines/Fabricator.cs index 67a1cff73..ea55a8fe6 100644 --- a/Barotrauma/BarotraumaShared/SharedSource/Items/Components/Machines/Fabricator.cs +++ b/Barotrauma/BarotraumaShared/SharedSource/Items/Components/Machines/Fabricator.cs @@ -733,17 +733,24 @@ namespace Barotrauma.Items.Components private readonly HashSet usedIngredients = new HashSet(); - private bool CanBeFabricated(FabricationRecipe fabricableItem, IReadOnlyDictionary> availableIngredients, Character character) + public bool MissingRequiredRecipe(FabricationRecipe fabricableItem, Character character) { - if (fabricableItem == null) { return false; } - if (fabricableItem.RequiresRecipe) + if (fabricableItem.RequiresRecipe) { if (character == null) { return false; } if (!AnyOneHasRecipeForItem(character, fabricableItem.TargetItem)) { - return false; + return true; } } + return false; + } + + private bool CanBeFabricated(FabricationRecipe fabricableItem, IReadOnlyDictionary> availableIngredients, Character character) + { + if (fabricableItem == null) { return false; } + + if (MissingRequiredRecipe(fabricableItem, character)) { return false; } if (fabricableItem.HideForNonTraitors) { diff --git a/Barotrauma/BarotraumaShared/SharedSource/Items/Components/Machines/Steering.cs b/Barotrauma/BarotraumaShared/SharedSource/Items/Components/Machines/Steering.cs index 4f39c973f..d015d680b 100644 --- a/Barotrauma/BarotraumaShared/SharedSource/Items/Components/Machines/Steering.cs +++ b/Barotrauma/BarotraumaShared/SharedSource/Items/Components/Machines/Steering.cs @@ -61,6 +61,7 @@ namespace Barotrauma.Items.Components private Sonar sonar; private Submarine controlledSub; + public Submarine ControlledSub => controlledSub; // AI interfacing public Vector2 AITacticalTarget { get; set; } @@ -75,6 +76,7 @@ namespace Barotrauma.Items.Components private double lastReceivedSteeringSignalTime; + [Serialize(defaultValue: false, isSaveable: IsPropertySaveable.Yes, AlwaysUseInstanceValues = true)] public bool AutoPilot { get { return autoPilot; } diff --git a/Barotrauma/BarotraumaShared/SharedSource/Items/Components/Planter.cs b/Barotrauma/BarotraumaShared/SharedSource/Items/Components/Planter.cs index 4c7cb459e..f00e9bf4a 100644 --- a/Barotrauma/BarotraumaShared/SharedSource/Items/Components/Planter.cs +++ b/Barotrauma/BarotraumaShared/SharedSource/Items/Components/Planter.cs @@ -131,7 +131,7 @@ namespace Barotrauma.Items.Components SuitablePlantItem plantItem = GetSuitableItem(character); - if (!plantItem.IsNull()) + if (!plantItem.IsNull() && item.GetComponent() is not { Attachable: true, Attached: false }) { Msg = plantItem.Type switch { @@ -159,7 +159,6 @@ namespace Barotrauma.Items.Components { SuitablePlantItem plantItem = GetSuitableItem(character); PickingMsg = plantItem.IsNull() ? MsgUprooting : plantItem.ProgressBarMessage; - return base.Pick(character); } diff --git a/Barotrauma/BarotraumaShared/SharedSource/Items/Components/Projectile.cs b/Barotrauma/BarotraumaShared/SharedSource/Items/Components/Projectile.cs index 203213d19..bf9bac303 100644 --- a/Barotrauma/BarotraumaShared/SharedSource/Items/Components/Projectile.cs +++ b/Barotrauma/BarotraumaShared/SharedSource/Items/Components/Projectile.cs @@ -28,20 +28,24 @@ namespace Barotrauma.Items.Components SpreadCounter = 0; } - struct HitscanResult + readonly struct HitscanResult { - public Fixture Fixture; - public Vector2 Point; - public Vector2 Normal; - public float Fraction; - public HitscanResult(Fixture fixture, Vector2 point, Vector2 normal, float fraction) + public readonly Fixture Fixture; + public readonly Vector2 Point; + public readonly Vector2 Normal; + public readonly float Fraction; + public readonly Submarine Submarine; + + public HitscanResult(Fixture fixture, Vector2 point, Vector2 normal, float fraction, Submarine sub) { Fixture = fixture; Point = point; Normal = normal; Fraction = fraction; + Submarine = sub; } } + struct Impact { public Fixture Fixture; @@ -393,8 +397,6 @@ namespace Barotrauma.Items.Components if (Item.Removed) { return; } launchPos = simPosition; LaunchSub = item.Submarine; - //set the rotation of the projectile again because dropping the projectile resets the rotation - Item.SetTransform(simPosition, rotation + (Item.body.Dir * LaunchRotationRadians), findNewHull: false); if (DeactivationTime > 0) { deactivationTimer = DeactivationTime; @@ -483,6 +485,13 @@ namespace Barotrauma.Items.Components { float modifiedLaunchImpulse = (LaunchImpulse + launchImpulseModifier) * (1 + Rand.Range(-ImpulseSpread, ImpulseSpread)); DoLaunch(launchDir * modifiedLaunchImpulse); + //needs to be set after DoLaunch, because dropping the item resets the rotation and dir + float afterLaunchAngle = launchAngle + (item.body.Dir * LaunchRotationRadians); + if (item.body.Dir < 0) + { + afterLaunchAngle -= MathHelper.Pi; + } + item.SetTransform(item.body.SimPosition, afterLaunchAngle, findNewHull: false); } } User = character; @@ -607,7 +616,8 @@ namespace Barotrauma.Items.Components inSubHits[i].Fixture, inSubHits[i].Point + submarine.SimPosition, inSubHits[i].Normal, - inSubHits[i].Fraction); + inSubHits[i].Fraction, + sub: null); } hits.AddRange(inSubHits); } @@ -620,6 +630,7 @@ namespace Barotrauma.Items.Components { var h = hits[i]; item.SetTransform(h.Point, rotation); + item.Submarine = h.Submarine; item.UpdateTransform(); if (HandleProjectileCollision(h.Fixture, h.Normal, Vector2.Zero)) { @@ -717,7 +728,7 @@ namespace Barotrauma.Items.Components fixture.Body.GetTransform(out FarseerPhysics.Common.Transform transform); if (!fixture.Shape.TestPoint(ref transform, ref rayStart)) { return true; } - hits.Add(new HitscanResult(fixture, rayStart, -dir, 0.0f)); + hits.Add(new HitscanResult(fixture, rayStart, -dir, 0.0f, submarine)); return true; }, ref aabb); @@ -789,7 +800,7 @@ namespace Barotrauma.Items.Components } } - hits.Add(new HitscanResult(fixture, point, normal, fraction)); + hits.Add(new HitscanResult(fixture, point, normal, fraction, submarine)); return 1; }, rayStart, rayEnd, Physics.CollisionCharacter | Physics.CollisionWall | Physics.CollisionLevel | Physics.CollisionItemBlocking | Physics.CollisionProjectile | Physics.CollisionLagCompensationBody); @@ -1101,11 +1112,11 @@ namespace Barotrauma.Items.Components { if (Attack != null) { - Vector2 pos = item.WorldPosition; if (item.Submarine == null && damageable is Structure structure && structure.Submarine != null && Vector2.DistanceSquared(item.WorldPosition, structure.WorldPosition) > 10000.0f * 10000.0f) { item.Submarine = structure.Submarine; } + Vector2 pos = item.WorldPosition; attackResult = Attack.DoDamage(User ?? Attacker, damageable, pos, 1.0f); } } diff --git a/Barotrauma/BarotraumaShared/SharedSource/Items/Components/Signal/CircuitBox.cs b/Barotrauma/BarotraumaShared/SharedSource/Items/Components/Signal/CircuitBox.cs index 5b5dd6619..ab13440a8 100644 --- a/Barotrauma/BarotraumaShared/SharedSource/Items/Components/Signal/CircuitBox.cs +++ b/Barotrauma/BarotraumaShared/SharedSource/Items/Components/Signal/CircuitBox.cs @@ -640,6 +640,18 @@ namespace Barotrauma.Items.Components OnViewUpdateProjSpecific(); } + public void RemoveWire(Wire wireItem) + { + foreach (CircuitBoxWire wire in Wires.ToImmutableArray()) + { + if (wire.BackingWire.TryUnwrap(out var backingWire) && backingWire == wireItem.Item) + { + RemoveWireCollectionUnsafe(wire); + } + } + OnViewUpdateProjSpecific(); + } + private void RemoveWireCollectionUnsafe(CircuitBoxWire wire) { foreach (CircuitBoxOutputConnection output in Outputs) diff --git a/Barotrauma/BarotraumaShared/SharedSource/Items/Components/Signal/LightComponent.cs b/Barotrauma/BarotraumaShared/SharedSource/Items/Components/Signal/LightComponent.cs index e7d231744..a0f8fae49 100644 --- a/Barotrauma/BarotraumaShared/SharedSource/Items/Components/Signal/LightComponent.cs +++ b/Barotrauma/BarotraumaShared/SharedSource/Items/Components/Signal/LightComponent.cs @@ -198,6 +198,13 @@ namespace Barotrauma.Items.Components set; } + [Serialize("0,0", IsPropertySaveable.No, description: "Offset of the light from the position of the item (in pixels).")] + public Vector2 LightOffset + { + get; + set; + } + /// /// Returns true if the red component of the light is twice as bright as the blue and green. Can be used by StatusEffects. /// @@ -304,17 +311,18 @@ namespace Barotrauma.Items.Components (statusEffectLists == null || !statusEffectLists.ContainsKey(ActionType.OnActive)) && (IsActiveConditionals == null || IsActiveConditionals.Count == 0)) { - if (item.body == null || item.body.Enabled || - (item.ParentInventory is ItemInventory itemInventory && !itemInventory.Container.HideItems)) - { - lightBrightness = 1.0f; - SetLightSourceState(true, lightBrightness); - } - else + PhysicsBody body = ParentBody ?? item.body; + if ((body == null || !body.Enabled) && + (item.FindParentInventory(static it => it is ItemInventory { Container.HideItems: true }) != null)) { lightBrightness = 0.0f; SetLightSourceState(false, 0.0f); } + else + { + lightBrightness = 1.0f; + SetLightSourceState(true, lightBrightness); + } isOn = true; SetLightSourceTransformProjSpecific(); base.IsActive = false; @@ -366,7 +374,7 @@ namespace Barotrauma.Items.Components SetLightSourceTransformProjSpecific(); PhysicsBody body = ParentBody ?? item.body; - if (body != null && !body.Enabled && !visibleInContainer) + if ((body == null || !body.Enabled) && !visibleInContainer) { lightBrightness = 0.0f; SetLightSourceState(false, 0.0f); diff --git a/Barotrauma/BarotraumaShared/SharedSource/Items/Components/Signal/MotionSensor.cs b/Barotrauma/BarotraumaShared/SharedSource/Items/Components/Signal/MotionSensor.cs index 8985556c9..46f1d1948 100644 --- a/Barotrauma/BarotraumaShared/SharedSource/Items/Components/Signal/MotionSensor.cs +++ b/Barotrauma/BarotraumaShared/SharedSource/Items/Components/Signal/MotionSensor.cs @@ -272,8 +272,15 @@ namespace Barotrauma.Items.Components if (worldBorders.Intersects(detectRect)) { - MotionDetected = true; - return; + foreach (Structure wall in Structure.WallList) + { + if (wall.Submarine == sub && + wall.WorldRect.Intersects(detectRect)) + { + MotionDetected = true; + return; + } + } } } } diff --git a/Barotrauma/BarotraumaShared/SharedSource/Items/Components/Signal/WifiComponent.cs b/Barotrauma/BarotraumaShared/SharedSource/Items/Components/Signal/WifiComponent.cs index ae3e411b6..e59ab6599 100644 --- a/Barotrauma/BarotraumaShared/SharedSource/Items/Components/Signal/WifiComponent.cs +++ b/Barotrauma/BarotraumaShared/SharedSource/Items/Components/Signal/WifiComponent.cs @@ -8,7 +8,7 @@ using System.Xml.Linq; namespace Barotrauma.Items.Components { - partial class WifiComponent : ItemComponent, IServerSerializable + partial class WifiComponent : ItemComponent, IServerSerializable, IClientSerializable { private static readonly List list = new List(); @@ -56,7 +56,6 @@ namespace Barotrauma.Items.Components } } - [Editable, Serialize(false, IsPropertySaveable.Yes, description: "Can the component communicate with wifi components in another team's submarine (e.g. enemy sub in Combat missions, respawn shuttle). Needs to be enabled on both the component transmitting the signal and the component receiving it.", alwaysUseInstanceValues: true)] public bool AllowCrossTeamCommunication { @@ -376,5 +375,25 @@ namespace Barotrauma.Items.Components element.Add(new XAttribute("channelmemory", string.Join(',', channelMemory))); return element; } + + protected void SharedEventWrite(IWriteMessage msg) + { + msg.WriteRangedInteger(Channel, MinChannel, MaxChannel); + + for (int i = 0; i < ChannelMemorySize; i++) + { + msg.WriteRangedInteger(channelMemory[i], MinChannel, MaxChannel); + } + } + + protected void SharedEventRead(IReadMessage msg) + { + Channel = msg.ReadRangedInteger(MinChannel, MaxChannel); + + for (int i = 0; i < ChannelMemorySize; i++) + { + channelMemory[i] = msg.ReadRangedInteger(MinChannel, MaxChannel); + } + } } } diff --git a/Barotrauma/BarotraumaShared/SharedSource/Items/Components/Signal/Wire.cs b/Barotrauma/BarotraumaShared/SharedSource/Items/Components/Signal/Wire.cs index fb1cede42..bb4c8f643 100644 --- a/Barotrauma/BarotraumaShared/SharedSource/Items/Components/Signal/Wire.cs +++ b/Barotrauma/BarotraumaShared/SharedSource/Items/Components/Signal/Wire.cs @@ -84,6 +84,13 @@ namespace Barotrauma.Items.Components public float Length { get; private set; } + [Serialize(0.3f, IsPropertySaveable.No), Editable(MinValueFloat = 0.01f, MaxValueFloat = 10.0f, DecimalCount = 2)] + public float Width + { + get; + set; + } + [Serialize(5000.0f, IsPropertySaveable.No, description: "The maximum distance the wire can extend (in pixels).")] public float MaxLength { @@ -885,8 +892,13 @@ namespace Barotrauma.Items.Components protected override void RemoveComponentSpecific() { + if (item.Container?.GetComponent() is { } circuitBox) + { + circuitBox.RemoveWire(this); + } ClearConnections(); base.RemoveComponentSpecific(); + #if CLIENT if (DraggingWire == this) { draggingWire = null; } overrideSprite?.Remove(); diff --git a/Barotrauma/BarotraumaShared/SharedSource/Items/Components/Turret.cs b/Barotrauma/BarotraumaShared/SharedSource/Items/Components/Turret.cs index a7bafa522..56d1bfbed 100644 --- a/Barotrauma/BarotraumaShared/SharedSource/Items/Components/Turret.cs +++ b/Barotrauma/BarotraumaShared/SharedSource/Items/Components/Turret.cs @@ -324,9 +324,19 @@ namespace Barotrauma.Items.Components Editable(TransferToSwappedItem = true)] public Identifier FriendlyTag { get; private set; } - [Serialize("None", IsPropertySaveable.Yes, description: "[Auto Operate] Team that the turret considers friendly. If set to None, the team the submarine/outpost belongs to is considered the friendly team."), + [Serialize("OwnSub", IsPropertySaveable.Yes, description: "[Auto Operate] Team that the turret considers friendly."), Editable(TransferToSwappedItem = true)] - public CharacterTeamType FriendlyTeam { get; private set; } + public TeamType FriendlyTeamType { get; private set; } + + public enum TeamType + { + OwnSub, + Team1, + Team2, + FriendlyNPC, + NoneTeam + } + #endregion private const string SetAutoOperateConnection = "set_auto_operate"; @@ -336,7 +346,7 @@ namespace Barotrauma.Items.Components : base(item, element) { IsActive = true; - + foreach (var subElement in element.Elements()) { switch (subElement.Name.ToString().ToLowerInvariant()) @@ -388,6 +398,7 @@ namespace Barotrauma.Items.Components base.OnMapLoaded(); if (loadedRotationLimits.HasValue) { RotationLimits = loadedRotationLimits.Value; } if (loadedBaseRotation.HasValue) { BaseRotation = loadedBaseRotation.Value; } + if (loadedFriendlyTeamType.HasValue) { FriendlyTeamType = loadedFriendlyTeamType.Value; } targetRotation = Rotation; UpdateTransformedBarrelPos(); if (!AllowAutoOperateWithWiring && @@ -1141,7 +1152,7 @@ namespace Barotrauma.Items.Components if (target is Hull targetHull) { Vector2 barrelDir = GetBarrelDir(); - if (!MathUtils.GetLineRectangleIntersection(item.WorldPosition, item.WorldPosition + barrelDir * AIRange, targetHull.WorldRect, out _)) + if (!MathUtils.GetLineWorldRectangleIntersection(item.WorldPosition, item.WorldPosition + barrelDir * AIRange, targetHull.WorldRect, out _)) { return; } @@ -1375,7 +1386,7 @@ namespace Barotrauma.Items.Components } // Don't aim monsters that are inside any submarine. if (!enemy.IsHuman && enemy.CurrentHull != null) { continue; } - if (HumanAIController.IsFriendly(character, enemy)) { continue; } + if (HumanAIController.IsFriendly(character, enemy, ignoreHuskDisguising: true)) { continue; } // Don't shoot at captured enemies. if (enemy.LockHands) { continue; } float dist = Vector2.DistanceSquared(enemy.WorldPosition, item.WorldPosition); @@ -1699,26 +1710,34 @@ namespace Barotrauma.Items.Components return true; } + private CharacterTeamType GetFriendlyTeam() + { + return FriendlyTeamType switch + { + TeamType.Team1 => CharacterTeamType.Team1, + TeamType.Team2 => CharacterTeamType.Team2, + TeamType.FriendlyNPC => CharacterTeamType.FriendlyNPC, + TeamType.NoneTeam => CharacterTeamType.None, + TeamType.OwnSub => item.Submarine?.TeamID ?? CharacterTeamType.None, + _ => throw new NotImplementedException(), + }; + } + private bool IsValidTargetForAutoOperate(Character target, Identifier friendlyTag) { if (!friendlyTag.IsEmpty) { if (target.SpeciesName.Equals(friendlyTag) || target.Group.Equals(friendlyTag)) { return false; } } - if (FriendlyTeam != CharacterTeamType.None) - { - if (target.TeamID == FriendlyTeam) { return false; } - } + + CharacterTeamType friendlyTeam = GetFriendlyTeam(); + + if (target.TeamID == friendlyTeam) { return false; } + bool isHuman = target.IsHuman || target.Group == CharacterPrefab.HumanSpeciesName; if (isHuman) { - if (item.Submarine != null) - { - // Check that the target is not in the friendly team, e.g. pirate or a hostile player sub (PvP). - var turretTeam = FriendlyTeam == CharacterTeamType.None ? item.Submarine.TeamID : FriendlyTeam; - return !target.IsOnFriendlyTeam(turretTeam) && TargetHumans; - } - return TargetHumans; + return !target.IsOnFriendlyTeam(friendlyTeam) && TargetHumans; } else { @@ -1747,7 +1766,7 @@ namespace Barotrauma.Items.Components { if (user != null) { - if (HumanAIController.IsFriendly(user, targetCharacter)) + if (HumanAIController.IsFriendly(user, targetCharacter, ignoreHuskDisguising: true)) { return false; } @@ -1770,7 +1789,7 @@ namespace Barotrauma.Items.Components if (sub.Info.IsOutpost || sub.Info.IsWreck || sub.Info.IsBeacon || sub.Info.IsRuin) { return false; } if (item.Submarine == null) { - if (sub.TeamID == FriendlyTeam) { return false; } + if (sub.TeamID == GetFriendlyTeam()) { return false; } } else { @@ -2013,11 +2032,27 @@ namespace Barotrauma.Items.Components private Vector2? loadedRotationLimits; private float? loadedBaseRotation; + private TeamType? loadedFriendlyTeamType; + public override void Load(ContentXElement componentElement, bool usePrefabValues, IdRemap idRemap, bool isItemSwap) { base.Load(componentElement, usePrefabValues, idRemap, isItemSwap); loadedRotationLimits = componentElement.GetAttributeVector2("rotationlimits", RotationLimits); loadedBaseRotation = componentElement.GetAttributeFloat("baserotation", componentElement.Parent.GetAttributeFloat("rotation", BaseRotation)); + + //backwards compatibility: previously None made the turret consider the team the submarine/outpost belongs as the friendly team + if (componentElement.GetAttribute("FriendlyTeam") is { } friendlyTeamAttribute) + { + CharacterTeamType friendlyTeam = XMLExtensions.ParseEnumValue(friendlyTeamAttribute.Value, defaultValue: CharacterTeamType.None, friendlyTeamAttribute); + loadedFriendlyTeamType = friendlyTeam switch + { + CharacterTeamType.None => TeamType.OwnSub, + CharacterTeamType.Team1 => TeamType.Team1, + CharacterTeamType.Team2 => TeamType.Team2, + CharacterTeamType.FriendlyNPC => TeamType.FriendlyNPC, + _ => throw new NotImplementedException() + }; + } } public override void OnItemLoaded() @@ -2030,6 +2065,8 @@ namespace Barotrauma.Items.Components if (item.FlippedX) { FlipX(relativeToSub: false); } if (item.FlippedY) { FlipY(relativeToSub: false); } } + UpdateTransformedBarrelPos(); + UpdateLightComponents(); } public void ServerEventWrite(IWriteMessage msg, Client c, NetEntityEvent.IData extraData = null) diff --git a/Barotrauma/BarotraumaShared/SharedSource/Items/Item.cs b/Barotrauma/BarotraumaShared/SharedSource/Items/Item.cs index 98ea41e21..f121cecb0 100644 --- a/Barotrauma/BarotraumaShared/SharedSource/Items/Item.cs +++ b/Barotrauma/BarotraumaShared/SharedSource/Items/Item.cs @@ -1060,7 +1060,7 @@ namespace Barotrauma get { return allPropertyObjects; } } - public bool IgnoreByAI(Character character) => HasTag(Barotrauma.Tags.ItemIgnoredByAI) || OrderedToBeIgnored && character.IsOnPlayerTeam; + public bool IgnoreByAI(Character character) => HasTag(Barotrauma.Tags.IgnoredByAI) || OrderedToBeIgnored && character.IsOnPlayerTeam; public bool OrderedToBeIgnored { get; set; } public bool HasBallastFloraInHull @@ -1902,7 +1902,6 @@ namespace Barotrauma public void AddTag(Identifier tag) { - if (tags.Contains(tag)) { return; } tags.Add(tag); } @@ -1914,19 +1913,13 @@ namespace Barotrauma public bool HasTag(Identifier tag) { - if (tag == null) { return true; } return tags.Contains(tag) || base.Prefab.Tags.Contains(tag); } public bool HasIdentifierOrTags(IEnumerable identifiersOrTags) { - if (identifiersOrTags == null) { return false; } if (identifiersOrTags.Contains(Prefab.Identifier)) { return true; } - foreach (Identifier tag in identifiersOrTags) - { - if (HasTag(tag)) { return true; } - } - return false; + return HasTag(identifiersOrTags); } public void ReplaceTag(string tag, string newTag) @@ -1948,10 +1941,9 @@ namespace Barotrauma public bool HasTag(IEnumerable allowedTags) { - if (allowedTags == null) return true; foreach (Identifier tag in allowedTags) { - if (tags.Contains(tag)) return true; + if (HasTag(tag)) { return true; } } return false; } @@ -2063,7 +2055,7 @@ namespace Barotrauma } hasTargets = true; - targets.Add(containedItem); + targets.AddRange(containedItem.AllPropertyObjects); } } @@ -2148,7 +2140,7 @@ namespace Barotrauma } - public AttackResult AddDamage(Character attacker, Vector2 worldPosition, Attack attack, Vector2 impulseDirection, float deltaTime,bool playSound = true) + public AttackResult AddDamage(Character attacker, Vector2 worldPosition, Attack attack, Vector2 impulseDirection, float deltaTime, bool playSound = true) { if (Indestructible || InvulnerableToDamage) { return new AttackResult(); } @@ -3107,8 +3099,11 @@ namespace Barotrauma foreach (ItemComponent ic in components) { bool pickHit = false, selectHit = false; - if (user.IsKeyDown(InputType.Aim)) + if (ic is not Ladder && user.IsKeyDown(InputType.Aim)) { + // Don't allow selecting items while aiming. + // This was added in cdc68f30. I can't remember what was the reason for it, but it might be related to accidental shots? + // However, we shouldn't disallow picking the ladders, because doing that would make the bot get stuck while trying to get on to ladders. pickHit = false; selectHit = false; } @@ -3662,6 +3657,9 @@ namespace Barotrauma var allProperties = inGameEditableOnly ? GetInGameEditableProperties(ignoreConditions: true) : GetProperties(); SerializableProperty property = extraData.SerializableProperty; ISerializableEntity entity = extraData.Entity; + + msg.WriteVariableUInt32((uint)allProperties.Count); + if (property != null) { if (allProperties.Count > 1) @@ -3776,6 +3774,12 @@ namespace Barotrauma var allProperties = inGameEditableOnly ? GetInGameEditableProperties(ignoreConditions: true) : GetProperties(); if (allProperties.Count == 0) { return; } + int propertyCount = (int)msg.ReadVariableUInt32(); + if (propertyCount != allProperties.Count) + { + throw new Exception($"Error in {nameof(ReadPropertyChange)}. The number of properties on the item \"{Prefab.Identifier}\" does not match between the server and the client. Server: {propertyCount}, client: {allProperties.Count}."); + } + int propertyIndex = 0; if (allProperties.Count > 1) { @@ -3784,7 +3788,7 @@ namespace Barotrauma if (propertyIndex >= allProperties.Count || propertyIndex < 0) { - throw new Exception($"Error in ReadPropertyChange. Property index out of bounds (item: {Prefab.Identifier}, index: {propertyIndex}, property count: {allProperties.Count}, in-game editable only: {inGameEditableOnly})"); + throw new Exception($"Error in {nameof(ReadPropertyChange)}. Property index out of bounds (item: {Prefab.Identifier}, index: {propertyIndex}, property count: {allProperties.Count}, in-game editable only: {inGameEditableOnly})"); } bool allowEditing = true; @@ -3953,7 +3957,7 @@ namespace Barotrauma } logPropertyChangeCoroutine = CoroutineManager.Invoke(() => { - GameServer.Log($"{sender.Character?.Name ?? sender.Name} set the value \"{property.Name}\" of the item \"{Name}\" to \"{logValue}\".", ServerLog.MessageType.ItemInteraction); + GameServer.Log($"{GameServer.CharacterLogName(sender.Character)} set the value \"{property.Name}\" of the item \"{Name}\" to \"{logValue}\".", ServerLog.MessageType.ItemInteraction); }, delay: 1.0f); } #endif diff --git a/Barotrauma/BarotraumaShared/SharedSource/Items/ItemPrefab.cs b/Barotrauma/BarotraumaShared/SharedSource/Items/ItemPrefab.cs index 64401aa07..c98548641 100644 --- a/Barotrauma/BarotraumaShared/SharedSource/Items/ItemPrefab.cs +++ b/Barotrauma/BarotraumaShared/SharedSource/Items/ItemPrefab.cs @@ -63,9 +63,12 @@ namespace Barotrauma OutConditionMax = element.GetAttributeFloat("outconditionmax", element.GetAttributeFloat("outcondition", 1.0f)); CopyCondition = element.GetAttributeBool("copycondition", false); Commonness = element.GetAttributeFloat("commonness", 1.0f); + + Identifier[] defaultRequiredDeconstructor = new Identifier[] { "deconstructor".ToIdentifier() }; RequiredDeconstructor = element.GetAttributeIdentifierArray("requireddeconstructor", - element.Parent?.GetAttributeIdentifierArray("requireddeconstructor", Array.Empty()) ?? Array.Empty()); + element.Parent?.GetAttributeIdentifierArray("requireddeconstructor", null) ?? defaultRequiredDeconstructor); RequiredOtherItem = element.GetAttributeIdentifierArray("requiredotheritem", Array.Empty()); + ActivateButtonText = element.GetAttributeString("activatebuttontext", string.Empty); InfoText = element.GetAttributeString("infotext", string.Empty); InfoTextOnOtherItemMissing = element.GetAttributeString("infotextonotheritemmissing", string.Empty); @@ -197,7 +200,8 @@ namespace Barotrauma { Tag = tag; using MD5 md5 = MD5.Create(); - UintIdentifier = ToolBoxCore.IdentifierToUint32Hash(tag, md5); + //add "tag:" to the hash, so we don't get a hash collision between recipes configured as identifier="smth" and tag="smth" + UintIdentifier = ToolBoxCore.IdentifierToUint32Hash(("tag:" + tag).ToIdentifier(), md5); } public override string ToString() @@ -216,7 +220,8 @@ namespace Barotrauma public readonly ImmutableArray SuitableFabricatorIdentifiers; public readonly float RequiredTime; public readonly int RequiredMoney; - public readonly bool RequiresRecipe; + public readonly bool RequiresRecipe; + public readonly bool HideIfNoRecipe; public readonly float OutCondition; //Percentage-based from 0 to 1 public readonly ImmutableArray RequiredSkills; public readonly uint RecipeHash; @@ -251,6 +256,7 @@ namespace Barotrauma } var requiredItems = new List(); RequiresRecipe = element.GetAttributeBool("requiresrecipe", false); + HideIfNoRecipe = element.GetAttributeBool("hideifnorecipe", false); Amount = element.GetAttributeInt("amount", 1); int limitDefault = element.GetAttributeInt("fabricationlimit", -1); diff --git a/Barotrauma/BarotraumaShared/SharedSource/Map/IDamageable.cs b/Barotrauma/BarotraumaShared/SharedSource/Map/IDamageable.cs index bc01ab05d..8a3417e93 100644 --- a/Barotrauma/BarotraumaShared/SharedSource/Map/IDamageable.cs +++ b/Barotrauma/BarotraumaShared/SharedSource/Map/IDamageable.cs @@ -11,7 +11,6 @@ namespace Barotrauma AttackResult AddDamage(Character attacker, Vector2 worldPosition, Attack attack, Vector2 impulseDirection, float deltaTime, bool playSound = true); - public readonly struct AttackEventData { public readonly ISpatialEntity Attacker; diff --git a/Barotrauma/BarotraumaShared/SharedSource/Map/Levels/DestructibleLevelWall.cs b/Barotrauma/BarotraumaShared/SharedSource/Map/Levels/DestructibleLevelWall.cs index cd6bcf0ad..a8dda6147 100644 --- a/Barotrauma/BarotraumaShared/SharedSource/Map/Levels/DestructibleLevelWall.cs +++ b/Barotrauma/BarotraumaShared/SharedSource/Map/Levels/DestructibleLevelWall.cs @@ -93,7 +93,7 @@ namespace Barotrauma public AttackResult AddDamage(Character attacker, Vector2 worldPosition, Attack attack, Vector2 impulseDirection, float deltaTime, bool playSound = true) { - AddDamage(attack.StructureDamage, worldPosition); + AddDamage(attack.LevelWallDamage, worldPosition); return new AttackResult(attack.StructureDamage); } diff --git a/Barotrauma/BarotraumaShared/SharedSource/Map/Levels/Level.cs b/Barotrauma/BarotraumaShared/SharedSource/Map/Levels/Level.cs index 394fc935e..30132cf6c 100644 --- a/Barotrauma/BarotraumaShared/SharedSource/Map/Levels/Level.cs +++ b/Barotrauma/BarotraumaShared/SharedSource/Map/Levels/Level.cs @@ -207,11 +207,17 @@ namespace Barotrauma private set; } + /// + /// The top of the abyss area (the y-coordinate at which the abyss starts) + /// public int AbyssStart { get { return AbyssArea.Y + AbyssArea.Height; } } + /// + /// The bottom of the abyss area (the y-coordinate at which the abyss ends, below which there's nothing but the ocean floor) + /// public int AbyssEnd { get { return AbyssArea.Y; } @@ -547,11 +553,7 @@ namespace Barotrauma Mirrored = mirror; #if CLIENT - if (backgroundCreatureManager == null) - { - var files = ContentPackageManager.EnabledPackages.All.SelectMany(p => p.GetFiles()).ToArray(); - backgroundCreatureManager = files.Any() ? new BackgroundCreatureManager(files) : new BackgroundCreatureManager("Content/BackgroundCreatures/BackgroundCreaturePrefabs.xml"); - } + backgroundCreatureManager ??= new BackgroundCreatureManager(); #endif Stopwatch sw = new Stopwatch(); sw.Start(); @@ -2170,7 +2172,7 @@ namespace Barotrauma } } - if (!MathUtils.GetLineRectangleIntersection(closestParentNode.ToVector2(), cavePos.ToVector2(), new Rectangle(caveArea.X, caveArea.Y + caveArea.Height, caveArea.Width, caveArea.Height), out Vector2 caveStartPosVector)) + if (!MathUtils.GetLineWorldRectangleIntersection(closestParentNode.ToVector2(), cavePos.ToVector2(), new Rectangle(caveArea.X, caveArea.Y + caveArea.Height, caveArea.Width, caveArea.Height), out Vector2 caveStartPosVector)) { caveStartPosVector = caveArea.Location.ToVector2(); } @@ -3361,7 +3363,7 @@ namespace Barotrauma if (r.Contains(e.Point2)) { return true; } if (r.Contains(eCenter)) { return true; } - if (MathUtils.GetLineRectangleIntersection(e.Point1, e.Point2, r, out _)) + if (MathUtils.GetLineWorldRectangleIntersection(e.Point1, e.Point2, r, out _)) { return true; } @@ -3588,7 +3590,7 @@ namespace Barotrauma public void Update(float deltaTime, Camera cam) { - LevelObjectManager.Update(deltaTime); + LevelObjectManager.Update(deltaTime, cam); foreach (LevelWall wall in ExtraWalls) { wall.Update(deltaTime); } for (int i = UnsyncedExtraWalls.Count - 1; i >= 0; i--) @@ -5083,6 +5085,11 @@ namespace Barotrauma return Loaded != null && worldPosition.Y > Loaded.Size.Y; } + public static bool IsPositionInAbyss(Vector2 worldPosition) + { + return Loaded != null && worldPosition.Y < loaded.AbyssStart && worldPosition.Y > loaded.AbyssEnd; + } + public void DebugSetStartLocation(Location newStartLocation) { StartLocation = newStartLocation; diff --git a/Barotrauma/BarotraumaShared/SharedSource/Map/Levels/LevelData.cs b/Barotrauma/BarotraumaShared/SharedSource/Map/Levels/LevelData.cs index 0b40de388..db21e3a4e 100644 --- a/Barotrauma/BarotraumaShared/SharedSource/Map/Levels/LevelData.cs +++ b/Barotrauma/BarotraumaShared/SharedSource/Map/Levels/LevelData.cs @@ -11,10 +11,11 @@ namespace Barotrauma { class LevelData { + [Flags] public enum LevelType { - LocationConnection, - Outpost + LocationConnection = 1, + Outpost = 2 } public readonly LevelType Type; @@ -87,10 +88,15 @@ namespace Barotrauma public readonly Dictionary FinishedEvents = new Dictionary(); + /// + /// For backwards compatibility (previously "exhausting" one event set exhausted all of them (now we use instead). + /// + private bool allEventsExhausted; + /// /// 'Exhaustible' sets won't appear in the same level until after one world step (~10 min, see Map.ProgressWorld) has passed. . /// - public bool EventsExhausted { get; set; } + private HashSet exhaustedEventSets = new HashSet(); /// /// The crush depth of a non-upgraded submarine in in-game coordinates. Note that this can be above the top of the level! @@ -221,7 +227,9 @@ namespace Barotrauma } } - EventsExhausted = element.GetAttributeBool(nameof(EventsExhausted).ToLower(), false); + exhaustedEventSets = element.GetAttributeIdentifierArray(nameof(exhaustedEventSets), Array.Empty()).ToHashSet(); + //backwards compatibility: previously we didn't track which individual event sets have been exhausted + allEventsExhausted = element.GetAttributeBool("EventsExhausted", false); } /// @@ -328,6 +336,32 @@ namespace Barotrauma return levelData; } + /// + /// Marks the event set as "exhausted". Exhausted sets won't appear in the same level until after one world step (~10 min, see Map.ProgressWorld) has passed. . + /// + public void ExhaustEventSet(EventSet eventSet) + { + exhaustedEventSets.Add(eventSet.Identifier); + } + + /// + /// Has the event set been "exhausted"? Exhausted sets won't appear in the same level until after one world step (~10 min, see Map.ProgressWorld) has passed. . + /// + public bool IsEventSetExhausted(EventSet eventSet) + { + if (allEventsExhausted) { return true; } + return exhaustedEventSets.Contains(eventSet.Identifier); + } + + /// + /// Resets all "exhausted" event sets, allowing them to appear in the level again. + /// + public void ResetExhaustedEventSets() + { + allEventsExhausted = false; + exhaustedEventSets.Clear(); + } + public void ReassignGenerationParams(string seed) { GenerationParams = LevelGenerationParams.GetRandom(seed, Type, Difficulty, Biome.Identifier); @@ -363,7 +397,10 @@ namespace Barotrauma new XAttribute("size", XMLExtensions.PointToString(Size)), new XAttribute("generationparams", GenerationParams.Identifier), new XAttribute("initialdepth", InitialDepth), - new XAttribute(nameof(EventsExhausted).ToLower(), EventsExhausted)); + new XAttribute("exhaustedeventsets", allEventsExhausted)); + + newElement.Add( + new XAttribute(nameof(exhaustedEventSets), string.Join(',', exhaustedEventSets.Select(e => e.Value)))); if (HasBeaconStation) { diff --git a/Barotrauma/BarotraumaShared/SharedSource/Map/Levels/LevelObjects/LevelObjectManager.cs b/Barotrauma/BarotraumaShared/SharedSource/Map/Levels/LevelObjects/LevelObjectManager.cs index bb04f3fa0..59c14535a 100644 --- a/Barotrauma/BarotraumaShared/SharedSource/Map/Levels/LevelObjects/LevelObjectManager.cs +++ b/Barotrauma/BarotraumaShared/SharedSource/Map/Levels/LevelObjects/LevelObjectManager.cs @@ -20,7 +20,7 @@ namespace Barotrauma private List updateableObjects; private List[,] objectGrid; - const float ParallaxStrength = 0.0001f; + public const float ParallaxStrength = 0.0001f; public float GlobalForceDecreaseTimer { @@ -416,11 +416,11 @@ namespace Barotrauma } } - float minX = spriteCorners.Min(c => c.X) - newObject.Position.Z * ParallaxStrength; - float maxX = spriteCorners.Max(c => c.X) + newObject.Position.Z * ParallaxStrength; + float minX = spriteCorners.Min(c => c.X) - newObject.Position.Z; + float maxX = spriteCorners.Max(c => c.X) + newObject.Position.Z; - float minY = spriteCorners.Min(c => c.Y) - newObject.Position.Z * ParallaxStrength - level.BottomPos; - float maxY = spriteCorners.Max(c => c.Y) + newObject.Position.Z * ParallaxStrength - level.BottomPos; + float minY = spriteCorners.Min(c => c.Y) - newObject.Position.Z - level.BottomPos; + float maxY = spriteCorners.Max(c => c.Y) + newObject.Position.Z - level.BottomPos; if (newObject.Triggers != null) { @@ -455,6 +455,8 @@ namespace Barotrauma #endif objects.Add(newObject); if (newObject.NeedsUpdate) { updateableObjects.Add(newObject); } + //add some variance to the Z position to prevent z-fighting + //(based on the x and y position of the object, scaled to be visually insignificant) newObject.Position.Z += (minX + minY) % 100.0f * 0.00001f; int xStart = (int)Math.Floor(minX / GridSize); @@ -566,7 +568,7 @@ namespace Barotrauma return availableSpawnPositions; } - public void Update(float deltaTime) + public void Update(float deltaTime, Camera cam) { GlobalForceDecreaseTimer += deltaTime; if (GlobalForceDecreaseTimer > 1000000.0f) @@ -612,10 +614,10 @@ namespace Barotrauma } } - UpdateProjSpecific(deltaTime); + UpdateProjSpecific(deltaTime, cam); } - partial void UpdateProjSpecific(float deltaTime); + partial void UpdateProjSpecific(float deltaTime, Camera cam); private void OnObjectTriggered(LevelObject triggeredObject, LevelTrigger trigger, Entity triggerer) { diff --git a/Barotrauma/BarotraumaShared/SharedSource/Map/Levels/LevelObjects/LevelObjectPrefab.cs b/Barotrauma/BarotraumaShared/SharedSource/Map/Levels/LevelObjects/LevelObjectPrefab.cs index 960aa818f..aa7113bcc 100644 --- a/Barotrauma/BarotraumaShared/SharedSource/Map/Levels/LevelObjects/LevelObjectPrefab.cs +++ b/Barotrauma/BarotraumaShared/SharedSource/Map/Levels/LevelObjects/LevelObjectPrefab.cs @@ -1,7 +1,6 @@ using Microsoft.Xna.Framework; using System; using System.Collections.Generic; -using System.Collections.Immutable; using System.Linq; using System.Xml.Linq; @@ -136,6 +135,14 @@ namespace Barotrauma private set; } + + [Serialize(3000.0f, IsPropertySaveable.Yes, description: "Objects fade out to the background color of the level the further they are from the camera. This value is the depth at which the object becomes \"maximally\" faded out."), Editable] + public float FadeOutDepth + { + get; + private set; + } + [Editable(MinValueFloat = 0.0f, MaxValueFloat = 10.0f), Serialize(0.0f, IsPropertySaveable.Yes, description: "The tendency for the prefab to form clusters. Used as an exponent for perlin noise values that are used to determine the probability for an object to spawn at a specific position.")] /// diff --git a/Barotrauma/BarotraumaShared/SharedSource/Map/Map/Location.cs b/Barotrauma/BarotraumaShared/SharedSource/Map/Map/Location.cs index c07c87098..29ddeb2e4 100644 --- a/Barotrauma/BarotraumaShared/SharedSource/Map/Map/Location.cs +++ b/Barotrauma/BarotraumaShared/SharedSource/Map/Map/Location.cs @@ -76,6 +76,17 @@ namespace Barotrauma public bool Visited => GameMain.GameSession?.Map?.IsVisited(this) ?? false; + /// + /// How many "world steps" ( must pass for the stores to be reset in the location? + /// Mainly an optimization + /// + public const int ClearStoresDelay = 10; + + /// + /// How many "world steps" ( have passed since this location was last visited? + /// + public int WorldStepsSinceVisited; + public readonly Dictionary ProximityTimer = new Dictionary(); public (LocationTypeChange typeChange, int delay, MissionPrefab parentMission)? PendingLocationTypeChange; public int LocationTypeChangeCooldown; @@ -103,6 +114,11 @@ namespace Barotrauma public Faction SecondaryFaction { get; set; } + /// + /// Not used by the vanilla game. Can be used by code mods to change the color of the location icon on the campaign map. + /// + public Color? OverrideIconColor; + public Reputation Reputation => Faction?.Reputation; public bool IsFactionHostile => Faction?.Reputation.NormalizedValue < Reputation.HostileThreshold; @@ -131,7 +147,7 @@ namespace Barotrauma private float MaxReputationModifier => Location.StoreMaxReputationModifier; /// - /// The maximum effect negative reputation can have on store prices (e.g. 0.5 = 50% price increase with minimum reputation). + /// The minimum effect negative reputation can have on store prices (e.g. 0.5 = 50% price increase with minimum reputation). /// private float MinReputationModifier => Location.StoreMinReputationModifier; @@ -198,9 +214,11 @@ namespace Barotrauma } } - public static PurchasedItem CreateInitialStockItem(ItemPrefab itemPrefab, PriceInfo priceInfo) + public static PurchasedItem CreateInitialStockItem(Location location, ItemPrefab itemPrefab, PriceInfo priceInfo) { int quantity = Rand.Range(priceInfo.MinAvailableAmount, priceInfo.MaxAvailableAmount + 1); + //simulate stores stocking up if the location hasn't been visited in a while + quantity = Math.Min(quantity + location.WorldStepsSinceVisited, priceInfo.MaxAvailableAmount); return new PurchasedItem(itemPrefab, quantity, buyer: null); } @@ -210,7 +228,7 @@ namespace Barotrauma foreach (var prefab in ItemPrefab.Prefabs) { if (!prefab.CanBeBoughtFrom(this, out var priceInfo)) { continue; } - stock.Add(CreateInitialStockItem(prefab, priceInfo)); + stock.Add(CreateInitialStockItem(Location, prefab, priceInfo)); } return stock; } @@ -263,6 +281,7 @@ namespace Barotrauma } availableStock.Add(stockItem.ItemPrefab, weight); } + DailySpecials.Clear(); int extraSpecialSalesCount = GetExtraSpecialSalesCount(); for (int i = 0; i < Location.DailySpecialsCount + extraSpecialSalesCount; i++) @@ -273,14 +292,16 @@ namespace Barotrauma DailySpecials.Add(item); availableStock.Remove(item); } + RequestedGoods.Clear(); for (int i = 0; i < Location.RequestedGoodsCount; i++) { - var item = ItemPrefab.Prefabs.GetRandom(p => - p.CanBeSold && !RequestedGoods.Contains(p) && - p.GetPriceInfo(this) is PriceInfo pi && pi.CanBeSpecial, Rand.RandSync.Unsynced); - if (item == null) { break; } - RequestedGoods.Add(item); + + var selectedPrefab = ItemPrefab.Prefabs.GetRandom(prefab => + prefab.CanBeSold && !RequestedGoods.Contains(prefab) && + prefab.GetPriceInfo(this) is PriceInfo pi && pi.CanBeSpecial, Rand.RandSync.Unsynced); + if (selectedPrefab == null) { break; } + RequestedGoods.Add(selectedPrefab); } Location.StepsSinceSpecialsUpdated = 0; } @@ -296,7 +317,7 @@ namespace Barotrauma { priceInfo ??= item?.GetPriceInfo(this); if (priceInfo == null) { return 0; } - float price = priceInfo.Price; + float price = Location.StoreBuyPriceModifier * priceInfo.Price; // Adjust by random price modifier price = (100 + PriceModifier) / 100.0f * price; price *= priceInfo.BuyingPriceMultiplier; @@ -305,6 +326,11 @@ namespace Barotrauma { price = Location.DailySpecialPriceModifier * price; } + // Adjust by requested good status (to avoid the store selling items that it requests potentially for less than it pays for them) + if (RequestedGoods.Contains(item)) + { + price = Location.RequestGoodBuyPriceModifier * price; + } // Adjust by current reputation price *= GetReputationModifier(true); @@ -332,7 +358,7 @@ namespace Barotrauma } /// If null, item.GetPriceInfo() will be used to get it. - /// If false, the price won't be affected by + /// If false, the price won't be affected by public int GetAdjustedItemSellPrice(ItemPrefab item, PriceInfo priceInfo = null, bool considerRequestedGoods = true) { priceInfo ??= item?.GetPriceInfo(this); @@ -343,7 +369,7 @@ namespace Barotrauma // Adjust by requested good status if (considerRequestedGoods && RequestedGoods.Contains(item)) { - price = Location.RequestGoodPriceModifier * price; + price = Location.RequestGoodSellPriceModifier * price; } // Adjust by location reputation price *= GetReputationModifier(false); @@ -404,13 +430,15 @@ namespace Barotrauma } } - public Dictionary Stores { get; set; } + public Dictionary Stores { get; private set; } private float StoreMaxReputationModifier => Type.StoreMaxReputationModifier; private float StoreMinReputationModifier => Type.StoreMinReputationModifier; private float StoreSellPriceModifier => Type.StoreSellPriceModifier; + private float StoreBuyPriceModifier => Type.StoreBuyPriceModifier; private float DailySpecialPriceModifier => Type.DailySpecialPriceModifier; - private float RequestGoodPriceModifier => Type.RequestGoodPriceModifier; + private float RequestGoodBuyPriceModifier => Type.RequestGoodBuyPriceModifier; + private float RequestGoodSellPriceModifier => Type.RequestGoodPriceModifier; public int StoreInitialBalance => Type.StoreInitialBalance; private int StorePriceModifierRange => Type.StorePriceModifierRange; @@ -588,24 +616,11 @@ namespace Barotrauma DisplayName = GetName(Type, nameFormatIndex, nameIdentifier); } + LoadChangingProperties(element, campaign); + MapPosition = element.GetAttributeVector2("position", Vector2.Zero); - - PriceMultiplier = element.GetAttributeFloat("pricemultiplier", 1.0f); IsGateBetweenBiomes = element.GetAttributeBool("isgatebetweenbiomes", false); - MechanicalPriceMultiplier = element.GetAttributeFloat("mechanicalpricemultipler", 1.0f); - TurnsInRadiation = element.GetAttributeInt(nameof(TurnsInRadiation).ToLower(), 0); - StepsSinceSpecialsUpdated = element.GetAttributeInt("stepssincespecialsupdated", 0); - var factionIdentifier = element.GetAttributeIdentifier("faction", Identifier.Empty); - if (!factionIdentifier.IsEmpty) - { - Faction = campaign.Factions.Find(f => f.Prefab.Identifier == factionIdentifier); - } - var secondaryFactionIdentifier = element.GetAttributeIdentifier("secondaryfaction", Identifier.Empty); - if (!secondaryFactionIdentifier.IsEmpty) - { - SecondaryFaction = campaign.Factions.Find(f => f.Prefab.Identifier == secondaryFactionIdentifier); - } Identifier biomeId = element.GetAttributeIdentifier("biome", Identifier.Empty); if (biomeId != Identifier.Empty) { @@ -697,6 +712,24 @@ namespace Barotrauma } } + /// + /// Load the values of properties that can change mid-campaign and need to be updated when a client receives a new campaign save from the server + /// + public void LoadChangingProperties(XElement element, CampaignMode campaign) + { + PriceMultiplier = element.GetAttributeFloat(nameof(PriceMultiplier), 1.0f); + MechanicalPriceMultiplier = element.GetAttributeFloat(nameof(MechanicalPriceMultiplier), 1.0f); + TurnsInRadiation = element.GetAttributeInt(nameof(TurnsInRadiation).ToLower(), 0); + StepsSinceSpecialsUpdated = element.GetAttributeInt(nameof(StepsSinceSpecialsUpdated), 0); + WorldStepsSinceVisited = element.GetAttributeInt(nameof(WorldStepsSinceVisited), 0); + + var factionIdentifier = element.GetAttributeIdentifier("faction", Identifier.Empty); + Faction = factionIdentifier.IsEmpty ? null : campaign.Factions.Find(f => f.Prefab.Identifier == factionIdentifier); + + var secondaryFactionIdentifier = element.GetAttributeIdentifier("secondaryfaction", Identifier.Empty); + SecondaryFaction = secondaryFactionIdentifier.IsEmpty ? null : campaign.Factions.Find(f => f.Prefab.Identifier == secondaryFactionIdentifier); + } + public void LoadLocationTypeChange(XElement locationElement) { TimeSinceLastTypeChange = locationElement.GetAttributeInt("timesincelasttypechange", 0); @@ -793,13 +826,20 @@ namespace Barotrauma if (Type.Faction == Identifier.Empty) { Faction = null; } if (Type.SecondaryFaction == Identifier.Empty) { SecondaryFaction = null; } } - - UnlockInitialMissions(Rand.RandSync.Unsynced); + + if (!IsCriticallyRadiated()) + { + UnlockInitialMissions(Rand.RandSync.Unsynced); + } if (createStores) { CreateStores(force: true); } + else + { + ClearStores(); + } } public void TryAssignFactionBasedOnLocationType(CampaignMode campaign) @@ -1089,12 +1129,17 @@ namespace Barotrauma return false; } - public LocationType GetLocationType() + public LocationType GetLocationTypeToDisplay(out Identifier overrideDescriptionIdentifier) { + overrideDescriptionIdentifier = Identifier.Empty; if (IsCriticallyRadiated() && !Type.ReplaceInRadiation.IsEmpty) { if (LocationType.Prefabs.TryGet(Type.ReplaceInRadiation, out LocationType newLocationType)) { + if (!newLocationType.DescriptionInRadiation.IsEmpty) + { + overrideDescriptionIdentifier = newLocationType.DescriptionInRadiation; + } return newLocationType; } else @@ -1105,6 +1150,11 @@ namespace Barotrauma return Type; } + public LocationType GetLocationTypeToDisplay() + { + return GetLocationTypeToDisplay(out _); + } + public IEnumerable GetMissionsInConnection(LocationConnection connection) { System.Diagnostics.Debug.Assert(Connections.Contains(connection)); @@ -1223,8 +1273,11 @@ namespace Barotrauma { UpdateStoreIdentifiers(); Stores?.Clear(); + + bool hasStores = false; foreach (var storeElement in locationElement.GetChildElements("store")) { + hasStores = true; Stores ??= new Dictionary(); var identifier = storeElement.GetAttributeIdentifier("identifier", ""); if (identifier.IsEmpty) @@ -1255,13 +1308,16 @@ namespace Barotrauma } } // Backwards compatibility: create new stores for any identifiers not present in the save data - foreach (var id in StoreIdentifiers) + if (hasStores) { - AddNewStore(id); + foreach (var id in StoreIdentifiers) + { + AddNewStore(id); + } } } - public bool IsRadiated() => GameMain.GameSession?.Map?.Radiation != null && GameMain.GameSession.Map.Radiation.Enabled && GameMain.GameSession.Map.Radiation.Contains(this); + public bool IsRadiated() => GameMain.GameSession?.Map?.Radiation != null && GameMain.GameSession.Map.Radiation.Enabled && GameMain.GameSession.Map.Radiation.DepthInRadiation(this) > 0; /// /// Mark the items that have been taken from the outpost to prevent them from spawning when re-entering the outpost @@ -1331,6 +1387,9 @@ namespace Barotrauma return null; } + /// + /// Create stores and stocks for the location. If the location already has stores, the method will not do anything unless the "force" argument is true. /> + /// /// If true, the stores will be recreated if they already exists. public void CreateStores(bool force = false) { @@ -1384,13 +1443,13 @@ namespace Barotrauma } } - public void UpdateStores() + public void UpdateStores(bool createStoresIfNotCreated = true) { // In multiplayer, stores should be updated by the server and loaded from save data by clients if (GameMain.NetworkMember is { IsClient: true }) { return; } if (Stores == null) { - CreateStores(); + if (createStoresIfNotCreated) { CreateStores(); } return; } var storesToRemove = new HashSet(); @@ -1410,13 +1469,13 @@ namespace Barotrauma foreach (var itemPrefab in ItemPrefab.Prefabs) { - var existingStock = stock.FirstOrDefault(s => s.ItemPrefab == itemPrefab); + var existingStock = stock.FirstOrDefault(s => s.ItemPrefabIdentifier == itemPrefab.Identifier); if (itemPrefab.CanBeBoughtFrom(store, out PriceInfo priceInfo)) { if (existingStock == null) { //can be bought from the location, but not in stock - some new item added by an update or mod? - stock.Add(StoreInfo.CreateInitialStockItem(itemPrefab, priceInfo)); + stock.Add(StoreInfo.CreateInitialStockItem(this, itemPrefab, priceInfo)); } else { @@ -1496,6 +1555,16 @@ namespace Barotrauma } } + /// + /// Removes all information about stores from the location (can be used to avoid storing unnecessary + /// store info about locations that haven't been visited in a long time). The stores are automatically + /// recreated when the player enters the location. + /// + public void ClearStores() + { + Stores = null; + } + public void RemoveStock(Dictionary> items) { if (items == null) { return; } @@ -1548,7 +1617,7 @@ namespace Barotrauma ChangeType(campaign, OriginalType); PendingLocationTypeChange = null; } - CreateStores(force: true); + ClearStores(); ClearMissions(); LevelData?.EventHistory?.Clear(); UnlockInitialMissions(); @@ -1569,7 +1638,8 @@ namespace Barotrauma new XAttribute("mechanicalpricemultipler", MechanicalPriceMultiplier), new XAttribute("timesincelasttypechange", TimeSinceLastTypeChange), new XAttribute(nameof(TurnsInRadiation).ToLower(), TurnsInRadiation), - new XAttribute("stepssincespecialsupdated", StepsSinceSpecialsUpdated)); + new XAttribute(nameof(StepsSinceSpecialsUpdated), StepsSinceSpecialsUpdated), + new XAttribute(nameof(WorldStepsSinceVisited), WorldStepsSinceVisited)); if (!rawName.IsNullOrEmpty()) { @@ -1726,3 +1796,4 @@ namespace Barotrauma } } } + diff --git a/Barotrauma/BarotraumaShared/SharedSource/Map/Map/LocationType.cs b/Barotrauma/BarotraumaShared/SharedSource/Map/Map/LocationType.cs index 0b0de6065..3c9960488 100644 --- a/Barotrauma/BarotraumaShared/SharedSource/Map/Map/LocationType.cs +++ b/Barotrauma/BarotraumaShared/SharedSource/Map/Map/LocationType.cs @@ -91,6 +91,8 @@ namespace Barotrauma public Identifier ReplaceInRadiation { get; } + public Identifier DescriptionInRadiation { get; } + /// /// If set, forces the location to be assigned to this faction. Set to "None" if you don't want the location to be assigned to any faction. /// @@ -120,8 +122,10 @@ namespace Barotrauma public float StoreMaxReputationModifier { get; } = 0.1f; public float StoreMinReputationModifier { get; } = 1.0f; public float StoreSellPriceModifier { get; } = 0.3f; + public float StoreBuyPriceModifier { get; } = 1f; public float DailySpecialPriceModifier { get; } = 0.5f; public float RequestGoodPriceModifier { get; } = 2f; + public float RequestGoodBuyPriceModifier { get; } = 5f; public int StoreInitialBalance { get; } = 5000; /// /// In percentages @@ -161,6 +165,7 @@ namespace Barotrauma HideEntitySubcategories = element.GetAttributeStringArray("hideentitysubcategories", Array.Empty()).ToList(); ReplaceInRadiation = element.GetAttributeIdentifier(nameof(ReplaceInRadiation), Identifier.Empty); + DescriptionInRadiation = element.GetAttributeIdentifier(nameof(DescriptionInRadiation), "locationdescription.abandonedirradiated"); forceOutpostGenerationParamsIdentifier = element.GetAttributeIdentifier("forceoutpostgenerationparams", Identifier.Empty); @@ -265,10 +270,12 @@ namespace Barotrauma break; case "store": StoreMaxReputationModifier = subElement.GetAttributeFloat("maxreputationmodifier", StoreMaxReputationModifier); + StoreBuyPriceModifier = subElement.GetAttributeFloat("buypricemodifier", StoreBuyPriceModifier); StoreMinReputationModifier = subElement.GetAttributeFloat("minreputationmodifier", StoreMaxReputationModifier); StoreSellPriceModifier = subElement.GetAttributeFloat("sellpricemodifier", StoreSellPriceModifier); DailySpecialPriceModifier = subElement.GetAttributeFloat("dailyspecialpricemodifier", DailySpecialPriceModifier); RequestGoodPriceModifier = subElement.GetAttributeFloat("requestgoodpricemodifier", RequestGoodPriceModifier); + RequestGoodBuyPriceModifier = subElement.GetAttributeFloat("requestgoodbuypricemodifier", RequestGoodBuyPriceModifier); StoreInitialBalance = subElement.GetAttributeInt("initialbalance", StoreInitialBalance); StorePriceModifierRange = subElement.GetAttributeInt("pricemodifierrange", StorePriceModifierRange); DailySpecialsCount = subElement.GetAttributeInt("dailyspecialscount", DailySpecialsCount); diff --git a/Barotrauma/BarotraumaShared/SharedSource/Map/Map/Map.cs b/Barotrauma/BarotraumaShared/SharedSource/Map/Map/Map.cs index 697e43005..7565080b8 100644 --- a/Barotrauma/BarotraumaShared/SharedSource/Map/Map/Map.cs +++ b/Barotrauma/BarotraumaShared/SharedSource/Map/Map/Map.cs @@ -328,7 +328,7 @@ namespace Barotrauma { if (LocationType.Prefabs.TryGet("outpost".ToIdentifier(), out LocationType outpostLocationType)) { - otherLocation.ChangeType(campaign, outpostLocationType); + otherLocation.ChangeType(campaign, outpostLocationType, createStores: false); } } @@ -732,7 +732,6 @@ namespace Barotrauma location.SecondaryFaction ??= campaign.GetRandomSecondaryFaction(Rand.RandSync.ServerAndClient); } } - location.CreateStores(force: true); } foreach (LocationConnection connection in Connections) @@ -1020,11 +1019,11 @@ namespace Barotrauma } CurrentLocation = SelectedLocation; + CurrentLocation.CreateStores(); Discover(CurrentLocation); Visit(CurrentLocation); SelectedLocation = null; - CurrentLocation.CreateStores(); OnLocationChanged?.Invoke(new LocationChangeInfo(prevLocation, CurrentLocation)); if (GameMain.GameSession is { Campaign.CampaignMetadata: { } metadata }) @@ -1212,7 +1211,19 @@ namespace Barotrauma { foreach (Location location in Locations) { - location.LevelData.EventsExhausted = false; + if (location.Visited) + { + location.WorldStepsSinceVisited++; + if (location.WorldStepsSinceVisited > Location.ClearStoresDelay) + { + location.ClearStores(); + } + } + else + { + location.ClearStores(); + } + location.LevelData.ResetExhaustedEventSets(); if (location.Discovered) { if (furthestDiscoveredLocation == null || @@ -1224,7 +1235,7 @@ namespace Barotrauma } foreach (LocationConnection connection in Connections) { - connection.LevelData.EventsExhausted = false; + connection.LevelData.ResetExhaustedEventSets(); } foreach (Location location in Locations) @@ -1234,12 +1245,27 @@ namespace Barotrauma continue; } - if (location == CurrentLocation || location == SelectedLocation || location.IsGateBetweenBiomes) { continue; } - - if (!ProgressLocationTypeChanges(campaign, location) && location.Discovered) + bool shouldUpdateStores = location.Discovered; + //don't allow the type of the current location or the destination to change (it'd be weird to arrive at a different type of location than the one you were travelling to) + //biome gates should also remain unchanged + bool shouldProcessLocationTypeChanges = location != CurrentLocation && location != SelectedLocation && !location.IsGateBetweenBiomes; + if (shouldProcessLocationTypeChanges && + ProgressLocationTypeChanges(campaign, location)) { - location.UpdateStores(); + //don't update stores if the location type changed (that recreates the stores anyway) + shouldUpdateStores = false; } + + if (shouldUpdateStores) + { + location.UpdateStores(createStoresIfNotCreated: false); + } + } + + if (CurrentLocation != null) + { + CurrentLocation.UpdateStores(createStoresIfNotCreated: true); + CurrentLocation.WorldStepsSinceVisited = 0; } } @@ -1338,7 +1364,7 @@ namespace Barotrauma { location.ClearMissions(); } - location.ChangeType(campaign, newType); + location.ChangeType(campaign, newType, createStores: false); ChangeLocationTypeProjSpecific(location, prevName, change); foreach (var requirement in change.Requirements) { @@ -1409,9 +1435,13 @@ namespace Barotrauma } } - public void Visit(Location location) + public void Visit(Location location, bool resetTimeSinceVisited = true) { if (location is null) { return; } + if (resetTimeSinceVisited) + { + location.WorldStepsSinceVisited = 0; + } if (locationsVisited.Contains(location)) { return; } locationsVisited.Add(location); RemoveFogOfWarProjSpecific(location); @@ -1510,12 +1540,14 @@ namespace Barotrauma } location.LoadLocationTypeChange(subElement); + location.LoadChangingProperties(subElement, campaign); + // Backwards compatibility: if the discovery status is defined in the location element, // the game was saved using when the discovery order still wasn't being tracked if (subElement.GetAttributeBool("discovered", false)) { Discover(location); - Visit(location); + Visit(location, resetTimeSinceVisited: false); trackedLocationDiscoveryAndVisitOrder = false; } @@ -1525,9 +1557,6 @@ namespace Barotrauma LocationType newLocationType = LocationType.Prefabs.Find(lt => lt.Identifier == locationType) ?? LocationType.Prefabs.First(); location.ChangeType(campaign, newLocationType); - var factionIdentifier = subElement.GetAttributeIdentifier("faction", Identifier.Empty); - location.Faction = factionIdentifier.IsEmpty ? null : campaign.Factions.Find(f => f.Prefab.Identifier == factionIdentifier); - if (showNotifications && prevLocationType != location.Type) { var change = prevLocationType.CanChangeTo.Find(c => c.ChangeToType == location.Type.Identifier); @@ -1538,9 +1567,6 @@ namespace Barotrauma } } - var secondaryFactionIdentifier = subElement.GetAttributeIdentifier("secondaryfaction", Identifier.Empty); - location.SecondaryFaction = secondaryFactionIdentifier.IsEmpty ? null : campaign.Factions.Find(f => f.Prefab.Identifier == secondaryFactionIdentifier); - location.LoadStores(subElement); location.LoadMissions(subElement); @@ -1563,16 +1589,24 @@ namespace Barotrauma break; case "discovered": bool trackedVisitedEmptyLocations = subElement.GetAttributeBool("trackedvisitedemptylocations", false); + int[] discoveredIndices = subElement.GetAttributeIntArray("indices", Array.Empty()); + foreach (int discoveredIndex in discoveredIndices) + { + Discover(Locations[discoveredIndex]); + } + //backwards compatibility foreach (var childElement in subElement.GetChildElements("location")) { if (GetLocation(childElement) is Location l) { Discover(l); + //even older backwards compatibility: previously we didn't track whether you've "visited" empty locations, + //nor the order in which locations are visited - we need to handle that here if (!trackedVisitedEmptyLocations) { if (!l.HasOutpost()) { - Visit(l); + Visit(l, resetTimeSinceVisited: false); } trackedLocationDiscoveryAndVisitOrder = false; } @@ -1580,11 +1614,17 @@ namespace Barotrauma } break; case "visited": + int[] visitedIndices = subElement.GetAttributeIntArray("indices", Array.Empty()); + foreach (int visitedIndex in visitedIndices) + { + Visit(Locations[visitedIndex], resetTimeSinceVisited: false); + } + //backwards compatibility foreach (var childElement in subElement.GetChildElements("location")) { if (GetLocation(childElement) is Location l) { - Visit(l); + Visit(l, resetTimeSinceVisited: false); } } break; @@ -1708,25 +1748,15 @@ namespace Barotrauma if (locationsDiscovered.Any()) { var discoveryElement = new XElement("discovered", - new XAttribute("trackedvisitedemptylocations", true)); - foreach (Location location in locationsDiscovered) - { - int index = Locations.IndexOf(location); - var locationElement = new XElement("location", new XAttribute("i", index)); - discoveryElement.Add(locationElement); - } + new XAttribute("trackedvisitedemptylocations", true), + new XAttribute("indices", string.Join(',', locationsDiscovered.Select(l => Locations.IndexOf(l))))); mapElement.Add(discoveryElement); } if (locationsVisited.Any()) { - var visitElement = new XElement("visited"); - foreach (Location location in locationsVisited) - { - int index = Locations.IndexOf(location); - var locationElement = new XElement("location", new XAttribute("i", index)); - visitElement.Add(locationElement); - } + var visitElement = new XElement("visited", + new XAttribute("indices", string.Join(',', locationsVisited.Select(l => Locations.IndexOf(l))))); mapElement.Add(visitElement); } diff --git a/Barotrauma/BarotraumaShared/SharedSource/Map/Map/MapGenerationParams.cs b/Barotrauma/BarotraumaShared/SharedSource/Map/Map/MapGenerationParams.cs index fecc3bdf1..283443302 100644 --- a/Barotrauma/BarotraumaShared/SharedSource/Map/Map/MapGenerationParams.cs +++ b/Barotrauma/BarotraumaShared/SharedSource/Map/Map/MapGenerationParams.cs @@ -25,6 +25,9 @@ namespace Barotrauma [Serialize(true, IsPropertySaveable.Yes), Editable] public bool ShowOverlay { get; set; } + + [Serialize(false, IsPropertySaveable.Yes, description: "When enabled, locations that have an active store (= a store whose stocks are kept track of in the save file) are displayed in green, locations with no active store (due to not having been visited in a long time, or not having a store in the first place) are displayed in blue, and everything else in yellow."), Editable] + public bool ShowStoreInfo { get; set; } #else public readonly bool ShowLocations = true; public readonly bool ShowLevelTypeNames = false; diff --git a/Barotrauma/BarotraumaShared/SharedSource/Map/Map/Radiation.cs b/Barotrauma/BarotraumaShared/SharedSource/Map/Map/Radiation.cs index a510fd2f3..3380cfa66 100644 --- a/Barotrauma/BarotraumaShared/SharedSource/Map/Map/Radiation.cs +++ b/Barotrauma/BarotraumaShared/SharedSource/Map/Map/Radiation.cs @@ -1,4 +1,4 @@ -#nullable enable +#nullable enable using System; using System.Collections.Generic; using System.Linq; @@ -23,8 +23,6 @@ namespace Barotrauma public readonly Map Map; public readonly RadiationParams Params; - private Affliction? radiationAffliction; - private float radiationTimer; private float increasedAmount; @@ -62,7 +60,7 @@ namespace Barotrauma int amountOfOutposts = Map.Locations.Count(location => location.Type.HasOutpost && !location.IsCriticallyRadiated()); - foreach (Location location in Map.Locations.Where(Contains)) + foreach (Location location in Map.Locations.Where(l => DepthInRadiation(l) > 0)) { if (location.IsGateBetweenBiomes) { @@ -96,8 +94,6 @@ namespace Barotrauma increasedAmount = lastIncrease = amount; } - - public void UpdateRadiation(float deltaTime) { if (!(GameMain.GameSession?.IsCurrentLocationRadiated() ?? false)) { return; } @@ -110,55 +106,66 @@ namespace Barotrauma return; } - if (radiationAffliction == null) - { - float radiationStrengthChange = AfflictionPrefab.RadiationSickness.Effects.FirstOrDefault()?.StrengthChange ?? 0.0f; - radiationAffliction = new Affliction( - AfflictionPrefab.RadiationSickness, - (Params.RadiationDamageAmount - radiationStrengthChange) * Params.RadiationDamageDelay); - } - radiationTimer = Params.RadiationDamageDelay; foreach (Character character in Character.CharacterList) { if (character.IsDead || character.Removed || !(character.CharacterHealth is { } health)) { continue; } - - if (IsEntityRadiated(character)) + + float depthInRadiation = DepthInRadiation(character); + if (depthInRadiation > 0) { - var limb = character.AnimController.MainLimb; - AttackResult attackResult = limb.AddDamage(limb.SimPosition, radiationAffliction.ToEnumerable(), playSound: false); - character.CharacterHealth.ApplyDamage(limb, attackResult); + AfflictionPrefab afflictionPrefab; + // Get the related affliction (if necessary, fall back to the traditional radiation sickness for slightly better backwards compatibility) + afflictionPrefab = AfflictionPrefab.JovianRadiation ?? AfflictionPrefab.RadiationSickness; + float currentAfflictionStrength = character.CharacterHealth.GetAfflictionStrengthByIdentifier(afflictionPrefab.Identifier); + + // Get Jovian radiation strength, and cancel out the affliction's strength change (meant for decaying it) + // (for simplicity, let's assume each Effect of the Affliction has the same strengthchange) + float addedStrength = Params.RadiationDamageAmount - afflictionPrefab.Effects.FirstOrDefault()?.StrengthChange ?? 0.0f; + + // Damage is applied periodically, so we must apply the total damage for the full period at once (after deducting strengthchange) + addedStrength *= Params.RadiationDamageDelay; + + // The JovianRadiation affliction has brackets of 25 strength determined by the multiplier (1x = 0-25, 2x = 25-50 etc.) + int multiplier = (int)Math.Ceiling(depthInRadiation / Params.RadiationEffectMultipliedPerPixelDistance); + float growthPotentialInBracket = (multiplier * 25) - currentAfflictionStrength; + if (growthPotentialInBracket > 0) + { + addedStrength = Math.Min(addedStrength, growthPotentialInBracket); + character.CharacterHealth.ApplyAffliction( + character.AnimController?.MainLimb, + afflictionPrefab.Instantiate(addedStrength)); + } } } } - public bool Contains(Location location) + public float DepthInRadiation(Location location) { - return Contains(location.MapPosition); + return DepthInRadiation(location.MapPosition); + } + + private float DepthInRadiation(Vector2 pos) + { + return Amount - pos.X; } - public bool Contains(Vector2 pos) + public float DepthInRadiation(Entity entity) { - return pos.X < Amount; - } - - public bool IsEntityRadiated(Entity entity) - { - if (!Enabled) { return false; } + if (!Enabled) { return 0; } if (Level.Loaded is { Type: LevelData.LevelType.LocationConnection, StartLocation: { } startLocation, EndLocation: { } endLocation } level) { - if (Contains(startLocation) && Contains(endLocation)) { return true; } - - float distance = MathHelper.Clamp((entity.WorldPosition.X - level.StartPosition.X) / (level.EndPosition.X - level.StartPosition.X), 0.0f, 1.0f); + // Approximate how far between the level start and end points the entity is on the map + float distanceNormalized = MathHelper.Clamp((entity.WorldPosition.X - level.StartPosition.X) / (level.EndPosition.X - level.StartPosition.X), 0.0f, 1.0f); var (startX, startY) = startLocation.MapPosition; var (endX, endY) = endLocation.MapPosition; - Vector2 mapPos = new Vector2(startX + (endX - startX), startY + (endY - startY)) * distance; + Vector2 mapPos = new Vector2(startX, startY) + (new Vector2(endX - startX, endY - startY) * distanceNormalized); - return Contains(mapPos); + return DepthInRadiation(mapPos); } - return false; + return 0; } public XElement Save() diff --git a/Barotrauma/BarotraumaShared/SharedSource/Map/Map/RadiationParams.cs b/Barotrauma/BarotraumaShared/SharedSource/Map/Map/RadiationParams.cs index 20933077b..0b056c9d7 100644 --- a/Barotrauma/BarotraumaShared/SharedSource/Map/Map/RadiationParams.cs +++ b/Barotrauma/BarotraumaShared/SharedSource/Map/Map/RadiationParams.cs @@ -9,37 +9,40 @@ namespace Barotrauma public string Name => nameof(RadiationParams); public Dictionary SerializableProperties { get; } - [Serialize(defaultValue: -100f, isSaveable: IsPropertySaveable.No, "How much radiation the world starts with.")] + [Serialize(defaultValue: -100f, isSaveable: IsPropertySaveable.No, "How much Jovian radiation the world starts with.")] public float StartingRadiation { get; set; } - [Serialize(defaultValue: 100f, isSaveable: IsPropertySaveable.No, "How much radiation is added on each step.")] + [Serialize(defaultValue: 100f, isSaveable: IsPropertySaveable.No, "How much Jovian radiation is added on each step.")] public float RadiationStep { get; set; } + + [Serialize(defaultValue: 250f, isSaveable: IsPropertySaveable.No, "The interval at which Jovian radiation's effect multiplies in intensity, measured in map pixels. For example if this is 200, then 400 map pixels into the radiation its effect will be doubled.")] + public float RadiationEffectMultipliedPerPixelDistance { get; set; } - [Serialize(defaultValue: 10, isSaveable: IsPropertySaveable.No, "How many turns in radiation does it take for an outpost to be removed from the map.")] + [Serialize(defaultValue: 10, isSaveable: IsPropertySaveable.No, "How many turns in Jovian radiation does it take for an outpost to be removed from the map.")] public int CriticalRadiationThreshold { get; set; } - [Serialize(defaultValue: 3, isSaveable: IsPropertySaveable.No, "Minimum amount of outposts in the level that cannot be removed due to radiation.")] + [Serialize(defaultValue: 3, isSaveable: IsPropertySaveable.No, "Minimum amount of outposts in the level that cannot be removed due to Jovian radiation.")] public int MinimumOutpostAmount { get; set; } - [Serialize(defaultValue: 3f, isSaveable: IsPropertySaveable.No, "How fast the radiation increase animation goes.")] - public float AnimationSpeed { get; set; } - - [Serialize(defaultValue: 10f, isSaveable: IsPropertySaveable.No, "How long it takes to apply more radiation damage while in a radiated zone.")] + [Serialize(defaultValue: 10f, isSaveable: IsPropertySaveable.No, "How long it takes to apply more of the Jovian radiation's effect while in the radiated zone.")] public float RadiationDamageDelay { get; set; } - [Serialize(defaultValue: 1f, isSaveable: IsPropertySaveable.No, "How much is the radiation affliction increased by while in a radiated zone.")] + [Serialize(defaultValue: 1f, isSaveable: IsPropertySaveable.No, "How much is the Jovian radiation affliction increased by while in a radiated zone.")] public float RadiationDamageAmount { get; set; } - [Serialize(defaultValue: -1.0f, isSaveable: IsPropertySaveable.No, "Maximum amount of radiation.")] + [Serialize(defaultValue: -1.0f, isSaveable: IsPropertySaveable.No, "Maximum amount of Jovian radiation.")] public float MaxRadiation { get; set; } - - [Serialize(defaultValue: "139,0,0,85", isSaveable: IsPropertySaveable.No, "The color of the radiated area.")] + + [Serialize(defaultValue: 3f, isSaveable: IsPropertySaveable.No, "How fast the Jovian radiation increase animation goes in the map view.")] + public float AnimationSpeed { get; set; } + + [Serialize(defaultValue: "139,0,0,85", isSaveable: IsPropertySaveable.No, "The color of the radiated area in the map view.")] public Color RadiationAreaColor { get; set; } - [Serialize(defaultValue: "255,0,0,255", isSaveable: IsPropertySaveable.No, "The tint of the radiation border sprites.")] + [Serialize(defaultValue: "255,0,0,255", isSaveable: IsPropertySaveable.No, "The tint of the Jovian radiation border sprites in the map view.")] public Color RadiationBorderTint { get; set; } - [Serialize(defaultValue: 16.66f, isSaveable: IsPropertySaveable.No, "Speed of the border spritesheet animation.")] + [Serialize(defaultValue: 16.66f, isSaveable: IsPropertySaveable.No, "Speed of the border spritesheet animation in the map view.")] public float BorderAnimationSpeed { get; set; } public RadiationParams(XElement element) diff --git a/Barotrauma/BarotraumaShared/SharedSource/Map/MapEntity.cs b/Barotrauma/BarotraumaShared/SharedSource/Map/MapEntity.cs index 0ac5c39cd..d99cd9078 100644 --- a/Barotrauma/BarotraumaShared/SharedSource/Map/MapEntity.cs +++ b/Barotrauma/BarotraumaShared/SharedSource/Map/MapEntity.cs @@ -549,7 +549,24 @@ namespace Barotrauma return; } + //sort damageable walls by sprite depth: + //necessary because rendering the damage effect starts a new sprite batch and breaks the order otherwise int i = 0; + if (this is Structure { DrawDamageEffect: true } structure) + { + //insertion sort according to draw depth + float drawDepth = structure.SpriteDepth; + while (i < MapEntityList.Count) + { + float otherDrawDepth = (MapEntityList[i] as Structure)?.SpriteDepth ?? 1.0f; + if (otherDrawDepth < drawDepth) { break; } + i++; + } + MapEntityList.Insert(i, this); + return; + } + + i = 0; while (i < MapEntityList.Count) { i++; diff --git a/Barotrauma/BarotraumaShared/SharedSource/Map/Outposts/NPCSet.cs b/Barotrauma/BarotraumaShared/SharedSource/Map/Outposts/NPCSet.cs index 4655040ef..502ab7a89 100644 --- a/Barotrauma/BarotraumaShared/SharedSource/Map/Outposts/NPCSet.cs +++ b/Barotrauma/BarotraumaShared/SharedSource/Map/Outposts/NPCSet.cs @@ -8,7 +8,7 @@ namespace Barotrauma { public readonly static PrefabCollection Sets = new PrefabCollection(); - private readonly ImmutableArray Humans; + public readonly ImmutableArray Humans; public NPCSet(ContentXElement element, NPCSetsFile file) : base(file, element.GetAttributeIdentifier("identifier", "")) { diff --git a/Barotrauma/BarotraumaShared/SharedSource/Map/Outposts/OutpostGenerationParams.cs b/Barotrauma/BarotraumaShared/SharedSource/Map/Outposts/OutpostGenerationParams.cs index 4c11a83fd..bdbf0c3cb 100644 --- a/Barotrauma/BarotraumaShared/SharedSource/Map/Outposts/OutpostGenerationParams.cs +++ b/Barotrauma/BarotraumaShared/SharedSource/Map/Outposts/OutpostGenerationParams.cs @@ -59,6 +59,13 @@ namespace Barotrauma set; } + [Serialize(true, IsPropertySaveable.Yes, description: "Should hallways of the minimum hallway length be always generated between modules, even if they could be placed directly against each other with no overlaps?"), Editable] + public bool AlwaysGenerateHallways + { + get; + set; + } + [Serialize(false, IsPropertySaveable.Yes, description: "Should this outpost always be destructible, regardless if damaging outposts is allowed by the server?"), Editable] public bool AlwaysDestructible { diff --git a/Barotrauma/BarotraumaShared/SharedSource/Map/Outposts/OutpostGenerator.cs b/Barotrauma/BarotraumaShared/SharedSource/Map/Outposts/OutpostGenerator.cs index 8c11def4e..99c20a501 100644 --- a/Barotrauma/BarotraumaShared/SharedSource/Map/Outposts/OutpostGenerator.cs +++ b/Barotrauma/BarotraumaShared/SharedSource/Map/Outposts/OutpostGenerator.cs @@ -56,6 +56,11 @@ namespace Barotrauma } } + /// + /// How many times the generator retries generating an outpost with a different seed if it fails to generate a valid outpost with no overlaps. + /// + const int MaxOutpostGenerationRetries = 6; + public static Submarine Generate(OutpostGenerationParams generationParams, LocationType locationType, bool onlyEntrance = false, bool allowInvalidOutpost = false) { return Generate(generationParams, locationType, location: null, onlyEntrance, allowInvalidOutpost); @@ -86,7 +91,7 @@ namespace Barotrauma generationParams = newParams; } - locationType = location.GetLocationType(); + locationType = location.Type; } Submarine sub = null; @@ -182,8 +187,8 @@ namespace Barotrauma List selectedModules = new List(); bool generationFailed = false; - int remainingTries = 5; - while (remainingTries > -1 && outpostModules.Any()) + int remainingOutpostGenerationTries = MaxOutpostGenerationRetries; + while (remainingOutpostGenerationTries > -1 && outpostModules.Any()) { if (sub != null) { @@ -208,7 +213,7 @@ namespace Barotrauma GameMain.Server.EntityEventManager.Events.RemoveRange(eventCount, GameMain.Server.EntityEventManager.Events.Count - eventCount); GameMain.Server.EntityEventManager.UniqueEvents.RemoveRange(uniqueEventCount, GameMain.Server.EntityEventManager.UniqueEvents.Count - uniqueEventCount); #endif - if (remainingTries <= 0) + if (remainingOutpostGenerationTries <= 0) { generationFailed = true; break; @@ -269,7 +274,7 @@ namespace Barotrauma if (pendingModuleFlags.Contains("initialFlag".ToIdentifier())) { pendingModuleFlags.Remove(initialFlag); } } - if (remainingTries == 1) + if (remainingOutpostGenerationTries == 1) { //generation has failed and only one attempt left, try removing duplicate modules pendingModuleFlags = pendingModuleFlags.Distinct().ToList(); @@ -283,13 +288,13 @@ namespace Barotrauma selectedModules, locationType, allowExtendBelowInitialModule: generationParams is RuinGeneration.RuinGenerationParams, - allowDifferentLocationType: remainingTries == 1); + allowDifferentLocationType: remainingOutpostGenerationTries == 1); if (GameMain.GameSession?.ForceOutpostModule != null) { - if (remainingTries > 0) + if (remainingOutpostGenerationTries > 0) { - remainingTries--; + remainingOutpostGenerationTries--; continue; } DebugConsole.ThrowError($"Could not place force outpost module: {GameMain.GameSession.ForceOutpostModule.OutpostModuleInfo.Name}"); @@ -301,8 +306,8 @@ namespace Barotrauma { if (!allowInvalidOutpost) { - remainingTries--; - if (remainingTries <= 0) + remainingOutpostGenerationTries--; + if (remainingOutpostGenerationTries <= 0) { DebugConsole.ThrowError("Could not generate an outpost with all of the required modules. Some modules may not have enough connections at the edges to generate a valid layout. Pending modules: " + string.Join(", ", pendingModuleFlags)); } @@ -346,7 +351,7 @@ namespace Barotrauma EnableFactionSpecificEntities(sub, location); return sub; } - remainingTries--; + remainingOutpostGenerationTries--; } DebugConsole.AddSafeError("Failed to generate an outpost without overlapping modules. Trying to use a pre-built outpost instead..."); @@ -444,9 +449,12 @@ namespace Barotrauma selectedModule.Offset = (selectedModule.PreviousGap.WorldPosition + selectedModule.PreviousModule.Offset) - selectedModule.ThisGap.WorldPosition; - if (selectedModule.PreviousGap.ConnectedDoor != null || selectedModule.ThisGap.ConnectedDoor != null) + if (generationParams.AlwaysGenerateHallways) { - selectedModule.Offset += moveDir * generationParams.MinHallwayLength; + if (selectedModule.PreviousGap.ConnectedDoor != null || selectedModule.ThisGap.ConnectedDoor != null) + { + selectedModule.Offset += moveDir * generationParams.MinHallwayLength; + } } } entities[selectedModule] = moduleEntities; @@ -467,24 +475,30 @@ namespace Barotrauma GetSubsequentModules(placedModule, selectedModules, ref subsequentModules); List otherModules = selectedModules.Except(subsequentModules).ToList(); - int remainingTries = 10; - while (FindOverlap(subsequentModules, otherModules, out var module1, out var module2) && remainingTries > 0) + int remainingOverlapPreventionTries = 10; + while (FindOverlap(subsequentModules, otherModules, out var module1, out var module2) && remainingOverlapPreventionTries > 0) { overlapsFound = true; - if (FindOverlapSolution(subsequentModules, module1, module2, selectedModules, maxMoveAmount, out Dictionary solution)) + if (FindOverlapSolution(subsequentModules, module1, module2, selectedModules, generationParams.MinHallwayLength, maxMoveAmount, out Dictionary solution)) { foreach (KeyValuePair kvp in solution) { - kvp.Key.Offset += kvp.Value; + kvp.Key.Offset += kvp.Value; } } else { break; } - remainingTries--; + remainingOverlapPreventionTries--; + } + if (remainingOutpostGenerationTries > MaxOutpostGenerationRetries / 2 && + ModuleBelowInitialModule(placedModule, selectedModules.First())) + { + overlapsFound = true; } } + iteration++; if (iteration > 10) { @@ -647,7 +661,8 @@ namespace Barotrauma /// The modules we've already selected to be used in the outpost. /// The type of the location we're generating the outpost for. /// If we fail to append to the current module, should we try replacing it with something else and see if we can append to it then? - /// Is the module allowed to be placed further down than the initial module (usually the airlock module)? + /// Is the module allowed to be placed further down than the initial module (usually the airlock module)? + /// Note that at this point we only determine which module to attach to which, but not the actual positions or bounds of the modules, so it's possible for a module to attach to the side of the airlock but still extend below the airlock if it's very tall for example. /// If we fail to find a module suitable for the location type, should we use a module that's meant for a different location type instead? private static bool AppendToModule(PlacedModule currentModule, List availableModules, @@ -886,7 +901,7 @@ namespace Barotrauma Vector2 gapPos2 = otherModule.PreviousModule.Offset + otherModule.PreviousGap.Position + gapEdgeOffset + otherModule.PreviousModule.MoveOffset; if (Submarine.RectContains(rect, gapPos1) || Submarine.RectContains(rect, gapPos2) || - MathUtils.GetLineRectangleIntersection(gapPos1, gapPos2, rect, out _)) + MathUtils.GetLineWorldRectangleIntersection(gapPos1, gapPos2, rect, out _)) { return true; } @@ -904,6 +919,21 @@ namespace Barotrauma return false; } + /// + /// Check if the lowest point of the module is below the lowest point of the initial (docking) module. + /// This shouldn't happen, because it can cause modules to overlap with the docked sub. + /// + private static bool ModuleBelowInitialModule(PlacedModule module, PlacedModule initialModule) + { + Rectangle bounds = module.Bounds; + bounds.Location += (module.Offset + module.MoveOffset).ToPoint(); + + Rectangle initialModuleBounds = initialModule.Bounds; + initialModuleBounds.Location += (initialModule.Offset + initialModule.MoveOffset).ToPoint(); + + return bounds.Bottom < initialModuleBounds.Bottom; + } + /// /// Attempt to find a way to move the modules in a way that stops the 2 specific modules from overlapping. /// Done by iterating through the modules and testing how much the subsequent modules (i.e. modules that are further from the initial outpost) @@ -919,6 +949,7 @@ namespace Barotrauma IEnumerable movableModules, PlacedModule module1, PlacedModule module2, IEnumerable allmodules, + float minMoveAmount, int maxMoveAmount, out Dictionary solution) { @@ -934,14 +965,13 @@ namespace Barotrauma { if (module.ThisGap.ConnectedDoor == null && module.PreviousGap.ConnectedDoor == null) { continue; } Vector2 moveDir = GetMoveDir(module.ThisGapPosition); - Vector2 moveStep = moveDir * 50.0f; - Vector2 currentMove = Vector2.Zero; + const float moveStep = 50.0f; + Vector2 currentMove = moveDir * Math.Max(minMoveAmount, moveStep); List subsequentModules2 = new List(); GetSubsequentModules(module, movableModules, ref subsequentModules2); while (currentMove.LengthSquared() < maxMoveAmount * maxMoveAmount) { - currentMove += moveStep; foreach (PlacedModule movedModule in subsequentModules2) { movedModule.MoveOffset = currentMove; @@ -958,6 +988,7 @@ namespace Barotrauma } break; } + currentMove += moveDir * moveStep; } foreach (PlacedModule movedModule in allmodules) { diff --git a/Barotrauma/BarotraumaShared/SharedSource/Map/Structure.cs b/Barotrauma/BarotraumaShared/SharedSource/Map/Structure.cs index 4abde1f09..0fbaa6e0b 100644 --- a/Barotrauma/BarotraumaShared/SharedSource/Map/Structure.cs +++ b/Barotrauma/BarotraumaShared/SharedSource/Map/Structure.cs @@ -98,10 +98,19 @@ namespace Barotrauma { get { return base.Prefab.Name.Value; } } - public bool HasBody { - get { return Prefab.Body; } + get { return Prefab.Body && !DisableCollision; } + } + + [Serialize(false, IsPropertySaveable.Yes), ConditionallyEditable(ConditionallyEditable.ConditionType.HasBodyByDefault)] + /// + /// Note that changing the value mid-round will not have an effect: this is only intended for disabling the collisions on a structure in the sub editor. + /// + public bool DisableCollision + { + get; + set; } public List Bodies { get; private set; } @@ -254,7 +263,7 @@ namespace Barotrauma { CreateStairBodies(); } - else if (Prefab.Body) + else if (HasBody) { CreateSections(); UpdateSections(); @@ -325,7 +334,7 @@ namespace Barotrauma { Rectangle oldRect = Rect; base.Rect = value; - if (Prefab.Body) + if (HasBody) { CreateSections(); UpdateSections(); @@ -506,10 +515,16 @@ namespace Barotrauma Indestructible = Prefab.ConfigElement.GetAttributeBool(nameof(Indestructible), false); } + //if the prefab normally has a body, but it has been disabled by DisableCollision, + //we still want the item in the wall list to render it correctly if (Prefab.Body) { - Bodies = new List(); WallList.Add(this); + } + + if (HasBody) + { + Bodies = new List(); CreateSections(); UpdateSections(); } @@ -969,7 +984,7 @@ namespace Barotrauma public void AddDamage(int sectionIndex, float damage, Character attacker = null, bool emitParticles = true, bool createWallDamageProjectiles = false) { - if (!Prefab.Body || Prefab.Platform || Indestructible) { return; } + if (!HasBody || Prefab.Platform || Indestructible) { return; } if (sectionIndex < 0 || sectionIndex > Sections.Length - 1) { return; } @@ -1115,7 +1130,7 @@ namespace Barotrauma public AttackResult AddDamage(Character attacker, Vector2 worldPosition, Attack attack, Vector2 impulseDirection, float deltaTime, bool playSound = false) { if (Submarine != null && Submarine.GodMode) { return new AttackResult(0.0f, null); } - if (!Prefab.Body || Prefab.Platform || Indestructible) { return new AttackResult(0.0f, null); } + if (!HasBody || Prefab.Platform || Indestructible) { return new AttackResult(0.0f, null); } Vector2 transformedPos = worldPosition; if (Submarine != null) { transformedPos -= Submarine.Position; } @@ -1177,7 +1192,7 @@ namespace Barotrauma bool createWallDamageProjectiles = false) { if (Submarine != null && Submarine.GodMode || (Indestructible && !isNetworkEvent)) { return; } - if (!Prefab.Body) { return; } + if (!HasBody) { return; } if (!MathUtils.IsValid(damage)) { return; } damage = MathHelper.Clamp(damage, 0.0f, MaxHealth - Prefab.MinHealth); @@ -1219,7 +1234,9 @@ namespace Barotrauma Sections[sectionIndex].gap = null; } } - else + //do not create gaps on damaged walls in editors, + //they're created at the start of a round and "pre-creating" them in the editors causes issues (see #12998) + else if (Screen.Selected is not { IsEditor: true }) { float prevGapOpenState = Sections[sectionIndex].gap?.Open ?? 0.0f; if (Sections[sectionIndex].gap == null) @@ -1727,7 +1744,7 @@ namespace Barotrauma //structures with a body drop a shadow by default if (element.GetAttribute(nameof(UseDropShadow)) == null) { - s.UseDropShadow = prefab.Body; + s.UseDropShadow = s.HasBody; } if (element.GetAttribute(nameof(NoAITarget)) == null) diff --git a/Barotrauma/BarotraumaShared/SharedSource/Map/Submarine.cs b/Barotrauma/BarotraumaShared/SharedSource/Map/Submarine.cs index 2d90d017c..1d262b380 100644 --- a/Barotrauma/BarotraumaShared/SharedSource/Map/Submarine.cs +++ b/Barotrauma/BarotraumaShared/SharedSource/Map/Submarine.cs @@ -1917,6 +1917,7 @@ namespace Barotrauma } element.Add(new XAttribute("tags", Info.Tags.ToString())); element.Add(new XAttribute("outposttags", Info.OutpostTags.ConvertToString())); + element.Add(new XAttribute("triggeroutpostmissionevents", Info.TriggerOutpostMissionEvents.ConvertToString())); element.Add(new XAttribute("gameversion", GameMain.Version.ToString())); Rectangle dimensions = VisibleBorders; diff --git a/Barotrauma/BarotraumaShared/SharedSource/Map/SubmarineBody.cs b/Barotrauma/BarotraumaShared/SharedSource/Map/SubmarineBody.cs index 0c20ed708..e1be7173a 100644 --- a/Barotrauma/BarotraumaShared/SharedSource/Map/SubmarineBody.cs +++ b/Barotrauma/BarotraumaShared/SharedSource/Map/SubmarineBody.cs @@ -515,7 +515,7 @@ namespace Barotrauma //cast a line from the position of the character to the same direction as the translation of the sub //and see where it intersects with the bounding box - if (!MathUtils.GetLineRectangleIntersection(limb.WorldPosition, + if (!MathUtils.GetLineWorldRectangleIntersection(limb.WorldPosition, limb.WorldPosition + translateDir * 100000.0f, worldBorders, out Vector2 intersection)) { //should never happen when casting a line out from inside the bounding box diff --git a/Barotrauma/BarotraumaShared/SharedSource/Map/SubmarineInfo.cs b/Barotrauma/BarotraumaShared/SharedSource/Map/SubmarineInfo.cs index 70e4f497a..2ae85e4eb 100644 --- a/Barotrauma/BarotraumaShared/SharedSource/Map/SubmarineInfo.cs +++ b/Barotrauma/BarotraumaShared/SharedSource/Map/SubmarineInfo.cs @@ -1,4 +1,4 @@ -using Microsoft.Xna.Framework; +using Microsoft.Xna.Framework; using System; using System.Collections.Generic; using System.Collections.Immutable; @@ -129,24 +129,23 @@ namespace Barotrauma public ImmutableHashSet OutpostTags { get; set; } = ImmutableHashSet.Empty; - public bool IsOutpost => Type == SubmarineType.Outpost || Type == SubmarineType.OutpostModule; + public ImmutableHashSet TriggerOutpostMissionEvents { get; set; } = ImmutableHashSet.Empty; + + public bool IsOutpost => Type is SubmarineType.Outpost or SubmarineType.OutpostModule; public bool IsWreck => Type == SubmarineType.Wreck; public bool IsBeacon => Type == SubmarineType.BeaconStation; public bool IsEnemySubmarine => Type == SubmarineType.EnemySubmarine; public bool IsPlayer => Type == SubmarineType.Player; public bool IsRuin => Type == SubmarineType.Ruin; - + /// /// Ruin modules are of type SubmarineType.OutpostModule, until the ruin generator (or the test game mode) sets them as ruins. /// This is a helper workaround check intended to be used only in the context of the sub editor and the test game mode, where ruins aren't generated. /// - public bool ShouldBeRuin => Type is SubmarineType.Ruin or SubmarineType.OutpostModule && - (OutpostModuleInfo.ModuleFlags.Contains("ruin".ToIdentifier()) || - OutpostModuleInfo.ModuleFlags.Contains("ruinentrance".ToIdentifier()) || - OutpostModuleInfo.ModuleFlags.Contains("ruinvault".ToIdentifier()) || - OutpostModuleInfo.ModuleFlags.Contains("ruinworkshop".ToIdentifier()) || - OutpostModuleInfo.ModuleFlags.Contains("ruinshrine".ToIdentifier())); + public bool ShouldBeRuin => + Type is SubmarineType.Ruin or SubmarineType.OutpostModule && + OutpostModuleInfo.ModuleFlags.Any(f => f.StartsWith("ruin")); public bool IsCampaignCompatible => IsPlayer && !HasTag(SubmarineTag.Shuttle) && !HasTag(SubmarineTag.HideInMenus) && SubmarineClass != SubmarineClass.Undefined; public bool IsCampaignCompatibleIgnoreClass => IsPlayer && !HasTag(SubmarineTag.Shuttle) && !HasTag(SubmarineTag.HideInMenus); @@ -341,6 +340,7 @@ namespace Barotrauma OutpostGenerationParams = original.OutpostGenerationParams; LayersHiddenByDefault = original.LayersHiddenByDefault; OutpostTags = original.OutpostTags; + TriggerOutpostMissionEvents = original.TriggerOutpostMissionEvents; if (original.OutpostModuleInfo != null) { OutpostModuleInfo = new OutpostModuleInfo(original.OutpostModuleInfo); @@ -438,6 +438,14 @@ namespace Barotrauma OutpostTags = SubmarineElement.GetAttributeIdentifierImmutableHashSet(nameof(OutpostTags), ImmutableHashSet.Empty); + TriggerOutpostMissionEvents = SubmarineElement.GetAttributeIdentifierImmutableHashSet(nameof(TriggerOutpostMissionEvents), ImmutableHashSet.Empty); + //backwards compatibility: previously the outpost deathmatch mission always triggered an event with the tag "deathmatchweapondrop" + //now that's configured in the outpost itself, so let's make older outposts trigger it automatically + if (GameVersion < new Version(1, 8, 0, 0) && OutpostTags.Contains("PvPOutpost")) + { + TriggerOutpostMissionEvents = TriggerOutpostMissionEvents.Add("deathmatchweapondrop".ToIdentifier()); + } + if (SubmarineElement?.Attribute("type") != null) { if (Enum.TryParse(SubmarineElement.GetAttributeString("type", ""), out SubmarineType type)) diff --git a/Barotrauma/BarotraumaShared/SharedSource/Map/WayPoint.cs b/Barotrauma/BarotraumaShared/SharedSource/Map/WayPoint.cs index ae2e4a6b1..1dc9d991c 100644 --- a/Barotrauma/BarotraumaShared/SharedSource/Map/WayPoint.cs +++ b/Barotrauma/BarotraumaShared/SharedSource/Map/WayPoint.cs @@ -1011,26 +1011,52 @@ namespace Barotrauma return assignedWayPoints; } - public static List GetOutpostSpawnPoints(CharacterTeamType teamID) + /// + /// Find appropriate spawnpoints in the outpost for the player crew (preferring job-specific spawnpoints in the initial/airlock module normally, and job and team -specific spawnpoints in the PvP mode). + /// + public static WayPoint[] SelectOutpostSpawnPoints(List crew, CharacterTeamType teamID) { - List spawnWaypoints = WayPointList.FindAll(wp => + List potentialSpawnPoints = WayPointList.FindAll(wp => wp.SpawnType == SpawnType.Human && wp.Submarine == Level.Loaded.StartOutpost); if (GameMain.GameSession.GameMode is PvPMode) { Identifier teamSpawnTag = ("deathmatch" + teamID).ToIdentifier(); - if (spawnWaypoints.Any(wp => wp.Tags.Contains(teamSpawnTag))) + if (potentialSpawnPoints.Any(wp => wp.Tags.Contains(teamSpawnTag))) { - spawnWaypoints = spawnWaypoints.FindAll(wp => wp.Tags.Contains(teamSpawnTag)); + potentialSpawnPoints = potentialSpawnPoints.FindAll(wp => wp.Tags.Contains(teamSpawnTag)); } } else { - spawnWaypoints = spawnWaypoints.FindAll(wp => + potentialSpawnPoints = potentialSpawnPoints.FindAll(wp => wp.CurrentHull?.OutpostModuleTags != null && wp.CurrentHull.OutpostModuleTags.Contains(Barotrauma.Tags.Airlock)); } - return spawnWaypoints; + if (potentialSpawnPoints.None()) { return potentialSpawnPoints.ToArray(); } + + List spawnPoints = new List(); + for (int i = 0; i < crew.Count; i++) + { + var spawnPointsForJob = potentialSpawnPoints.Where(wp => wp.AssignedJob == crew[i].Job.Prefab); + var spawnPointsForAnyJob = potentialSpawnPoints.Where(wp => wp.AssignedJob == null); + if (spawnPointsForJob.Any()) + { + //prefer job-specific spawnpoints + spawnPoints.Add(spawnPointsForJob.GetRandomUnsynced()); + } + else if (spawnPointsForAnyJob.Any()) + { + //2nd option: a non-job-specific spawnpoint + spawnPoints.Add(spawnPointsForAnyJob.GetRandomUnsynced()); + } + else + { + //last option: whatever spawnpoint (no matter if it's not for this job) + spawnPoints.Add(potentialSpawnPoints.GetRandomUnsynced()); + } + } + return spawnPoints.ToArray(); } public void FindHull() diff --git a/Barotrauma/BarotraumaShared/SharedSource/Networking/ChatMessage.cs b/Barotrauma/BarotraumaShared/SharedSource/Networking/ChatMessage.cs index c5a5a955b..912acd019 100644 --- a/Barotrauma/BarotraumaShared/SharedSource/Networking/ChatMessage.cs +++ b/Barotrauma/BarotraumaShared/SharedSource/Networking/ChatMessage.cs @@ -225,15 +225,17 @@ namespace Barotrauma.Networking public static string ApplyDistanceEffect(string text, float garbleAmount) { - if (garbleAmount < 0.3f) return text; - if (garbleAmount >= 1.0f) return ""; + if (garbleAmount < 0.3f) { return text; } + if (garbleAmount >= 1.0f) { return ""; } - int startIndex = Math.Max(text.IndexOf(':') + 1, 1); + string textWithoutColorTags = RichString.Rich(text).SanitizedValue; + + int startIndex = Math.Max(textWithoutColorTags.IndexOf(':') + 1, 1); StringBuilder sb = new StringBuilder(text.Length); - for (int i = 0; i < text.Length; i++) + for (int i = 0; i < textWithoutColorTags.Length; i++) { - sb.Append((i > startIndex && Rand.Range(0.0f, 1.0f) < garbleAmount) ? '-' : text[i]); + sb.Append((i > startIndex && Rand.Range(0.0f, 1.0f) < garbleAmount) ? '-' : textWithoutColorTags[i]); } return sb.ToString(); diff --git a/Barotrauma/BarotraumaShared/SharedSource/Networking/ClientPermissions.cs b/Barotrauma/BarotraumaShared/SharedSource/Networking/ClientPermissions.cs index d8f76a414..3923f8585 100644 --- a/Barotrauma/BarotraumaShared/SharedSource/Networking/ClientPermissions.cs +++ b/Barotrauma/BarotraumaShared/SharedSource/Networking/ClientPermissions.cs @@ -83,10 +83,16 @@ namespace Barotrauma.Networking XDocument doc = XMLExtensions.TryLoadXml(file); if (doc == null) { return; } - List.Clear(); foreach (XElement element in doc.Root.Elements()) { - List.Add(new PermissionPreset(element)); + var newPermissionPreset = new PermissionPreset(element); + var existingPreset = List.FirstOrDefault(p => p.Identifier == newPermissionPreset.Identifier); + if (existingPreset != null) + { + List.Remove(existingPreset); + DebugConsole.AddWarning($"The permission preset file {file} contains a permission preset that conflicts with another preset. Overriding the previous preset..."); + } + List.Add(newPermissionPreset); } } diff --git a/Barotrauma/BarotraumaShared/SharedSource/Networking/EntitySpawner.cs b/Barotrauma/BarotraumaShared/SharedSource/Networking/EntitySpawner.cs index 885953314..0b85e7ac2 100644 --- a/Barotrauma/BarotraumaShared/SharedSource/Networking/EntitySpawner.cs +++ b/Barotrauma/BarotraumaShared/SharedSource/Networking/EntitySpawner.cs @@ -74,7 +74,7 @@ namespace Barotrauma { return null; } - spawnedItem = new Item(Prefab, Vector2.Zero, null); + spawnedItem = new Item(Prefab, Inventory.Owner.Position, Inventory.Owner.Submarine); //this needs to be done before attempting to put the item in the inventory, //because the quality and condition may affect whether it can go in the inventory (into an existing stack) SetItemProperties(spawnedItem); @@ -93,7 +93,6 @@ namespace Barotrauma } } } - spawnedItem.SetTransform(FarseerPhysics.ConvertUnits.ToSimUnits(Inventory.Owner?.WorldPosition ?? Vector2.Zero), spawnedItem.body?.Rotation ?? 0.0f, findNewHull: false); } } else diff --git a/Barotrauma/BarotraumaShared/SharedSource/Networking/NetworkMember.cs b/Barotrauma/BarotraumaShared/SharedSource/Networking/NetworkMember.cs index 9a8526249..f0b1e6345 100644 --- a/Barotrauma/BarotraumaShared/SharedSource/Networking/NetworkMember.cs +++ b/Barotrauma/BarotraumaShared/SharedSource/Networking/NetworkMember.cs @@ -27,6 +27,8 @@ namespace Barotrauma.Networking RESPONSE_STARTGAME, //tell the server whether you're ready to start SERVER_COMMAND, //tell the server to end a round or kick/ban someone (special permissions required) + ENDROUND_SELF, //the client wants to end the round for themselves only and return to the lobby + EVENTMANAGER_RESPONSE, REQUEST_STARTGAMEFINALIZE, //tell the server you're ready to finalize round initialization @@ -43,6 +45,7 @@ namespace Barotrauma.Networking READY_CHECK, READY_TO_SPAWN, TAKEOVERBOT, + TOGGLE_RESERVE_BENCH, REQUEST_BACKUP_INDICES, // client wants a list of available backups for a save file LUA_NET_MESSAGE diff --git a/Barotrauma/BarotraumaShared/SharedSource/Networking/RespawnManager.cs b/Barotrauma/BarotraumaShared/SharedSource/Networking/RespawnManager.cs index 2bab13e6a..8f67b919d 100644 --- a/Barotrauma/BarotraumaShared/SharedSource/Networking/RespawnManager.cs +++ b/Barotrauma/BarotraumaShared/SharedSource/Networking/RespawnManager.cs @@ -109,6 +109,15 @@ namespace Barotrauma.Networking teamSpecificStates = new Dictionary(); int teamCount = GameMain.GameSession?.GameMode is PvPMode ? 2 : 1; + if (Level.Loaded == null) + { + throw new InvalidOperationException("Attempted to instantiate a respawn manager before a level was loaded."); + } + + bool shouldLoadShuttle = + shuttleInfo != null && + !Level.Loaded.ShouldSpawnCrewInsideOutpost(); + respawnShuttles.Clear(); List wifiComponents = new List(); for (int i = 0; i < teamCount; i++) @@ -116,7 +125,7 @@ namespace Barotrauma.Networking var teamId = i == 0 ? CharacterTeamType.Team1 : CharacterTeamType.Team2; teamSpecificStates.Add(teamId, new TeamSpecificState(teamId)); - if (shuttleInfo != null && networkMember.ServerSettings is not { RespawnMode: RespawnMode.Permadeath }) + if (shouldLoadShuttle) { shuttleDoors.Add(teamId, new List()); shuttleSteering.Add(teamId, new List()); @@ -179,7 +188,7 @@ namespace Barotrauma.Networking { foreach (Wire wire in connection.Wires) { - if (wire != null) wire.Locked = true; + if (wire != null) { wire.Locked = true; } } } } @@ -286,6 +295,15 @@ namespace Barotrauma.Networking return null; } + private void SetShuttleBodyType(CharacterTeamType team, BodyType bodyType) + { + var shuttle = GetShuttle(team); + if (shuttle != null) + { + shuttle.PhysicsBody.BodyType = bodyType; + } + } + private void ResetShuttle(TeamSpecificState teamSpecificState) { teamSpecificState.ReturnTime = DateTime.Now + new TimeSpan(0, 0, 0, 0, milliseconds: (int)(maxTransportTime * 1000)); @@ -384,12 +402,13 @@ namespace Barotrauma.Networking shuttle.SetPosition(new Vector2( teamSpecificState.TeamID == CharacterTeamType.Team1 ? Level.Loaded.StartPosition.X : Level.Loaded.EndPosition.X, Level.Loaded.Size.Y + shuttle.Borders.Height)); - shuttle.Velocity = Vector2.Zero; + shuttle.Velocity = Vector2.Zero; foreach (var characterPosition in characterPositions) { characterPosition.Key.TeleportTo(characterPosition.Value); } + SetShuttleBodyType(teamSpecificState.TeamID, BodyType.Static); } public static float GetReducedSkill(CharacterInfo characterInfo, Skill skill, float skillLossPercentage, float? currentSkillLevel = null) diff --git a/Barotrauma/BarotraumaShared/SharedSource/Networking/ServerSettings.cs b/Barotrauma/BarotraumaShared/SharedSource/Networking/ServerSettings.cs index 47df65222..7c3e941b4 100644 --- a/Barotrauma/BarotraumaShared/SharedSource/Networking/ServerSettings.cs +++ b/Barotrauma/BarotraumaShared/SharedSource/Networking/ServerSettings.cs @@ -69,6 +69,7 @@ namespace Barotrauma.Networking } public static readonly string PermissionPresetFile = "Data" + Path.DirectorySeparatorChar + "permissionpresets.xml"; + public static readonly string PermissionPresetFileCustom = "Data" + Path.DirectorySeparatorChar + "permissionpresets_player.xml"; public string Name { @@ -285,7 +286,9 @@ namespace Barotrauma.Networking HiddenSubs = new HashSet(); + PermissionPreset.List.Clear(); PermissionPreset.LoadAll(PermissionPresetFile); + PermissionPreset.LoadAll(PermissionPresetFileCustom); InitProjSpecific(); ServerName = serverName; @@ -615,6 +618,19 @@ namespace Barotrauma.Networking } } + private bool allowAFK; + [Serialize(true, IsPropertySaveable.Yes)] + public bool AllowAFK + { + get { return allowAFK; } + private set + { + if (allowAFK == value) { return; } + allowAFK = value; + ServerDetailsChanged = true; + } + } + [Serialize(true, IsPropertySaveable.Yes)] public bool SaveServerLogs { @@ -996,7 +1012,7 @@ namespace Barotrauma.Networking public float KillDisconnectedTime { get; - private set; + set; } /// diff --git a/Barotrauma/BarotraumaShared/SharedSource/PerformanceCounter.cs b/Barotrauma/BarotraumaShared/SharedSource/PerformanceCounter.cs index 1629f0694..1a834c163 100644 --- a/Barotrauma/BarotraumaShared/SharedSource/PerformanceCounter.cs +++ b/Barotrauma/BarotraumaShared/SharedSource/PerformanceCounter.cs @@ -109,7 +109,9 @@ namespace Barotrauma lastSecondMark = currentSecond; } - if (currentTime - lastMinuteMark >= 60 * 1000) + if (currentTime - lastMinuteMark >= 60 * 1000 && + /* we don't need info of the FPS every minute, we can get a good sample size just by logging a small sample */ + GameAnalyticsManager.ShouldLogRandomSample()) { //the FPS could be even higher than this on a high-end monitor, but let's restrict it to 144 to reduce the number of distinct event IDs const int MaxFPS = 144; diff --git a/Barotrauma/BarotraumaShared/SharedSource/Physics/PhysicsBody.cs b/Barotrauma/BarotraumaShared/SharedSource/Physics/PhysicsBody.cs index e695ebd74..04936983b 100644 --- a/Barotrauma/BarotraumaShared/SharedSource/Physics/PhysicsBody.cs +++ b/Barotrauma/BarotraumaShared/SharedSource/Physics/PhysicsBody.cs @@ -39,6 +39,7 @@ namespace Barotrauma public readonly float Timestamp; public readonly UInt16 ID; + public PosInfo(Vector2 pos, float? rotation, Vector2 linearVelocity, float? angularVelocity, float time) : this(pos, rotation, linearVelocity, angularVelocity, 0, time) { diff --git a/Barotrauma/BarotraumaShared/SharedSource/Screens/GameScreen.cs b/Barotrauma/BarotraumaShared/SharedSource/Screens/GameScreen.cs index d6952d74a..930719585 100644 --- a/Barotrauma/BarotraumaShared/SharedSource/Screens/GameScreen.cs +++ b/Barotrauma/BarotraumaShared/SharedSource/Screens/GameScreen.cs @@ -3,8 +3,12 @@ using Microsoft.Xna.Framework; using System.Threading; using FarseerPhysics.Dynamics; +using FarseerPhysics; + + #if DEBUG && CLIENT using System; +using Barotrauma.Sounds; using Microsoft.Xna.Framework.Input; #endif @@ -58,7 +62,9 @@ namespace Barotrauma cam.Position = Submarine.MainSub.WorldPosition; cam.UpdateTransform(true); } - GameMain.GameSession?.CrewManager?.AutoShowCrewList(); + GameMain.GameSession?.CrewManager?.ResetCrewListOpenState(); + ChatBox.ResetChatBoxOpenState(); + #endif MapEntity.ClearHighlightedEntities(); @@ -82,7 +88,7 @@ namespace Barotrauma config.ChatOpen = ChatBox.PreferChatBoxOpen; GameSettings.SetCurrentConfig(config); GameSettings.SaveCurrentConfig(); - GameMain.SoundManager.SetCategoryMuffle("default", false); + GameMain.SoundManager.SetCategoryMuffle(Sounds.SoundManager.SoundCategoryDefault, false); GUI.ClearMessages(); #if !DEBUG if (GameMain.GameSession?.GameMode is TestGameMode) @@ -208,21 +214,27 @@ namespace Barotrauma Lights.LightManager.ViewTarget != null) { Vector2 targetPos = Lights.LightManager.ViewTarget.WorldPosition; - if (Lights.LightManager.ViewTarget == Character.Controlled && - (CharacterHealth.OpenHealthWindow != null || CrewManager.IsCommandInterfaceOpen || ConversationAction.IsDialogOpen)) + if (Lights.LightManager.ViewTarget == Character.Controlled) { - Vector2 screenTargetPos = new Vector2(GameMain.GraphicsWidth, GameMain.GraphicsHeight) * 0.5f; - if (CharacterHealth.OpenHealthWindow != null) + //take the NetworkPositionErrorOffset into account, meaning the camera is positioned + //where we've smoothed out the draw position of the character after a positional correction, + //instead of where the character's collider actually is + targetPos += ConvertUnits.ToDisplayUnits(Character.Controlled.AnimController.Collider.NetworkPositionErrorOffset); + if (CharacterHealth.OpenHealthWindow != null || CrewManager.IsCommandInterfaceOpen || ConversationAction.IsDialogOpen) { - screenTargetPos.X = GameMain.GraphicsWidth * (CharacterHealth.OpenHealthWindow.Alignment == Alignment.Left ? 0.6f : 0.4f); + Vector2 screenTargetPos = new Vector2(GameMain.GraphicsWidth, GameMain.GraphicsHeight) * 0.5f; + if (CharacterHealth.OpenHealthWindow != null) + { + screenTargetPos.X = GameMain.GraphicsWidth * (CharacterHealth.OpenHealthWindow.Alignment == Alignment.Left ? 0.6f : 0.4f); + } + else if (ConversationAction.IsDialogOpen) + { + screenTargetPos.Y = GameMain.GraphicsHeight * 0.4f; + } + Vector2 screenOffset = screenTargetPos - new Vector2(GameMain.GraphicsWidth / 2, GameMain.GraphicsHeight / 2); + screenOffset.Y = -screenOffset.Y; + targetPos -= screenOffset / cam.Zoom; } - else if (ConversationAction.IsDialogOpen) - { - screenTargetPos.Y = GameMain.GraphicsHeight * 0.4f; - } - Vector2 screenOffset = screenTargetPos - new Vector2(GameMain.GraphicsWidth / 2, GameMain.GraphicsHeight / 2); - screenOffset.Y = -screenOffset.Y; - targetPos -= screenOffset / cam.Zoom; } cam.TargetPos = targetPos; } diff --git a/Barotrauma/BarotraumaShared/SharedSource/Serialization/Editable/ConditionallyEditable.cs b/Barotrauma/BarotraumaShared/SharedSource/Serialization/Editable/ConditionallyEditable.cs index 369823ee0..c187e6e6b 100644 --- a/Barotrauma/BarotraumaShared/SharedSource/Serialization/Editable/ConditionallyEditable.cs +++ b/Barotrauma/BarotraumaShared/SharedSource/Serialization/Editable/ConditionallyEditable.cs @@ -24,7 +24,14 @@ sealed class ConditionallyEditable : Editable IsSwappableItem, AllowRotating, Attachable, + /// + /// Does the entity currently have a physics body? + /// HasBody, + /// + /// Does the entity normally have a physics body? Can be used if a property should be enabled on a wall whose collisions have been disabled. + /// + HasBodyByDefault, Pickable, OnlyByStatusEffectsAndNetwork, HasIntegratedButtons, @@ -50,6 +57,8 @@ sealed class ConditionallyEditable : Editable => GetComponent(entity) is Holdable { Attachable: true }, ConditionType.HasBody => entity is Structure { HasBody: true } or Item { body: not null }, + ConditionType.HasBodyByDefault + => entity is Structure { Prefab.Body: true } or Item { body: not null }, ConditionType.Pickable => entity is Item item && item.GetComponent() != null, ConditionType.OnlyByStatusEffectsAndNetwork diff --git a/Barotrauma/BarotraumaShared/SharedSource/Serialization/XMLExtensions.cs b/Barotrauma/BarotraumaShared/SharedSource/Serialization/XMLExtensions.cs index dd727145f..0d1fa0fcd 100644 --- a/Barotrauma/BarotraumaShared/SharedSource/Serialization/XMLExtensions.cs +++ b/Barotrauma/BarotraumaShared/SharedSource/Serialization/XMLExtensions.cs @@ -513,7 +513,7 @@ namespace Barotrauma return ushortValue; } - private static T ParseEnumValue(string value, T defaultValue, XAttribute attribute) where T : struct, Enum + public static T ParseEnumValue(string value, T defaultValue, XAttribute attribute) where T : struct, Enum { if (Enum.TryParse(value, true, out T result)) { diff --git a/Barotrauma/BarotraumaShared/SharedSource/Settings/GameSettings.cs b/Barotrauma/BarotraumaShared/SharedSource/Settings/GameSettings.cs index 0a2e70721..540696e3c 100644 --- a/Barotrauma/BarotraumaShared/SharedSource/Settings/GameSettings.cs +++ b/Barotrauma/BarotraumaShared/SharedSource/Settings/GameSettings.cs @@ -527,6 +527,10 @@ namespace Barotrauma public static void Init() { + // Ensure the save folder exists early. Otherwise the game will crash on macOS, + // attempting to read the non-existent folder in SafeIO.CanWrite() when saving initial user config. + SaveUtil.EnsureSaveFolderExists(); + XDocument? currentConfigDoc = null; if (File.Exists(PlayerConfigPath)) @@ -538,6 +542,7 @@ namespace Barotrauma { currentConfig = Config.FromElement(currentConfigDoc.Root ?? throw new NullReferenceException("Config XML element is invalid: document is null.")); #if CLIENT + MainMenuScreen.DismissedNotifications = currentConfigDoc.Root.GetAttributeIdentifierArray(nameof(MainMenuScreen.DismissedNotifications), defaultValue: Array.Empty()).ToHashSet(); ServerListFilters.Init(currentConfigDoc.Root.GetChildElement("serverfilters")); MultiplayerPreferences.Init( currentConfigDoc.Root.GetChildElement("player"), @@ -656,6 +661,8 @@ namespace Barotrauma } #if CLIENT + root.Add(new XAttribute(nameof(MainMenuScreen.DismissedNotifications), string.Join(',', MainMenuScreen.DismissedNotifications.Select(n => n.Value)))); + XElement serverFiltersElement = new XElement("serverfilters"); root.Add(serverFiltersElement); ServerListFilters.Instance.SaveTo(serverFiltersElement); diff --git a/Barotrauma/BarotraumaShared/SharedSource/StatusEffects/DelayedEffect.cs b/Barotrauma/BarotraumaShared/SharedSource/StatusEffects/DelayedEffect.cs index e07536c44..11c2e0ef2 100644 --- a/Barotrauma/BarotraumaShared/SharedSource/StatusEffects/DelayedEffect.cs +++ b/Barotrauma/BarotraumaShared/SharedSource/StatusEffects/DelayedEffect.cs @@ -74,7 +74,7 @@ namespace Barotrauma switch (delayType) { case DelayTypes.Timer: - DelayList.Add(new DelayedListElement(this, entity, currentTargets, delay, worldPosition, null)); + DelayList.Add(new DelayedListElement(this, entity, currentTargets, delay, worldPosition ?? GetPosition(entity, currentTargets, worldPosition), startPosition: null)); break; case DelayTypes.ReachCursor: Projectile projectile = (entity as Item)?.GetComponent(); diff --git a/Barotrauma/BarotraumaShared/SharedSource/StatusEffects/StatusEffect.cs b/Barotrauma/BarotraumaShared/SharedSource/StatusEffects/StatusEffect.cs index 090e457e9..8ad11b37f 100644 --- a/Barotrauma/BarotraumaShared/SharedSource/StatusEffects/StatusEffect.cs +++ b/Barotrauma/BarotraumaShared/SharedSource/StatusEffects/StatusEffect.cs @@ -589,7 +589,7 @@ namespace Barotrauma /// /// Can be used in conditionals to check if a StatusEffect with a specific tag is currently running. Only relevant for effects with a non-zero duration. /// - private readonly HashSet tags; + private readonly HashSet statusEffectTags; /// /// How long _can_ the event run (in seconds). The difference to is that @@ -620,12 +620,17 @@ namespace Barotrauma /// /// Only valid if the effect has a duration or delay. Can the effect be applied on the same target(s) if the effect is already being applied? /// - public readonly bool Stackable = true; + public readonly bool Stackable; + + /// + /// Only valid if the effect is non-stackable and has a duration. If the effect is reapplied while it's already running, should it reset the duration of the existing effect (i.e. keep the existing effect running)? + /// + public readonly bool ResetDurationWhenReapplied; /// /// The interval at which the effect is executed. The difference between delay and interval is that effects with a delay find the targets, check the conditions, etc /// immediately when Apply is called, but don't apply the effects until the delay has passed. Effects with an interval check if the interval has passed when Apply is - /// called and apply the effects if it has, otherwise they do nothing. + /// called and apply the effects if it has, otherwise they do nothing. Using this is preferred for performance reasons. /// public readonly float Interval; @@ -817,19 +822,25 @@ namespace Barotrauma /// public Vector2 Offset { get; private set; } + /// + /// An random offset (in a random direction) added to the position of the effect is executed at. Only relevant if the effect does something where position matters, + /// for example emitting particles or explosions, spawning something or playing sounds. + /// + public float RandomOffset { get; private set; } + public string Tags { - get { return string.Join(",", tags); } + get { return string.Join(",", statusEffectTags); } set { - tags.Clear(); + statusEffectTags.Clear(); if (value == null) return; string[] newTags = value.Split(','); foreach (string tag in newTags) { Identifier newTag = tag.Trim().ToIdentifier(); - if (!tags.Contains(newTag)) { tags.Add(newTag); }; + if (!statusEffectTags.Contains(newTag)) { statusEffectTags.Add(newTag); }; } } } @@ -848,7 +859,7 @@ namespace Barotrauma protected StatusEffect(ContentXElement element, string parentDebugName) { - tags = new HashSet(element.GetAttributeIdentifierArray("tags", Array.Empty())); + statusEffectTags = new HashSet(element.GetAttributeIdentifierArray("statuseffecttags", element.GetAttributeIdentifierArray("tags", Array.Empty()))); OnlyInside = element.GetAttributeBool("onlyinside", false); OnlyOutside = element.GetAttributeBool("onlyoutside", false); OnlyWhenDamagedByPlayer = element.GetAttributeBool("onlyplayertriggered", element.GetAttributeBool("onlywhendamagedbyplayer", false)); @@ -859,6 +870,7 @@ namespace Barotrauma disableDeltaTime = element.GetAttributeBool("disabledeltatime", false); setValue = element.GetAttributeBool("setvalue", false); Stackable = element.GetAttributeBool("stackable", true); + ResetDurationWhenReapplied = element.GetAttributeBool("resetdurationwhenreapplied", true); lifeTime = lifeTimer = element.GetAttributeFloat("lifetime", 0.0f); CheckConditionalAlways = element.GetAttributeBool("checkconditionalalways", false); @@ -867,6 +879,7 @@ namespace Barotrauma Range = element.GetAttributeFloat("range", 0.0f); Offset = element.GetAttributeVector2("offset", Vector2.Zero); + RandomOffset = element.GetAttributeFloat("randomoffset", 0.0f); string[] targetLimbNames = element.GetAttributeStringArray("targetlimb", null) ?? element.GetAttributeStringArray("targetlimbs", null); if (targetLimbNames != null) { @@ -971,7 +984,30 @@ namespace Barotrauma //a workaround to "tags" possibly meaning either an item's tags or this status effect's tags: //if the status effect doesn't have a duration, assume tags mean an item's tags, not this status effect's tags propertyAttributes.Add(attribute); + if (targetTypes.HasFlag(TargetType.UseTarget)) + { + DebugConsole.AddWarning( + $"Potential error in StatusEffect ({parentDebugName}). " + + "The effect is configured to set the tags of the use target, which will not work on most kinds of targets (only if the target is an item). "+ + "If you meant to configure the tags for the StatusEffect itself, please use the attribute 'statuseffecttags'. If you are sure you want to set the tags of the target, use the attribute 'settags'.", + contentPackage: element.ContentPackage); + } } + else + { +#if DEBUG + //it would be nice to warn modders about this too, but since the effects have always been configured like this before, + //it'd lead to an avalanche of console warnings + DebugConsole.AddWarning( + $"StatusEffect tags defined using the attribute 'tags' in StatusEffect ({parentDebugName}). "+ + "Please use the attribute 'statuseffecttags' or 'settags' instead to make it more explicit whether the 'tags' attribute means the status effect's tags, or tags the effect is supposed to set. " + + "The game now assumes it means the status effect's tags.", + contentPackage: element.ContentPackage); +#endif + } + break; + case "settags": + propertyAttributes.Add(attribute); break; case "oneshot": oneShot = attribute.GetAttributeBool(false); @@ -993,7 +1029,9 @@ namespace Barotrauma List<(Identifier propertyName, object value)> propertyEffects = new List<(Identifier propertyName, object value)>(); foreach (XAttribute attribute in propertyAttributes) { - propertyEffects.Add((attribute.NameAsIdentifier(), XMLExtensions.GetAttributeObject(attribute))); + Identifier attributeName = attribute.NameAsIdentifier(); + if (attributeName == "settags") { attributeName = "tags".ToIdentifier(); } + propertyEffects.Add((attributeName, XMLExtensions.GetAttributeObject(attribute))); } PropertyEffects = propertyEffects.ToImmutableArray(); @@ -1087,6 +1125,8 @@ namespace Barotrauma // Could probably be solved by using the NonClampedStrength or by bypassing the clamping, but ran out of time and played it safe here. afflictionInstance.Probability = subElement.GetAttributeFloat(1.0f, nameof(afflictionInstance.Probability)); afflictionInstance.MultiplyByMaxVitality = subElement.GetAttributeBool(nameof(afflictionInstance.MultiplyByMaxVitality), false); + afflictionInstance.DivideByLimbCount = subElement.GetAttributeBool(nameof(afflictionInstance.DivideByLimbCount), false); + afflictionInstance.Penetration = subElement.GetAttributeFloat(0.0f, nameof(Attack.Penetration)); Afflictions.Add(afflictionInstance); break; case "reduceaffliction": @@ -1566,7 +1606,10 @@ namespace Barotrauma DurationListElement existingEffect = DurationList.Find(d => d.Parent == this && d.Targets.FirstOrDefault() == target); if (existingEffect != null) { - existingEffect.Reset(Math.Max(existingEffect.Timer, Duration), user); + if (ResetDurationWhenReapplied) + { + existingEffect.Reset(Math.Max(existingEffect.Timer, Duration), user); + } return; } } @@ -1633,7 +1676,7 @@ namespace Barotrauma return hull; } - private Vector2 GetPosition(Entity entity, IReadOnlyList targets, Vector2? worldPosition = null) + protected Vector2 GetPosition(Entity entity, IReadOnlyList targets, Vector2? worldPosition = null) { Vector2 position = worldPosition ?? (entity == null || entity.Removed ? Vector2.Zero : entity.WorldPosition); if (worldPosition == null) @@ -1675,6 +1718,7 @@ namespace Barotrauma } position += Offset; + position += Rand.Vector(Rand.Range(0.0f, RandomOffset)); return position; } @@ -1882,10 +1926,8 @@ namespace Barotrauma character.LastDamageSource = entity; foreach (Limb limb in character.AnimController.Limbs) { - if (limb.Removed) { continue; } - if (limb.IsSevered) { continue; } - if (targetLimbs != null && !targetLimbs.Contains(limb.type)) { continue; } - AttackResult result = limb.character.DamageLimb(position, limb, newAffliction.ToEnumerable(), stun: 0.0f, playSound: false, attackImpulse: Vector2.Zero, attacker: affliction.Source, allowStacking: !setValue); + if (!IsValidTargetLimb(limb)) { continue; } + AttackResult result = limb.character.DamageLimb(position, limb, newAffliction.ToEnumerable(), stun: 0.0f, playSound: false, attackImpulse: Vector2.Zero, attacker: affliction.Source, penetration: newAffliction.Penetration, allowStacking: !setValue); limb.character.TrySeverLimbJoints(limb, SeverLimbsProbability, disableDeltaTime ? result.Damage : result.Damage / deltaTime, allowBeheading: true, attacker: affliction.Source); RegisterTreatmentResults(user, entity as Item, limb, affliction, result); //only apply non-limb-specific afflictions to the first limb @@ -1894,11 +1936,9 @@ namespace Barotrauma } else if (target is Limb limb) { - if (limb.IsSevered) { continue; } - if (limb.character.Removed || limb.Removed) { continue; } - if (targetLimbs != null && !targetLimbs.Contains(limb.type)) { continue; } + if (!IsValidTargetLimb(limb)) { continue; } newAffliction = GetMultipliedAffliction(affliction, entity, limb.character, deltaTime, multiplyAfflictionsByMaxVitality); - AttackResult result = limb.character.DamageLimb(position, limb, newAffliction.ToEnumerable(), stun: 0.0f, playSound: false, attackImpulse: Vector2.Zero, attacker: affliction.Source, allowStacking: !setValue); + AttackResult result = limb.character.DamageLimb(position, limb, newAffliction.ToEnumerable(), stun: 0.0f, playSound: false, attackImpulse: Vector2.Zero, attacker: affliction.Source, penetration: newAffliction.Penetration, allowStacking: !setValue); limb.character.TrySeverLimbJoints(limb, SeverLimbsProbability, disableDeltaTime ? result.Damage : result.Damage / deltaTime, allowBeheading: true, attacker: affliction.Source); RegisterTreatmentResults(user, entity as Item, limb, affliction, result); } @@ -2010,8 +2050,12 @@ namespace Barotrauma #if SERVER GameMain.Server?.SendChatMessage(messageToSay.Value, messageType, senderClient: null, targetCharacter); #elif CLIENT - AIChatMessage message = new AIChatMessage(messageToSay.Value, messageType); - targetCharacter.SendSinglePlayerMessage(message, canUseRadio, radio); + //no need to create the message when playing as a client, the server will send it to us + if (isNotClient) + { + AIChatMessage message = new AIChatMessage(messageToSay.Value, messageType); + targetCharacter.SendSinglePlayerMessage(message, canUseRadio, radio); + } #endif } } @@ -2238,7 +2282,11 @@ namespace Barotrauma //ApplyAffliction modified the strength based on max vitality, let's undo that before transferring the affliction //(otherwise e.g. a character with 1000 vitality would only get a tenth of the strength) float afflictionStrength = affliction.Strength * (newCharacter.MaxVitality / 100.0f); - newCharacter.CharacterHealth.ApplyAffliction(newCharacter.AnimController.MainLimb, affliction.Prefab.Instantiate(afflictionStrength)); + + Limb afflictionLimb = character.CharacterHealth.GetAfflictionLimb(affliction) ?? character.AnimController.MainLimb; + Limb newAfflictionLimb = newCharacter.AnimController.GetLimb(afflictionLimb.type) ?? newCharacter.AnimController.MainLimb; + + newCharacter.CharacterHealth.ApplyAffliction(newAfflictionLimb, affliction.Prefab.Instantiate(afflictionStrength)); } } if (i == characterSpawnInfo.Count) // Only perform the below actions if this is the last character being spawned. @@ -2340,6 +2388,14 @@ namespace Barotrauma intervalTimers[entity] = Interval; } } + + private bool IsValidTargetLimb(Limb limb) + { + if (limb == null || limb.Removed) { return false; } + if (limb.IsSevered) { return false; } + if (targetLimbs != null && !targetLimbs.Contains(limb.type)) { return false; } + return true; + } private static Character GetCharacterFromTarget(ISerializableEntity target) { Character targetCharacter = target as Character; @@ -2434,7 +2490,7 @@ namespace Barotrauma } float spread = Rand.Range(-chosenItemSpawnInfo.AimSpreadRad, chosenItemSpawnInfo.AimSpreadRad); float rotation = chosenItemSpawnInfo.RotationRad; - Vector2 worldPos; + Vector2 worldPos = position; if (sourceBody != null) { worldPos = sourceBody.Position; @@ -2443,7 +2499,7 @@ namespace Barotrauma worldPos += user.Submarine.Position; } } - else + else if (!entity.Removed) { worldPos = entity.WorldPosition; } @@ -2463,7 +2519,10 @@ namespace Barotrauma } break; case ItemSpawnInfo.SpawnRotationType.Target: - rotation = MathUtils.VectorToAngle(entity.WorldPosition - worldPos); + if (!entity.Removed) + { + rotation = MathUtils.VectorToAngle(entity.WorldPosition - worldPos); + } break; case ItemSpawnInfo.SpawnRotationType.Limb: if (sourceBody != null) @@ -2509,6 +2568,7 @@ namespace Barotrauma { var sourceEntity = (sourceBody?.UserData as ISpatialEntity) ?? entity; Vector2 spawnPos = sourceEntity.SimPosition; + projectile.Item.Submarine = sourceEntity?.Submarine; List ignoredBodies = null; if (!projectile.DamageUser) { @@ -2863,6 +2923,15 @@ namespace Barotrauma afflictionMultiplier *= 1 + user.GetStatValue(StatTypes.PoisonMultiplier); } } + + if (affliction.DivideByLimbCount) + { + int limbCount = targetCharacter.AnimController.Limbs.Count(limb => IsValidTargetLimb(limb)); + if (limbCount > 0) + { + afflictionMultiplier *= 1.0f / limbCount; + } + } if (!MathUtils.NearlyEqual(afflictionMultiplier, 1.0f)) { return affliction.CreateMultiplied(afflictionMultiplier, affliction); @@ -2908,14 +2977,14 @@ namespace Barotrauma public void AddTag(Identifier tag) { - if (tags.Contains(tag)) { return; } - tags.Add(tag); + if (statusEffectTags.Contains(tag)) { return; } + statusEffectTags.Add(tag); } public bool HasTag(Identifier tag) { if (tag == null) { return true; } - return tags.Contains(tag); + return statusEffectTags.Contains(tag); } } } diff --git a/Barotrauma/BarotraumaShared/SharedSource/Steam/Workshop.cs b/Barotrauma/BarotraumaShared/SharedSource/Steam/Workshop.cs index ce4225cc2..52d3f765e 100644 --- a/Barotrauma/BarotraumaShared/SharedSource/Steam/Workshop.cs +++ b/Barotrauma/BarotraumaShared/SharedSource/Steam/Workshop.cs @@ -130,7 +130,8 @@ namespace Barotrauma.Steam .WithFileId( ids .Select(id => (Steamworks.Data.PublishedFileId)id) - .ToArray())); + .ToArray()) + .WithChildren(true)); ids.Clear(); // Immediately clear the current batch so the next request starts a new one diff --git a/Barotrauma/BarotraumaShared/SharedSource/Tags.cs b/Barotrauma/BarotraumaShared/SharedSource/Tags.cs index 71858086d..e398bed82 100644 --- a/Barotrauma/BarotraumaShared/SharedSource/Tags.cs +++ b/Barotrauma/BarotraumaShared/SharedSource/Tags.cs @@ -71,7 +71,7 @@ public static class Tags public static readonly Identifier CargoMissionItem = "cargomission".ToIdentifier(); - public static readonly Identifier ItemIgnoredByAI = "ignorebyai".ToIdentifier(); + public static readonly Identifier IgnoredByAI = "ignorebyai".ToIdentifier(); public static readonly Identifier GuardianShelter = "guardianshelter".ToIdentifier(); @@ -163,5 +163,6 @@ public static class Tags public static readonly Identifier Nasonov = "nasonov".ToIdentifier(); public static readonly Identifier Decoy = "decoy".ToIdentifier(); public static readonly Identifier Provocative = "provocative".ToIdentifier(); + } diff --git a/Barotrauma/BarotraumaShared/SharedSource/Text/TextManager.cs b/Barotrauma/BarotraumaShared/SharedSource/Text/TextManager.cs index 147e7256e..99334e23f 100644 --- a/Barotrauma/BarotraumaShared/SharedSource/Text/TextManager.cs +++ b/Barotrauma/BarotraumaShared/SharedSource/Text/TextManager.cs @@ -33,6 +33,8 @@ namespace Barotrauma public static int LanguageVersion { get; private set; } = 0; + private readonly static object mutex = new object(); + private static ImmutableArray> UnicodeToIntRanges(params UnicodeRange[] ranges) => ranges .Select(r => new Range(r.FirstCodePoint, r.FirstCodePoint + r.Length - 1)) @@ -109,10 +111,13 @@ namespace Barotrauma { if (string.IsNullOrEmpty(text)) { return SpeciallyHandledCharCategory.None; } - if (SpeciallyHandledCategoriesCache.TryGetValue(text, out var cachedCategory)) + lock (mutex) { - SpeciallyHandledCategoriesCache[text] = new CachedCategory(cachedCategory.Category); - return cachedCategory.Category; + if (SpeciallyHandledCategoriesCache.TryGetValue(text, out var cachedCategory)) + { + SpeciallyHandledCategoriesCache[text] = new CachedCategory(cachedCategory.Category); + return cachedCategory.Category; + } } var retVal = SpeciallyHandledCharCategory.None; @@ -150,8 +155,13 @@ namespace Barotrauma break; } } - SpeciallyHandledCategoriesCache[text] = new CachedCategory(retVal); - TrimSpeciallyHandledCategoriesCache(); + + lock (mutex) + { + SpeciallyHandledCategoriesCache[text] = new CachedCategory(retVal); + TrimSpeciallyHandledCategoriesCache(); + } + return retVal; } diff --git a/Barotrauma/BarotraumaShared/SharedSource/Utils/Rand.cs b/Barotrauma/BarotraumaShared/SharedSource/Utils/Rand.cs index 8bda617ae..84d609f92 100644 --- a/Barotrauma/BarotraumaShared/SharedSource/Utils/Rand.cs +++ b/Barotrauma/BarotraumaShared/SharedSource/Utils/Rand.cs @@ -2,10 +2,7 @@ using Microsoft.Xna.Framework; using System; using System.Collections.Generic; -using System.Collections.Immutable; using System.Diagnostics; -using System.Linq; -using Barotrauma.IO; using Voronoi2; namespace Barotrauma @@ -51,16 +48,9 @@ namespace Barotrauma public static int ThreadId = 0; private static void CheckRandThreadSafety(RandSync sync) { - if (ThreadId != 0 && sync == RandSync.Unsynced) - { - if (System.Threading.Thread.CurrentThread.ManagedThreadId != ThreadId) - { - Debug.WriteLine($"Unsynced rand used in synced thread! {Environment.StackTrace}"); - } - } if (ThreadId != 0 && sync == RandSync.ServerAndClient) { - if (System.Threading.Thread.CurrentThread.ManagedThreadId != ThreadId) + if (Environment.CurrentManagedThreadId != ThreadId) { #if DEBUG throw new Exception("Unauthorized multithreaded access to RandSync.ServerAndClient"); @@ -71,7 +61,7 @@ namespace Barotrauma } } - public static float Range(float minimum, float maximum, RandSync sync=RandSync.Unsynced) + public static float Range(float minimum, float maximum, RandSync sync = RandSync.Unsynced) => GetRNG(sync).Range(minimum, maximum); public static double Range(double minimum, double maximum, RandSync sync = RandSync.Unsynced) diff --git a/Barotrauma/BarotraumaShared/SharedSource/Utils/SaveUtil.cs b/Barotrauma/BarotraumaShared/SharedSource/Utils/SaveUtil.cs index 8d2f3b49a..66151feec 100644 --- a/Barotrauma/BarotraumaShared/SharedSource/Utils/SaveUtil.cs +++ b/Barotrauma/BarotraumaShared/SharedSource/Utils/SaveUtil.cs @@ -119,6 +119,20 @@ namespace Barotrauma get { return Path.Combine(GetSaveFolder(SaveType.Singleplayer), "temp"); } #endif } + + public static void EnsureSaveFolderExists() + { + try + { + // Create the default save folder (only) if it doesn't exist yet. + // note, uses System.IO.Directory.CreateDirectory instead of Directory.CreateDirectory from Baro namespace on purpose. + System.IO.Directory.CreateDirectory(DefaultSaveFolder); + } + catch (Exception e) + { + DebugConsole.ThrowError($"Failed to create the default save folder \"{DefaultSaveFolder}\"!", e); + } + } public enum SaveType { diff --git a/Barotrauma/BarotraumaShared/changelog.txt b/Barotrauma/BarotraumaShared/changelog.txt index 965237fcb..8c23a48ed 100644 --- a/Barotrauma/BarotraumaShared/changelog.txt +++ b/Barotrauma/BarotraumaShared/changelog.txt @@ -1,4 +1,223 @@ ------------------------------------------------------------------------------------------------------------------------------------------------- +v1.8.6.2 +------------------------------------------------------------------------------------------------------------------------------------------------- + +- Fixed some icons still scaling incorrectly (e.g. location icons on the campaign map, device icons on the status monitor). +- Fixed device previews not appearing in the shipyard menu when you've selected a category with only swappable items but no upgrades (doesn't affect any vanilla content). + +------------------------------------------------------------------------------------------------------------------------------------------------- +v1.8.6.1 +------------------------------------------------------------------------------------------------------------------------------------------------- + +- Fixed an issue that caused the scaling of item icons in the submarine editor (and potentially other places) to be incorrect. + +------------------------------------------------------------------------------------------------------------------------------------------------- +v1.8.6.0 +------------------------------------------------------------------------------------------------------------------------------------------------- + +- Fixed certain scripted events breaking when you switch the character (e.g. interaction icons disappearing from some NPCs). +- Fixed hostages being hostile to you and the outpost team in jailbreak missions. +- Fixed inability to communicate using the headset with characters spawned via the debug console (once again). + +------------------------------------------------------------------------------------------------------------------------------------------------- +v1.8.5.0 +------------------------------------------------------------------------------------------------------------------------------------------------- + +- Updated translations. +- Fixed "favorite" checkbox rendering behind the server settings button in the server lobby. +- Fixed server not allowing new characters to be hired to the reserve bench if over the maximum crew size. + +------------------------------------------------------------------------------------------------------------------------------------------------- +v1.8.4.2 +------------------------------------------------------------------------------------------------------------------------------------------------- + +- Fixed PvP variants of the underwater scooter being sold in campaign stores. +- Hid the new sorting and filtering options from vending machines. +- Fixes to several AI issues that sometimes made it difficult for monsters to board the sub (e.g. repeatedly moving in and out of the airlock). +- Fixes to issues that sometimes caused monsters that are indoors to attempt to attack a target through a wall/floor/ceiling even if they have a valid path to the target. + +------------------------------------------------------------------------------------------------------------------------------------------------- +v1.8.4.1 +------------------------------------------------------------------------------------------------------------------------------------------------- + +- Fixed characters sometimes ending up in an invalid state when a player goes back to the server lobby, opts to spectate and rejoins the round: the character wouldn't go unconscious or braindead as it should, but the client would not regain control of it either. +- AI characters no longer accidentally hit friendly characters with melee weapons. +- Fixed bots not reacting to enemies while following, unless being attacked. +- Fixed bots being allowed to operate items in unsafe hulls, meaning they'd attempt to get to a device in a room with e.g. monsters or fires, and then immediately flee, and repeat. +- AI fixes to make characters better at dealing with fires. +- Fixed bots getting stuck in ladders while following the player and aiming at the enemy. +- Fixed bots not facing the enemies (or sometimes glitching while trying to face both the player and the enemy) while following a player. +- Fixes to several AI issues that caused issues when bots where trying to find safety. +- Fixed bots sometimes changing targets when they should just stick to the current target, causing indecisive behavior. +- Made bots crouch by default when using ranged weapons, so that other bots (or players) can shoot past them. +- Improved AI behavior in close combat: when a bot has a valid ranged weapon in the inventory, they now try to keep the distance to the target and switch back to the ranged weapon when possible. Previously the bots just used melee, because they can't shoot the targets when they are really close.Improved AI behavior in close combat: when a bot has a valid ranged weapon in the inventory, they now try to keep the distance to the target and switch back to the ranged weapon when possible. Previously the bots just used melee, because they can't shoot the targets when they are really close. + +------------------------------------------------------------------------------------------------------------------------------------------------- +v1.8.4.0 +------------------------------------------------------------------------------------------------------------------------------------------------- + +Changes and additions: +- Added PvP versions of outpost levels for all biomes. Previously the outpost deathmatch rounds always used cold caverns levels despite the biome setting (which doesn't make much difference in the vanilla game since you don't usually go outside, but can be more relevant in custom outpost deathmatch missions). +- New alien ruin "outpost" for the outpost deathmatch mode. +- Reworked the loading screens with new handmade art. +- Visual improvements to cold caverns and great sea. +- Reworked the decorative "background fish" that wander around in the levels. +- One small endgame lore related outpost event. +- Renamed and recategorized a bunch of structures to make them easier to find in the sub editor. +- Reworked abandoned outpost modules, added 14 new modules. Kudos to NotWendy! +- Reduced the health of outpost security. +- Outpost security can spawn more batteries and ammo for themselves (making them better at defending themselves, compensating for the reduced health). +- Reorganized abandoned outpost mission types in the server lobby screen (separate types for assassination, destroy monsters, rescue and destroy outpost missions). +- Increased outpost wall health and made the junction boxes in outposts more resistant to overvoltage. +- Improvements to fabricator UI to make it dump less information on the player (especially new players): more options for sorting the items, option to only show the items you can currently fabricate. +- The console commands heal, teleportcharacter and godmode accept “/crew” and “/me” as arguments (making the command apply to either the whole crew or your current character). +- Added “killall” console command: kills every character in the level (players, NPCs, monsters). +- Added “spawnnpc” command: spawns a pre-configured NPC from a prefab. +- Added some sounds and particle effects to drinkable/edible items. + +Reserve bench +- Added “reserve bench” feature to multiplayer campaign: bots can be hired to a “reserve bench” instead of active duty, which means they won’t spawn until a player chooses to take them over after dying in the permadeath mode, or until they’re moved to active duty in the HR manager interface in an outpost. +- Allows hiring bots as “backup characters” for the permadeath mode, without having to have the bots as active characters in the crew. +- Also usable when permadeath is not enabled: allows you to for example hire a high-level character you happen to come across for future use. + +Alien ruins: +- New alien ruin modules ("husk maze", "toxins maze", "hazard maze"). +- New "Lost in Ruins" ruin mission. +- Artifacts always spawn in artifact holders in ruins, even if you don't have an artifact mission active. +- Fixed doors that allow fractal guardians to pass through in ruins sometimes getting stuck. +- Fixed ruins sometimes spawning partially inside level walls. + +Submarines: +- Reworked the Remora and updated the description to match. The layout should now be less confusing, and the auxiliary engine works with batteries. Added a brig and dedicated gunnery compartment. The drone is also reworked and has tougher shells. +- Minor visual touch-ups to the Kastrull and the Winterhalter. +- Kastrull glass windows at the navigation terminal have increased health. +- Option to disable wall collisions in the sub editor. +- Increased the output of the Typhon's oxygen generator to fix prisoners suffocating in the brig. + +Radiation changes: +- Improvements to Jovian radiation: the intensity of the radiation increases when you're deeper in the radiation zone, and eventually becomes too intense to counter with Hazmat suits or PUCS. +- Jovian radiation buffs certain monsters instead of killing them. +- Fixed radiation sickness causing burns at a rate relative to the number of limbs a character has (making characters with lots of limbs take more damage). +- Radiation sickness does less damage at low levels and can wear off a bit faster. + +Fixes: +- Fixed particles jittering when the submarine moves. +- Fixes to columns being misaligned and some columns being too narrow on the tab menu's crew list and round summary. +- Fixed ammo boxes (coilgun, chaingun, pulse laser) still firing one more round after appearing empty. +- Fixed grabbing another character causing the inventories to overlap on certain resolutions and higher inventory scale settings. +- Fixed long server descriptions getting cut off on the server list on dedicated servers. +- Fixed "light tower" item emitting light from the center instead of the top of the sprite. +- Fixed perks affecting the enemy sub in "Enemy submarine" missions in mission mode. +- Fixed genes that give you buffs when you receive damage getting triggered by the organ damage you get when you remove the genes. +- Fixed "dying" due to a disconnect giving you the permadeath achievement when permadeath is enabled (despite the death not counting as a "real" permanent death). +- Fixed items held by monsters that are currently inactive appearing at level origin (most often, the weapons held by fractal guardians). +- Fixed deconstruction output not getting automatically moved from the outpost to the main sub the next round if a bot deconstructs something in the outpost, and the item has to be dropped on the floor because there's no more space in the deconstructor's output slots. +- Fixed afflictions' periodic effects (for example, vomiting triggered by nausea) often appearing with some delay client-side. +- Fixed disabling boss health bars also disabling mission progress bars. +- Fixed "path unlock" NPCs sometimes spawning in jail cells. +- Fixed merchants sometimes selling an item they're requesting for less than what they're willing to pay for it. +- Fixed mission state resetting when you save and quit at a friendly outpost. This meant that you could e.g. kill and loot a target you're meant to assassinate, save and quit, and then kill and loot them again. +- Fixed inability to unlock the path to the next biome with reputation if your reputation goes over the threshold during the round (= you needed to already be above the threshold at the start of the round). +- Fixed a rare crash on startup due to an exception in TextManager.TrimSpeciallyHandledCategoriesCache. +- Fixed detonators blowing up even if you remove the explosive before the timer runs out. +- Fixed research station not having a broken version of the sprite. +- Fixed events disappearing from the outpost if you complete a clown or husk cult event and save and reload. +- Fixed artifact missions potentially targeting an artifact inside the sub, instead of spawning the artifact in an abyss cave, beacon station or the abyss like they’re supposed to. +- Fixed locking an item UI and starting a new round sometimes causing it to overlap with other UIs. +- Fixed assistant talent "Tasty Target" affliction not having an icon. +- Fixed afflictions from talents not having a tooltip describing the actual benefit / effect on the affliction. +- "Beat Cop" talent now also affects "Zapped", making it work with Stun Baton / Stun Dart items. +- Fixed inability to play with husked storage containers. +- Fixed opening the command menu in the sub editor's test mode and then switching back to the editor preventing interactions with inventories. +- Fixed sub editor crashing when you delete a wire that's in a circuit box by some other way than the circuit box UI (e.g. by selecting and deleting all wires), and then attempt to save a sub. +- Fixed outpost modules (most often, mine modules) sometimes overlapping with the sub when docked to the outpost. +- Fixed skills getting clamped below the maximum if they've been set above the max with e.g. console commands or modded content. +- Fixed thalamus organs/devices rendering in front of most dropped items. +- Fixed hitscan projectiles not damaging walls outside your own sub when the gun is fired from inside the sub. +- Fixed boarding pods going through indestructible or very tough walls. +- Fixed characters not taking fall damage when they land on a hatch. +- Fixed outposts sometimes having really short hallways between modules. Happened when the modules needed to be moved away from each other just a bit to prevent overlaps between modules: now when they need to be moved, the hallway is forced to be at least 2 meters long. +- Fixed inconsistent draw order between non-damaged and damaged walls (doing damage on a wall sometimes caused the draw order of the walls to change). +- Fixed decorative sprites rotating incorrectly on held items (e.g. the fans on artifact transport case drawing at an incorrect position). +- Fixed items inside items having the “stolen” icon when the team of a character changes to the player team mid-round (e.g. when you hire one of the special faction NPCs). + +Optimization: +- Fixed acid clouds spawned by watchers causing a significant performance hit. +- Optimization to situations where the game attempts to loop the same sound multiple times at the same time. Commonly happened when near a large number of hydrothermal vents and lava vents at the bottom of the sea. +- Optimization to situations where the game tries to spawn a very large number of particles at the same time. Commonly happened when near a large number of hydrothermal vents and lava vents at the bottom of the sea. +- Optimizations to reduce save file sizes (which matters in MP because the save files are frequently sent to clients). +- Fixed an issue that occasionally caused performance drops in the outpost deathmatch mode. +- Fixed guardian repair bots causing significant performance hits especially when there's several of them repairing at the same time. + +Multiplayer: +- Fixed a syncing issue that made very short (<1s) stuns almost always cause some desync and rubberbanding. +- Fixed a syncing issue that caused inaccuracies in the rotation of the character's collider, often leading to rubberbanding when swimming (since the rotation of the collider affects swimming speed: characters swim faster when they're facing the movement direction). +- Fixed "error while reading a message from server" when moving a docking port's connection panel in multiplayer. +- Players (excluding the host) can return to the lobby without ending the round for everyone. Added an "AFK" checkbox in case a player wants to stay in the lobby when a new round starts. +- Fixed dialog prompts from certain events sometimes triggering when the players are still loading into the round, causing the players to miss the prompt and making them unable to continue the event. Happened for example with some of the events that allow you to hire a special faction NPC. +- Fixed duplicating items by allowing a disconnected player to despawn, taking their items from the duffel bag, and then having that player rejoin and respawn with the same items. +- Fixed players being unable to fabricate items unlocked with bots' talents. +- Fixed errors and disconnects if a new campaign round starts while you're still loading into the previous one. +- Fixed Camaraderie talent icon starting to flicker in multiplayer after a crewmember has died. +- Added a customizable permission preset file (Data/permissionpresets_player.xml) which can be edited without game updates overwriting it. +- Fixed hostage missions displaying as completed even if the hostages have been killed. +- Fixed radio channel presets not persisting between rounds in multiplayer. +- Fixed bot disappearing from the crew if you take control of it with SetClientCharacter, then switch back to your original character, and save and quit. +- Fixed spectators only hearing characters when the camera is within speaking distance (should be based on the radio range if the character is speaking through radio). +- Fixed respawn shuttle dropdown not displaying the name of the selected shuttle (just said "shuttle"). +- Fixed paths between biomes unlocked during the round showing up as locked to midround-joining clients. +- Fixed several issues with conversations that target a specific player: e.g. in the "husk cultist" event it was possible for other players to deliver the eggs the cultist was requesting from a specific player. + +AI: +- Fixed a handful of bugs that occasionally caused bots to get stuck on ladders. +- Fixed bots sometimes saying "can't reach [name]" (with the variable not getting replaced by the actual name) when they can't get to a target they're trying to give medical assistance to. +- Fixed bots disguised as husks not targeting husks with turrets. +- Fixed many NPCs ignoring threats, because they were set to wait with 100 priority. Exposed the objective priority for NPCWaitAction and NPCFollowAction. +- Fixed bots with the operate item objective always reacting to fires (and enemies), regardless of the size of the fire and their objective priority. +- Fixed fires close enough to damage not always being considered threats. +- Fixed the find safety objective triggering from enemies/fires, even when ordered to follow. +- Fixed and changed how the bots behave in combat while following/holding position. Now they should stick to the follow/hold position more obediently, yet still should be able to fight back. +- Fixes and changes to how bots react to enemies attacking other bots. Made the security officers be less reactive to hostiles when they have an active order (e.g. operate the turrets). +- Fixed bots attacking handcuffed enemies. +- Fixed outpost security officers reacting if bots take diving gear or fire extinguishers in emergency situations (they shouldn't steal items otherwise). +- Fixed bots not being able to steal oxygen tanks for existing diving gear in emergency situations. +- Fixed bots seemingly ignoring oxygen tanks inside diving gear in containers (also affected other items). +- Bots now drop all stolen items when being inspected, instead of getting arrested because of them. +- Fixed outpost security officers doing inspections in unsafe rooms, such as when they are flooded. + +Modding: +- The "decorative background creatures" can be edited in the level editor. +- Fixed event sets with SelectAlways set to true getting selected despite their commonness being zero. +- Fixed level wall damage done by Attacks ignoring damage multipliers. +- Show a warning when enabling a mod that requires some other Workshop mods that aren't currently enabled. +- The “RequiredDeconstructor” attribute now defaults to “deconstructor” on all items when not defined. This means that vanilla items are now only deconstructible in items with the identifier or tag “deconstructor”, which makes it a lot easier to create custom deconstructors that can only deconstruct some custom items (just leave out the “deconstructor” tag and use something else instead). +- Fixed turret barrels getting hidden when the static part of the turret goes off-screen if the turret only has the "rail sprite", but not the barrel part that moves back and forth when firing. +- Note for modders: the issue with ammo boxes spawning one extra round happened because the boxes executed an OnUse effect to spawn an item in the box, and then reduced the condition, which meant that on the last shot that took the condition to 0 they would still spawn a projectile in the box. If your mod uses a similar approach, please check the vanilla ammo boxes for an example on how we handle the spawning now. +- Added a LightOffset property to LightComponents (can be used to make the item emit light from somewhere else than the center). +- Made "residual waste" and "mass production" talents a bit more modder-friendly. The former had a chance of duplicating deconstruction output and the latter a chance of not consuming some of the fabrication ingredients. It was hard to exclude items from those talents though: addressed that by making them ignore items with the tags "disallowduplicatedeconstructoutput" and "disallowremovefabricationingredient" respectively. +- Implemented armor penetration for status effects. Configured with the property "penetration" (1 = completely ignores armor). +- Option to hide an item from the fabricator if you don't have the recipe for it by adding the attribute HideIfNoRecipe="true". +- Fixed overriding a character variant that replaces the base character's texture (such as crawler hatchling) causing it to use the base character's texture instead. +- Fixed animation triggers breaking animations if a modded character uses animations defined in a custom folder (as opposed to the default Animations folder). +- Added StoreBuyPriceModifier to locations. +- Fixed fabrication recipes where the only difference is that one uses an identifier and the other a tag getting assigned the same hash, preventing the item from loading. +- Fixed statuseffect's "forcesay" making the message appear twice client-side in multiplayer. +- Fixed inability to give stats (via items/talents) to non-human characters. +- Changes to how the game differentiates between a "tags" argument in StatusEffects meaning either tags of the status effect, or setting the Tags property of the target. Previously the game tried to basically guess it, and sometimes got it wrong. Now the tags of the effect should be defined using the attribute "StatusEffectTags" and setting tags of the target should be done with "SetTags". The old "tags" argument is still handled the same way as before for backwards compatibility. +- Added a "ConvulseAmount" setting to affliction effects. Makes the character shake/convulse similarly to late-stage husk infections. +- Fixed StatusEffect's TransferAfflictions setting (which transfers afflictions to a new character spawned by the effect) transferring everything to the main limb. +- Fixed StatusEffects targeting Contained being unable to access the ItemComponents of the contained items (they could only affect the properties of the Item instances). +- Support for configuring monster-specific music tracks. Configured with the attributes “MusicType”, “MusicCommonness” and “MusicRangeMultiplier” in the character xml. +- Support for forcing a music clip to play for a minimum duration of time, even if the situation changes to make it no longer suitable. Configured with the attribute “MinimumPlayDuration” in the sound xml. +- Fixed husk appendage failing to attach correctly if another joint than the first one attaches to the character's normal limbs. +- Fixed stores not updating in the current or adjacent locations when you enable a mod that modifies store items. +- Fixed job-specific spawnpoints not working in the outpost deathmatch mode (doesn't affect the vanilla game). +- Made the "dumpeventtexts" command (which collects texts from events, moves them to a text file and replaces the texts in the event with the appropriate tags) more usable for modding: instead of going through all event files and attempting to modify vanilla content, it now takes in arguments that lets you specify which files or folders you want it to process. +- Improved crosshair accuracy by compensating for the barrel offset. Not very noticeable with vanilla weapons, but can be useful for mods that have weapons with a significant barrel offset. +- Fixed skill modifiers in Wearables not working if the item is worn in multiple slots. +- Fixed submarine upgrade categories being hidden if the category only contains items that can be swapped, but no “normal” upgrades that affect existing installations. + +------------------------------------------------------------------------------------------------------------------------------------------------- v1.7.7.0 ------------------------------------------------------------------------------------------------------------------------------------------------- @@ -25,7 +244,7 @@ Changes: - Option to refresh the available audio devices (both playback and input devices) in the game settings. The game should now also automatically attempt switch to another device if the current one is disconnected. However, whether this can be done automatically can depend on the audio device, driver and the operating system, and in some cases it may be necessary to choose a new device manually from the game settings. - Made electrician's goggles easier to get (spawns as a part of the sub's initial supplies, can be purchased from outposts, doesn't require fulgurium to fabricate). The goggles are intended to help less experienced engineers get a hang of wiring in particular, so it doesn't make sense for them to be so difficult to get. - "Safe rooms" are no longer indestructible in beacon stations (felt confusing, especially when it wasn't apparent from the look of the walls). -- One new research outpost events to foreshadow the longer ruin event chain. +- One new research outpost event to foreshadow the longer ruin event chain. Optimization: - Rendering optimizations that should give a major performance boost to situations in which there's lots of structures visible. diff --git a/Barotrauma/BarotraumaTest/MathUtilsTests.cs b/Barotrauma/BarotraumaTest/MathUtilsTests.cs index e624b3f4e..f32da27b2 100644 --- a/Barotrauma/BarotraumaTest/MathUtilsTests.cs +++ b/Barotrauma/BarotraumaTest/MathUtilsTests.cs @@ -62,6 +62,62 @@ public class MathUtilsTests { return MathUtils.NearlyEqual(MathUtils.GetShortestAngle(MathHelper.ToRadians(deg1), MathHelper.ToRadians(deg2)), MathHelper.ToRadians(angle)); } + } + [Fact] + public void TestUpscaleVector2Array() + { + Vector2[,] inputArray = new Vector2[,] + { + { new Vector2(0, 0), new Vector2(10, 10) }, + { new Vector2(20, 20), new Vector2(30, 30) } + }; + + int newWidth = 4; + int newHeight = 4; + + Vector2[,] result = MathUtils.ResizeVector2Array(inputArray, newWidth, newHeight); + + MathUtils.NearlyEqual(new Vector2(0, 0), result[0, 0]).Should().BeTrue(); + MathUtils.NearlyEqual(new Vector2(30, 30), result[3, 3]).Should().BeTrue(); + MathUtils.NearlyEqual(new Vector2(20, 20), result[2, 2]).Should().BeTrue(); + MathUtils.NearlyEqual(new Vector2(26.666666f, 26.666666f), result[3, 2]).Should().BeTrue(); + } + + [Fact] + public void TestDownScaleVector2Array() + { + Vector2[,] inputArray = new Vector2[,] + { + { new Vector2(0, 0), new Vector2(10, 10), new Vector2(20, 20) }, + { new Vector2(30, 30), new Vector2(40, 40), new Vector2(50, 50) }, + { new Vector2(60, 60), new Vector2(70, 70), new Vector2(80, 80) } + }; + + int newWidth = 2; + int newHeight = 2; + + Vector2[,] result = MathUtils.ResizeVector2Array(inputArray, newWidth, newHeight); + + MathUtils.NearlyEqual(new Vector2(0, 0), result[0, 0]).Should().BeTrue(); + MathUtils.NearlyEqual(new Vector2(80, 80), result[1, 1]).Should().BeTrue(); + } + + [Fact] + public void TestNoChangesToVector2Array() + { + Vector2[,] inputArray = new Vector2[,] + { + { new Vector2(0, 0), new Vector2(10, 10) }, + { new Vector2(20, 20), new Vector2(30, 30) } + }; + + int newWidth = 2; + int newHeight = 2; + + Vector2[,] result = MathUtils.ResizeVector2Array(inputArray, newWidth, newHeight); + + MathUtils.NearlyEqual(new Vector2(0, 0), result[0, 0]).Should().BeTrue(); + MathUtils.NearlyEqual(new Vector2(30, 30), result[1, 1]).Should().BeTrue(); } } \ No newline at end of file diff --git a/HelperScripts/cleanup_obj.sh b/HelperScripts/cleanup_obj.sh old mode 100644 new mode 100755 diff --git a/Libraries/BarotraumaLibs/BarotraumaCore/Utils/MathUtils.cs b/Libraries/BarotraumaLibs/BarotraumaCore/Utils/MathUtils.cs index f2340cb03..7ef405c90 100644 --- a/Libraries/BarotraumaLibs/BarotraumaCore/Utils/MathUtils.cs +++ b/Libraries/BarotraumaLibs/BarotraumaCore/Utils/MathUtils.cs @@ -375,7 +375,19 @@ namespace Barotrauma } } + /// + /// Get the point where the line segment between a1 and a2 intersects the rectangle. Note that the rectangle's y-coordinate is handled so that up is lower and bottom is higher (the way rectangles work by default in XNA). + /// public static bool GetLineRectangleIntersection(Vector2 a1, Vector2 a2, Rectangle rect, out Vector2 intersection) + { + rect.Y += rect.Height; + return GetLineWorldRectangleIntersection(a1, a2, rect, out intersection); + } + + /// + /// Get the point where the line segment between a1 and a2 intersects the rectangle. Note that the rectangle's y-coordinate is handled so that up is greater and down is lower (the way e.g. MapEntity rects are defined). + /// + public static bool GetLineWorldRectangleIntersection(Vector2 a1, Vector2 a2, Rectangle rect, out Vector2 intersection) { if (GetAxisAlignedLineIntersection(a1, a2, new Vector2(rect.X, rect.Y), @@ -1100,6 +1112,52 @@ namespace Barotrauma while (val > po2) { po2 <<= 1; } return po2; } + + /// + /// Resizes an array of vectors to a different size. Uses bilinear interpolation to "scale" the values to the size of the new array: + /// for instance, an array such as "1, 0" would become "1, 0.5, 0" when the width is scaled from 2 to 3. + /// + public static Vector2[,] ResizeVector2Array(Vector2[,] sourceArray, int newWidth, int newHeight) + { + if (newWidth < 1) + { + throw new ArgumentException("Width must be larger than zero.", nameof(newWidth)); + } + if (newHeight < 1) + { + throw new ArgumentException("Height must be larger than zero.", nameof(newHeight)); + } + + var destinationArray = new Vector2[newWidth, newHeight]; + int oldWidth = sourceArray.GetLength(0), oldHeight = sourceArray.GetLength(1); + + for (int x = 0; x < newWidth; x++) + { + for (int y = 0; y < newHeight; y++) + { + // Calculate the position in the original array + float sourceX = oldWidth == 1 ? 0 : (x / (float)(newWidth - 1)) * (oldWidth - 1); + float sourceY = oldHeight == 1 ? 0 : (y / (float)(newHeight - 1)) * (oldHeight - 1); + + // Find the indices of the surrounding points + int startIndexX = (int)Math.Floor(sourceX); + int endIndexX = Math.Min(startIndexX + 1, oldWidth - 1); + int startIndexY = (int)Math.Floor(sourceY); + int endIndexY = Math.Min(startIndexY + 1, oldHeight - 1); + + // Calculate interpolation weights + float tx = sourceX - startIndexX; + float ty = sourceY - startIndexY; + + // Perform bilinear interpolation + Vector2 top = Vector2.Lerp(sourceArray[startIndexX, startIndexY], sourceArray[endIndexX, startIndexY], tx); + Vector2 bottom = Vector2.Lerp(sourceArray[startIndexX, endIndexY], sourceArray[endIndexX, endIndexY], tx); + destinationArray[x, y] = Vector2.Lerp(top, bottom, ty); + } + } + + return destinationArray; + } } public class CompareCW : IComparer diff --git a/Libraries/Facepunch.Steamworks/Structs/UgcItem.cs b/Libraries/Facepunch.Steamworks/Structs/UgcItem.cs index ebfb6b8ec..b02a8d7ab 100644 --- a/Libraries/Facepunch.Steamworks/Structs/UgcItem.cs +++ b/Libraries/Facepunch.Steamworks/Structs/UgcItem.cs @@ -116,10 +116,10 @@ namespace Steamworks.Ugc /// The number of downvotes of this item /// public uint VotesDown => details.VotesDown; - /// - /// Dependencies/children of this item or collection, available only from WithDependencies(true) queries - /// - public PublishedFileId[]? Children; + /// + /// Dependencies/children of this item or collection, available only from WithChildren(true) queries + /// + public PublishedFileId[]? Children; /// /// Additional previews of this item or collection, available only from WithAdditionalPreviews(true) queries