From a4a3427e4e1dff35d1de1ef8e9079ddb15641b09 Mon Sep 17 00:00:00 2001 From: Markus Isberg <3e849f2e5c@pm.me> Date: Wed, 12 Mar 2025 12:56:27 +0000 Subject: [PATCH] Unstable 1.8.4.0 --- .../BarotraumaClient/ClientSource/Camera.cs | 15 +- .../Characters/AI/EnemyAIController.cs | 8 +- .../Characters/AI/HumanAIController.cs | 22 +- .../Characters/Animation/Ragdoll.cs | 126 +- .../ClientSource/Characters/Attack.cs | 2 +- .../ClientSource/Characters/Character.cs | 106 +- .../ClientSource/Characters/CharacterHUD.cs | 60 +- .../ClientSource/Characters/CharacterInfo.cs | 36 +- .../Characters/CharacterNetworking.cs | 74 +- .../Characters/Health/AfflictionPsychosis.cs | 2 +- .../Characters/Health/CharacterHealth.cs | 81 +- .../Characters/InteractionLabelManager.cs | 17 +- .../ClientSource/Characters/Jobs/JobPrefab.cs | 152 +- .../ClientSource/Characters/Limb.cs | 129 +- .../CircuitBox/CircuitBoxComponent.cs | 2 +- .../ClientSource/CircuitBox/CircuitBoxUI.cs | 3 +- .../ContentPackage/ModProject.cs | 4 +- .../Transition/LegacySteamUgcTransition.cs | 2 +- .../ClientSource/DebugConsole.cs | 384 +++- .../Events/EventActions/ConversationAction.cs | 29 +- .../ClientSource/Events/EventLog.cs | 1 + .../ClientSource/Events/EventManager.cs | 27 + .../Missions/AbandonedOutpostMission.cs | 20 +- .../Events/Missions/CombatMission.cs | 100 +- .../ClientSource/Events/Missions/Mission.cs | 12 +- .../Events/Missions/MissionPrefab.cs | 4 +- .../Events/Missions/SalvageMission.cs | 58 +- .../Events/Missions/ScanMission.cs | 17 +- .../ClientSource/Fonts/ScalableFont.cs | 75 +- .../ClientSource/GUI/ChatBox.cs | 50 +- .../ClientSource/GUI/DeathPrompt.cs | 70 +- .../ClientSource/GUI/FileSelection.cs | 55 +- .../BarotraumaClient/ClientSource/GUI/GUI.cs | 141 +- .../ClientSource/GUI/GUIButton.cs | 80 +- .../ClientSource/GUI/GUIComponent.cs | 77 +- .../ClientSource/GUI/GUIDropDown.cs | 48 +- .../ClientSource/GUI/GUIListBox.cs | 31 +- .../ClientSource/GUI/GUIMessageBox.cs | 2 +- .../ClientSource/GUI/GUIStyle.cs | 12 +- .../ClientSource/GUI/GUITextBox.cs | 7 + .../ClientSource/GUI/HRManagerUI.cs | 268 ++- .../ClientSource/GUI/Store.cs | 42 +- .../ClientSource/GUI/SubmarineSelection.cs | 4 +- .../ClientSource/GUI/TabMenu.cs | 282 ++- .../ClientSource/GUI/TalentMenu.cs | 206 +- .../ClientSource/GUI/UpgradeStore.cs | 56 +- .../BarotraumaClient/ClientSource/GameMain.cs | 54 +- .../ClientSource/GameSession/CargoManager.cs | 2 +- .../ClientSource/GameSession/CrewManager.cs | 191 +- .../GameSession/GameModes/CampaignMode.cs | 6 +- .../GameModes/MultiPlayerCampaign.cs | 87 +- .../GameModes/SinglePlayerCampaign.cs | 10 +- .../GameSession/GameModes/TestGameMode.cs | 37 +- .../GameModes/Tutorials/Tutorial.cs | 4 +- .../ClientSource/GameSession/GameSession.cs | 28 +- .../ClientSource/GameSession/HintManager.cs | 4 +- .../ClientSource/GameSession/PvPMode.cs | 84 + .../ClientSource/GameSession/RoundSummary.cs | 132 +- .../ClientSource/Items/CharacterInventory.cs | 21 +- .../ClientSource/Items/Components/Door.cs | 7 + .../Items/Components/GeneticMaterial.cs | 38 +- .../Items/Components/Holdable/Holdable.cs | 8 +- .../Items/Components/Holdable/RangedWeapon.cs | 35 +- .../Items/Components/Holdable/Sprayer.cs | 1 + .../Items/Components/ItemComponent.cs | 106 +- .../Items/Components/ItemContainer.cs | 107 +- .../Items/Components/ItemLabel.cs | 2 +- .../Items/Components/LightComponent.cs | 28 +- .../Items/Components/Machines/Controller.cs | 45 +- .../Components/Machines/Deconstructor.cs | 25 +- .../Items/Components/Machines/Fabricator.cs | 457 +++-- .../Items/Components/Machines/MiniMap.cs | 169 +- .../Items/Components/Machines/Reactor.cs | 5 +- .../Items/Components/Machines/Sonar.cs | 109 +- .../Items/Components/Machines/Steering.cs | 53 +- .../Items/Components/Projectile.cs | 8 +- .../Items/Components/RemoteController.cs | 1 + .../Items/Components/RepairTool.cs | 29 +- .../Items/Components/Repairable.cs | 2 + .../ClientSource/Items/Components/Rope.cs | 10 +- .../Items/Components/Signal/ButtonTerminal.cs | 17 +- .../Items/Components/Signal/CircuitBox.cs | 20 +- .../Components/Signal/CustomInterface.cs | 226 ++- .../Items/Components/Signal/Terminal.cs | 62 +- .../Items/Components/Signal/WifiComponent.cs | 7 +- .../Items/Components/Signal/Wire.cs | 9 +- .../Items/Components/StatusHUD.cs | 69 +- .../Items/Components/TriggerComponent.cs | 18 +- .../ClientSource/Items/Components/Turret.cs | 44 +- .../ClientSource/Items/Inventory.cs | 5 +- .../ClientSource/Items/Item.cs | 126 +- .../BarotraumaClient/ClientSource/Map/Gap.cs | 11 +- .../BarotraumaClient/ClientSource/Map/Hull.cs | 34 +- .../BackgroundCreatures/BackgroundCreature.cs | 76 +- .../BackgroundCreatureManager.cs | 48 +- .../BackgroundCreaturePrefab.cs | 117 +- .../ClientSource/Map/Levels/CaveGenerator.cs | 124 +- .../ClientSource/Map/Levels/Level.cs | 79 +- .../Map/Levels/LevelObjects/LevelObject.cs | 19 +- .../Levels/LevelObjects/LevelObjectManager.cs | 38 +- .../ClientSource/Map/Levels/LevelRenderer.cs | 110 +- .../ClientSource/Map/Levels/LevelWall.cs | 40 +- .../ClientSource/Map/Lights/ConvexHull.cs | 8 +- .../ClientSource/Map/Lights/LightManager.cs | 63 +- .../ClientSource/Map/Lights/LightSource.cs | 53 +- .../ClientSource/Map/Map/Map.cs | 66 +- .../ClientSource/Map/Map/Radiation.cs | 36 +- .../ClientSource/Map/MapEntity.cs | 22 + .../ClientSource/Map/RoundSound.cs | 14 +- .../ClientSource/Map/Structure.cs | 47 +- .../ClientSource/Map/StructurePrefab.cs | 12 +- .../ClientSource/Map/Submarine.cs | 63 +- .../ClientSource/Map/SubmarinePreview.cs | 5 +- .../ClientSource/Map/WayPoint.cs | 37 +- .../ClientSource/Networking/Client.cs | 2 +- .../ClientSource/Networking/ConnectCommand.cs | 12 +- .../Networking/FileTransfer/FileReceiver.cs | 4 +- .../ClientSource/Networking/GameClient.cs | 481 ++++- .../ClientEntityEventManager.cs | 38 +- .../P2PSocket/SteamConnectSocket.cs | 11 +- .../Primitives/P2PSocket/SteamListenSocket.cs | 2 +- .../Networking/Primitives/Peers/ClientPeer.cs | 23 +- .../Primitives/Peers/LidgrenClientPeer.cs | 11 +- .../ClientSource/Networking/RespawnManager.cs | 103 +- .../Networking/ServerList/PingUtils.cs | 4 +- .../Networking/ServerList/ServerInfo.cs | 32 +- .../SteamDedicatedServerProvider.cs | 3 +- .../ClientSource/Networking/ServerSettings.cs | 47 +- .../Networking/ServerSettingsUI.cs | 49 +- .../Networking/Voip/VoipCapture.cs | 76 +- .../Networking/Voip/VoipClient.cs | 8 +- .../ClientSource/Networking/Voting.cs | 173 +- .../ClientSource/Particles/Particle.cs | 98 +- .../ClientSource/Particles/ParticleEmitter.cs | 22 +- .../ClientSource/Particles/ParticleManager.cs | 24 +- .../ClientSource/Particles/ParticlePrefab.cs | 17 +- .../ClientSource/Physics/PhysicsBody.cs | 126 +- .../CampaignSetupUI/CampaignSetupUI.cs | 136 +- .../MultiPlayerCampaignSetupUI.cs | 126 +- .../SinglePlayerCampaignSetupUI.cs | 54 +- .../ClientSource/Screens/CampaignUI.cs | 12 +- .../CharacterEditor/CharacterEditorScreen.cs | 87 +- .../Screens/CharacterEditor/Wizard.cs | 2 +- .../Screens/EventEditor/EventEditorScreen.cs | 4 +- .../ClientSource/Screens/GameScreen.cs | 192 +- .../ClientSource/Screens/LevelEditorScreen.cs | 448 ++++- .../Screens/MainMenuScreen/MainMenuScreen.cs | 43 +- .../ClientSource/Screens/NetLobbyScreen.cs | 1663 ++++++++++++++--- .../ServerListScreen/ServerListScreen.cs | 37 +- .../Screens/SpriteEditorScreen.cs | 78 +- .../ClientSource/Screens/SubEditorScreen.cs | 360 +++- .../ClientSource/Screens/TestScreen.cs | 7 +- .../Serialization/SerializableEntityEditor.cs | 11 +- .../ClientSource/Settings/SettingsMenu.cs | 115 +- .../ClientSource/Sounds/OggSound.cs | 2 +- .../ClientSource/Sounds/OpenAL/Alc.cs | 6 +- .../ClientSource/Sounds/Sound.cs | 13 +- .../ClientSource/Sounds/SoundChannel.cs | 10 +- .../ClientSource/Sounds/SoundManager.cs | 101 +- .../ClientSource/Sounds/SoundPlayer.cs | 159 +- .../ClientSource/Sounds/SoundPrefab.cs | 14 +- .../ClientSource/Sounds/VideoSound.cs | 2 +- .../ClientSource/Sounds/VoipSound.cs | 2 +- .../ClientSource/SpamServerFilter.cs | 2 + .../ClientSource/Sprite/DecorativeSprite.cs | 70 +- .../DeformAnimations/CustomDeformation.cs | 35 +- .../Sprite/DeformAnimations/Inflate.cs | 2 +- .../DeformAnimations/JointBendDeformation.cs | 2 +- .../DeformAnimations/NoiseDeformation.cs | 2 +- .../DeformAnimations/PositionalDeformation.cs | 2 +- .../DeformAnimations/SpriteDeformation.cs | 18 +- .../ClientSource/Sprite/DeformableSprite.cs | 5 +- .../ClientSource/Sprite/Sprite.cs | 36 +- .../StatusEffects/StatusEffect.cs | 49 +- .../ClientSource/Steam/SteamManager.cs | 8 +- .../ClientSource/Steam/Workshop.cs | 2 +- .../WorkshopMenu/Mutable/InstalledTab.cs | 269 ++- .../WorkshopMenu/Mutable/ModListPreset.cs | 2 +- .../Mutable/MutableWorkshopMenu.cs | 5 + .../ClientSource/Steam/WorkshopMenu/UiUtil.cs | 10 + .../Utils/LocalizationCSVtoXML.cs | 4 +- .../ClientSource/Utils/MathUtils.cs | 19 + .../ClientSource/Utils/WikiImage.cs | 11 +- .../Content/Effects/damageshader.xnb | Bin 2340 -> 2341 bytes .../Content/Effects/damageshader_opengl.xnb | Bin 2274 -> 2274 bytes .../BarotraumaClient/LinuxClient.csproj | 4 +- Barotrauma/BarotraumaClient/MacClient.csproj | 4 +- .../BarotraumaClient/Shaders/damageshader.fx | 6 +- .../Shaders/damageshader_opengl.fx | 8 +- .../BarotraumaClient/WindowsClient.csproj | 4 +- .../BarotraumaServer/LinuxServer.csproj | 4 +- Barotrauma/BarotraumaServer/MacServer.csproj | 4 +- .../ServerSource/Characters/Character.cs | 14 +- .../ServerSource/Characters/CharacterInfo.cs | 10 +- .../Characters/CharacterNetworking.cs | 104 +- .../ServerSource/Characters/Limb.cs | 70 + .../ServerSource/DebugConsole.cs | 145 +- .../Events/EventActions/ConversationAction.cs | 76 +- .../ServerSource/Events/EventManager.cs | 26 +- .../Missions/AbandonedOutpostMission.cs | 4 +- .../Events/Missions/CombatMission.cs | 252 ++- .../Events/Missions/SalvageMission.cs | 33 +- .../Events/Missions/ScanMission.cs | 9 +- .../BarotraumaServer/ServerSource/GameMain.cs | 12 +- .../ServerSource/GameSession/CrewManager.cs | 116 +- .../GameModes/CharacterCampaignData.cs | 11 +- .../GameModes/MultiPlayerCampaign.cs | 88 +- .../Items/Components/Holdable/Holdable.cs | 11 +- .../Items/Components/ItemLabel.cs | 2 +- .../Items/Components/Machines/Steering.cs | 16 +- .../Items/Components/Signal/CircuitBox.cs | 10 +- .../Components/Signal/CustomInterface.cs | 73 +- .../Items/Components/Signal/WifiComponent.cs | 10 +- .../ServerSource/Items/Item.cs | 25 +- .../BarotraumaServer/ServerSource/Map/Hull.cs | 6 + .../ServerSource/Networking/BanList.cs | 2 +- .../ServerSource/Networking/ChatMessage.cs | 2 +- .../ServerSource/Networking/Client.cs | 34 +- .../Networking/FileTransfer/FileSender.cs | 4 +- .../ServerSource/Networking/GameServer.cs | 1093 +++++++++-- .../ServerSource/Networking/KarmaManager.cs | 6 +- .../ServerEntityEventManager.cs | 47 +- .../Peers/Server/LidgrenServerPeer.cs | 35 +- .../Primitives/Peers/Server/P2PServerPeer.cs | 4 +- .../Primitives/Peers/Server/ServerPeer.cs | 13 + .../ServerSource/Networking/RespawnManager.cs | 436 +++-- .../ServerSource/Networking/ServerSettings.cs | 107 +- .../ServerSource/Networking/Voting.cs | 35 +- .../ServerSource/Screens/NetLobbyScreen.cs | 35 +- .../ServerSource/Steam/SteamManager.cs | 21 +- .../ServerSource/Traitors/TraitorManager.cs | 11 +- .../BarotraumaServer/WindowsServer.csproj | 4 +- .../Data/campaignsettings.xml | 6 +- .../Data/clientpermissions.xml | 3 - .../Data/permissionpresets.xml | 3 + .../Data/permissionpresets_player.xml | 36 + .../Crawler/Animations/CrawlerRun.xml | 2 + .../Crawler/Animations/CrawlerSwimFast.xml | 2 + .../Crawler/Animations/CrawlerSwimSlow.xml | 2 + .../Crawler/Animations/CrawlerWalk.xml | 2 + .../Characters/Crawler/Crawler.xml | 77 + .../Ragdolls/CrawlerDefaultRagdoll.xml | 127 ++ .../Characters/Crawler/crawler.png | Bin 0 -> 220731 bytes .../Human.xml | 312 ++++ .../Mudraptor.xml | 68 + .../README.txt | 32 + .../Spineling_morbusine_m.xml | 1 + .../Testcrawlerhatchling.xml | 15 + .../Testcyborgworm_m.xml | 3 + .../filelist.xml | 9 + .../RotationAndFlippingTests.sub | Bin 0 -> 9607 bytes .../filelist.xml | 4 + .../Animations/HumanRunDivingSuit.xml | 40 + .../Animations/HumanWalkDivingSuit.xml | 40 + .../Testhuman/Animations/TesthumanCrouch.xml | 2 + .../Testhuman/Animations/TesthumanRun.xml | 2 + .../Animations/TesthumanSwimFast.xml | 2 + .../Animations/TesthumanSwimSlow.xml | 2 + .../Testhuman/Animations/TesthumanWalk.xml | 2 + .../Ragdolls/TesthumanDefaultRagdoll.xml | 130 ++ .../Characters/Testhuman/Testhuman.xml | 308 +++ .../[DebugOnlyTest]Testhuman/README.txt | 8 + .../[DebugOnlyTest]Testhuman/filelist.xml | 4 + Barotrauma/BarotraumaShared/README.txt | 4 +- .../SharedSource/AchievementManager.cs | 208 ++- .../SharedSource/CachedDistance.cs | 2 + .../Characters/AI/AIController.cs | 53 +- .../SharedSource/Characters/AI/AITarget.cs | 2 +- .../Characters/AI/EnemyAIController.cs | 1102 +++++++---- .../Characters/AI/HumanAIController.cs | 526 +++--- .../Characters/AI/IndoorsSteeringManager.cs | 113 +- .../SharedSource/Characters/AI/LatchOntoAI.cs | 2 +- .../Characters/AI/Objectives/AIObjective.cs | 9 +- .../Objectives/AIObjectiveCheckStolenItems.cs | 56 +- .../AI/Objectives/AIObjectiveCleanupItem.cs | 16 +- .../AI/Objectives/AIObjectiveCleanupItems.cs | 2 + .../AI/Objectives/AIObjectiveCombat.cs | 382 ++-- .../AI/Objectives/AIObjectiveContainItem.cs | 14 +- .../Objectives/AIObjectiveDeconstructItem.cs | 47 +- .../Objectives/AIObjectiveDeconstructItems.cs | 6 +- .../Objectives/AIObjectiveEscapeHandcuffs.cs | 2 +- .../Objectives/AIObjectiveExtinguishFire.cs | 79 +- .../Objectives/AIObjectiveFightIntruders.cs | 4 +- .../Objectives/AIObjectiveFindDivingGear.cs | 150 +- .../AI/Objectives/AIObjectiveFindSafety.cs | 152 +- .../AI/Objectives/AIObjectiveFindThieves.cs | 41 +- .../AI/Objectives/AIObjectiveFixLeak.cs | 12 +- .../AI/Objectives/AIObjectiveGetItem.cs | 30 +- .../AI/Objectives/AIObjectiveGetItems.cs | 4 +- .../AI/Objectives/AIObjectiveGoTo.cs | 211 ++- .../AI/Objectives/AIObjectiveIdle.cs | 297 +-- .../AI/Objectives/AIObjectiveInspectNoises.cs | 3 +- .../AI/Objectives/AIObjectiveLoadItem.cs | 23 +- .../AI/Objectives/AIObjectiveLoadItems.cs | 2 +- .../AI/Objectives/AIObjectiveLoop.cs | 2 +- .../AI/Objectives/AIObjectiveManager.cs | 25 +- ...econtainItem.cs => AIObjectiveMoveItem.cs} | 43 +- .../AI/Objectives/AIObjectiveOperateItem.cs | 102 +- .../AI/Objectives/AIObjectivePrepare.cs | 2 +- .../AI/Objectives/AIObjectiveRepairItem.cs | 5 +- .../AI/Objectives/AIObjectiveRepairItems.cs | 54 +- .../AI/Objectives/AIObjectiveRescue.cs | 57 +- .../AI/Objectives/AIObjectiveRescueAll.cs | 23 +- .../AI/Objectives/AIObjectiveReturn.cs | 33 +- .../SharedSource/Characters/AI/Order.cs | 8 +- .../SharedSource/Characters/AI/PetBehavior.cs | 62 +- .../AI/ShipCommand/ShipIssueWorker.cs | 14 +- .../AI/ShipCommand/ShipIssueWorkerSteer.cs | 2 +- .../Characters/AI/Wreck/WreckAI.cs | 177 +- .../Characters/Animation/AnimController.cs | 369 +++- .../Animation/FishAnimController.cs | 160 +- .../Animation/HumanoidAnimController.cs | 313 +--- .../Characters/Animation/Ragdoll.cs | 161 +- .../SharedSource/Characters/Attack.cs | 68 +- .../SharedSource/Characters/Character.cs | 840 +++++---- .../Characters/CharacterEventData.cs | 33 +- .../SharedSource/Characters/CharacterInfo.cs | 163 +- .../Characters/CharacterNetworking.cs | 17 +- .../Characters/CharacterPrefab.cs | 7 +- .../Health/Afflictions/Affliction.cs | 43 +- .../Health/Afflictions/AfflictionBleeding.cs | 2 +- .../Health/Afflictions/AfflictionHusk.cs | 120 +- .../Health/Afflictions/AfflictionPrefab.cs | 52 +- .../Characters/Health/CharacterHealth.cs | 124 +- .../Characters/Health/DamageModifier.cs | 28 +- .../SharedSource/Characters/HumanPrefab.cs | 26 +- .../SharedSource/Characters/Jobs/Job.cs | 76 +- .../SharedSource/Characters/Jobs/JobPrefab.cs | 106 +- .../SharedSource/Characters/Jobs/Skill.cs | 27 +- .../Characters/Jobs/SkillPrefab.cs | 45 +- .../SharedSource/Characters/Limb.cs | 49 +- .../Params/Animation/AnimationParams.cs | 65 +- .../Characters/Params/CharacterParams.cs | 190 +- .../Params/Ragdoll/RagdollParams.cs | 66 +- .../SharedSource/Characters/SkillSettings.cs | 2 +- .../AbilityConditionals/AbilityCondition.cs | 7 +- .../AbilityConditionMission.cs | 23 +- .../AbilityConditionCrewMemberUnconscious.cs | 2 +- .../AbilityConditionHasDifferentJobs.cs | 5 +- .../AbilityConditionHasSkill.cs | 11 +- .../AbilityConditionLevelsBehindHighest.cs | 6 +- .../AbilityConditionLowestLevel.cs | 2 +- .../AbilityConditionNoCrewDied.cs | 8 +- .../AbilityConditionShipFlooded.cs | 3 +- .../Talents/Abilities/CharacterAbility.cs | 2 +- .../CharacterAbilityApplyStatusEffects.cs | 20 +- ...ilityApplyStatusEffectsToApprenticeship.cs | 4 +- .../CharacterAbilityGainSimultaneousSkill.cs | 2 +- .../CharacterAbilityGiveAffliction.cs | 6 +- .../CharacterAbilityGivePermanentStat.cs | 2 +- ...haracterAbilityGiveTalentPointsToAllies.cs | 3 +- .../CharacterAbilityModifyStatToSkill.cs | 4 +- .../Abilities/CharacterAbilityModifyValue.cs | 2 + .../CharacterAbilityReduceAffliction.cs | 26 +- .../CharacterAbilityUpgradeSubmarine.cs | 12 +- .../CharacterAbilityByTheBook.cs | 8 +- ...erAbilityUnlockApprenticeshipTalentTree.cs | 17 +- .../CharacterAbilityWarStories.cs | 27 +- .../AbilityGroups/CharacterAbilityGroup.cs | 2 +- .../Characters/Talents/TalentPrefab.cs | 7 + .../Characters/Talents/TalentTree.cs | 6 +- .../SharedSource/CircuitBox/CircuitBoxWire.cs | 7 +- .../ContentFile/AfflictionsFile.cs | 11 +- .../BackgroundCreaturePrefabsFile.cs | 25 +- .../ContentFile/CharacterFile.cs | 26 +- .../ContentFile/DisembarkPerkFile.cs | 17 + .../ContentFile/RandomEventsFile.cs | 8 +- .../ContentPackage/ContentPackage.cs | 169 +- .../ContentPackageManager.cs | 22 +- .../ContentManagement/ContentXElement.cs | 1 + .../SharedSource/DebugConsole.cs | 1065 ++++++++--- .../DisembarkPerks/DisembarkPerkPrefab.cs | 58 + .../PerkBehaviors/GiveTalentPointPerk.cs | 20 + .../DisembarkPerks/PerkBehaviors/PerkBase.cs | 84 + .../PerkBehaviors/SpawnItemPerk.cs | 210 +++ .../PerkBehaviors/SubItemSwapPerk.cs | 61 + .../PerkBehaviors/UpgradeSubmarinePerk.cs | 57 + .../BarotraumaShared/SharedSource/Enums.cs | 51 +- .../Events/EventActions/AddScoreAction.cs | 84 + .../EventActions/CheckConditionalAction.cs | 17 +- .../Events/EventActions/CheckDataAction.cs | 4 + .../Events/EventActions/CheckItemAction.cs | 5 +- .../EventActions/CheckVisibilityAction.cs | 2 +- .../Events/EventActions/ConversationAction.cs | 66 +- .../Events/EventActions/EventAction.cs | 4 + .../SharedSource/Events/EventActions/GoTo.cs | 5 +- .../Events/EventActions/MissionAction.cs | 4 +- .../Events/EventActions/MissionStateAction.cs | 9 +- .../EventActions/NPCChangeTeamAction.cs | 63 +- .../Events/EventActions/NPCFollowAction.cs | 19 +- .../EventActions/NPCOperateItemAction.cs | 20 +- .../Events/EventActions/NPCWaitAction.cs | 19 +- .../Events/EventActions/RemoveItemAction.cs | 2 +- .../Events/EventActions/SpawnAction.cs | 157 +- .../Events/EventActions/TagAction.cs | 57 +- .../Events/EventActions/TriggerAction.cs | 76 +- .../Events/EventActions/TriggerEventAction.cs | 14 +- .../Events/EventActions/UnlockPathAction.cs | 36 +- .../EventActions/WaitForItemUsedAction.cs | 10 +- .../SharedSource/Events/EventManager.cs | 161 +- .../SharedSource/Events/EventPrefab.cs | 48 +- .../SharedSource/Events/EventSet.cs | 61 +- .../Missions/AbandonedOutpostMission.cs | 195 +- .../Events/Missions/BeaconMission.cs | 4 +- .../Events/Missions/CargoMission.cs | 4 +- .../Events/Missions/CombatMission.cs | 124 +- .../Events/Missions/EscortMission.cs | 10 +- .../SharedSource/Events/Missions/Mission.cs | 213 ++- .../Events/Missions/MissionPrefab.cs | 248 ++- .../Events/Missions/PirateMission.cs | 181 +- .../Events/Missions/SalvageMission.cs | 183 +- .../Events/Missions/ScanMission.cs | 35 +- .../SharedSource/Events/MonsterEvent.cs | 50 +- .../SharedSource/Events/ScriptedEvent.cs | 90 +- .../Extensions/IEnumerableExtensions.cs | 40 +- .../SharedSource/ForbiddenWordFilter.cs | 2 +- .../GameAnalytics/GameAnalyticsManager.cs | 27 + .../GameSession/AutoItemPlacer.cs | 20 +- .../SharedSource/GameSession/CargoManager.cs | 57 +- .../SharedSource/GameSession/CrewManager.cs | 91 +- .../GameSession/GameModes/CampaignMode.cs | 86 +- .../GameSession/GameModes/CampaignSettings.cs | 18 +- .../GameSession/GameModes/CoOpMode.cs | 5 +- .../GameSession/GameModes/MissionMode.cs | 30 +- .../GameModes/MultiPlayerCampaign.cs | 30 +- .../GameSession/GameModes/PvPMode.cs | 47 +- .../GameSession/GameModes/TestGameMode.cs | 19 + .../SharedSource/GameSession/GameSession.cs | 710 +++++-- .../SharedSource/GameSession/HireManager.cs | 6 +- .../SharedSource/InputType.cs | 4 +- .../SharedSource/Items/CharacterInventory.cs | 60 +- .../Items/Components/DockingPort.cs | 20 +- .../SharedSource/Items/Components/Door.cs | 57 +- .../Items/Components/ElectricalDischarger.cs | 2 +- .../Components/EntitySpawnerComponent.cs | 44 +- .../Items/Components/GeneticMaterial.cs | 320 +++- .../SharedSource/Items/Components/Growable.cs | 2 +- .../Items/Components/Holdable/Holdable.cs | 99 +- .../Items/Components/Holdable/IdCard.cs | 8 +- .../Components/Holdable/LevelResource.cs | 6 +- .../Items/Components/Holdable/MeleeWeapon.cs | 48 +- .../Items/Components/Holdable/Pickable.cs | 16 +- .../Items/Components/Holdable/RangedWeapon.cs | 13 +- .../Items/Components/Holdable/RepairTool.cs | 9 +- .../Items/Components/ItemComponent.cs | 27 +- .../Items/Components/ItemContainer.cs | 299 +-- .../SharedSource/Items/Components/Ladder.cs | 3 +- .../Items/Components/Machines/Controller.cs | 101 +- .../Components/Machines/Deconstructor.cs | 39 +- .../Items/Components/Machines/Engine.cs | 4 +- .../Items/Components/Machines/Fabricator.cs | 44 +- .../Items/Components/Machines/MiniMap.cs | 2 +- .../Components/Machines/OxygenGenerator.cs | 2 +- .../Items/Components/Machines/Pump.cs | 6 +- .../Items/Components/Machines/Reactor.cs | 14 +- .../Items/Components/Machines/Sonar.cs | 29 +- .../Components/Machines/SonarTransducer.cs | 2 +- .../Items/Components/Machines/Steering.cs | 6 +- .../SharedSource/Items/Components/Planter.cs | 3 +- .../Items/Components/Power/PowerContainer.cs | 2 +- .../Items/Components/Power/PowerTransfer.cs | 23 +- .../Items/Components/Power/Powered.cs | 13 +- .../Items/Components/Projectile.cs | 128 +- .../Items/Components/Repairable.cs | 2 +- .../SharedSource/Items/Components/Rope.cs | 130 +- .../Items/Components/Signal/ButtonTerminal.cs | 177 +- .../Items/Components/Signal/CircuitBox.cs | 23 +- .../Items/Components/Signal/Connection.cs | 2 +- .../Signal/ConnectionSelectorComponent.cs | 122 ++ .../Components/Signal/CustomInterface.cs | 216 ++- .../Signal/DemultiplexerComponent.cs | 57 + .../Items/Components/Signal/LightComponent.cs | 29 +- .../Items/Components/Signal/MotionSensor.cs | 132 +- .../Components/Signal/MultiplexerComponent.cs | 59 + .../Items/Components/Signal/OxygenDetector.cs | 12 +- .../Items/Components/Signal/RelayComponent.cs | 1 - .../Items/Components/Signal/SmokeDetector.cs | 7 +- .../Items/Components/Signal/Terminal.cs | 16 +- .../Items/Components/Signal/WifiComponent.cs | 39 +- .../Items/Components/Signal/Wire.cs | 13 + .../Items/Components/TriggerComponent.cs | 226 ++- .../SharedSource/Items/Components/Turret.cs | 110 +- .../SharedSource/Items/Components/Wearable.cs | 15 +- .../SharedSource/Items/ContainerTagPrefab.cs | 19 +- .../SharedSource/Items/Inventory.cs | 68 +- .../SharedSource/Items/Item.cs | 488 ++++- .../SharedSource/Items/ItemEventData.cs | 16 +- .../SharedSource/Items/ItemInventory.cs | 2 +- .../SharedSource/Items/ItemPrefab.cs | 45 +- .../SharedSource/Items/RelatedItem.cs | 22 +- .../Map/Creatures/BallastFloraBehavior.cs | 3 +- .../SharedSource/Map/Entity.cs | 7 +- .../SharedSource/Map/Explosion.cs | 30 +- .../BarotraumaShared/SharedSource/Map/Gap.cs | 99 +- .../BarotraumaShared/SharedSource/Map/Hull.cs | 8 +- .../SharedSource/Map/IDamageable.cs | 1 - .../SharedSource/Map/ISpatialEntity.cs | 112 +- .../SharedSource/Map/ItemAssemblyPrefab.cs | 3 +- .../SharedSource/Map/Levels/Biome.cs | 8 +- .../SharedSource/Map/Levels/CaveGenerator.cs | 110 +- .../Map/Levels/DestructibleLevelWall.cs | 2 +- .../SharedSource/Map/Levels/Level.cs | 644 +++++-- .../SharedSource/Map/Levels/LevelData.cs | 83 +- .../Map/Levels/LevelGenerationParams.cs | 86 +- .../Levels/LevelObjects/LevelObjectManager.cs | 31 +- .../Levels/LevelObjects/LevelObjectPrefab.cs | 17 +- .../Map/Levels/LevelObjects/LevelTrigger.cs | 65 +- .../SharedSource/Map/Map/Location.cs | 187 +- .../SharedSource/Map/Map/LocationType.cs | 18 +- .../SharedSource/Map/Map/Map.cs | 127 +- .../Map/Map/MapGenerationParams.cs | 15 +- .../SharedSource/Map/Map/Radiation.cs | 75 +- .../SharedSource/Map/Map/RadiationParams.cs | 31 +- .../SharedSource/Map/MapEntity.cs | 17 + .../SharedSource/Map/MapEntityPrefab.cs | 12 +- .../Map/Outposts/ExtraSubmarineInfo.cs | 44 +- .../SharedSource/Map/Outposts/NPCSet.cs | 6 +- .../Map/Outposts/OutpostGenerationParams.cs | 126 +- .../Map/Outposts/OutpostGenerator.cs | 378 +++- .../SharedSource/Map/PriceInfo.cs | 18 +- .../SharedSource/Map/Structure.cs | 45 +- .../SharedSource/Map/Submarine.cs | 168 +- .../SharedSource/Map/SubmarineBody.cs | 28 +- .../SharedSource/Map/SubmarineInfo.cs | 43 +- .../SharedSource/Map/WayPoint.cs | 78 +- .../SharedSource/Networking/ChatMessage.cs | 22 +- .../SharedSource/Networking/Client.cs | 22 +- .../Networking/ClientPermissions.cs | 10 +- .../SharedSource/Networking/EntitySpawner.cs | 62 +- .../SharedSource/Networking/NetConfig.cs | 14 +- .../NetEntityEvent/NetEntityEventManager.cs | 16 +- .../SharedSource/Networking/NetworkMember.cs | 20 +- .../Networking/OrderChatMessage.cs | 4 +- .../NetworkConnection/EosP2PConnection.cs | 2 + .../NetworkConnection/LidgrenConnection.cs | 5 + .../NetworkConnection/NetworkConnection.cs | 5 + .../NetworkConnection/PipeConnection.cs | 3 + .../NetworkConnection/SteamP2PConnection.cs | 5 +- .../SharedSource/Networking/RespawnManager.cs | 412 ++-- .../SharedSource/Networking/ServerLog.cs | 5 +- .../SharedSource/Networking/ServerSettings.cs | 228 ++- .../SharedSource/PerformanceCounter.cs | 45 +- .../SharedSource/Physics/Physics.cs | 1 + .../SharedSource/Physics/PhysicsBody.cs | 37 +- .../Prefabs/IImplementsVariants.cs | 44 +- .../SharedSource/ProcGen/VoronoiElements.cs | 54 +- .../SharedSource/Screens/GameScreen.cs | 45 +- .../SharedSource/Screens/NetLobbyScreen.cs | 4 + .../Editable/ConditionallyEditable.cs | 11 +- .../Serialization/Editable/Editable.cs | 10 +- .../SerializableProperty.cs | 61 +- .../Serialization/XMLExtensions.cs | 82 +- .../SharedSource/Settings/GameSettings.cs | 16 +- .../SharedSource/Sprite/ConditionalSprite.cs | 9 +- .../SharedSource/Sprite/Sprite.cs | 4 - .../StatusEffects/DelayedEffect.cs | 39 +- .../StatusEffects/PropertyConditional.cs | 142 +- .../StatusEffects/StatusEffect.cs | 386 +++- .../SharedSource/Steam/SteamManager.cs | 14 +- .../SharedSource/Steam/Workshop.cs | 3 +- .../BarotraumaShared/SharedSource/Tags.cs | 47 +- .../SharedSource/Text/TextManager.cs | 66 +- .../SharedSource/Text/TextPack.cs | 13 +- .../Traitors/TraitorEventPrefab.cs | 13 +- .../SharedSource/Upgrades/UpgradePrefab.cs | 44 +- .../SharedSource/Utils/Rand.cs | 14 +- .../SharedSource/Utils/SafeIO.cs | 203 +- .../SharedSource/Utils/SaveUtil.cs | 345 +++- .../SharedSource/Utils/ToolBox.cs | 94 +- Barotrauma/BarotraumaShared/changelog.txt | 651 ++++++- Barotrauma/BarotraumaShared/hintmanager.xml | 1 + .../ClientServer/ClientServerTests.cs | 129 ++ .../ClientServer/HeadlessNetworkClient.cs | 62 + .../BarotraumaTest/CommonnessInfoTests.cs | 6 +- .../BarotraumaTest/CoordinateSpace2DTests.cs | 5 +- .../BarotraumaTest/EndpointParseTests.cs | 13 +- Barotrauma/BarotraumaTest/EnumTests.cs | 49 +- .../FabricatorQualityRollTests.cs | 8 +- .../BarotraumaTest/GenericToolBoxTests.cs | 52 +- ...tSerializableStructImplementationChecks.cs | 7 +- .../INetSerializableStructTests.cs | 24 +- Barotrauma/BarotraumaTest/LinuxTest.csproj | 3 +- Barotrauma/BarotraumaTest/MacTest.csproj | 3 +- Barotrauma/BarotraumaTest/MathUtilsTests.cs | 56 + Barotrauma/BarotraumaTest/NetIdUtilsTests.cs | 6 +- .../PropertyConditionalTests.cs | 4 +- .../SerializableDateTimeTests.cs | 6 +- Barotrauma/BarotraumaTest/WindowsTest.csproj | 3 +- HelperScripts/cleanup_obj.sh | 0 .../Extensions/ColorExtensions.cs | 9 +- .../Extensions/EnumExtensions.cs | 38 +- .../Primitives/AccountId/EpicAccountId.cs | 3 + .../BarotraumaCore/Utils/Identifier.cs | 21 +- .../BarotraumaCore/Utils/MathUtils.cs | 61 +- .../BarotraumaCore/Utils/ReflectionUtils.cs | 5 +- .../Generated/SteamStructFunctions.cs | 2 +- .../Networking/NetAddress.cs | 35 +- .../Facepunch.Steamworks/Structs/UgcItem.cs | 8 +- .../Collision/DynamicTree.cs | 34 +- .../Collision/DynamicTreeBroadPhase.cs | 2 +- .../Collision/Shapes/CircleShape.cs | 6 +- .../Decomposition/Seidel/MonotoneMountain.cs | 2 +- .../Farseer Physics Engine 3.5/Common/Math.cs | 2 +- .../Common/Maths/Complex.cs | 12 +- .../Farseer Physics Engine 3.5/Common/Path.cs | 2 +- .../Common/PhysicsLogic/RealExplosion.cs | 14 +- .../Common/PhysicsLogic/SimpleExplosion.cs | 4 +- .../PolygonManipulation/SimplifyTools.cs | 4 +- .../Common/PolygonTools.cs | 36 +- .../Common/Vertices.cs | 4 +- .../Controllers/GravityController.cs | 4 +- .../Controllers/VelocityLimitController.cs | 2 +- .../Dynamics/Body.cs | 69 +- .../Dynamics/ContactManager.cs | 1 - .../Dynamics/Fixture.cs | 12 +- .../Dynamics/Joints/Joint.cs | 4 +- .../Fluids/1/FluidSystem1.cs | 12 +- .../Fluids/2/FluidSystem2.cs | 6 +- .../Farseer Physics Engine 3.5/Settings.cs | 2 +- .../Src/MonoGame.Framework/Display.cs | 11 + .../Src/MonoGame.Framework/GameWindow.cs | 2 + ...onoGame.Framework.Linux.NetStandard.csproj | 1 + ...onoGame.Framework.MacOS.NetStandard.csproj | 1 + ...oGame.Framework.Windows.NetStandard.csproj | 1 + .../MonoGame.Framework/SDL/SDLGameWindow.cs | 32 +- README.md | 2 +- WindowsSolution.sln | 1 - 627 files changed, 29860 insertions(+), 10018 deletions(-) create mode 100644 Barotrauma/BarotraumaClient/ClientSource/GameSession/PvPMode.cs create mode 100644 Barotrauma/BarotraumaServer/ServerSource/Characters/Limb.cs delete mode 100644 Barotrauma/BarotraumaShared/Data/clientpermissions.xml create mode 100644 Barotrauma/BarotraumaShared/Data/permissionpresets_player.xml create mode 100644 Barotrauma/BarotraumaShared/LocalMods/[DebugOnlyTest]Character override and variant tests/Characters/Crawler/Animations/CrawlerRun.xml create mode 100644 Barotrauma/BarotraumaShared/LocalMods/[DebugOnlyTest]Character override and variant tests/Characters/Crawler/Animations/CrawlerSwimFast.xml create mode 100644 Barotrauma/BarotraumaShared/LocalMods/[DebugOnlyTest]Character override and variant tests/Characters/Crawler/Animations/CrawlerSwimSlow.xml create mode 100644 Barotrauma/BarotraumaShared/LocalMods/[DebugOnlyTest]Character override and variant tests/Characters/Crawler/Animations/CrawlerWalk.xml create mode 100644 Barotrauma/BarotraumaShared/LocalMods/[DebugOnlyTest]Character override and variant tests/Characters/Crawler/Crawler.xml create mode 100644 Barotrauma/BarotraumaShared/LocalMods/[DebugOnlyTest]Character override and variant tests/Characters/Crawler/Ragdolls/CrawlerDefaultRagdoll.xml create mode 100644 Barotrauma/BarotraumaShared/LocalMods/[DebugOnlyTest]Character override and variant tests/Characters/Crawler/crawler.png create mode 100644 Barotrauma/BarotraumaShared/LocalMods/[DebugOnlyTest]Character override and variant tests/Human.xml create mode 100644 Barotrauma/BarotraumaShared/LocalMods/[DebugOnlyTest]Character override and variant tests/Mudraptor.xml create mode 100644 Barotrauma/BarotraumaShared/LocalMods/[DebugOnlyTest]Character override and variant tests/README.txt create mode 100644 Barotrauma/BarotraumaShared/LocalMods/[DebugOnlyTest]Character override and variant tests/Spineling_morbusine_m.xml create mode 100644 Barotrauma/BarotraumaShared/LocalMods/[DebugOnlyTest]Character override and variant tests/Testcrawlerhatchling.xml create mode 100644 Barotrauma/BarotraumaShared/LocalMods/[DebugOnlyTest]Character override and variant tests/Testcyborgworm_m.xml create mode 100644 Barotrauma/BarotraumaShared/LocalMods/[DebugOnlyTest]Character override and variant tests/filelist.xml create mode 100644 Barotrauma/BarotraumaShared/LocalMods/[DebugOnlyTest]RotationAndFlippingTests/RotationAndFlippingTests.sub create mode 100644 Barotrauma/BarotraumaShared/LocalMods/[DebugOnlyTest]RotationAndFlippingTests/filelist.xml create mode 100644 Barotrauma/BarotraumaShared/LocalMods/[DebugOnlyTest]Testhuman/Characters/Testhuman/Animations/HumanRunDivingSuit.xml create mode 100644 Barotrauma/BarotraumaShared/LocalMods/[DebugOnlyTest]Testhuman/Characters/Testhuman/Animations/HumanWalkDivingSuit.xml create mode 100644 Barotrauma/BarotraumaShared/LocalMods/[DebugOnlyTest]Testhuman/Characters/Testhuman/Animations/TesthumanCrouch.xml create mode 100644 Barotrauma/BarotraumaShared/LocalMods/[DebugOnlyTest]Testhuman/Characters/Testhuman/Animations/TesthumanRun.xml create mode 100644 Barotrauma/BarotraumaShared/LocalMods/[DebugOnlyTest]Testhuman/Characters/Testhuman/Animations/TesthumanSwimFast.xml create mode 100644 Barotrauma/BarotraumaShared/LocalMods/[DebugOnlyTest]Testhuman/Characters/Testhuman/Animations/TesthumanSwimSlow.xml create mode 100644 Barotrauma/BarotraumaShared/LocalMods/[DebugOnlyTest]Testhuman/Characters/Testhuman/Animations/TesthumanWalk.xml create mode 100644 Barotrauma/BarotraumaShared/LocalMods/[DebugOnlyTest]Testhuman/Characters/Testhuman/Ragdolls/TesthumanDefaultRagdoll.xml create mode 100644 Barotrauma/BarotraumaShared/LocalMods/[DebugOnlyTest]Testhuman/Characters/Testhuman/Testhuman.xml create mode 100644 Barotrauma/BarotraumaShared/LocalMods/[DebugOnlyTest]Testhuman/README.txt create mode 100644 Barotrauma/BarotraumaShared/LocalMods/[DebugOnlyTest]Testhuman/filelist.xml rename Barotrauma/BarotraumaShared/SharedSource/Characters/AI/Objectives/{AIObjectiveDecontainItem.cs => AIObjectiveMoveItem.cs} (73%) create mode 100644 Barotrauma/BarotraumaShared/SharedSource/ContentManagement/ContentFile/DisembarkPerkFile.cs create mode 100644 Barotrauma/BarotraumaShared/SharedSource/DisembarkPerks/DisembarkPerkPrefab.cs create mode 100644 Barotrauma/BarotraumaShared/SharedSource/DisembarkPerks/PerkBehaviors/GiveTalentPointPerk.cs create mode 100644 Barotrauma/BarotraumaShared/SharedSource/DisembarkPerks/PerkBehaviors/PerkBase.cs create mode 100644 Barotrauma/BarotraumaShared/SharedSource/DisembarkPerks/PerkBehaviors/SpawnItemPerk.cs create mode 100644 Barotrauma/BarotraumaShared/SharedSource/DisembarkPerks/PerkBehaviors/SubItemSwapPerk.cs create mode 100644 Barotrauma/BarotraumaShared/SharedSource/DisembarkPerks/PerkBehaviors/UpgradeSubmarinePerk.cs create mode 100644 Barotrauma/BarotraumaShared/SharedSource/Events/EventActions/AddScoreAction.cs create mode 100644 Barotrauma/BarotraumaShared/SharedSource/GameSession/GameModes/TestGameMode.cs create mode 100644 Barotrauma/BarotraumaShared/SharedSource/Items/Components/Signal/ConnectionSelectorComponent.cs create mode 100644 Barotrauma/BarotraumaShared/SharedSource/Items/Components/Signal/DemultiplexerComponent.cs create mode 100644 Barotrauma/BarotraumaShared/SharedSource/Items/Components/Signal/MultiplexerComponent.cs create mode 100644 Barotrauma/BarotraumaTest/ClientServer/ClientServerTests.cs create mode 100644 Barotrauma/BarotraumaTest/ClientServer/HeadlessNetworkClient.cs mode change 100644 => 100755 HelperScripts/cleanup_obj.sh create mode 100644 Libraries/MonoGame.Framework/Src/MonoGame.Framework/Display.cs diff --git a/Barotrauma/BarotraumaClient/ClientSource/Camera.cs b/Barotrauma/BarotraumaClient/ClientSource/Camera.cs index c497c157f..66429655b 100644 --- a/Barotrauma/BarotraumaClient/ClientSource/Camera.cs +++ b/Barotrauma/BarotraumaClient/ClientSource/Camera.cs @@ -63,6 +63,12 @@ namespace Barotrauma private float prevZoom; public float Shake; + + /// + /// Should the camera's transform matrices be automatically updated to match the screen resolution? + /// + public bool AutoUpdateToScreenResolution = true; + public Vector2 ShakePosition { get; private set; } private float shakeTimer; @@ -198,10 +204,13 @@ namespace Barotrauma public void UpdateTransform(bool interpolate = true, bool updateListener = true) { - if (GameMain.GraphicsWidth != Resolution.X || - GameMain.GraphicsHeight != Resolution.Y) + if (AutoUpdateToScreenResolution) { - CreateMatrices(); + if (GameMain.GraphicsWidth != Resolution.X || + GameMain.GraphicsHeight != Resolution.Y) + { + CreateMatrices(); + } } Vector2 interpolatedPosition = interpolate ? Timing.Interpolate(prevPosition, position) : position; diff --git a/Barotrauma/BarotraumaClient/ClientSource/Characters/AI/EnemyAIController.cs b/Barotrauma/BarotraumaClient/ClientSource/Characters/AI/EnemyAIController.cs index 9ecd47a4e..bdc1abf4b 100644 --- a/Barotrauma/BarotraumaClient/ClientSource/Characters/AI/EnemyAIController.cs +++ b/Barotrauma/BarotraumaClient/ClientSource/Characters/AI/EnemyAIController.cs @@ -42,13 +42,13 @@ namespace Barotrauma if (wallTarget != null && !IsCoolDownRunning) { Vector2 wallTargetPos = wallTarget.Position; - if (wallTarget.Structure.Submarine != null) { wallTargetPos += wallTarget.Structure.Submarine.Position; } + if (wallTarget.Structure.Submarine != null) { wallTargetPos += wallTarget.Structure.Submarine.DrawPosition; } wallTargetPos.Y = -wallTargetPos.Y; GUI.DrawRectangle(spriteBatch, wallTargetPos - new Vector2(10.0f, 10.0f), new Vector2(20.0f, 20.0f), Color.Orange, false); GUI.DrawLine(spriteBatch, pos, wallTargetPos, Color.Orange * 0.5f, 0, 5); } GUI.DrawString(spriteBatch, pos - Vector2.UnitY * 60.0f, $"{SelectedAiTarget.Entity}", GUIStyle.Red, Color.Black); - GUI.DrawString(spriteBatch, pos - Vector2.UnitY * 40.0f, $"{targetValue.FormatZeroDecimal()} (M: {SelectedTargetMemory?.Priority.FormatZeroDecimal()}, P: {SelectedTargetingParams?.Priority.FormatZeroDecimal()})", GUIStyle.Red, Color.Black); + GUI.DrawString(spriteBatch, pos - Vector2.UnitY * 40.0f, $"{targetValue.FormatZeroDecimal()} (M: {CurrentTargetMemory?.Priority.FormatZeroDecimal()}, P: {CurrentTargetingParams?.Priority.FormatZeroDecimal()})", GUIStyle.Red, Color.Black); } /*GUIStyle.Font.DrawString(spriteBatch, targetValue.ToString(), pos - Vector2.UnitY * 80.0f, GUIStyle.Red); @@ -73,7 +73,7 @@ namespace Barotrauma } GUI.DrawString(spriteBatch, pos - Vector2.UnitY * 80.0f, State.ToString(), stateColor, Color.Black); - if (State == AIState.Attack && selectedTargetingParams != null && selectedTargetingParams.AttackPattern == AttackPattern.Circle) + if (State == AIState.Attack && currentTargetingParams != null && currentTargetingParams.AttackPattern == AttackPattern.Circle) { GUI.DrawString(spriteBatch, pos - Vector2.UnitY * 100.0f, CirclePhase.ToString(), stateColor, Color.Black); } @@ -134,8 +134,8 @@ namespace Barotrauma //GUI.DrawLine(spriteBatch, pos, ConvertUnits.ToDisplayUnits(steeringManager.AvoidLookAheadPos.X, -steeringManager.AvoidLookAheadPos.Y), Color.Orange, width: 4); } } + GUI.DrawLine(spriteBatch, pos, pos + ConvertUnits.ToDisplayUnits(new Vector2(Steering.X, -Steering.Y)), Color.Blue, width: 4); GUI.DrawLine(spriteBatch, pos, pos + ConvertUnits.ToDisplayUnits(new Vector2(Character.AnimController.TargetMovement.X, -Character.AnimController.TargetMovement.Y)), Color.SteelBlue, width: 2); - GUI.DrawLine(spriteBatch, pos, pos + ConvertUnits.ToDisplayUnits(new Vector2(Steering.X, -Steering.Y)), Color.Blue, width: 3); } } } diff --git a/Barotrauma/BarotraumaClient/ClientSource/Characters/AI/HumanAIController.cs b/Barotrauma/BarotraumaClient/ClientSource/Characters/AI/HumanAIController.cs index 672fffbb7..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 @@ -11,7 +12,7 @@ namespace Barotrauma { if (Character == Character.Controlled) { return; } if (!DebugAI) { return; } - Vector2 pos = Character.WorldPosition; + Vector2 pos = Character.DrawPosition; pos.Y = -pos.Y; Vector2 textOffset = new Vector2(-40, -160); textOffset.Y -= Math.Max(ObjectiveManager.CurrentOrders.Count - 1, 0) * 20; @@ -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 e6b91667c..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(); @@ -93,6 +93,9 @@ namespace Barotrauma character.AnimController.Anim = AnimController.Animation.None; } + character.AnimController.IgnorePlatforms = character.MemState[0].IgnorePlatforms; + character.AnimController.overrideTargetMovement = character.MemState[0].TargetMovement; + Vector2 newVelocity = Collider.LinearVelocity; Vector2 newPosition = Collider.SimPosition; float newRotation = Collider.Rotation; @@ -103,16 +106,17 @@ namespace Barotrauma { newVelocity = newVelocity.ClampLength(100.0f); if (!MathUtils.IsValid(newVelocity)) { newVelocity = Vector2.Zero; } - overrideTargetMovement = newVelocity.LengthSquared() > 0.01f ? newVelocity : Vector2.Zero; Collider.LinearVelocity = newVelocity; Collider.AngularVelocity = newAngularVelocity; } float distSqrd = Vector2.DistanceSquared(newPosition, Collider.SimPosition); - float errorTolerance = character.CanMove && (!character.IsRagdolled || character.AnimController.IsHangingWithRope) ? 0.01f : 0.2f; + float errorTolerance = + ColliderControlsMovement && (!character.IsRagdolled || character.AnimController.IsHangingWithRope) ? 0.01f : 0.2f; if (distSqrd > errorTolerance) { - if (distSqrd > 10.0f || !character.CanMove) + character.AnimController.BodyInRest = false; + if (distSqrd > 10.0f) { Collider.TargetRotation = newRotation; if (distSqrd > 10.0f) @@ -126,30 +130,35 @@ namespace Barotrauma } } SetPosition(newPosition, lerp: distSqrd < 5.0f, ignorePlatforms: false); + //make sure ragdoll isn't stuck at the wrong side of a platform if the movement is controlled by the ragdoll, and the ragdoll has come to rest server-side + if (!ColliderControlsMovement && newVelocity.LengthSquared() < 0.01f) { TryPlatformCorrection(newPosition); } } - else + else if (ColliderControlsMovement) { Collider.TargetRotation = newRotation; Collider.TargetPosition = newPosition; Collider.MoveToTargetPosition(true); } - } - - //immobilized characters can't correct their position using AnimController movement - // -> we need to correct it manually - if (!character.CanMove) - { - float mainLimbDistSqrd = Vector2.DistanceSquared(MainLimb.PullJointWorldAnchorA, Collider.SimPosition); - float mainLimbErrorTolerance = 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 (mainLimbDistSqrd > mainLimbErrorTolerance || Collider.LinearVelocity.LengthSquared() > 0.05f) + else { - MainLimb.PullJointWorldAnchorB = Collider.SimPosition; - MainLimb.PullJointEnabled = true; + float mainLimbDistSqrd = Vector2.DistanceSquared(MainLimb.PullJointWorldAnchorA, newPosition); + 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) + { + MainLimb.PullJointWorldAnchorB = newPosition; + MainLimb.PullJointEnabled = true; + if (!ColliderControlsMovement && newVelocity.LengthSquared() < 0.01f) { TryPlatformCorrection(newPosition); } + } } } + else if (!ColliderControlsMovement) + { + //correct velocity regardless of the positional error + MainLimb.body.LinearVelocity = newVelocity; + } } character.MemLocalState.Clear(); } @@ -179,12 +188,14 @@ namespace Barotrauma } } - if (character.MemState.Count < 1) return; + if (character.MemState.Count < 1) { return; } - overrideTargetMovement = Vector2.Zero; + overrideTargetMovement = null; CharacterStateInfo serverPos = character.MemState.Last(); + Collider.LastServerState = serverPos; + if (!character.isSynced) { SetPosition(serverPos.Position, lerp: false); @@ -282,18 +293,65 @@ 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(); } } + + /// + /// Attempts to correct the ragdoll to the correct side of a platform if the server position is above the platform and some of the ragdoll's limbs below it client-side, or vice versa. + /// + private void TryPlatformCorrection(Vector2 serverPos) + { + float highestPos = limbs.Where(static l => !l.IsSevered).Max(static l => l.SimPosition.Y); + highestPos = Math.Max(serverPos.Y, highestPos); + float lowestPos = limbs.Where(static l => !l.IsSevered).Min(static l => l.SimPosition.Y); + lowestPos = Math.Min(serverPos.Y, lowestPos); + + var platform = Submarine.PickBody(new Vector2(serverPos.X, highestPos), new Vector2(serverPos.X, lowestPos), collisionCategory: Physics.CollisionPlatform, allowInsideFixture: true); + if (platform == null) { return; } + + int serverDir = Math.Sign(serverPos.Y - platform.Position.Y); + foreach (var limb in limbs) + { + if (limb.IsSevered) { continue; } + int limbDir = Math.Sign(limb.SimPosition.Y - platform.Position.Y); + + const float Margin = 0.01f; + + if (limbDir != serverDir) + { + limb.body.SetTransformIgnoreContacts( + new Vector2( + limb.SimPosition.X, + serverDir > 0 ? Math.Max(serverPos.Y + Margin + limb.body.GetMaxExtent(), limb.SimPosition.Y) : Math.Min(serverPos.Y - Margin - limb.body.GetMaxExtent(), limb.SimPosition.Y)), + limb.Rotation); + } + } + } partial void ImpactProjSpecific(float impact, Body body) { @@ -563,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/Attack.cs b/Barotrauma/BarotraumaClient/ClientSource/Characters/Attack.cs index 8f503a225..0a043c1bc 100644 --- a/Barotrauma/BarotraumaClient/ClientSource/Characters/Attack.cs +++ b/Barotrauma/BarotraumaClient/ClientSource/Characters/Attack.cs @@ -48,7 +48,7 @@ namespace Barotrauma if (sound != null) { - SoundPlayer.PlaySound(sound.Sound, worldPosition, sound.Volume, sound.Range, ignoreMuffling: sound.IgnoreMuffling, freqMult: sound.GetRandomFrequencyMultiplier()); + SoundPlayer.PlaySound(sound, worldPosition); } } } diff --git a/Barotrauma/BarotraumaClient/ClientSource/Characters/Character.cs b/Barotrauma/BarotraumaClient/ClientSource/Characters/Character.cs index e35f32c4e..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 @@ -27,7 +28,8 @@ namespace Barotrauma protected float lastRecvPositionUpdateTime; - private float hudInfoHeight = 100.0f; + private const float DefaultHudInfoHeight = 78.0f; + private float hudInfoHeight = DefaultHudInfoHeight; private List sounds; @@ -249,7 +251,9 @@ namespace Barotrauma public Vector2 Position; public Vector2 DrawPosition; public float MoveUpAmount; - public readonly string Text; + public readonly RichString Text; + public ImmutableArray? RichTextData { get; private set; } + public readonly Character Character; public readonly Submarine Submarine; public readonly Vector2 TextSize; @@ -259,8 +263,10 @@ namespace Barotrauma public SpeechBubble(Character character, float lifeTime, Color color, string text = "") { - Text = 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(); @@ -321,7 +327,6 @@ namespace Barotrauma /// public void ControlLocalPlayer(float deltaTime, Camera cam, bool moveCam = true) { - if (DisableControls || GUI.InputBlockingMenuOpen) { foreach (Key key in keys) @@ -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 @@ -416,6 +421,11 @@ namespace Barotrauma UpdateLocalCursor(cam); + if (IsKeyHit(InputType.ToggleRun)) + { + ToggleRun = !ToggleRun; + } + Vector2 mouseSimPos = ConvertUnits.ToSimUnits(cursorPosition); if (GUI.PauseMenuOpen) { @@ -471,7 +481,7 @@ namespace Barotrauma if (!GUI.InputBlockingMenuOpen) { if (SelectedItem != null && - (SelectedItem.ActiveHUDs.Any(ic => ic.GuiFrame != null && HUD.CloseHUD(ic.GuiFrame.Rect)) || + (SelectedItem.ActiveHUDs.Any(ic => ic.GuiFrame != null && ic.CloseByClickingOutsideGUIFrame && HUD.CloseHUD(ic.GuiFrame.Rect)) || ((ViewTarget as Item)?.Prefab.FocusOnSelected ?? false) && PlayerInput.KeyHit(Microsoft.Xna.Framework.Input.Keys.Escape))) { if (GameMain.Client != null) @@ -543,7 +553,10 @@ namespace Barotrauma { if (attackResult.Damage <= 1.0f) { return; } } - PlaySound(CharacterSound.SoundType.Damage, maxInterval: 2); + if (AIState != AIState.PlayDead) + { + PlaySound(CharacterSound.SoundType.Damage, maxInterval: 2); + } } partial void KillProjSpecific(CauseOfDeathType causeOfDeath, Affliction causeOfDeathAffliction, bool log) @@ -588,7 +601,6 @@ namespace Barotrauma } } - sounds.ForEach(s => s.Sound?.Dispose()); sounds.Clear(); if (GameMain.GameSession?.CrewManager != null && @@ -814,9 +826,12 @@ namespace Barotrauma PlaySound(CharacterSound.SoundType.Idle); } break; + case AIState.PlayDead: + case AIState.Freeze: + case AIState.Hiding: + break; default: - var petBehavior = enemyAI.PetBehavior; - if (petBehavior != null && + if (enemyAI.PetBehavior is PetBehavior petBehavior && (petBehavior.Happiness < petBehavior.UnhappyThreshold || petBehavior.Hunger > petBehavior.HungryThreshold)) { PlaySound(CharacterSound.SoundType.Unhappy); @@ -948,7 +963,9 @@ namespace Barotrauma Controlled != this && Submarine != null && Controlled.Submarine == Submarine && - GameSettings.CurrentConfig.Graphics.LosMode != LosMode.None) + GameSettings.CurrentConfig.Graphics.LosMode != LosMode.None && + //less restrictions on name tag visibility in PvP mode (always show them if the character is visible) + GameMain.GameSession?.GameMode is not PvPMode) { float yPos = Controlled.AnimController.FloorY - 1.5f; @@ -965,15 +982,16 @@ namespace Barotrauma Vector2 pos = DrawPosition; pos.Y += hudInfoHeight; - if (CurrentHull != null && DrawPosition.Y > CurrentHull.WorldRect.Y - 130.0f) + float paddingBelowCeiling = 30.0f; + if (CurrentHull != null && DrawPosition.Y + DefaultHudInfoHeight > CurrentHull.WorldRect.Y - paddingBelowCeiling) { - float lowerAmount = DrawPosition.Y - (CurrentHull.WorldRect.Y - 130.0f); - hudInfoHeight = MathHelper.Lerp(hudInfoHeight, 100.0f - lowerAmount, 0.1f); + float lowerAmount = (DrawPosition.Y + DefaultHudInfoHeight) - (CurrentHull.WorldRect.Y - paddingBelowCeiling); + hudInfoHeight = MathHelper.Lerp(hudInfoHeight, DefaultHudInfoHeight - lowerAmount, 0.1f); hudInfoHeight = Math.Max(hudInfoHeight, 20.0f); } else { - hudInfoHeight = MathHelper.Lerp(hudInfoHeight, 100.0f, 0.1f); + hudInfoHeight = MathHelper.Lerp(hudInfoHeight, DefaultHudInfoHeight, 0.1f); } pos.Y = -pos.Y; @@ -1013,6 +1031,8 @@ namespace Barotrauma CampaignInteractionType == CampaignMode.InteractionType.None ? MathHelper.Clamp(1.0f - (cursorDist - (hoverRange - fadeOutRange)) / fadeOutRange, 0.2f, 1.0f) : 1.0f; + //full name tag visibility in PvP mode to make it easier to tell who's an enemy + float nameTextAlpha = GameMain.GameSession?.GameMode is PvPMode ? 1.0f : hudInfoAlpha; if (!GUI.DisableCharacterNames && hudInfoVisible && (controlled == null || this != controlled.FocusedCharacter || IsPet) && cam.Zoom > 0.4f) @@ -1030,7 +1050,7 @@ namespace Barotrauma } Vector2 nameSize = GUIStyle.Font.MeasureString(name); - Vector2 namePos = new Vector2(pos.X, pos.Y - 10.0f - (5.0f / cam.Zoom)) - nameSize * 0.5f / cam.Zoom; + Vector2 namePos = new Vector2(pos.X, pos.Y - 5.0f - (5.0f / cam.Zoom)) - nameSize * 0.5f / cam.Zoom; Color nameColor = GetNameColor(); Vector2 screenSize = new Vector2(GameMain.GraphicsWidth, GameMain.GraphicsHeight); @@ -1057,7 +1077,7 @@ namespace Barotrauma } GUIStyle.Font.DrawString(spriteBatch, name, namePos + new Vector2(1.0f / cam.Zoom, 1.0f / cam.Zoom), Color.Black, 0.0f, Vector2.Zero, 1.0f / cam.Zoom, SpriteEffects.None, 0.001f); - GUIStyle.Font.DrawString(spriteBatch, name, namePos, nameColor * hudInfoAlpha, 0.0f, Vector2.Zero, 1.0f / cam.Zoom, SpriteEffects.None, 0.0f); + GUIStyle.Font.DrawString(spriteBatch, name, namePos, nameColor * nameTextAlpha, 0.0f, Vector2.Zero, 1.0f / cam.Zoom, SpriteEffects.None, 0.0f); if (GameMain.DebugDraw) { GUIStyle.Font.DrawString(spriteBatch, ID.ToString(), namePos - new Vector2(0.0f, 20.0f), Color.White); @@ -1068,15 +1088,18 @@ namespace Barotrauma if (petBehavior != null && !IsDead && !IsUnconscious) { var petStatus = petBehavior.GetCurrentStatusIndicatorType(); - var iconStyle = GUIStyle.GetComponentStyle("PetIcon." + petStatus); - if (iconStyle != null) + if (petStatus != PetBehavior.StatusIndicatorType.None) { - Vector2 headPos = AnimController.GetLimb(LimbType.Head)?.body?.DrawPosition ?? DrawPosition + Vector2.UnitY * 100.0f; - Vector2 iconPos = headPos; - iconPos.Y = -iconPos.Y; - var icon = iconStyle.Sprites[GUIComponent.ComponentState.None].First(); - float iconScale = 30.0f / icon.Sprite.size.X / cam.Zoom; - icon.Sprite.Draw(spriteBatch, iconPos + new Vector2(-35.0f, -25.0f), iconStyle.Color * hudInfoAlpha, scale: iconScale); + var iconStyle = GUIStyle.GetComponentStyle("PetIcon." + petStatus); + if (iconStyle != null) + { + Vector2 headPos = AnimController.GetLimb(LimbType.Head)?.body?.DrawPosition ?? DrawPosition + Vector2.UnitY * 100.0f; + Vector2 iconPos = headPos; + iconPos.Y = -iconPos.Y; + var icon = iconStyle.Sprites[GUIComponent.ComponentState.None].First(); + float iconScale = 30.0f / icon.Sprite.size.X / cam.Zoom; + icon.Sprite.Draw(spriteBatch, iconPos + new Vector2(-35.0f, -25.0f), iconStyle.Color * hudInfoAlpha, scale: iconScale); + } } } } @@ -1100,7 +1123,7 @@ namespace Barotrauma } } - if (Params.ShowHealthBar && CharacterHealth.DisplayedVitality < MaxVitality * 0.98f && hudInfoVisible) + if (Params.ShowHealthBar && CharacterHealth.DisplayedVitality < MaxVitality * 0.98f && hudInfoVisible && AIState != AIState.PlayDead && AIState != AIState.Hiding) { hudInfoAlpha = Math.Max(hudInfoAlpha, Math.Min(CharacterHealth.DamageOverlayTimer, 1.0f)); @@ -1175,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.DrawString(spriteBatch, iconPos - bubble.TextSize / 2, bubble.Text, bubble.Color * Math.Min(bubble.LifeTime, 1.0f) * alpha, 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(); } @@ -1233,7 +1256,7 @@ namespace Barotrauma public Color GetNameColor() { CharacterTeamType team = teamID; - if (Info?.IsDisguisedAsAnother != null) + if (Info is { IsDisguisedAsAnother: true }) { var idCard = Inventory.GetItemInLimbSlot(InvSlotType.Card)?.GetComponent(); if (idCard != null) @@ -1249,18 +1272,22 @@ namespace Barotrauma } } + CharacterTeamType myTeam = + Controlled?.TeamID ?? + GameMain.Client?.MyClient?.TeamID ?? + CharacterTeamType.Team1; + Color nameColor = GUIStyle.TextColorNormal; - if (Controlled != null && team != Controlled.TeamID) + if (TeamID == CharacterTeamType.FriendlyNPC) { - if (TeamID == CharacterTeamType.FriendlyNPC) - { - nameColor = UniqueNameColor ?? Color.SkyBlue; - } - else - { - nameColor = GUIStyle.Red; - } + nameColor = UniqueNameColor ?? Color.SkyBlue; } + else if (team != myTeam) + { + //opposing team is red when controlling a character + nameColor = GUIStyle.Red; + } + return nameColor; } @@ -1417,7 +1444,10 @@ namespace Barotrauma partial void OnTalentGiven(TalentPrefab talentPrefab) { - AddMessage(TextManager.Get("talentname." + talentPrefab.Identifier).Value, GUIStyle.Yellow, playSound: this == Controlled); + if (!talentPrefab.IsHiddenExtraTalent) + { + AddMessage(TextManager.Get("talentname." + talentPrefab.Identifier).Value, GUIStyle.Yellow, playSound: this == Controlled); + } } } } diff --git a/Barotrauma/BarotraumaClient/ClientSource/Characters/CharacterHUD.cs b/Barotrauma/BarotraumaClient/ClientSource/Characters/CharacterHUD.cs index 633630298..8c95ece49 100644 --- a/Barotrauma/BarotraumaClient/ClientSource/Characters/CharacterHUD.cs +++ b/Barotrauma/BarotraumaClient/ClientSource/Characters/CharacterHUD.cs @@ -11,17 +11,15 @@ namespace Barotrauma { partial class CharacterHUD { - const float BossHealthBarDuration = 120.0f; - - abstract class BossProgressBar + abstract class ProgressBar { public float FadeTimer; public readonly GUIComponent TopContainer; public readonly GUIComponent SideContainer; - public readonly GUIProgressBar TopHealthBar; - public readonly GUIProgressBar SideHealthBar; + public readonly GUIProgressBar TopBar; + public readonly GUIProgressBar SideBar; public abstract bool Completed { get; } @@ -33,9 +31,9 @@ namespace Barotrauma public abstract Color Color { get; } - public BossProgressBar(LocalizedString label) + public ProgressBar(LocalizedString label, float fadeTimer = 120.0f) { - FadeTimer = BossHealthBarDuration; + FadeTimer = fadeTimer; TopContainer = new GUILayoutGroup(new RectTransform(new Vector2(0.18f, 0.03f), HUDFrame.RectTransform, Anchor.TopCenter) { @@ -43,25 +41,25 @@ namespace Barotrauma RelativeOffset = new Vector2(0.0f, 0.01f) }, isHorizontal: false, childAnchor: Anchor.TopCenter); new GUITextBlock(new RectTransform(new Vector2(1.0f, 0.4f), TopContainer.RectTransform), label, textAlignment: Alignment.Center, textColor: GUIStyle.Red); - TopHealthBar = new GUIProgressBar(new RectTransform(new Vector2(1.0f, 0.6f), TopContainer.RectTransform) + TopBar = new GUIProgressBar(new RectTransform(new Vector2(1.0f, 0.6f), TopContainer.RectTransform) { MinSize = new Point(100, HUDLayoutSettings.HealthBarArea.Size.Y) }, barSize: 0.0f, style: "CharacterHealthBarCentered") { Color = GUIStyle.Red }; - CreateNumberText(TopHealthBar); + CreateNumberText(TopBar); SideContainer = new GUILayoutGroup(new RectTransform(new Vector2(1.0f, 0.05f), bossHealthContainer.RectTransform) { MinSize = new Point(80, 60) }, isHorizontal: false, childAnchor: Anchor.TopRight); new GUITextBlock(new RectTransform(new Vector2(1.0f, 0.3f), SideContainer.RectTransform), label, textAlignment: Alignment.CenterRight, textColor: GUIStyle.Red); - SideHealthBar = new GUIProgressBar(new RectTransform(new Vector2(1.0f, 0.7f), SideContainer.RectTransform), barSize: 0.0f, style: "CharacterHealthBar") + SideBar = new GUIProgressBar(new RectTransform(new Vector2(1.0f, 0.7f), SideContainer.RectTransform), barSize: 0.0f, style: "CharacterHealthBar") { Color = GUIStyle.Red }; - CreateNumberText(SideHealthBar); + CreateNumberText(SideBar); TopContainer.Visible = SideContainer.Visible = false; TopContainer.CanBeFocused = false; @@ -88,7 +86,7 @@ namespace Barotrauma public abstract bool IsDuplicate(object targetObject); } - class BossHealthBar : BossProgressBar + class HealthBar : ProgressBar { public readonly Character Character; @@ -104,7 +102,7 @@ namespace Barotrauma public override string NumberToDisplay => string.Empty; - public BossHealthBar(Character character) : base(character.DisplayName) + public HealthBar(Character character) : base(character.DisplayName) { Character = character; } @@ -115,7 +113,7 @@ namespace Barotrauma } } - class MissionProgressBar : BossProgressBar + class MissionProgressBar : ProgressBar { public readonly Mission Mission; @@ -125,13 +123,13 @@ namespace Barotrauma public override bool Interrupted => Mission.Failed || GameMain.GameSession?.Missions == null || !GameMain.GameSession.Missions.Contains(Mission); - public override Color Color => GUIStyle.Red; + public override Color Color => Mission.Prefab.ProgressBarColor; public override string NumberToDisplay => Mission.Prefab.ShowProgressInNumbers ? $"{Mission.State}/{Mission.Prefab.MaxProgressState}" : string.Empty; - public MissionProgressBar(Mission mission) : base(mission.Prefab.ProgressBarLabel) + public MissionProgressBar(Mission mission) : base(mission.Prefab.ProgressBarLabel, fadeTimer: float.PositiveInfinity) { Mission = mission; } @@ -150,7 +148,7 @@ namespace Barotrauma private static readonly List brokenItems = new List(); private static float brokenItemsCheckTimer; - private static readonly List bossProgressBars = new List(); + private static readonly List bossProgressBars = new List(); private static readonly Dictionary cachedHudTexts = new Dictionary(); private static LanguageIdentifier cachedHudTextLanguage = LanguageIdentifier.None; @@ -394,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); @@ -564,7 +563,7 @@ namespace Barotrauma float alpha = MathHelper.Lerp(0.3f, 1.0f, distFactor); GUI.DrawIndicator( spriteBatch, - entity.WorldPosition, + entity.DrawPosition, cam, visibleRange, style.GetDefaultSprite(), @@ -592,7 +591,7 @@ namespace Barotrauma if (Vector2.DistanceSquared(character.Position, item.Position) > 500f * 500f) { continue; } var body = Submarine.CheckVisibility(character.SimPosition, item.SimPosition, ignoreLevel: true); if (body != null && body.UserData as Item != item) { continue; } - GUI.DrawIndicator(spriteBatch, item.WorldPosition + new Vector2(0f, item.RectHeight * 0.65f), cam, new Range(-100f, 500.0f), item.IconStyle.GetDefaultSprite(), item.IconStyle.Color, createOffset: false); + GUI.DrawIndicator(spriteBatch, item.DrawPosition + new Vector2(0f, item.RectHeight * 0.65f), cam, new Range(-100f, 500.0f), item.IconStyle.GetDefaultSprite(), item.IconStyle.Color, createOffset: false); } } @@ -752,7 +751,8 @@ namespace Barotrauma } textPos.X += 10.0f * GUI.Scale; - if (!character.FocusedCharacter.IsIncapacitated && character.FocusedCharacter.IsPet) + 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); @@ -773,7 +773,7 @@ namespace Barotrauma GUIStyle.Green, Color.Black, 2, GUIStyle.SmallFont); textPos.Y += textSize.Y; } - if (!character.FocusedCharacter.CustomInteractHUDText.IsNullOrEmpty() && character.FocusedCharacter.AllowCustomInteract) + if (character.FocusedCharacter.ShouldShowCustomInteractText) { GUI.DrawString(spriteBatch, textPos, character.FocusedCharacter.CustomInteractHUDText, GUIStyle.Green, Color.Black, 2, GUIStyle.SmallFont); textPos.Y += textSize.Y; @@ -784,7 +784,7 @@ namespace Barotrauma { if (character == null || character.IsDead || character.Removed) { return; } if (bossProgressBars.Any(b => b.IsDuplicate(character))) { return; } - AddBossProgressBar(new BossHealthBar(character)); + AddBossProgressBar(new HealthBar(character)); } public static void ShowMissionProgressBar(Mission mission) @@ -803,26 +803,26 @@ namespace Barotrauma bossProgressBars.Clear(); } - private static void RemoveBossProgressBar(BossProgressBar progressBar) + private static void RemoveBossProgressBar(ProgressBar progressBar) { progressBar.SideContainer.Parent?.RemoveChild(progressBar.SideContainer); progressBar.TopContainer.Parent?.RemoveChild(progressBar.TopContainer); bossProgressBars.Remove(progressBar); } - private static void AddBossProgressBar(BossProgressBar progressBar) + 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; } if (bossProgressBars.Count > 5) { - BossProgressBar oldestHealthBar = bossProgressBars.First(); + ProgressBar oldestHealthBar = bossProgressBars.First(); foreach (var bar in bossProgressBars) { - if (bar.TopHealthBar.BarSize < oldestHealthBar.TopHealthBar.BarSize) + if (bar.TopBar.BarSize < oldestHealthBar.TopBar.BarSize) { oldestHealthBar = bar; } @@ -850,7 +850,7 @@ namespace Barotrauma bossHealthBar.TopContainer.Visible = showTopBar; bossHealthBar.SideContainer.Visible = !bossHealthBar.TopContainer.Visible; - bossHealthBar.TopHealthBar.BarSize = bossHealthBar.SideHealthBar.BarSize = bossHealthBar.State; + bossHealthBar.TopBar.BarSize = bossHealthBar.SideBar.BarSize = bossHealthBar.State; float alpha = Math.Min(bossHealthBar.FadeTimer, 1.0f); if (bossHealthBar.TopContainer.Visible) @@ -862,7 +862,7 @@ namespace Barotrauma SetColor(bossHealthBar, bossHealthBar.SideContainer, alpha); } - static void SetColor(BossProgressBar bossHealthBar, GUIComponent container, float alpha) + static void SetColor(ProgressBar bossHealthBar, GUIComponent container, float alpha) { foreach (var component in container.GetAllChildren()) { @@ -887,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 e34cbb98f..5afb4c2d0 100644 --- a/Barotrauma/BarotraumaClient/ClientSource/Characters/CharacterInfo.cs +++ b/Barotrauma/BarotraumaClient/ClientSource/Characters/CharacterInfo.cs @@ -17,7 +17,8 @@ namespace Barotrauma private static Sprite infoAreaPortraitBG; public bool LastControlled; - public int CrewListIndex { get; set; } = -1; + + public int CrewListIndex { get; set; } = int.MaxValue; //default to the bottom of the list private Sprite disguisedPortrait; private List disguisedAttachmentSprites; @@ -32,6 +33,8 @@ namespace Barotrauma private float tintHighlightThreshold; private float tintHighlightMultiplier; + public bool ShowTalentResetPopupOnOpen = true; + public static void Init() { infoAreaPortraitBG = GUIStyle.GetComponentStyle("InfoAreaPortraitBG")?.GetDefaultSprite(); @@ -208,7 +211,7 @@ namespace Barotrauma return frame; } - partial void OnSkillChanged(Identifier skillIdentifier, float prevLevel, float newLevel) + partial void OnSkillChanged(Identifier skillIdentifier, float prevLevel, float newLevel, bool forceNotification) { if (TeamID == CharacterTeamType.FriendlyNPC) { return; } if (Character.Controlled != null && Character.Controlled.TeamID != TeamID) { return; } @@ -226,6 +229,18 @@ namespace Barotrauma specialIncrease ? GUIStyle.Orange : GUIStyle.Green, playSound: Character == Character.Controlled, skillIdentifier, increase); } + else if (forceNotification) + { + float change = newLevel - prevLevel; + if (Math.Abs(change) > 0.01f) + { + string sign = change > 0 ? "+" : "-"; + Character?.AddMessage( + $"{sign}{Math.Round(change, 2)} {TextManager.Get("SkillName." + skillIdentifier).Value}", + specialIncrease ? GUIStyle.Orange : GUIStyle.Green, + playSound: Character == Character.Controlled); + } + } } partial void OnExperienceChanged(int prevAmount, int newAmount) @@ -511,6 +526,17 @@ namespace Barotrauma else { origin = attachment.Sprite.Origin; + if (spriteEffects.HasFlag(SpriteEffects.FlipHorizontally)) + { + origin.X = attachment.Sprite.size.X - origin.X; + } + if (spriteEffects.HasFlag(SpriteEffects.FlipVertically)) + { + origin.Y = attachment.Sprite.size.Y - origin.Y; + } + //the portrait's origin is forced to 0,0 (presumably for easier drawing on the UI?), see LoadHeadElement + //we need to take that into account here and draw the attachment at where the origin of the "actual" head sprite would be + drawPos += HeadSprite.Origin * scale; } float depth = attachment.Sprite.Depth; if (attachment.InheritLimbDepth) @@ -526,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++) @@ -576,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; @@ -586,6 +616,8 @@ namespace Barotrauma ch.ExperiencePoints = inc.ReadInt32(); ch.AdditionalTalentPoints = inc.ReadRangedInteger(0, MaxAdditionalTalentPoints); ch.PermanentlyDead = inc.ReadBoolean(); + ch.TalentRefundPoints = inc.ReadInt32(); + ch.TalentResetCount = inc.ReadInt32(); return ch; } diff --git a/Barotrauma/BarotraumaClient/ClientSource/Characters/CharacterNetworking.cs b/Barotrauma/BarotraumaClient/ClientSource/Characters/CharacterNetworking.cs index ee0137f93..06e788eb3 100644 --- a/Barotrauma/BarotraumaClient/ClientSource/Characters/CharacterNetworking.cs +++ b/Barotrauma/BarotraumaClient/ClientSource/Characters/CharacterNetworking.cs @@ -47,6 +47,7 @@ namespace Barotrauma SelectedCharacter, SelectedItem, SelectedSecondaryItem, + AnimController.TargetMovement, AnimController.Anim); memLocalState.Add(posInfo); @@ -56,7 +57,7 @@ namespace Barotrauma if (IsKeyDown(InputType.Right)) newInput |= InputNetFlags.Right; if (IsKeyDown(InputType.Up)) newInput |= InputNetFlags.Up; if (IsKeyDown(InputType.Down)) newInput |= InputNetFlags.Down; - if (IsKeyDown(InputType.Run)) newInput |= InputNetFlags.Run; + if (IsKeyDown(InputType.Run) || ToggleRun) newInput |= InputNetFlags.Run; if (IsKeyDown(InputType.Crouch)) newInput |= InputNetFlags.Crouch; if (IsKeyHit(InputType.Select)) newInput |= InputNetFlags.Select; //TODO: clean up the way this input is registered if (IsKeyHit(InputType.Deselect)) newInput |= InputNetFlags.Deselect; @@ -68,7 +69,7 @@ namespace Barotrauma if (IsKeyDown(InputType.Attack)) newInput |= InputNetFlags.Attack; if (IsKeyDown(InputType.Ragdoll)) newInput |= InputNetFlags.Ragdoll; - if (AnimController.TargetDir == Direction.Left) newInput |= InputNetFlags.FacingLeft; + if (AnimController.Dir < 0) newInput |= InputNetFlags.FacingLeft; Vector2 relativeCursorPos = cursorPosition - AimRefPosition; relativeCursorPos.Normalize(); @@ -154,6 +155,9 @@ namespace Barotrauma case TreatmentEventData _: msg.WriteBoolean(AnimController.Anim == AnimController.Animation.CPR); break; + case ConfirmRefundEventData _: + //do nothing + break; case CharacterStatusEventData _: //do nothing break; @@ -202,12 +206,16 @@ namespace Barotrauma keys[(int)InputType.Use].Held = useInput; keys[(int)InputType.Use].SetState(false, useInput); - bool crouching = msg.ReadBoolean(); if (AnimController is HumanoidAnimController) { + bool crouching = msg.ReadBoolean(); keys[(int)InputType.Crouch].Held = crouching; keys[(int)InputType.Crouch].SetState(false, crouching); } + else if (AnimController is FishAnimController fishAnim) + { + fishAnim.Reverse = msg.ReadBoolean(); + } bool attackInput = msg.ReadBoolean(); keys[(int)InputType.Attack].Held = attackInput; @@ -255,17 +263,22 @@ namespace Barotrauma msg.ReadRangedSingle(-MaxVel, MaxVel, 12)); linearVelocity = NetConfig.Quantize(linearVelocity, -MaxVel, MaxVel, 12); + Vector2 targetMovement = new Vector2( + msg.ReadRangedSingle(-Ragdoll.MAX_SPEED, Ragdoll.MAX_SPEED, 12), + msg.ReadRangedSingle(-Ragdoll.MAX_SPEED, Ragdoll.MAX_SPEED, 12)); + targetMovement = NetConfig.Quantize(targetMovement, -Ragdoll.MAX_SPEED, Ragdoll.MAX_SPEED, 12); + bool fixedRotation = msg.ReadBoolean(); float? rotation = null; float? angularVelocity = null; 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(); + bool readStatus = msg.ReadBoolean(); if (readStatus) { @@ -287,7 +300,7 @@ namespace Barotrauma { byte happiness = msg.ReadByte(); byte hunger = msg.ReadByte(); - if ((AIController as EnemyAIController)?.PetBehavior is PetBehavior petBehavior) + if (AIController is EnemyAIController { PetBehavior: PetBehavior petBehavior }) { petBehavior.Happiness = (float)happiness / byte.MaxValue * petBehavior.MaxHappiness; petBehavior.Hunger = (float)hunger / byte.MaxValue * petBehavior.MaxHunger; @@ -303,13 +316,13 @@ namespace Barotrauma msg.ReadPadBits(); int index = 0; - if (GameMain.Client.Character == this && CanMove) + if (GameMain.Client.Character == this) { var posInfo = new CharacterStateInfo( pos, rotation, networkUpdateID, facingRight ? Direction.Right : Direction.Left, - selectedCharacter, selectedItem, selectedSecondaryItem, animation); + selectedCharacter, selectedItem, selectedSecondaryItem, targetMovement, animation, ignorePlatforms); while (index < memState.Count && NetIdUtils.IdMoreRecent(posInfo.ID, memState[index].ID)) index++; @@ -321,7 +334,7 @@ namespace Barotrauma pos, rotation, linearVelocity, angularVelocity, sendingTime, facingRight ? Direction.Right : Direction.Left, - selectedCharacter, selectedItem, selectedSecondaryItem, animation); + selectedCharacter, selectedItem, selectedSecondaryItem, targetMovement, animation, ignorePlatforms); while (index < memState.Count && posInfo.Timestamp > memState[index].Timestamp) index++; @@ -371,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; } @@ -393,15 +409,16 @@ namespace Barotrauma break; case EventType.Status: ReadStatus(msg); + GodMode = msg.ReadBoolean(); break; case EventType.UpdateSkills: - int skillCount = msg.ReadByte(); - for (int i = 0; i < skillCount; i++) + Identifier skillIdentifier = msg.ReadIdentifier(); + if (!skillIdentifier.IsEmpty) { - Identifier skillIdentifier = msg.ReadIdentifier(); + bool forceNotification = msg.ReadBoolean(); float skillLevel = msg.ReadSingle(); - info?.SetSkillLevel(skillIdentifier, skillLevel); - } + info?.SetSkillLevel(skillIdentifier, skillLevel, forceNotification: forceNotification); + } break; case EventType.SetAttackTarget: case EventType.ExecuteAttack: @@ -512,7 +529,12 @@ namespace Barotrauma break; case EventType.UpdateExperience: int experienceAmount = msg.ReadInt32(); - info?.SetExperience(experienceAmount); + int additionalTalentPoints = msg.ReadInt32(); + if (info != null) + { + info.SetExperience(experienceAmount); + info.AdditionalTalentPoints = additionalTalentPoints; + } break; case EventType.UpdateTalents: ushort talentCount = msg.ReadUInt16(); @@ -527,6 +549,20 @@ namespace Barotrauma int moneyAmount = msg.ReadInt32(); SetMoney(moneyAmount); break; + case EventType.UpdateTalentRefundPoints: + int refundPoints = msg.ReadInt32(); + if (info != null) + { + if (refundPoints > info.TalentRefundPoints) + { + info.ShowTalentResetPopupOnOpen = true; + } + info.TalentRefundPoints = refundPoints; + } + break; + case EventType.ConfirmTalentRefund: + Info?.RefundTalents(); + break; case EventType.UpdatePermanentStats: byte savedStatValueCount = msg.ReadByte(); StatTypes statType = (StatTypes)msg.ReadByte(); @@ -730,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) { @@ -750,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; @@ -817,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/AfflictionPsychosis.cs b/Barotrauma/BarotraumaClient/ClientSource/Characters/Health/AfflictionPsychosis.cs index 77672ac7e..c353f07be 100644 --- a/Barotrauma/BarotraumaClient/ClientSource/Characters/Health/AfflictionPsychosis.cs +++ b/Barotrauma/BarotraumaClient/ClientSource/Characters/Health/AfflictionPsychosis.cs @@ -180,7 +180,7 @@ namespace Barotrauma fakeBrokenTimer -= deltaTime; if (fakeBrokenTimer > 0.0f) { return; } - foreach (Item item in Item.ItemList) + foreach (Item item in Item.RepairableItems) { var repairable = item.GetComponent(); if (repairable == null) { continue; } diff --git a/Barotrauma/BarotraumaClient/ClientSource/Characters/Health/CharacterHealth.cs b/Barotrauma/BarotraumaClient/ClientSource/Characters/Health/CharacterHealth.cs index 01a89a762..275d5ce4d 100644 --- a/Barotrauma/BarotraumaClient/ClientSource/Characters/Health/CharacterHealth.cs +++ b/Barotrauma/BarotraumaClient/ClientSource/Characters/Health/CharacterHealth.cs @@ -31,8 +31,7 @@ namespace Barotrauma } public static Sprite DamageOverlay => DamageOverlayPrefab.Prefabs.ActivePrefab.DamageOverlay; - - + private Point screenResolution; private float uiScale, inventoryScale; @@ -105,6 +104,12 @@ namespace Barotrauma private GUILayoutGroup treatmentLayout; private GUIListBox recommendedTreatmentContainer; + /// + /// Timer for updating visuals (limb tints and overlays) caused by the affliction + /// + private float updateVisualsTimer = Rand.Range(0.0f, UpdateVisualsInterval); + const float UpdateVisualsInterval = 0.5f; + private float distortTimer; // 0-1 @@ -461,15 +466,17 @@ namespace Barotrauma private void OnAttacked(Character attacker, AttackResult attackResult) { if (Math.Abs(attackResult.Damage) < 0.01f) { return; } - DamageOverlayTimer = MathHelper.Clamp(attackResult.Damage / MaxVitality, DamageOverlayTimer, 1.0f); - if (healthShadowDelay <= 0.0f) { healthShadowDelay = 1.0f; } + if (ShowDamageOverlay) + { + DamageOverlayTimer = MathHelper.Clamp(attackResult.Damage / MaxVitality, DamageOverlayTimer, 1.0f); + float additionalIntensity = MathHelper.Lerp(0, 1, MathUtils.InverseLerp(0, 0.1f, attackResult.Damage / MaxVitality)); + damageIntensity = MathHelper.Clamp(damageIntensity + additionalIntensity, 0, 1); + } + + if (healthShadowDelay <= 0.0f) { healthShadowDelay = 1.0f; } if (healthBarPulsateTimer <= 0.0f) { healthBarPulsatePhase = 0.0f; } healthBarPulsateTimer = 1.0f; - - float additionalIntensity = MathHelper.Lerp(0, 1, MathUtils.InverseLerp(0, 0.1f, attackResult.Damage / MaxVitality)); - damageIntensity = MathHelper.Clamp(damageIntensity + additionalIntensity, 0, 1); - DisplayVitalityDelay = 0.5f; } @@ -1036,6 +1043,12 @@ namespace Barotrauma var affliction = kvp.Key; affliction.Prefab.AfflictionOverlay?.Draw(spriteBatch, Vector2.Zero, Color.White * affliction.GetAfflictionOverlayMultiplier(), Vector2.Zero, 0.0f, new Vector2(GameMain.GraphicsWidth / DamageOverlay.size.X, GameMain.GraphicsHeight / DamageOverlay.size.Y)); + + var activeEffect = affliction.GetActiveEffect(); + if (activeEffect is { ThermalOverlayRange: > 0.0f }) + { + StatusHUD.DrawThermalOverlay(spriteBatch, Character, Character, activeEffect.ThermalOverlayColor, activeEffect.ThermalOverlayRange, effectState: (float)Timing.TotalTimeUnpaused, showDeadCharacters: false); + } } float damageOverlayAlpha = DamageOverlayTimer; @@ -1130,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); @@ -1138,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"); @@ -1149,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 @@ -1159,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) @@ -1380,7 +1401,7 @@ namespace Barotrauma recommendedTreatmentContainer.Content.ClearChildren(); - float characterSkillLevel = Character.Controlled == null ? 0.0f : Character.Controlled.GetSkillLevel("medical"); + float characterSkillLevel = Character.Controlled == null ? 0.0f : Character.Controlled.GetSkillLevel(Tags.MedicalSkill); //key = item identifier //float = suitability @@ -1388,7 +1409,9 @@ namespace Barotrauma GetSuitableTreatments(treatmentSuitability, user: Character.Controlled, ignoreHiddenAfflictions: true, - limb: selectedLimbIndex == -1 ? null : Character.AnimController.Limbs.Find(l => l.HealthIndex == selectedLimbIndex)); + limb: selectedLimbIndex == -1 ? null : Character.AnimController.Limbs.Find(l => l.HealthIndex == selectedLimbIndex), + checkTreatmentSuggestionThreshold: true, + checkTreatmentThreshold: false); foreach (Identifier treatment in treatmentSuitability.Keys.ToList()) { @@ -1949,7 +1972,6 @@ namespace Barotrauma } } - private bool ShouldDisplayAfflictionOnLimb(KeyValuePair kvp, LimbHealth limbHealth) { if (!kvp.Key.ShouldShowIcon(Character)) { return false; } @@ -2058,23 +2080,23 @@ namespace Barotrauma newAfflictions.Add((limbHealths[limbIndex], afflictionPrefab, afflictionStrength)); } - foreach (KeyValuePair kvp in afflictions) + foreach ((Affliction affliction, LimbHealth limbHealth) in afflictions) { //deactivate afflictions that weren't included in the network message - if (!newAfflictions.Any(a => kvp.Key.Prefab == a.afflictionPrefab && kvp.Value == a.limb)) + if (newAfflictions.None(a => affliction.Prefab == a.afflictionPrefab && limbHealth == a.limb)) { - kvp.Key.Strength = 0.0f; + affliction.Strength = 0.0f; } } foreach (var (limb, afflictionPrefab, strength) in newAfflictions) { Affliction existingAffliction = null; - foreach (KeyValuePair kvp in afflictions) + foreach ((Affliction affliction, LimbHealth limbHealth) in afflictions) { - if (kvp.Key.Prefab == afflictionPrefab && kvp.Value == limb) + if (affliction.Prefab == afflictionPrefab && limbHealth == limb) { - existingAffliction = kvp.Key; + existingAffliction = affliction; break; } } @@ -2123,9 +2145,8 @@ namespace Barotrauma if (!Character.Params.Health.ApplyAfflictionColors) { return; } - foreach (KeyValuePair kvp in afflictions) + foreach ((Affliction affliction, LimbHealth _) in afflictions) { - var affliction = kvp.Key; Color faceTint = affliction.GetFaceTint(); if (faceTint.A > FaceTint.A) { FaceTint = faceTint; } Color bodyTint = affliction.GetBodyTint(); @@ -2137,17 +2158,23 @@ namespace Barotrauma { foreach (Limb limb in Character.AnimController.Limbs) { - if (limb.HealthIndex < 0 || limb.HealthIndex >= limbHealths.Count) { continue; } limb.BurnOverlayStrength = 0.0f; limb.DamageOverlayStrength = 0.0f; - foreach (KeyValuePair kvp in afflictions) + } + + foreach ((Affliction affliction, LimbHealth limbHealth) in afflictions) + { + if (affliction.Prefab.BurnOverlayAlpha <= 0.0f && affliction.Prefab.DamageOverlayAlpha <= 0.0f) { continue; } + + float burnStrength = affliction.Strength / Math.Min(affliction.Prefab.MaxStrength, 100) * affliction.Prefab.BurnOverlayAlpha; + float damageOverlayStrength = affliction.Strength / Math.Min(affliction.Prefab.MaxStrength, 100) * affliction.Prefab.DamageOverlayAlpha; + foreach (Limb limb in Character.AnimController.Limbs) { - var affliction = kvp.Key; - float burnStrength = affliction.Strength / Math.Min(affliction.Prefab.MaxStrength, 100) * affliction.Prefab.BurnOverlayAlpha; - if (kvp.Value == limbHealths[limb.HealthIndex] || !affliction.Prefab.LimbSpecific) + if (limb.HealthIndex < 0 || limb.HealthIndex >= limbHealths.Count) { continue; } + if (limbHealth == limbHealths[limb.HealthIndex] || !affliction.Prefab.LimbSpecific) { limb.BurnOverlayStrength += burnStrength; - limb.DamageOverlayStrength += affliction.Strength / Math.Min(affliction.Prefab.MaxStrength, 100) * affliction.Prefab.DamageOverlayAlpha; + limb.DamageOverlayStrength += damageOverlayStrength; } else { 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 1ac28a552..be656e146 100644 --- a/Barotrauma/BarotraumaClient/ClientSource/Characters/Jobs/JobPrefab.cs +++ b/Barotrauma/BarotraumaClient/ClientSource/Characters/Jobs/JobPrefab.cs @@ -1,85 +1,75 @@ using Microsoft.Xna.Framework; -using System.Linq; -using System; -using System.Xml.Linq; using System.Collections.Generic; +using System.Linq; namespace Barotrauma { partial class JobPrefab : PrefabWithUintIdentifier { - public GUIButton CreateInfoFrame(out GUIComponent buttonContainer) + 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) { - new GUITextBlock(new RectTransform(new Vector2(1.0f, 0.0f), skillContainer.RectTransform), - " - " + TextManager.AddPunctuation(':', TextManager.Get("SkillName." + skill.Identifier), (int)skill.LevelRange.Start + " - " + (int)skill.LevelRange.End), - font: GUIStyle.SmallFont); + var levelRange = skill.GetLevelRange(isPvP); + + string levelStr = + levelRange.End > levelRange.Start ? + (int)levelRange.Start + " - " + (int)levelRange.End : + ((int)levelRange.Start).ToString(); + new GUITextBlock(new RectTransform(new Vector2(1.0f, 0.0f), contentList.Content.RectTransform), + " - " + TextManager.AddPunctuation(':', TextManager.Get("SkillName." + skill.Identifier), levelStr), + font: GUIStyle.SmallFont, wrap: true) + { + CanBeFocused = false + }; } buttonContainer = paddedFrame; - - /*if (!ItemIdentifiers.TryGetValue(variant, out var itemIdentifiers)) { return backFrame; } - var itemContainer = new GUILayoutGroup(new RectTransform(new Vector2(0.45f, 0.5f), paddedFrame.RectTransform, Anchor.TopRight) - { RelativeOffset = new Vector2(0.0f, 0.2f + descriptionBlock.RectTransform.RelativeSize.Y) }) - { - Stretch = true - }; - new GUITextBlock(new RectTransform(new Vector2(1.0f, 0.0f), itemContainer.RectTransform), - TextManager.Get("Items", "mapentitycategory.equipment"), font: GUIStyle.LargeFont); - foreach (string identifier in itemIdentifiers.Distinct()) - { - if (!(MapEntityPrefab.Find(name: null, identifier: identifier) is ItemPrefab itemPrefab)) { continue; } - int count = itemIdentifiers.Count(i => i == identifier); - new GUITextBlock(new RectTransform(new Vector2(1.0f, 0.0f), itemContainer.RectTransform), - " - " + (count == 1 ? itemPrefab.Name : itemPrefab.Name + " x" + count), - font: GUIStyle.SmallFont); - }*/ - + return frameHolder; } - - public class OutfitPreview + + public IEnumerable GetJobOutfitSprites(CharacterTeamType team, bool isPvPMode) { - public readonly List<(Sprite sprite, Vector2 drawOffset)> Sprites; - - public Vector2 Dimensions; - - public OutfitPreview() - { - Sprites = new List<(Sprite sprite, Vector2 drawOffset)>(); - Dimensions = Vector2.One; - } - - public void AddSprite(Sprite sprite, Vector2 drawOffset) - { - Sprites.Add((sprite, drawOffset)); - } - } - - public List GetJobOutfitSprites(CharacterInfoPrefab charInfoPrefab, bool useInventoryIcon, out Vector2 maxDimensions) - { - List outfitPreviews = new List(); - maxDimensions = Vector2.One; - - var equipIdentifiers = Element.GetChildElements("ItemSet").Elements().Where(e => e.GetAttributeBool("outfit", false)).Select(e => e.GetAttributeIdentifier("identifier", "")); + var equipIdentifiers = JobItems + .SelectMany(kvp => kvp.Value) + .Where(j => j.Outfit) + .Select(j => j.GetItemIdentifier(team, isPvPMode)); List outfitPrefabs = new List(); foreach (var equipIdentifier in equipIdentifiers) @@ -88,45 +78,9 @@ namespace Barotrauma if (itemPrefab != null) { outfitPrefabs.Add(itemPrefab); } } - if (!outfitPrefabs.Any()) { return null; } + if (!outfitPrefabs.Any()) { return Enumerable.Empty(); } - for (int i = 0; i < outfitPrefabs.Count; i++) - { - var outfitPreview = new OutfitPreview(); - - if (!ItemSets.TryGetValue(i, out var itemSetElement)) { continue; } - var previewElement = itemSetElement.GetChildElement("PreviewSprites"); - if (previewElement == null || useInventoryIcon) - { - if (outfitPrefabs[i] is ItemPrefab prefab && prefab.InventoryIcon != null) - { - outfitPreview.AddSprite(prefab.InventoryIcon, Vector2.Zero); - outfitPreview.Dimensions = prefab.InventoryIcon.SourceRect.Size.ToVector2(); - maxDimensions.X = MathHelper.Max(maxDimensions.X, outfitPreview.Dimensions.X); - maxDimensions.Y = MathHelper.Max(maxDimensions.Y, outfitPreview.Dimensions.Y); - } - outfitPreviews.Add(outfitPreview); - continue; - } - - var children = previewElement.Elements().ToList(); - for (int n = 0; n < children.Count; n++) - { - var spriteElement = children[n]; - string spriteTexture = charInfoPrefab.ReplaceVars(spriteElement.GetAttributeString("texture", ""), charInfoPrefab.Heads.First()); - var sprite = new Sprite(spriteElement, file: spriteTexture); - sprite.size = new Vector2(sprite.SourceRect.Width, sprite.SourceRect.Height); - outfitPreview.AddSprite(sprite, children[n].GetAttributeVector2("offset", Vector2.Zero)); - } - - outfitPreview.Dimensions = previewElement.GetAttributeVector2("dims", Vector2.One); - maxDimensions.X = MathHelper.Max(maxDimensions.X, outfitPreview.Dimensions.X); - maxDimensions.Y = MathHelper.Max(maxDimensions.Y, outfitPreview.Dimensions.Y); - - outfitPreviews.Add(outfitPreview); - } - - return outfitPreviews; + return outfitPrefabs.Select(p => p.InventoryIcon ?? p.Sprite); } } } diff --git a/Barotrauma/BarotraumaClient/ClientSource/Characters/Limb.cs b/Barotrauma/BarotraumaClient/ClientSource/Characters/Limb.cs index 04fb57758..47e90a6cb 100644 --- a/Barotrauma/BarotraumaClient/ClientSource/Characters/Limb.cs +++ b/Barotrauma/BarotraumaClient/ClientSource/Characters/Limb.cs @@ -137,7 +137,16 @@ namespace Barotrauma { get { - var conditionalSprite = ConditionalSprites.FirstOrDefault(c => c.Exclusive && c.IsActive && c.DeformableSprite != null); + // Performance-sensitive, hence implemented without Linq. + ConditionalSprite conditionalSprite = null; + foreach (ConditionalSprite cs in ConditionalSprites) + { + if (cs.Exclusive && cs.IsActive && cs.DeformableSprite != null) + { + conditionalSprite = cs; + break; + } + } if (conditionalSprite != null) { return conditionalSprite.DeformableSprite; @@ -155,7 +164,16 @@ namespace Barotrauma { get { - var conditionalSprite = ConditionalSprites.FirstOrDefault(c => c.Exclusive && c.IsActive && c.ActiveSprite != null); + // Performance-sensitive, hence implemented without Linq. + ConditionalSprite conditionalSprite = null; + foreach (ConditionalSprite cs in ConditionalSprites) + { + if (cs.Exclusive && cs.IsActive && cs.ActiveSprite != null) + { + conditionalSprite = cs; + break; + } + } if (conditionalSprite != null) { return conditionalSprite.ActiveSprite; @@ -184,12 +202,6 @@ namespace Barotrauma public Sprite DamagedSprite { get; private set; } - public bool Hide - { - get => Params.Hide; - set => Params.Hide = value; - } - public List ConditionalSprites { get; private set; } = new List(); private Dictionary spriteAnimState = new Dictionary(); private Dictionary> DecorativeSpriteGroups = new Dictionary>(); @@ -198,6 +210,7 @@ namespace Barotrauma { public float RotationState; public float OffsetState; + public float ScaleState; public Vector2 RandomOffsetMultiplier = new Vector2(Rand.Range(-1.0f, 1.0f), Rand.Range(-1.0f, 1.0f)); public float RandomRotationFactor = Rand.Range(0.0f, 1.0f); public float RandomScaleFactor = Rand.Range(0.0f, 1.0f); @@ -301,7 +314,16 @@ namespace Barotrauma DamagedSprite = new Sprite(subElement, file: GetSpritePath(subElement, Params.damagedSpriteParams, ref _damagedTexturePath), sourceRectScale: sourceRectScale); break; case "conditionalsprite": - var conditionalSprite = new ConditionalSprite(subElement, GetConditionalTarget(), file: GetSpritePath(subElement, null, ref _texturePath), sourceRectScale: sourceRectScale); + string conditionalSpritePath = string.Empty; + GetSpritePath(subElement.GetChildElement("sprite") ?? subElement.GetChildElement("deformablesprite") ?? subElement, null, ref conditionalSpritePath); + if (conditionalSpritePath.IsNullOrEmpty()) + { + DebugConsole.ThrowError($"Failed to find a sprite path in the conditional sprite defined in {character.SpeciesName}, limb {type}.", + contentPackage: subElement.ContentPackage); + } + var conditionalSprite = new ConditionalSprite(subElement, GetConditionalTarget(), + file: conditionalSpritePath, + sourceRectScale: sourceRectScale); ConditionalSprites.Add(conditionalSprite); if (conditionalSprite.DeformableSprite != null) { @@ -475,13 +497,24 @@ namespace Barotrauma private string _damagedTexturePath; private string GetSpritePath(ContentXElement element, SpriteParams spriteParams, ref string path) { - if (path == null) + if (path.IsNullOrEmpty()) { if (spriteParams != null) { - //1. check if the variant file redefines the texture - ContentPath texturePath = character.Params.VariantFile?.Root?.GetAttributeContentPath("texture", character.Prefab.ContentPackage); - //2. check if the base prefab defines the texture + ContentPath texturePath; + //1. check if the limb defines the texture directly + var definedTexturePath = element?.GetAttributeContentPath("texture"); + if (!definedTexturePath.IsNullOrEmpty()) + { + texturePath = definedTexturePath; + } + else + { + //2. check if the character file defines the texture directly + texturePath = character.Params.VariantFile?.GetRootExcludingOverride()?.GetAttributeContentPath("texture", character.Prefab.ContentPackage); + } + + //3. check if the base prefab defines the texture if (texturePath.IsNullOrEmpty() && !character.Prefab.VariantOf.IsEmpty) { Identifier speciesName = character.GetBaseCharacterSpeciesName(); @@ -491,7 +524,7 @@ namespace Barotrauma texturePath = parentRagdollParams.OriginalElement?.GetAttributeContentPath("texture"); } - //3. "default case", get the texture from this character's XML + //4. "default case", get the texture from this character's XML texturePath ??= ContentPath.FromRaw(spriteParams.Element.ContentPackage ?? character.Prefab.ContentPackage, spriteParams.GetTexturePath()); path = GetSpritePath(texturePath); } @@ -749,9 +782,29 @@ namespace Barotrauma float herpesStrength = character.CharacterHealth.GetAfflictionStrengthByType(AfflictionPrefab.SpaceHerpesType); - bool hideLimb = Hide || - OtherWearables.Any(w => w.HideLimb) || - WearingItems.Any(w => w.HideLimb); + bool hideLimb = ShouldHideLimb(this); + if (!hideLimb && Params.InheritHiding != LimbType.None) + { + if (character.AnimController.GetLimb(Params.InheritHiding) is Limb otherLimb) + { + hideLimb = ShouldHideLimb(otherLimb); + } + } + + static bool ShouldHideLimb(Limb limb) + { + if (limb.Hide) { return true; } + // Performance-sensitive code -> implemented without Linq + foreach (var wearable in limb.OtherWearables) + { + if (wearable.HideLimb) { return true; } + } + foreach (var wearable in limb.WearingItems) + { + if (wearable.HideLimb) { return true; } + } + return false; + } bool drawHuskSprite = HuskSprite != null && !wearableTypesToHide.Contains(WearableType.Husk); @@ -771,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); } } @@ -826,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 @@ -883,28 +938,28 @@ namespace Barotrauma } depthStep += step; } - foreach (WearableSprite wearable in OtherWearables) + if (!hideLimb) { - if (wearable.Type == WearableType.Husk) { continue; } - if (wearableTypesToHide.Contains(wearable.Type)) + foreach (WearableSprite wearable in OtherWearables) { - if (wearable.Type == WearableType.Hair) + if (wearable.Type == WearableType.Husk) { continue; } + if (wearableTypesToHide.Contains(wearable.Type)) { - if (HairWithHatSprite != null && !hideLimb) + // Draws the short hair + if (wearable.Type == WearableType.Hair) { - DrawWearable(HairWithHatSprite, depthStep, spriteBatch, blankColor, alpha: color.A / 255f, spriteEffect); - depthStep += step; - continue; + if (HairWithHatSprite != null) + { + DrawWearable(HairWithHatSprite, depthStep, spriteBatch, blankColor, alpha: color.A / 255f, spriteEffect); + depthStep += step; + } } - } - else - { continue; } + DrawWearable(wearable, depthStep, spriteBatch, blankColor, alpha: color.A / 255f, spriteEffect); + //if there are multiple sprites on this limb, make the successive ones be drawn in front + depthStep += step; } - DrawWearable(wearable, depthStep, spriteBatch, blankColor, alpha: color.A / 255f, spriteEffect); - //if there are multiple sprites on this limb, make the successive ones be drawn in front - depthStep += step; } } foreach (WearableSprite wearable in WearingItems) @@ -952,8 +1007,8 @@ namespace Barotrauma var ca = (float)Math.Cos(-body.Rotation); var sa = (float)Math.Sin(-body.Rotation); Vector2 transformedOffset = new Vector2(ca * offset.X + sa * offset.Y, -sa * offset.X + ca * offset.Y); - decorativeSprite.Sprite.Draw(spriteBatch, new Vector2(body.DrawPosition.X + transformedOffset.X, -(body.DrawPosition.Y + transformedOffset.Y)), c, - -body.Rotation + rotation, decorativeSprite.GetScale(spriteAnimState[decorativeSprite].RandomScaleFactor) * Scale, spriteEffect, + decorativeSprite.Sprite.Draw(spriteBatch, new Vector2(body.DrawPosition.X + transformedOffset.X, -(body.DrawPosition.Y + transformedOffset.Y)), c, decorativeSprite.Sprite.Origin, + -body.Rotation + rotation, decorativeSprite.GetScale(ref spriteAnimState[decorativeSprite].ScaleState, spriteAnimState[decorativeSprite].RandomScaleFactor) * Scale, spriteEffect, depth: activeSprite.Depth - depthStep); depthStep += step; } @@ -963,7 +1018,7 @@ namespace Barotrauma new Vector2(body.DrawPosition.X, -body.DrawPosition.Y), colorWithoutTint * damageOverlayStrength, activeSprite.Origin, -body.DrawRotation, - Scale, spriteEffect, activeSprite.Depth - depthStep * Math.Max(1, WearingItems.Count * 2)); // Multiply by 2 to get rid of z-fighting with some clothing combos + Scale * TextureScale, spriteEffect, activeSprite.Depth - depthStep * Math.Max(1, WearingItems.Count * 2)); // Multiply by 2 to get rid of z-fighting with some clothing combos } } diff --git a/Barotrauma/BarotraumaClient/ClientSource/CircuitBox/CircuitBoxComponent.cs b/Barotrauma/BarotraumaClient/ClientSource/CircuitBox/CircuitBoxComponent.cs index bdfc98c6e..d3de65b36 100644 --- a/Barotrauma/BarotraumaClient/ClientSource/CircuitBox/CircuitBoxComponent.cs +++ b/Barotrauma/BarotraumaClient/ClientSource/CircuitBox/CircuitBoxComponent.cs @@ -86,7 +86,7 @@ namespace Barotrauma var componentEditor = new SerializableEntityEditor(listBox.Content.RectTransform, ic, inGame: !isEditor, showName: false, titleFont: GUIStyle.SubHeadingFont) { - Readonly = CircuitBox.Locked + Readonly = CircuitBox.IsLocked() }; fieldCount += componentEditor.Fields.Count; diff --git a/Barotrauma/BarotraumaClient/ClientSource/CircuitBox/CircuitBoxUI.cs b/Barotrauma/BarotraumaClient/ClientSource/CircuitBox/CircuitBoxUI.cs index 17f1e9215..f0cf5ef70 100644 --- a/Barotrauma/BarotraumaClient/ClientSource/CircuitBox/CircuitBoxUI.cs +++ b/Barotrauma/BarotraumaClient/ClientSource/CircuitBox/CircuitBoxUI.cs @@ -35,7 +35,7 @@ namespace Barotrauma public List VirtualWires = new(); - public bool Locked => CircuitBox.Locked; + public bool Locked => CircuitBox.IsLocked(); public CircuitBoxUI(CircuitBox box) { @@ -786,6 +786,7 @@ namespace Barotrauma if (wireOption.TryUnwrap(out var wire)) { CircuitBox.RemoveWires(wire.IsSelected ? wireSelection : ImmutableArray.Create(wire)); + return; } switch (nodeOption) diff --git a/Barotrauma/BarotraumaClient/ClientSource/ContentManagement/ContentPackage/ModProject.cs b/Barotrauma/BarotraumaClient/ClientSource/ContentManagement/ContentPackage/ModProject.cs index 4c883b07d..8cd18148c 100644 --- a/Barotrauma/BarotraumaClient/ClientSource/ContentManagement/ContentPackage/ModProject.cs +++ b/Barotrauma/BarotraumaClient/ClientSource/ContentManagement/ContentPackage/ModProject.cs @@ -169,9 +169,9 @@ namespace Barotrauma return doc; } - public void Save(string path) + public void Save(string path, bool catchUnauthorizedAccessExceptions = true) { - Directory.CreateDirectory(Path.GetDirectoryName(path)!); + Directory.CreateDirectory(Path.GetDirectoryName(path)!, catchUnauthorizedAccessExceptions); ToXDocument().SaveSafe(path); } } diff --git a/Barotrauma/BarotraumaClient/ClientSource/ContentManagement/Transition/LegacySteamUgcTransition.cs b/Barotrauma/BarotraumaClient/ClientSource/ContentManagement/Transition/LegacySteamUgcTransition.cs index 5f45b7d56..036e488a4 100644 --- a/Barotrauma/BarotraumaClient/ClientSource/ContentManagement/Transition/LegacySteamUgcTransition.cs +++ b/Barotrauma/BarotraumaClient/ClientSource/ContentManagement/Transition/LegacySteamUgcTransition.cs @@ -265,7 +265,7 @@ namespace Barotrauma.Transition subs = getFiles(oldSubsPath, "*.sub"); itemAssemblies = getFiles(oldItemAssembliesPath, "*.xml"); - string[] allOldMods = Directory.GetDirectories(oldModsPath, "*", System.IO.SearchOption.TopDirectoryOnly); + string[] allOldMods = Directory.GetDirectories(oldModsPath, "*"); var publishedItems = await SteamManager.Workshop.GetPublishedItems(); foreach (var modDir in allOldMods) diff --git a/Barotrauma/BarotraumaClient/ClientSource/DebugConsole.cs b/Barotrauma/BarotraumaClient/ClientSource/DebugConsole.cs index d6400ca91..f11273dc5 100644 --- a/Barotrauma/BarotraumaClient/ClientSource/DebugConsole.cs +++ b/Barotrauma/BarotraumaClient/ClientSource/DebugConsole.cs @@ -587,6 +587,39 @@ namespace Barotrauma } GameMain.CharacterEditorScreen.Select(); })); + + commands.Add(new Command("settainted", "settainted [true/false]: Sets tainted effect on hovered genetic material.", + onExecute: (string[] args) => + { + + if (Character.Controlled == null) + { + NewMessage("No controlled character!", Color.Red); + return; + } + + Item focusedItem = Character.Controlled?.FocusedItem ?? Inventory.SelectedSlot?.Item; + + if (focusedItem == null) + { + NewMessage("No focused item, hover on something!", Color.Red); + return; + } + + var geneticMaterial = focusedItem.GetComponent(); + + if (geneticMaterial == null) + { + NewMessage("Not hovering on a genetic material!", Color.Red); + return; + } + else + { + bool newValue = args.None(arg => string.Equals(arg, "false", StringComparison.InvariantCultureIgnoreCase)); + geneticMaterial.SetTainted(newValue); + NewMessage($"Set tainted to {newValue} for {focusedItem.Name}", Color.Yellow); + } + }, isCheat: true)); commands.Add(new Command("quickstart", "Starts a singleplayer sandbox", (string[] args) => { @@ -625,6 +658,113 @@ namespace Barotrauma GameMain.MainMenuScreen.QuickStart(fixedSeed: false, subName, difficulty, levelGenerationParams); }, getValidArgs: () => new[] { SubmarineInfo.SavedSubmarines.Select(s => s.Name).Distinct().OrderBy(s => s).ToArray() })); + + commands.Add(new Command("forcewreck", "forcewreck [wreckname] (optional, ThalamusSpawn)[Random/Forced/Disabled]: When generating levels, ensures a specific wreck is generated. Second optional parameter to control thalamus spawning.", (string[] args) => + { + if (args.Length > 0) + { + var submarineFile = GetSubmarineFile(args[0]); + + if (submarineFile != null) + { + var matchingSub = SubmarineInfo.SavedSubmarines.FirstOrDefault(i => i.FilePath == submarineFile.Path.Value); + if (matchingSub != null) + { + NewMessage($"Setting ForceWreck to: {matchingSub.Name}, {submarineFile.Path}", color: Color.Yellow); + + LevelData.ConsoleForceWreck = matchingSub; + } + } + else + { + NewMessage($"Can't find: {args[0]}", color: Color.Red); + } + } + + if (args.Length > 1) + { + string forceThalamusArg = args[1]; + if (Enum.TryParse(forceThalamusArg, ignoreCase: true, out LevelData.ThalamusSpawn result)) + { + NewMessage($"Setting ThalamusSpawn to: {result}", color: Color.Yellow); + LevelData.ForceThalamus = result; + } + else + { + NewMessage($"Can't parse argument: {forceThalamusArg}", color: Color.Red); + } + } + else + { + NewMessage($"Setting ThalamusSpawn to: {LevelData.ThalamusSpawn.Random}", color: Color.Yellow); + LevelData.ForceThalamus = LevelData.ThalamusSpawn.Random; + } + }, + () => + { + return new string[][] + { + ListSubmarineFileNames(), + new string[] { LevelData.ThalamusSpawn.Random.ToString(), LevelData.ThalamusSpawn.Forced.ToString(), LevelData.ThalamusSpawn.Disabled.ToString() } + }; + }, isCheat: true)); + + commands.Add(new Command("forcebeaconstation|forcebeacon", "forcebeaconstation [station name]: When generating levels, ensures a specific beacon station is generated.", (string[] args) => + { + if (args.Length > 0) + { + var submarineFile = GetSubmarineFile(args[0]); + + if (submarineFile != null) + { + var matchingSub = SubmarineInfo.SavedSubmarines.FirstOrDefault(i => i.FilePath == submarineFile.Path.Value); + if (matchingSub != null) + { + NewMessage($"Setting ForceBeaconStation to: {matchingSub.Name}, {submarineFile.Path}", color: Color.Yellow); + + LevelData.ConsoleForceBeaconStation = matchingSub; + } + } + else + { + NewMessage($"Can't find: {args[0]}", color: Color.Red); + } + } + }, + () => + { + return new string[][] + { + ListSubmarineFileNames() + }; + }, isCheat: true)); + + commands.Add(new Command("reloadcontentfile", "reloadcontentfile [filepath]: Reloads a specific content xml file during runtime.", (string[] args) => + { + if (args.Length > 0) + { + string pathArgument = args[0]; + var contentFile = GetContentFile(pathArgument); + + if (contentFile != null) + { + NewMessage($"Reloading content file: {pathArgument}", Color.Yellow); + contentFile.UnloadFile(); + contentFile.LoadFile(); + } + else + { + NewMessage($"Can't find {args[0]} to reload", color:Color.Red); + } + } + }, + () => + { + return new string[][] + { + ListContentFilePaths() + }; + }, isCheat: true)); commands.Add(new Command("steamnetdebug", "steamnetdebug: Toggles Steamworks networking debug logging.", (string[] args) => { @@ -783,6 +923,7 @@ namespace Barotrauma AssignRelayToServer("spreadsheetexport", false); #if DEBUG AssignRelayToServer("listspamfilters", false); + AssignRelayToServer("showitemxml", false); AssignRelayToServer("crash", false); AssignRelayToServer("showballastflorasprite", false); AssignRelayToServer("simulatedlatency", false); @@ -855,17 +996,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) => { @@ -1924,7 +2059,7 @@ namespace Barotrauma addIfMissing($"missionname.{missionId}".ToIdentifier(), language); } - if (missionPrefab.Type == MissionType.Combat) + if (missionPrefab.Type == Tags.MissionTypeCombat) { addIfMissing($"MissionDescriptionNeutral.{missionId}".ToIdentifier(), language); addIfMissing($"MissionDescription1.{missionId}".ToIdentifier(), language); @@ -2360,7 +2495,47 @@ namespace Barotrauma GameMain.SubEditorScreen.LoadSub(wreckedSubmarineInfo); })); + commands.Add(new Command("showitemxml", "showitemxml [item]: Shows the XML configuration of an item in the console and copies it to the clipboard. Useful for debugging variants that partially override the XML of the base item for example.", (string[] args) => + { + if (args.Length == 0) + { + ThrowError("Please specify the name or identifier of the item."); + return; + } + + string itemNameOrId = args[0].ToLowerInvariant(); + ItemPrefab itemPrefab = + (MapEntityPrefab.FindByName(itemNameOrId) ?? + MapEntityPrefab.FindByIdentifier(itemNameOrId.ToIdentifier())) as ItemPrefab; + if (itemPrefab == null) + { + ThrowError("Item \"{itemNameOrId}\" not found!"); + return; + } + string xmlStr = itemPrefab.ConfigElement.Element.ToString(); + NewMessage(xmlStr); + Clipboard.SetText(xmlStr); + }, getValidArgs: () => + { + return new string[][] + { + GetItemNameOrIdParams().ToArray() + }; + })); + #if DEBUG + + commands.Add(new Command("unlockachievement", "unlockachievement [identifier]: Unlocks the specified achievement.", (string[] args) => + { + if (args.Length < 1) + { + ThrowError("Please specify the achievement to unlock."); + return; + } + NewMessage($"Unlocked \"{args[0]}\"."); + AchievementManager.UnlockAchievement(args[0].ToIdentifier()); + }, isCheat: true)); + commands.Add(new Command("deathprompt", "Shows the death prompt for testing purposes.", (string[] args) => { DeathPrompt.Create(delay: 1.0f); @@ -2665,7 +2840,7 @@ namespace Barotrauma string[] lines; try { - lines = File.ReadAllLines(sourcePath); + lines = File.ReadAllLines(sourcePath, catchUnauthorizedAccessExceptions: false); } catch (Exception e) { @@ -2746,34 +2921,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 is not TraitorEventPrefab) { continue; } - 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 @@ -2783,10 +2984,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; @@ -2805,14 +3008,10 @@ 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}"; - if (!string.IsNullOrEmpty(text) && !text.Contains("EventText.", StringComparison.OrdinalIgnoreCase)) + if (!string.IsNullOrEmpty(text) && !text.StartsWith("EventText.", StringComparison.OrdinalIgnoreCase) && !text.StartsWith("Tutorial.", StringComparison.OrdinalIgnoreCase)) { if (existingTexts.TryGetValue(text, out string existingTextId)) { @@ -2982,9 +3181,18 @@ namespace Barotrauma })); #if DEBUG + commands.Add(new Command("playovervc", "Plays a sound over voice chat.", (args) => + { + VoipCapture.Instance?.SetOverrideSound(args.Length > 0 ? args[0] : null); + })); + commands.Add(new Command("checkduplicates", "Checks the given language for duplicate translation keys and writes to file.", (string[] args) => { - if (args.Length != 1) { return; } + if (args.Length != 1) + { + ThrowError("Please specify a language to check."); + return; + } TextManager.CheckForDuplicates(args[0].ToIdentifier().ToLanguageIdentifier()); })); @@ -3443,9 +3651,7 @@ namespace Barotrauma } RagdollParams ragdollParams = character.AnimController.RagdollParams; ragdollParams.LimbScale = MathHelper.Clamp(value, RagdollParams.MIN_SCALE, RagdollParams.MAX_SCALE); - var pos = character.WorldPosition; - character.AnimController.Recreate(); - character.TeleportTo(pos); + character.AnimController.RecreateAndRespawn(); }, isCheat: true)); commands.Add(new Command("jointscale", "Define the jointscale for the controlled character. Provide id or name if you want to target another character. Note: the changes are not saved!", (string[] args) => @@ -3468,9 +3674,7 @@ namespace Barotrauma } RagdollParams ragdollParams = character.AnimController.RagdollParams; ragdollParams.JointScale = MathHelper.Clamp(value, RagdollParams.MIN_SCALE, RagdollParams.MAX_SCALE); - var pos = character.WorldPosition; - character.AnimController.Recreate(); - character.TeleportTo(pos); + character.AnimController.RecreateAndRespawn(); }, isCheat: true)); commands.Add(new Command("ragdollscale", "Rescale the ragdoll of the controlled character. Provide id or name if you want to target another character. Note: the changes are not saved!", (string[] args) => @@ -3494,9 +3698,7 @@ namespace Barotrauma RagdollParams ragdollParams = character.AnimController.RagdollParams; ragdollParams.LimbScale = MathHelper.Clamp(value, RagdollParams.MIN_SCALE, RagdollParams.MAX_SCALE); ragdollParams.JointScale = MathHelper.Clamp(value, RagdollParams.MIN_SCALE, RagdollParams.MAX_SCALE); - var pos = character.WorldPosition; - character.AnimController.Recreate(); - character.TeleportTo(pos); + character.AnimController.RecreateAndRespawn(); }, isCheat: true)); commands.Add(new Command("recreateragdoll", "Recreate the ragdoll of the controlled character. Provide id or name if you want to target another character.", (string[] args) => @@ -3507,21 +3709,43 @@ namespace Barotrauma ThrowError("Not controlling any character!"); return; } - var pos = character.WorldPosition; - character.AnimController.Recreate(); - character.TeleportTo(pos); - }, isCheat: true)); + character.AnimController.RecreateAndRespawn(); + }, isCheat: true, + getValidArgs: () => new[] { GetSpawnedSpeciesNames() })); - commands.Add(new Command("resetragdoll", "Reset the ragdoll of the controlled character. Provide id or name if you want to target another character.", (string[] args) => + commands.Add(new Command("resetragdoll", "Reset the ragdoll of the controlled character (and all of the same species). Provide species name if you want to target another character.", (string[] args) => { - var character = (args.Length == 0) ? Character.Controlled : FindMatchingCharacter(args, true); - if (character == null) + IEnumerable characters; + if (args.Length == 0) { - ThrowError("Not controlling any character!"); + if (Character.Controlled == null) + { + ThrowError("Invalid species name! Press [TAB] to get valid options in this context."); + return; + } + // Reset all characters of the same species, because the same params affect them too. + characters = FindMatchingSpecies(Character.Controlled.SpeciesName.ToString()); + } + else + { + characters = FindMatchingSpecies(args); + } + if (characters.None()) + { + ThrowError("Invalid species name!"); return; } - character.AnimController.ResetRagdoll(forceReload: true); - }, isCheat: true)); + characters.ForEach(c => c.AnimController.ResetRagdoll()); + foreach (Character character in characters) + { + // Variant scale multiplier doesn't work without recreating the ragdoll. + if (!character.VariantOf.IsEmpty) + { + character.AnimController.RecreateAndRespawn(); + } + } + }, isCheat: true, + getValidArgs: () => new[] { GetSpawnedSpeciesNames() })); commands.Add(new Command("loadanimation", "Loads an animation variation by name for the controlled character. The animation file has to be in the correct animations folder. Note: the changes are not saved!", (string[] args) => { @@ -3545,6 +3769,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 => { @@ -3634,7 +3881,7 @@ namespace Barotrauma ThrowError("Cannot use the flipx command while playing online."); return; } - if (Submarine.MainSub.SubBody != null) { Submarine.MainSub?.FlipX(); } + if (Submarine.MainSub?.SubBody != null) { Submarine.MainSub.FlipX(); } }, isCheat: true)); commands.Add(new Command("head", "Load the head sprite and the wearables (hair etc). Required argument: head id. Optional arguments: hair index, beard index, moustache index, face attachment index.", args => @@ -3997,5 +4244,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/EventActions/ConversationAction.cs b/Barotrauma/BarotraumaClient/ClientSource/Events/EventActions/ConversationAction.cs index efbdb6712..3a7988c21 100644 --- a/Barotrauma/BarotraumaClient/ClientSource/Events/EventActions/ConversationAction.cs +++ b/Barotrauma/BarotraumaClient/ClientSource/Events/EventActions/ConversationAction.cs @@ -36,7 +36,8 @@ namespace Barotrauma { return lastActiveAction != null && - lastActiveAction.ParentEvent != ParentEvent && + !lastActiveAction.ParentEvent.IsFinished && + lastActiveAction.ParentEvent != ParentEvent && Timing.TotalTime < lastActiveAction.lastActiveTime + duration; } @@ -101,6 +102,7 @@ namespace Barotrauma conversationList.BarScroll = (prevSize - conversationList.Content.Rect.Height) / (conversationList.TotalSize - conversationList.Content.Rect.Height); conversationList.ScrollToEnd(duration: 0.5f); lastMessageBox.SetBackgroundIcon(eventSprite); + MarkMessageBoxAsLastAction(lastMessageBox); return; } } @@ -123,16 +125,7 @@ namespace Barotrauma messageBox.AutoClose = false; GUIStyle.Apply(messageBox.InnerFrame, "DialogBox"); - if (actionInstance != null) - { - lastActiveAction = actionInstance; - actionInstance.lastActiveTime = Timing.TotalTime; - actionInstance.dialogBox = messageBox; - } - else - { - messageBox.UserData = new Pair("ConversationAction", actionId.Value); - } + MarkMessageBoxAsLastAction(messageBox); int padding = GUI.IntScale(16); @@ -155,6 +148,20 @@ namespace Barotrauma }; shadow.SetAsFirstChild(); + void MarkMessageBoxAsLastAction(GUIMessageBox messageBox) + { + if (actionInstance != null) + { + lastActiveAction = actionInstance; + actionInstance.lastActiveTime = Timing.TotalTime; + actionInstance.dialogBox = messageBox; + } + else + { + messageBox.UserData = new Pair("ConversationAction", actionId.Value); + } + } + static void RecalculateLastMessage(GUIListBox conversationList, bool append) { if (conversationList.Content.Children.LastOrDefault() is GUILayoutGroup lastElement) diff --git a/Barotrauma/BarotraumaClient/ClientSource/Events/EventLog.cs b/Barotrauma/BarotraumaClient/ClientSource/Events/EventLog.cs index 0db59ddea..ce75680fb 100644 --- a/Barotrauma/BarotraumaClient/ClientSource/Events/EventLog.cs +++ b/Barotrauma/BarotraumaClient/ClientSource/Events/EventLog.cs @@ -48,6 +48,7 @@ partial class EventLog textContent, difficultyIconCount, icon, GUIStyle.Red, + difficultyTooltipText: null, out GUIImage missionIcon); if (traitorResults != null && diff --git a/Barotrauma/BarotraumaClient/ClientSource/Events/EventManager.cs b/Barotrauma/BarotraumaClient/ClientSource/Events/EventManager.cs index d5687b38a..e5540cfa8 100644 --- a/Barotrauma/BarotraumaClient/ClientSource/Events/EventManager.cs +++ b/Barotrauma/BarotraumaClient/ClientSource/Events/EventManager.cs @@ -549,6 +549,33 @@ namespace Barotrauma } public void ClientRead(IReadMessage msg) + { + if (GameMain.GameSession.IsRunning && !GameMain.Instance.LoadingScreenOpen) + { + ClientApplyNetworkMessage(msg); + } + else + { + //if the game session is not currently running (round still loading), + //we need to wait because the entities the status effect / conversation / etc targets may not exist yet + CoroutineManager.StartCoroutine(ApplyNetworkMessageWhenRoundLoaded(msg)); + } + } + + public IEnumerable ApplyNetworkMessageWhenRoundLoaded(IReadMessage msg) + { + while (GameMain.GameSession is { IsRunning: false } || GameMain.Instance.LoadingScreenOpen) + { + yield return new WaitForSeconds(1.0f); + } + if (GameMain.GameSession != null && GameMain.Client != null) + { + ClientApplyNetworkMessage(msg); + } + yield return CoroutineStatus.Success; + } + + public void ClientApplyNetworkMessage(IReadMessage msg) { NetworkEventType eventType = (NetworkEventType)msg.ReadByte(); switch (eventType) diff --git a/Barotrauma/BarotraumaClient/ClientSource/Events/Missions/AbandonedOutpostMission.cs b/Barotrauma/BarotraumaClient/ClientSource/Events/Missions/AbandonedOutpostMission.cs index fa15748e0..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 } @@ -59,10 +64,13 @@ namespace Barotrauma if (character.Submarine != null && character.AIController is EnemyAIController enemyAi) { enemyAi.UnattackableSubmarines.Add(character.Submarine); - enemyAi.UnattackableSubmarines.Add(Submarine.MainSub); - foreach (Submarine sub in Submarine.MainSub.DockedTo) + if (Submarine.MainSub != null) { - enemyAi.UnattackableSubmarines.Add(sub); + enemyAi.UnattackableSubmarines.Add(Submarine.MainSub); + foreach (Submarine sub in Submarine.MainSub.DockedTo) + { + enemyAi.UnattackableSubmarines.Add(sub); + } } } } diff --git a/Barotrauma/BarotraumaClient/ClientSource/Events/Missions/CombatMission.cs b/Barotrauma/BarotraumaClient/ClientSource/Events/Missions/CombatMission.cs index 291366b9b..6c92275b0 100644 --- a/Barotrauma/BarotraumaClient/ClientSource/Events/Missions/CombatMission.cs +++ b/Barotrauma/BarotraumaClient/ClientSource/Events/Missions/CombatMission.cs @@ -1,7 +1,17 @@ -namespace Barotrauma +using Barotrauma.Networking; +using Microsoft.Xna.Framework; +using System.Collections.Generic; + +namespace Barotrauma { partial class CombatMission : Mission { + private readonly Dictionary clientKills = new Dictionary(); + private readonly Dictionary clientDeaths = new Dictionary(); + + private readonly Dictionary botKills = new Dictionary(); + private readonly Dictionary botDeaths = new Dictionary(); + public override LocalizedString Description { get @@ -21,5 +31,93 @@ public override bool DisplayAsCompleted => false; public override bool DisplayAsFailed => false; + + public override IEnumerable<(LocalizedString Label, Vector2 Position)> SonarLabels + { + get + { + if (targetSubmarine == null) + { + yield break; + } + else + { + yield return (targetSubmarineSonarLabel is { Loaded: true } ? targetSubmarineSonarLabel : targetSubmarine.Info.DisplayName, targetSubmarine.WorldPosition); + } + } + } + + public static Color GetTeamColor(CharacterTeamType teamID) + { + if (teamID == CharacterTeamType.Team1) + { + return GUIStyle.GetComponentStyle("CoalitionIcon")?.Color ?? GUIStyle.Blue; + } + else if (teamID == CharacterTeamType.Team2) + { + return GUIStyle.GetComponentStyle("SeparatistIcon")?.Color ?? GUIStyle.Orange; + } + return Color.White; + } + + public int GetClientKillCount(Client client) + { + if (clientKills.TryGetValue(client.SessionId, out int kills)) + { + return kills; + } + return 0; + } + + public int GetClientDeathCount(Client client) + { + if (clientDeaths.TryGetValue(client.SessionId, out int deaths)) + { + return deaths; + } + return 0; + } + + public int GetBotKillCount(CharacterInfo botInfo) + { + if (botKills.TryGetValue(botInfo.ID, out int kills)) + { + return kills; + } + return 0; + } + + public int GetBotDeathCount(CharacterInfo botInfo) + { + if (botDeaths.TryGetValue(botInfo.ID, out int deaths)) + { + return deaths; + } + return 0; + } + + public override void ClientRead(IReadMessage msg) + { + base.ClientRead(msg); + Scores[0] = msg.ReadUInt16(); + Scores[1] = msg.ReadUInt16(); + + uint clientCount = msg.ReadVariableUInt32(); + for (int i = 0; i < clientCount; i++) + { + byte clientId = msg.ReadByte(); + clientDeaths[clientId] = (int)msg.ReadVariableUInt32(); + clientKills[clientId] = (int)msg.ReadVariableUInt32(); + } + + uint botCount = msg.ReadVariableUInt32(); + for (int i = 0; i < botCount; i++) + { + ushort botId = msg.ReadUInt16(); + botDeaths[botId] = (int)msg.ReadVariableUInt32(); + botKills[botId] = (int)msg.ReadVariableUInt32(); + } + + } } } diff --git a/Barotrauma/BarotraumaClient/ClientSource/Events/Missions/Mission.cs b/Barotrauma/BarotraumaClient/ClientSource/Events/Missions/Mission.cs index 9081d1751..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 { @@ -61,6 +60,16 @@ namespace Barotrauma return RichString.Rich(TextManager.GetWithVariable("missionreward", "[reward]", "‖color:gui.orange‖" + rewardText + "‖end‖")); } + public RichString GetDifficultyToolTipText() + { + // 2 skulls give +10% XP, 3 skulls +20% XP and 4 skulls give +30% XP. + float xpBonusMultiplier = CalculateDifficultyXPMultiplier(); + float xpBonusPercentage = (xpBonusMultiplier - 1f) * 100f; + int bonusRounded = (int)Math.Round(xpBonusPercentage); + LocalizedString tooltipText = TextManager.GetWithVariable(tag: "missiondifficultyxpbonustooltip", varName: "[bonus]", value: bonusRounded.ToString()); + return RichString.Rich(tooltipText); + } + public RichString GetReputationRewardText() { List reputationRewardTexts = new List(); @@ -117,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/MissionPrefab.cs b/Barotrauma/BarotraumaClient/ClientSource/Events/Missions/MissionPrefab.cs index 0bddcc04f..e69cbb887 100644 --- a/Barotrauma/BarotraumaClient/ClientSource/Events/Missions/MissionPrefab.cs +++ b/Barotrauma/BarotraumaClient/ClientSource/Events/Missions/MissionPrefab.cs @@ -49,7 +49,8 @@ namespace Barotrauma { return hudIconColor ?? IconColor; } - } + } + public Color ProgressBarColor { get; private set; } private Sprite hudIcon; private Color? hudIconColor; @@ -90,6 +91,7 @@ namespace Barotrauma } this.portraits = portraits.ToImmutableArray(); overrideMusicOnState = overrideMusic.ToImmutableDictionary(); + ProgressBarColor = element.GetAttributeColor(nameof(ProgressBarColor), GUIStyle.Blue); } public Identifier GetOverrideMusicType(int state) 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/Events/Missions/ScanMission.cs b/Barotrauma/BarotraumaClient/ClientSource/Events/Missions/ScanMission.cs index 291de4759..7eb69a5e6 100644 --- a/Barotrauma/BarotraumaClient/ClientSource/Events/Missions/ScanMission.cs +++ b/Barotrauma/BarotraumaClient/ClientSource/Events/Missions/ScanMission.cs @@ -7,20 +7,7 @@ namespace Barotrauma { partial class ScanMission : Mission { - public override IEnumerable HudIconTargets - { - get - { - if (State == 0) - { - return scanTargets.Where(kvp => !kvp.Value).Select(kvp => kvp.Key); - } - else - { - return Enumerable.Empty(); - } - } - } + public override IEnumerable HudIconTargets => scanTargets.Where(kvp => !kvp.Value).Select(kvp => kvp.Key); public override bool DisplayAsCompleted => false; public override bool DisplayAsFailed => false; @@ -62,7 +49,7 @@ namespace Barotrauma ushort id = msg.ReadUInt16(); bool scanned = msg.ReadBoolean(); Entity entity = Entity.FindEntityByID(id); - if (!(entity is WayPoint wayPoint)) + if (entity is not WayPoint wayPoint) { string errorMsg = $"Failed to find a waypoint in ScanMission.ClientReadScanTargetStatus. Entity {id} was {(entity?.ToString() ?? null)}"; DebugConsole.ThrowError(errorMsg); diff --git a/Barotrauma/BarotraumaClient/ClientSource/Fonts/ScalableFont.cs b/Barotrauma/BarotraumaClient/ClientSource/Fonts/ScalableFont.cs index 5a41e279f..40532c06a 100644 --- a/Barotrauma/BarotraumaClient/ClientSource/Fonts/ScalableFont.cs +++ b/Barotrauma/BarotraumaClient/ClientSource/Fonts/ScalableFont.cs @@ -419,8 +419,7 @@ namespace Barotrauma if (anyChanges) { textures[^1].SetData(currentDynamicPixelBuffer); } } } - - // TODO: refactor this further + private void HandleNewLineAndAlignment( string text, in Vector2 advanceUnit, @@ -435,23 +434,29 @@ namespace Barotrauma out uint charIndex, out bool shouldContinue) { - if ((alignment.HasFlag(Alignment.CenterX) || alignment.HasFlag(Alignment.Right)) && (lineWidth < 0.0f || text[i] == '\n')) + if (lineWidth < 0.0f || text[i] == '\n') { - int startIndex = lineWidth < 0.0f ? i : (i + 1); - lineWidth = 0.0f; - for (int j = startIndex; j < text.Length; j++) + // Use bitwise operations instead of HasFlag or HasAnyFlag to avoid boxing, as this is performance-sensitive code. + bool isHorizontallyCentered = (alignment & Alignment.CenterX) == Alignment.CenterX; + bool isAlignedToRight = (alignment & Alignment.Right) == Alignment.Right; + if (isHorizontallyCentered || isAlignedToRight) { - if (text[j] == '\n') { break; } - uint chrIndex = text[j]; + int startIndex = lineWidth < 0.0f ? i : (i + 1); + lineWidth = 0.0f; + for (int j = startIndex; j < text.Length; j++) + { + if (text[j] == '\n') { break; } + uint chrIndex = text[j]; - var gd2 = GetGlyphData(chrIndex); - lineWidth += gd2.Advance; + var gd2 = GetGlyphData(chrIndex); + lineWidth += gd2.Advance; + } + currentLineOffset = -lineWidth * advanceUnit * scale.X; + if (isHorizontallyCentered) { currentLineOffset *= 0.5f; } + + currentLineOffset.X = MathF.Round(currentLineOffset.X); + currentLineOffset.Y = MathF.Round(currentLineOffset.Y); } - currentLineOffset = -lineWidth * advanceUnit * scale.X; - if (alignment.HasFlag(Alignment.CenterX)) { currentLineOffset *= 0.5f; } - - currentLineOffset.X = MathF.Round(currentLineOffset.X); - currentLineOffset.Y = MathF.Round(currentLineOffset.Y); } if (text[i] == '\n') { @@ -493,7 +498,7 @@ namespace Barotrauma int lineNum = 0; Vector2 currentPos = position; - Vector2 advanceUnit = rotation == 0.0f ? Vector2.UnitX : new Vector2((float)Math.Cos(rotation), (float)Math.Sin(rotation)); + Vector2 advanceUnit = rotation == 0.0f ? Vector2.UnitX : new Vector2(MathF.Cos(rotation), MathF.Sin(rotation)); for (int i = 0; i < text.Length; i++) { HandleNewLineAndAlignment(text, advanceUnit, position, scale, alignment, i, @@ -504,7 +509,7 @@ namespace Barotrauma GlyphData gd = GetGlyphData(charIndex); if (gd.TexIndex >= 0) { - if (gd.TexIndex < 0 || gd.TexIndex >= textures.Count) + if (gd.TexIndex >= textures.Count) { throw new ArgumentOutOfRangeException($"Error while rendering text. Texture index was out of range. Text: {text}, char: {charIndex} index: {gd.TexIndex}, texture count: {textures.Count}"); } @@ -542,6 +547,11 @@ namespace Barotrauma DynamicRenderAtlas(graphicsDevice, text); } + quadVertices[0].Color = color; + quadVertices[1].Color = color; + quadVertices[2].Color = color; + quadVertices[3].Color = color; + Vector2 currentPos = position; for (int i = 0; i < text.Length; i++) { @@ -558,26 +568,33 @@ namespace Barotrauma if (gd.TexIndex >= 0) { float halfCharHeight = gd.TexCoords.Height * 0.5f; - float slantStrength = 0.35f; - float topItalicOffset = italics ? ((halfCharHeight - gd.DrawOffset.Y) * slantStrength) + baseHeight * 0.18f : 0.0f; - float bottomItalicOffset = italics ? ((-halfCharHeight - gd.DrawOffset.Y) * slantStrength) + baseHeight * 0.18f : 0.0f; - + const float slantStrength = 0.35f; + float topItalicOffset = 0.0f; + float bottomItalicOffset = 0.0f; + if (italics) + { + topItalicOffset = ((halfCharHeight - gd.DrawOffset.Y) * slantStrength) + baseHeight * 0.18f; + bottomItalicOffset = ((-halfCharHeight - gd.DrawOffset.Y) * slantStrength) + baseHeight * 0.18f; + } + Texture2D tex = textures[gd.TexIndex]; + + float left = (float)gd.TexCoords.Left / tex.Width; + float bottom = (float)gd.TexCoords.Bottom / tex.Height; + float top = (float)gd.TexCoords.Top / tex.Height; + float right = (float)gd.TexCoords.Right / tex.Width; + quadVertices[0].Position = new Vector3(currentPos + gd.DrawOffset + (bottomItalicOffset, gd.TexCoords.Height), 0.0f); - quadVertices[0].TextureCoordinate = ((float)gd.TexCoords.Left / tex.Width, (float)gd.TexCoords.Bottom / tex.Height); - quadVertices[0].Color = color; + quadVertices[0].TextureCoordinate = new Vector2(left, bottom); quadVertices[1].Position = new Vector3(currentPos + gd.DrawOffset + (topItalicOffset, 0.0f), 0.0f); - quadVertices[1].TextureCoordinate = ((float)gd.TexCoords.Left / tex.Width, (float)gd.TexCoords.Top / tex.Height); - quadVertices[1].Color = color; + quadVertices[1].TextureCoordinate = new Vector2(left, top); quadVertices[2].Position = new Vector3(currentPos + gd.DrawOffset + (gd.TexCoords.Width + bottomItalicOffset, gd.TexCoords.Height), 0.0f); - quadVertices[2].TextureCoordinate = ((float)gd.TexCoords.Right / tex.Width, (float)gd.TexCoords.Bottom / tex.Height); - quadVertices[2].Color = color; + quadVertices[2].TextureCoordinate = new Vector2(right, bottom); quadVertices[3].Position = new Vector3(currentPos + gd.DrawOffset + (gd.TexCoords.Width + topItalicOffset, 0.0f), 0.0f); - quadVertices[3].TextureCoordinate = ((float)gd.TexCoords.Right / tex.Width, (float)gd.TexCoords.Top / tex.Height); - quadVertices[3].Color = color; + quadVertices[3].TextureCoordinate = new Vector2(right, top); sb.Draw(tex, quadVertices, 0.0f); } diff --git a/Barotrauma/BarotraumaClient/ClientSource/GUI/ChatBox.cs b/Barotrauma/BarotraumaClient/ClientSource/GUI/ChatBox.cs index 82b582124..c45345891 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() @@ -796,6 +819,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 f1cc3b397..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 @@ -58,7 +59,7 @@ internal class DeathPrompt const float FadeInDuration = 1.0f; bool permadeath = GameMain.NetworkMember is { ServerSettings.RespawnMode: RespawnMode.Permadeath }; - bool ironman = GameMain.NetworkMember is { ServerSettings: { RespawnMode: RespawnMode.Permadeath, IronmanMode: true } }; + bool ironman = GameMain.NetworkMember is { ServerSettings.IronmanModeActive: true }; var background = new GUICustomComponent(new RectTransform(Vector2.One, GUI.Canvas, Anchor.Center), onDraw: DrawBackground) { @@ -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/FileSelection.cs b/Barotrauma/BarotraumaClient/ClientSource/GUI/FileSelection.cs index d0b10b1e3..c53cc34d1 100644 --- a/Barotrauma/BarotraumaClient/ClientSource/GUI/FileSelection.cs +++ b/Barotrauma/BarotraumaClient/ClientSource/GUI/FileSelection.cs @@ -4,7 +4,6 @@ using System; using System.Collections.Generic; using Barotrauma.IO; using System.Linq; -using System.Text; using Barotrauma.Extensions; namespace Barotrauma @@ -84,16 +83,31 @@ namespace Barotrauma { currentDirectory += "/"; } - fileSystemWatcher?.Dispose(); - fileSystemWatcher = new System.IO.FileSystemWatcher(currentDirectory) + try { - Filter = "*", - NotifyFilter = System.IO.NotifyFilters.LastWrite | System.IO.NotifyFilters.FileName | System.IO.NotifyFilters.DirectoryName - }; - fileSystemWatcher.Created += OnFileSystemChanges; - fileSystemWatcher.Deleted += OnFileSystemChanges; - fileSystemWatcher.Renamed += OnFileSystemChanges; - fileSystemWatcher.EnableRaisingEvents = true; + fileSystemWatcher?.Dispose(); + fileSystemWatcher = new System.IO.FileSystemWatcher(currentDirectory) + { + Filter = "*", + NotifyFilter = System.IO.NotifyFilters.LastWrite | System.IO.NotifyFilters.FileName | System.IO.NotifyFilters.DirectoryName + }; + fileSystemWatcher.Created += OnFileSystemChanges; + fileSystemWatcher.Deleted += OnFileSystemChanges; + fileSystemWatcher.Renamed += OnFileSystemChanges; + fileSystemWatcher.EnableRaisingEvents = true; + } + catch (System.IO.FileNotFoundException exception) + { + DebugConsole.ThrowError("Failed to set the current directory, possibly due to insufficient access permissions.", exception); + } + catch (ArgumentException exception) + { + DebugConsole.ThrowError("Failed to set the current directory, possibly because it was deleted.", exception); + } + catch (Exception exception) + { + DebugConsole.ThrowError("Failed to set the current directory for an unknown reason.", exception); + } RefreshFileList(); } } @@ -218,6 +232,14 @@ namespace Barotrauma { if (Directory.Exists(txt)) { + var attributes = System.IO.File.GetAttributes(txt); + if (attributes.HasAnyFlag(System.IO.FileAttributes.System) || attributes.HasAnyFlag(System.IO.FileAttributes.Hidden)) + { + // System and hidden folders should be filtered out when populating the options, but the user can still write or copy-paste the path in the text field, + // which will throw a file not found exception when the file system watcher starts. Therefore, this extra check. + tb.Text = CurrentDirectory; + return false; + } CurrentDirectory = txt; return true; } @@ -354,20 +376,19 @@ namespace Barotrauma var directories = Directory.EnumerateDirectories(currentDirectory, "*" + filterBox!.Text + "*"); foreach (var directory in directories) { - string txt = directory; - if (txt.StartsWith(currentDirectory)) { txt = txt.Substring(currentDirectory.Length); } - if (!txt.EndsWith("/")) { txt += "/"; } - //get directory info - DirectoryInfo dirInfo = new DirectoryInfo(directory); try { - //this will throw an exception if the directory can't be opened - Directory.GetDirectories(directory); + //this will intentionally throw an exception if the directory can't be opened + System.IO.Directory.GetDirectories(directory); } catch (UnauthorizedAccessException) { + // Skip the folders that can't be accessed. continue; } + string txt = directory; + if (txt.StartsWith(currentDirectory)) { txt = txt.Substring(currentDirectory.Length); } + if (!txt.EndsWith("/")) { txt += "/"; } var itemFrame = new GUITextBlock(new RectTransform(new Vector2(1.0f, 0.05f), fileList.Content.RectTransform), txt) { UserData = ItemIsDirectory.Yes diff --git a/Barotrauma/BarotraumaClient/ClientSource/GUI/GUI.cs b/Barotrauma/BarotraumaClient/ClientSource/GUI/GUI.cs index e03511a3e..bc88aaa37 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 { @@ -469,7 +492,7 @@ namespace Barotrauma "Loaded sounds: " + GameMain.SoundManager.LoadedSoundCount + " (" + GameMain.SoundManager.UniqueLoadedSoundCount + " unique)", Color.White, Color.Black * 0.5f, 0, GUIStyle.SmallFont); soundTextY += yStep; - for (int i = 0; i < SoundManager.SOURCE_COUNT; i++) + for (int i = 0; i < SoundManager.SourceCount; i++) { Color clr = Color.White; string soundStr = i + ": "; @@ -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()) @@ -1546,7 +1573,7 @@ namespace Barotrauma private static readonly VertexPositionColorTexture[] donutVerts = new VertexPositionColorTexture[DonutSegments * 4]; public static void DrawDonutSection( - SpriteBatch sb, Vector2 center, Range radii, float sectionRad, Color clr, float depth = 0.0f) + SpriteBatch sb, Vector2 center, Range radii, float sectionRad, Color clr, float depth = 0.0f, float rotationRad = 0.0f) { float getRadius(int vertexIndex) => (vertexIndex % 4) switch @@ -1589,7 +1616,7 @@ namespace Barotrauma for (int vertexIndex = 0; vertexIndex < maxDirectionIndex * 4; vertexIndex++) { donutVerts[vertexIndex].Color = clr; - donutVerts[vertexIndex].Position = new Vector3(center + getDirection(vertexIndex) * getRadius(vertexIndex), 0.0f); + donutVerts[vertexIndex].Position = new Vector3(center + Vector2.Transform(getDirection(vertexIndex) * getRadius(vertexIndex), Matrix.CreateRotationZ(rotationRad)), 0.0f); } sb.Draw(solidWhiteTexture, donutVerts, depth, count: maxDirectionIndex); } @@ -1856,9 +1883,16 @@ namespace Barotrauma Vector2 pos = new Vector2(GameMain.GraphicsWidth, GameMain.GraphicsHeight) - new Vector2(HUDLayoutSettings.Padding) - 2 * Scale * sheet.FrameSize.ToVector2(); sheet.Draw(spriteBatch, (int)Math.Floor(savingIndicatorSpriteIndex), pos, savingIndicatorColor, origin: Vector2.Zero, rotate: 0.0f, scale: new Vector2(Scale)); } -#endregion -#region Element creation + public static void DrawCapsule(SpriteBatch sb, Vector2 origin, float length, float radius, float rotation, Color clr, float depth = 0, float thickness = 1) + { + DrawDonutSection(sb, origin + Vector2.Transform(-new Vector2(length / 2, 0), Matrix.CreateRotationZ(rotation)), new Range(radius - thickness / 2, radius + thickness / 2), MathHelper.Pi, clr, depth, rotation - MathHelper.Pi); + DrawRectangle(sb, origin, new Vector2(length, radius * 2), new Vector2(length / 2, radius), rotation, clr, depth, thickness); + DrawDonutSection(sb, origin + Vector2.Transform(new Vector2(length / 2, 0), Matrix.CreateRotationZ(rotation)), new Range(radius - thickness / 2, radius + thickness / 2), MathHelper.Pi, clr, depth, rotation); + } + #endregion + + #region Element creation public static Texture2D CreateCircle(int radius, bool filled = false) { @@ -2488,7 +2522,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); @@ -2524,23 +2563,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); + } } } @@ -2568,9 +2621,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) => { @@ -2586,25 +2639,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) @@ -2690,12 +2747,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/GUIButton.cs b/Barotrauma/BarotraumaClient/ClientSource/GUI/GUIButton.cs index c5e1a2b81..da33ae1cf 100644 --- a/Barotrauma/BarotraumaClient/ClientSource/GUI/GUIButton.cs +++ b/Barotrauma/BarotraumaClient/ClientSource/GUI/GUIButton.cs @@ -159,6 +159,34 @@ namespace Barotrauma } } + private GUIComponent holdOverlay; + + private bool requireHold; + public bool RequireHold + { + get => requireHold; + set + { + requireHold = value; + if (value) + { + holdOverlay ??= new GUIFrame(new RectTransform(new Vector2(0.5f, 1f), Frame.RectTransform, Anchor.CenterLeft), style: null) + { + Color = GUIStyle.Yellow * 0.33f, + CanBeFocused = false, + IgnoreLayoutGroups = true, + Visible = true + }; + } + else if (holdOverlay != null) + { + holdOverlay.Visible = false; + } + } + } + public float HoldDurationSeconds { get; set; } = 5f; + private float holdTimer; + public bool Pulse { get; set; } private float pulseTimer; private float pulseExpand; @@ -220,7 +248,7 @@ namespace Barotrauma Rectangle expandRect = Rect; float expand = (pulseExpand * 20.0f) * GUI.Scale; expandRect.Inflate(expand, expand); - + GUIStyle.EndRoundButtonPulse.Draw(spriteBatch, expandRect, ToolBox.GradientLerp(pulseExpand, Color.White, Color.White, Color.Transparent)); } } @@ -240,6 +268,11 @@ namespace Barotrauma } if (PlayerInput.PrimaryMouseButtonHeld()) { + if (RequireHold) + { + holdTimer += deltaTime; + } + if (OnPressed != null) { if (OnPressed()) @@ -254,25 +287,34 @@ namespace Barotrauma } else if (PlayerInput.PrimaryMouseButtonClicked()) { - if (PlaySoundOnSelect) + if (!RequireHold || holdTimer > HoldDurationSeconds) { - SoundPlayer.PlayUISound(ClickSound); - } - if (OnClicked != null) - { - if (OnClicked(this, UserData)) + if (PlaySoundOnSelect) { - State = ComponentState.Selected; + SoundPlayer.PlayUISound(ClickSound); + } + + if (OnClicked != null) + { + if (OnClicked(this, UserData)) + { + State = ComponentState.Selected; + } + } + else + { + Selected = !Selected; } } - else - { - Selected = !Selected; - } + } + else + { + holdTimer = 0.0f; } } else { + holdTimer = 0.0f; if (!ExternalHighlight) { State = Selected ? ComponentState.Selected : ComponentState.None; @@ -283,6 +325,20 @@ namespace Barotrauma } } + if (RequireHold) + { + float width = MathHelper.Clamp(holdTimer / HoldDurationSeconds, 0f, 1f); + if (!MathUtils.NearlyEqual(width, holdOverlay.RectTransform.RelativeSize.X)) + { + holdOverlay.RectTransform.RelativeSize = new Vector2(width, 1f); + } + + holdOverlay.Color = + holdTimer >= HoldDurationSeconds + ? Color.Green * 0.33f + : Color.Red * 0.33f; + } + foreach (GUIComponent child in Children) { child.State = State; diff --git a/Barotrauma/BarotraumaClient/ClientSource/GUI/GUIComponent.cs b/Barotrauma/BarotraumaClient/ClientSource/GUI/GUIComponent.cs index 8f58c3c11..2abaf9b71 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; @@ -246,6 +253,57 @@ namespace Barotrauma { get { return new Vector2(Rect.Center.X, Rect.Center.Y); } } + + /// + /// Clamps the component's rect position to the specified area. Does not resize the component. + /// + /// Area to contain the Rect of this component to + public void ClampToArea(Rectangle clampArea) + { + Rectangle componentRect = Rect; + + int x = componentRect.X; + int y = componentRect.Y; + + // Adjust the X position + if (componentRect.Width <= clampArea.Width) + { + if (componentRect.Left < clampArea.Left) + { + x = clampArea.Left; + } + else if (componentRect.Right > clampArea.Right) + { + x = clampArea.Right - componentRect.Width; + } + } + else + { + // Component is wider than clamp area, osition it to overlap as much as possible + x = clampArea.Left - (componentRect.Width - clampArea.Width) / 2; + } + + // Adjust the Y position + if (componentRect.Height <= clampArea.Height) + { + if (componentRect.Top < clampArea.Top) + { + y = clampArea.Top; + } + else if (componentRect.Bottom > clampArea.Bottom) + { + y = clampArea.Bottom - componentRect.Height; + } + } + else + { + // Component is taller than clamp area, osition it to overlap as much as possible + y = clampArea.Top - (componentRect.Height - clampArea.Height) / 2; + } + + Point moveAmount = new Point(x - componentRect.X, y - componentRect.Y); + RectTransform.ScreenSpaceOffset += moveAmount; + } protected Rectangle ClampRect(Rectangle r) { @@ -1087,6 +1145,8 @@ namespace Barotrauma FromXML(subElement, component is GUIListBox listBox ? listBox.Content.RectTransform : component.RectTransform); } + component.toolTip = element.GetAttributeString("tooltip", string.Empty); + if (element.GetAttributeBool("resizetofitchildren", false)) { Vector2 relativeResizeScale = element.GetAttributeVector2("relativeresizescale", Vector2.One); @@ -1129,7 +1189,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()) @@ -1171,6 +1232,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; } } diff --git a/Barotrauma/BarotraumaClient/ClientSource/GUI/GUIDropDown.cs b/Barotrauma/BarotraumaClient/ClientSource/GUI/GUIDropDown.cs index 5185b7b63..3116cfd81 100644 --- a/Barotrauma/BarotraumaClient/ClientSource/GUI/GUIDropDown.cs +++ b/Barotrauma/BarotraumaClient/ClientSource/GUI/GUIDropDown.cs @@ -9,8 +9,21 @@ namespace Barotrauma { public class GUIDropDown : GUIComponent, IKeyboardSubscriber { + /// The component that was selected from the dropdown. + /// of the component selected from the dropdown. public delegate bool OnSelectedHandler(GUIComponent selected, object obj = null); + /// + /// Triggers when some item is cliecked from the dropdown. + /// Note that is not set yet when this callback triggers, and returning false from the callback disallows selecting it. + /// If you want to access the new value, use the obj argument. + /// public OnSelectedHandler OnSelected; + + /// + /// Triggers after an item has been selected from the dropdown, all validation has been done and the new value has been set. + /// + public OnSelectedHandler AfterSelected; + public OnSelectedHandler OnDropped; private readonly GUIButton button; @@ -166,7 +179,7 @@ namespace Barotrauma public Vector4 Padding => button.TextBlock.Padding; - public GUIDropDown(RectTransform rectT, LocalizedString text = null, int elementCount = 4, string style = "", bool selectMultiple = false, bool dropAbove = false, Alignment textAlignment = Alignment.CenterLeft) : base(style, rectT) + public GUIDropDown(RectTransform rectT, LocalizedString text = null, int elementCount = 4, string style = "", bool selectMultiple = false, bool dropAbove = false, Alignment textAlignment = Alignment.CenterLeft, float listBoxScale = 1) : base(style, rectT) { text ??= LocalizedString.EmptyString; @@ -185,13 +198,21 @@ namespace Barotrauma Anchor listAnchor = dropAbove ? Anchor.TopCenter : Anchor.BottomCenter; Pivot listPivot = dropAbove ? Pivot.BottomCenter : Pivot.TopCenter; - listBox = new GUIListBox(new RectTransform(new Point(Rect.Width, Rect.Height * MathHelper.Clamp(elementCount, 2, 10)), rectT, listAnchor, listPivot) + listBox = new GUIListBox(new RectTransform(new Point((int)(Rect.Width * listBoxScale), Rect.Height * MathHelper.Clamp(elementCount, 2, 10)), rectT, listAnchor, listPivot) { IsFixedSize = false }, style: null) { Enabled = !selectMultiple, PlaySoundOnSelect = true, }; - if (!selectMultiple) { listBox.OnSelected = SelectItem; } + if (!selectMultiple) + { + listBox.AfterSelected = (component, obj) => + { + SelectItem(component, obj); + AfterSelected?.Invoke(component, obj); + return true; + }; + } GUIStyle.Apply(listBox, "GUIListBox", this); GUIStyle.Apply(listBox.ContentBackground, "GUIListBox", this); @@ -199,6 +220,8 @@ namespace Barotrauma { icon = new GUIImage(new RectTransform(new Vector2(0.6f, 0.6f), button.RectTransform, Anchor.CenterRight, scaleBasis: ScaleBasis.BothHeight) { AbsoluteOffset = new Point(5, 0) }, null, scaleToFit: true); icon.ApplyStyle(button.Style.ChildStyles["dropdownicon".ToIdentifier()]); + //move the text away from the icon + button.TextBlock.Padding += new Vector4(0, 0, icon.Rect.Width, 0); } currentHighestParent = FindHighestParent(); @@ -249,12 +272,12 @@ namespace Barotrauma toolTip ??= ""; if (selectMultiple) { - var frame = new GUIFrame(new RectTransform(new Point(button.Rect.Width, button.Rect.Height), listBox.Content.RectTransform) { IsFixedSize = false }, style: "ListBoxElement", color: color) + var frame = new GUIFrame(new RectTransform(new Point(listBox.Content.Rect.Width, button.Rect.Height), listBox.Content.RectTransform) { IsFixedSize = false }, style: "ListBoxElement", color: color) { UserData = userData, ToolTip = toolTip }; - new GUITickBox(new RectTransform(new Vector2(1.0f, 0.8f), frame.RectTransform, anchor: Anchor.CenterLeft) { MaxSize = new Point(int.MaxValue, (int)(button.Rect.Height * 0.8f)) }, text) + var tickBox = new GUITickBox(new RectTransform(new Vector2(1.0f, 0.8f), frame.RectTransform, anchor: Anchor.CenterLeft) { MaxSize = new Point(int.MaxValue, (int)(button.Rect.Height * 0.8f)) }, text) { UserData = userData, ToolTip = toolTip, @@ -266,6 +289,11 @@ namespace Barotrauma return false; } + if (OnSelected != null && !OnSelected.Invoke(tb.Parent, tb.Parent.UserData)) + { + return false; + } + List texts = new List(); selectedDataMultiple.Clear(); selectedIndexMultiple.Clear(); @@ -282,8 +310,7 @@ namespace Barotrauma i++; } button.Text = LocalizedString.Join(", ", texts); - // TODO: The callback is called at least twice, remove this? - OnSelected?.Invoke(tb.Parent, tb.Parent.UserData); + AfterSelected?.Invoke(tb.Parent, SelectedData); return true; } }; @@ -291,7 +318,7 @@ namespace Barotrauma } else { - return new GUITextBlock(new RectTransform(new Point(button.Rect.Width, button.Rect.Height), listBox.Content.RectTransform) { IsFixedSize = false }, text, style: "ListBoxElement", color: color, textColor: textColor) + return new GUITextBlock(new RectTransform(new Point(listBox.Content.Rect.Width, button.Rect.Height), listBox.Content.RectTransform) { IsFixedSize = false }, text, style: "ListBoxElement", color: color, textColor: textColor) { UserData = userData, ToolTip = toolTip @@ -328,9 +355,8 @@ namespace Barotrauma } button.Text = textBlock?.Text ?? ""; } + OnSelected?.Invoke(component, obj); Dropped = false; - // TODO: OnSelected can be called multiple times and when it shouldn't be called -> turn into an event so that nobody else can call it. - OnSelected?.Invoke(component, component.UserData); return true; } @@ -344,6 +370,7 @@ namespace Barotrauma { listBox.Select(userData); } + AfterSelected?.Invoke(SelectedComponent, SelectedData); } public void Select(int index) @@ -360,6 +387,7 @@ namespace Barotrauma { listBox.Select(index); } + AfterSelected?.Invoke(this, SelectedData); } private bool wasOpened; diff --git a/Barotrauma/BarotraumaClient/ClientSource/GUI/GUIListBox.cs b/Barotrauma/BarotraumaClient/ClientSource/GUI/GUIListBox.cs index aa1a6a882..a96e57bf9 100644 --- a/Barotrauma/BarotraumaClient/ClientSource/GUI/GUIListBox.cs +++ b/Barotrauma/BarotraumaClient/ClientSource/GUI/GUIListBox.cs @@ -14,8 +14,17 @@ namespace Barotrauma protected List selected; public delegate bool OnSelectedHandler(GUIComponent component, object obj); + /// + /// Triggers when some element is clicked on the listbox. + /// Note that is not set yet when this callback triggers, and returning false from the callback disallows selecting it. + /// public OnSelectedHandler OnSelected; + /// + /// Triggers after some element has been selected from the listbox. + /// + public OnSelectedHandler AfterSelected; + public delegate object CheckSelectedHandler(); public CheckSelectedHandler CheckSelected; @@ -1021,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) @@ -1040,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) @@ -1151,6 +1160,8 @@ namespace Barotrauma { SoundPlayer.PlayUISound(GUISoundType.Select); } + + AfterSelected?.Invoke(child, SelectedData); } public void Select(IEnumerable children) @@ -1160,8 +1171,9 @@ namespace Barotrauma selected.Clear(); selected.AddRange(children.Where(c => Content.Children.Contains(c))); foreach (var child in selected) { OnSelected?.Invoke(child, child.UserData); } + AfterSelected?.Invoke(children.FirstOrDefault(), SelectedData); } - + public void Deselect() { Selected = false; @@ -1172,6 +1184,15 @@ namespace Barotrauma selected.Clear(); } + public void DeselectElement(GUIComponent child) + { + if (child == null) { return; } + if (selected.Contains(child)) + { + selected.Remove(child); + } + } + public void UpdateScrollBarSize() { scrollBarNeedsRecalculation = false; @@ -1268,7 +1289,7 @@ namespace Barotrauma ContentBackground.DrawManually(spriteBatch, alsoChildren: false); Rectangle prevScissorRect = spriteBatch.GraphicsDevice.ScissorRectangle; - if (HideChildrenOutsideFrame) + if (HideChildrenOutsideFrame && Content.CountChildren > 0) { spriteBatch.End(); spriteBatch.GraphicsDevice.ScissorRectangle = Rectangle.Intersect(prevScissorRect, Content.Rect); @@ -1306,7 +1327,7 @@ namespace Barotrauma GUI.DrawRectangle(spriteBatch, drawRect, Color.White * 0.5f, thickness: 2f); } - if (HideChildrenOutsideFrame) + if (HideChildrenOutsideFrame && Content.CountChildren > 0) { spriteBatch.End(); spriteBatch.GraphicsDevice.ScissorRectangle = prevScissorRect; 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/GUIStyle.cs b/Barotrauma/BarotraumaClient/ClientSource/GUI/GUIStyle.cs index ffb851a97..019548557 100644 --- a/Barotrauma/BarotraumaClient/ClientSource/GUI/GUIStyle.cs +++ b/Barotrauma/BarotraumaClient/ClientSource/GUI/GUIStyle.cs @@ -51,6 +51,16 @@ namespace Barotrauma public readonly static GUISprite InteractionLabelBackground = new GUISprite("InteractionLabelBackground"); public readonly static GUISprite BrokenIcon = new GUISprite("BrokenIcon"); public readonly static GUISprite YouAreHereCircle = new GUISprite("YouAreHereCircle"); + + public readonly static GUISprite SubLocationIcon = new GUISprite("SubLocationIcon"); + public readonly static GUISprite ShuttleIcon = new GUISprite("ShuttleIcon"); + public readonly static GUISprite WreckIcon = new GUISprite("WreckIcon"); + public readonly static GUISprite CaveIcon = new GUISprite("CaveIcon"); + public readonly static GUISprite OutpostIcon = new GUISprite("OutpostIcon"); + public readonly static GUISprite RuinIcon = new GUISprite("RuinIcon"); + public readonly static GUISprite EnemyIcon = new GUISprite("EnemyIcon"); + public readonly static GUISprite CorpseIcon = new GUISprite("CorpseIcon"); + public readonly static GUISprite BeaconIcon = new GUISprite("BeaconIcon"); public readonly static GUISprite Radiation = new GUISprite("Radiation"); public readonly static GUISpriteSheet RadiationAnimSpriteSheet = new GUISpriteSheet("RadiationAnimSpriteSheet"); @@ -71,7 +81,7 @@ namespace Barotrauma public readonly static GUISprite EndRoundButtonPulse = new GUISprite("EndRoundButtonPulse"); public readonly static GUISpriteSheet FocusIndicator = new GUISpriteSheet("FocusIndicator"); - + public readonly static GUISprite IconOverflowIndicator = new GUISprite("IconOverflowIndicator"); /// diff --git a/Barotrauma/BarotraumaClient/ClientSource/GUI/GUITextBox.cs b/Barotrauma/BarotraumaClient/ClientSource/GUI/GUITextBox.cs index 7dca6fcbc..3d0299054 100644 --- a/Barotrauma/BarotraumaClient/ClientSource/GUI/GUITextBox.cs +++ b/Barotrauma/BarotraumaClient/ClientSource/GUI/GUITextBox.cs @@ -918,5 +918,12 @@ namespace Barotrauma DebugConsole.ThrowError($"GUITextBox: Invalid selection: ({exception})"); } } + + public void ResetDelegates() + { + OnKeyHit = null; + OnEnterPressed = null; + OnTextChanged = null; + } } } diff --git a/Barotrauma/BarotraumaClient/ClientSource/GUI/HRManagerUI.cs b/Barotrauma/BarotraumaClient/ClientSource/GUI/HRManagerUI.cs index b5e0531d3..de339e07e 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() + { + return (PendingHires.Count(ci => ci.BotStatus == BotStatus.PendingHireToActiveService) + 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/Store.cs b/Barotrauma/BarotraumaClient/ClientSource/GUI/Store.cs index 372f1d499..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 @@ -1562,7 +1563,7 @@ namespace Barotrauma bool locationHasDealOnItem = isSellingRelatedList ? ActiveStore.RequestedGoods.Contains(pi.ItemPrefab) : ActiveStore.DailySpecials.Contains(pi.ItemPrefab); GUITextBlock nameBlock = new GUITextBlock(new RectTransform(new Vector2(1.0f, 0.4f), nameAndQuantityGroup.RectTransform), - pi.ItemPrefab.Name, font: GUIStyle.SubHeadingFont, textAlignment: Alignment.BottomLeft) + RichString.Rich(pi.ItemPrefab.Name), font: GUIStyle.SubHeadingFont, textAlignment: Alignment.BottomLeft) { CanBeFocused = false, Shadow = locationHasDealOnItem, @@ -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 6fc380b99..dac7ac2d0 100644 --- a/Barotrauma/BarotraumaClient/ClientSource/GUI/SubmarineSelection.cs +++ b/Barotrauma/BarotraumaClient/ClientSource/GUI/SubmarineSelection.cs @@ -742,8 +742,8 @@ namespace Barotrauma private (LocalizedString header, LocalizedString body) GetItemTransferWarningText() { - var header = TextManager.Get("itemtransferheader").Fallback("lowfuelheader", useDefaultLanguageIfFound: false); - var body = TextManager.Get("itemtransferwarning").Fallback("lowfuelwarning", useDefaultLanguageIfFound: false); + var header = TextManager.Get("itemtransferheader").Fallback(TextManager.Get("lowfuelheader"), useDefaultLanguageIfFound: false); + var body = TextManager.Get("itemtransferwarning").Fallback(TextManager.Get("lowfuelwarning"), useDefaultLanguageIfFound: false); return (header, body); } diff --git a/Barotrauma/BarotraumaClient/ClientSource/GUI/TabMenu.cs b/Barotrauma/BarotraumaClient/ClientSource/GUI/TabMenu.cs index b71854188..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) @@ -416,7 +427,10 @@ namespace Barotrauma => TextManager.GetWithVariable("percentageformat", "[value]", $"{(int)MathF.Round(value)}"); } - var submarineButton = createTabButton(InfoFrameTab.Submarine, "submarine"); + if (Submarine.MainSub != null) + { + createTabButton(InfoFrameTab.Submarine, "submarine"); + } var talentsButton = createTabButton(InfoFrameTab.Talents, "tabmenu.character"); talentsButton.OnAddedToGUIUpdateList += (component) => @@ -458,17 +472,19 @@ 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; } } - private const float jobColumnWidthPercentage = 0.138f, - characterColumnWidthPercentage = 0.45f, - pingColumnWidthPercentage = 0.206f, - walletColumnWidthPercentage = 0.206f; + private const float JobColumnWidthPercentage = 0.138f, + CharacterColumnWidthPercentage = 0.45f, + KillColumnWidthPercentage = 0.1f, + DeathColumnWidthPercentage = 0.1f, + PingColumnWidthPercentage = 0.15f, + WalletColumnWidthPercentage = 0.206f; - private int jobColumnWidth, characterColumnWidth, pingColumnWidth, walletColumnWidth; + private int jobColumnWidth, characterColumnWidth, pingColumnWidth, walletColumnWidth, deathColumnWidth, killColumnWidth; private void CreateCrewListFrame(GUIFrame crewFrame) { @@ -496,11 +512,21 @@ namespace Barotrauma { if (teamIDs.Count > 1) { - new GUITextBlock(new RectTransform(new Vector2(1.0f, nameHeight), content.RectTransform), CombatMission.GetTeamName(teamIDs[i]), textColor: i == 0 ? GUIStyle.Green : GUIStyle.Orange) { ForceUpperCase = ForceUpperCase.Yes }; + var nameText = new GUITextBlock(new RectTransform(new Vector2(1.0f, nameHeight), content.RectTransform), CombatMission.GetTeamName(teamIDs[i]), textColor: CombatMission.GetTeamColor(teamIDs[i])) + { + ForceUpperCase = ForceUpperCase.Yes + }; + var teamIcon = new GUIImage(new RectTransform(Vector2.One, nameText.RectTransform, Anchor.CenterLeft, scaleBasis: ScaleBasis.BothHeight), + style: teamIDs[i] == CharacterTeamType.Team2 ? "SeparatistIcon" : "CoalitionIcon") + { + Color = nameText.TextColor + }; + nameText.Padding = new Vector4(teamIcon.Rect.Width + nameText.Padding.X, nameText.Padding.Y, nameText.Padding.Z, nameText.Padding.W); } headerFrames[i] = new GUILayoutGroup(new RectTransform(Vector2.Zero, content.RectTransform, Anchor.TopLeft, Pivot.BottomLeft) { AbsoluteOffset = new Point(2, -1) }, isHorizontal: true) { + Stretch = true, AbsoluteSpacing = 2, UserData = i }; @@ -587,8 +613,8 @@ namespace Barotrauma sizeMultiplier = (headerFrame.Rect.Width - headerFrame.AbsoluteSpacing * (headerFrame.CountChildren - 1)) / (float)headerFrame.Rect.Width; - jobButton.RectTransform.RelativeSize = new Vector2(jobColumnWidthPercentage * sizeMultiplier, 1f); - characterButton.RectTransform.RelativeSize = new Vector2((1f - jobColumnWidthPercentage * sizeMultiplier) * sizeMultiplier, 1f); + jobButton.RectTransform.RelativeSize = new Vector2(JobColumnWidthPercentage * sizeMultiplier, 1f); + characterButton.RectTransform.RelativeSize = new Vector2((1f - JobColumnWidthPercentage * sizeMultiplier) * sizeMultiplier, 1f); jobButton.TextBlock.Font = characterButton.TextBlock.Font = GUIStyle.HotkeyFont; jobButton.CanBeFocused = characterButton.CanBeFocused = false; @@ -626,7 +652,8 @@ namespace Barotrauma var paddedFrame = new GUILayoutGroup(new RectTransform(new Vector2(1.0f, 0.9f), frame.RectTransform, Anchor.Center), isHorizontal: true) { - AbsoluteSpacing = 2 + AbsoluteSpacing = 2, + 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)) @@ -639,21 +666,29 @@ namespace Barotrauma 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); + paddedFrame.Recalculate(); + linkedGUIList.Add(new LinkedGUI(character, frame, textBlock: null)); } private void CreateMultiPlayerListContentHolder(GUILayoutGroup headerFrame) { bool isCampaign = GameMain.GameSession?.Campaign is MultiPlayerCampaign; - GUIButton jobButton = new GUIButton(new RectTransform(new Vector2(0f, 1f), headerFrame.RectTransform), TextManager.Get("tabmenu.job"), style: "GUIButtonSmallFreeScale"); - GUIButton characterButton = new GUIButton(new RectTransform(new Vector2(0f, 1f), headerFrame.RectTransform), TextManager.Get("name"), style: "GUIButtonSmallFreeScale"); - GUIButton pingButton = new GUIButton(new RectTransform(new Vector2(0f, 1f), headerFrame.RectTransform), TextManager.Get("serverlistping"), style: "GUIButtonSmallFreeScale"); + 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 (GameMain.GameSession?.GameMode is PvPMode) + { + 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 pingButton = new GUIButton(new RectTransform(new Vector2(PingColumnWidthPercentage, 1f), headerFrame.RectTransform), TextManager.Get("serverlistping"), style: "GUIButtonSmallFreeScale"); if (isCampaign) { - GUIButton walletButton = new GUIButton(new RectTransform(new Vector2(0f, 1f), headerFrame.RectTransform) - { - RelativeSize = new Vector2(walletColumnWidthPercentage * sizeMultiplier, 1f) - }, TextManager.Get("crewwallet.wallet"), style: "GUIButtonSmallFreeScale") + GUIButton walletButton = new GUIButton(new RectTransform(new Vector2(WalletColumnWidthPercentage, 1f), headerFrame.RectTransform), TextManager.Get("crewwallet.wallet"), style: "GUIButtonSmallFreeScale") { TextBlock = { Font = GUIStyle.HotkeyFont }, CanBeFocused = false, @@ -662,15 +697,12 @@ namespace Barotrauma walletColumnWidth = walletButton.Rect.Width; } - sizeMultiplier = (headerFrame.Rect.Width - headerFrame.AbsoluteSpacing * (headerFrame.CountChildren - 1)) / (float)headerFrame.Rect.Width; - - jobButton.RectTransform.RelativeSize = new Vector2(jobColumnWidthPercentage * sizeMultiplier, 1f); - characterButton.RectTransform.RelativeSize = new Vector2((characterColumnWidthPercentage + (isCampaign ? 0 : walletColumnWidthPercentage)) * sizeMultiplier, 1f); - pingButton.RectTransform.RelativeSize = new Vector2(pingColumnWidthPercentage * sizeMultiplier, 1f); - - jobButton.TextBlock.Font = characterButton.TextBlock.Font = pingButton.TextBlock.Font = GUIStyle.HotkeyFont; - jobButton.CanBeFocused = characterButton.CanBeFocused = pingButton.CanBeFocused = false; - jobButton.TextBlock.ForceUpperCase = characterButton.TextBlock.ForceUpperCase = pingButton.ForceUpperCase = ForceUpperCase.Yes; + foreach (var btn in headerFrame.GetAllChildren()) + { + btn.TextBlock.Font = GUIStyle.HotkeyFont; + btn.CanBeFocused = false; + btn.ForceUpperCase = ForceUpperCase.Yes; + } jobColumnWidth = jobButton.Rect.Width; characterColumnWidth = characterButton.Rect.Width; @@ -688,45 +720,68 @@ 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 AICharacter) && connectedClients.Any(c => c.Character == null && c.Name == character.Name)) { continue; } - CreateMultiPlayerCharacterElement(character, GameMain.Client.PreviouslyConnectedClients.FirstOrDefault(c => c.Character == character), i); + 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), 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) { - AbsoluteSpacing = 2 + AbsoluteSpacing = 2, + 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, @@ -736,6 +791,19 @@ namespace Barotrauma if (client != null) { CreateNameWithPermissionIcon(client, paddedFrame, out GUIImage permissionIcon); + + 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)?.GetClientKillCount(client) ?? 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)?.GetClientDeathCount(client) ?? 0).ToString() + }; + } + linkedGUIList.Add(new LinkedGUI(client, frame, new GUITextBlock(new RectTransform(new Point(pingColumnWidth, paddedFrame.Rect.Height), paddedFrame.RectTransform), client.Ping.ToString(), textAlignment: Alignment.Center), permissionIcon)); @@ -743,27 +811,62 @@ 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(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(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 }; } } + + 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 }; + } - CreateWalletCrewFrame(character, paddedFrame); + paddedFrame.Recalculate(); } private void CreateMultiPlayerClientElement(Client client) @@ -785,11 +888,12 @@ namespace Barotrauma var paddedFrame = new GUILayoutGroup(new RectTransform(new Vector2(1.0f, 0.9f), frame.RectTransform, Anchor.Center), isHorizontal: true) { - AbsoluteSpacing = 2 + AbsoluteSpacing = 2, + Stretch = true }; 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, @@ -797,14 +901,26 @@ namespace Barotrauma }; CreateNameWithPermissionIcon(client, paddedFrame, out GUIImage permissionIcon); + + 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)?.GetClientKillCount(client) ?? 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)?.GetClientDeathCount(client) ?? 0).ToString() + }; + } + linkedGUIList.Add(new LinkedGUI(client, frame, 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(); } private int GetTeamIndex(Client client) @@ -837,12 +953,12 @@ namespace Barotrauma } } - return 0; + return teamIDs.IndexOf(client.TeamID); } private void CreateWalletCrewFrame(Character character, GUILayoutGroup paddedFrame) { - if (!(GameMain.GameSession?.Campaign is MultiPlayerCampaign)) { return; } + if (GameMain.GameSession?.Campaign is not MultiPlayerCampaign) { return; } GUILayoutGroup walletLayout = new GUILayoutGroup(new RectTransform(new Point(walletColumnWidth, paddedFrame.Rect.Height), paddedFrame.RectTransform, Anchor.Center), childAnchor: Anchor.Center) { @@ -860,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; @@ -947,7 +1063,6 @@ namespace Barotrauma float iconWidth = iconSize.X / (float)characterColumnWidth; int xOffset = (int)(jobColumnWidth + characterNameBlock.TextPos.X - GUIStyle.Font.MeasureString(characterNameBlock.Text).X / 2f - paddedFrame.AbsoluteSpacing - iconWidth * paddedFrame.Rect.Width); permissionIcon = new GUIImage(new RectTransform(new Vector2(iconWidth, 1f), paddedFrame.RectTransform) { AbsoluteOffset = new Point(xOffset + 2, 0) }, permissionIconSprite) { IgnoreLayoutGroups = true }; - if (client.Character != null && client.Character.IsDead) { @@ -969,18 +1084,15 @@ 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) { - if (client.Character.Info != null) - { - client.Character.Info.DrawJobIcon(spriteBatch, area); - } + client.Character.Info?.DrawJobIcon(spriteBatch, area); } else { @@ -1004,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" @@ -1028,7 +1146,7 @@ namespace Barotrauma { talentButton.OnClicked = (button, o) => { - talentMenu.CreateGUI(infoFrameHolder, character); + talentMenu.CreateGUI(infoFrameHolder, character.Info); return true; }; } @@ -1413,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; @@ -1503,6 +1621,11 @@ namespace Barotrauma } linkedGUIList.Clear(); + + foreach (GUIListBox crewList in crewListArray) + { + crewList.Content.ClearChildren(); + } } private void AddLineToLog(string line, PlayerConnectionChangeType type) @@ -1587,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) { @@ -1633,6 +1756,7 @@ namespace Barotrauma textContent, mission.Difficulty ?? 0, mission.Prefab.Icon, mission.Prefab.IconColor, + mission.GetDifficultyToolTipText(), out GUIImage missionIcon); if (missionIcon != null) { @@ -1663,6 +1787,8 @@ namespace Barotrauma private static void CreateSubmarineInfo(GUIFrame infoFrame, Submarine sub) { + if (sub == null) { return; } + GUIFrame subInfoFrame = new GUIFrame(new RectTransform(Vector2.One, infoFrame.RectTransform, Anchor.TopCenter), style: "GUIFrameListBox"); GUIFrame paddedFrame = new GUIFrame(new RectTransform(Vector2.One * 0.97f, subInfoFrame.RectTransform, Anchor.Center), style: null); @@ -1765,7 +1891,7 @@ namespace Barotrauma { parent.Content.ClearChildren(); List skillNames = new List(); - foreach (Skill skill in info.Job.GetSkills()) + foreach (Skill skill in info.Job.GetSkills().OrderByDescending(static s => s.Level)) { GUILayoutGroup skillContainer = new GUILayoutGroup(new RectTransform(new Vector2(1f, 0.0f), parent.Content.RectTransform), isHorizontal: true) { CanBeFocused = true }; var skillName = new GUITextBlock(new RectTransform(new Vector2(0.7f, 0.0f), skillContainer.RectTransform), TextManager.Get($"skillname.{skill.Identifier}").Fallback(skill.Identifier.Value)); diff --git a/Barotrauma/BarotraumaClient/ClientSource/GUI/TalentMenu.cs b/Barotrauma/BarotraumaClient/ClientSource/GUI/TalentMenu.cs index 1b6fad62f..6ddc6a467 100644 --- a/Barotrauma/BarotraumaClient/ClientSource/GUI/TalentMenu.cs +++ b/Barotrauma/BarotraumaClient/ClientSource/GUI/TalentMenu.cs @@ -7,6 +7,7 @@ using System.Linq; using Barotrauma.Extensions; using Barotrauma.Networking; using Microsoft.Xna.Framework; +using Microsoft.Xna.Framework.Graphics; using Microsoft.Xna.Framework.Input; using static Barotrauma.TalentTree; using static Barotrauma.TalentTree.TalentStages; @@ -83,17 +84,21 @@ namespace Barotrauma private GUIButton? talentApplyButton, talentResetButton; - public void CreateGUI(GUIFrame parent, Character? targetCharacter) + private delegate void StartAnimation(RectangleF start, RectangleF end, float duration); + private StartAnimation? startAnimation; + private GUIComponent? talentMainArea; + + 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); @@ -136,7 +141,7 @@ namespace Barotrauma GUILayoutGroup playerFrame = new GUILayoutGroup(new RectTransform(new Vector2(1.0f, 0.9f), containerFrame.RectTransform, Anchor.TopCenter)); GameMain.NetLobbyScreen.CreatePlayerFrame(playerFrame, alwaysAllowEditing: true, createPendingText: false); - if (!GameMain.NetLobbyScreen.PermadeathMode) + if (!GameMain.NetLobbyScreen.PermadeathMode && GameMain.GameSession?.GameMode is not PvPMode) { GUIButton newCharacterBox = new GUIButton(new RectTransform(new Vector2(0.5f, 0.2f), skillLayout.RectTransform, Anchor.BottomRight), text: GameMain.NetLobbyScreen.CampaignCharacterDiscarded ? TextManager.Get("settings") : TextManager.Get("createnew"), style: "GUIButtonSmall") @@ -294,7 +299,7 @@ namespace Barotrauma } ImmutableHashSet talentsOutsideTree = info.GetUnlockedTalentsOutsideTree().Select(static e => TalentPrefab.TalentPrefabs.Find(c => c.Identifier == e)).ToImmutableHashSet(); - if (talentsOutsideTree.Any()) + if (talentsOutsideTree.Any(static t => t != null && !t.IsHiddenExtraTalent)) { //spacing new GUIFrame(new RectTransform(new Vector2(1.0f, 0.01f), nameLayout.RectTransform), style: null); @@ -319,6 +324,7 @@ namespace Barotrauma foreach (var extraTalent in talentsOutsideTree) { if (extraTalent is null) { continue; } + if (extraTalent.IsHiddenExtraTalent) { continue; } GUIImage talentImg = new GUIImage(new RectTransform(Vector2.One, extraTalentList.Content.RectTransform, scaleBasis: ScaleBasis.BothHeight), sprite: extraTalent.Icon, scaleToFit: true) { ToolTip = RichString.Rich($"‖color:{Color.White.ToStringHex()}‖{extraTalent.DisplayName}‖color:end‖" + "\n\n" + ToolBox.ExtendColorToPercentageSigns(extraTalent.Description.Value)), @@ -341,7 +347,15 @@ namespace Barotrauma private void CreateTalentMenu(GUIComponent parent, CharacterInfo info, TalentTree tree) { - GUIListBox mainList = new GUIListBox(new RectTransform(new Vector2(1f, 0.9f), parent.RectTransform, anchor: Anchor.TopCenter)); + talentMainArea = new GUIFrame(new RectTransform(new Vector2(1f, 0.9f), parent.RectTransform, Anchor.TopCenter), style: null ); + + GUIListBox mainList = new GUIListBox(new RectTransform(Vector2.One, talentMainArea.RectTransform)); + startAnimation = CreatePopupAnimationHandler(talentMainArea); + + if (info is { TalentRefundPoints: > 0, ShowTalentResetPopupOnOpen: true }) + { + CreateTalentResetPopup(talentMainArea); + } selectedTalents = info.GetUnlockedTalentsInTree().ToHashSet(); @@ -425,6 +439,130 @@ namespace Barotrauma } } + private void CreateTalentResetPopup(GUIComponent parent) + { + int talentResetCount = 0; + if (character?.Info != null) + { + talentResetCount = Math.Min(character.Info.TalentResetCount, character.Info.GetCurrentLevel()); + } + bool hasResetTalentsBefore = talentResetCount > 0; + var bgBlocker = new GUIFrame(new RectTransform(Vector2.One, parent.RectTransform, anchor: Anchor.Center), style: "GUIBackgroundBlocker") + { + IgnoreLayoutGroups = true + }; + + var popup = new GUIFrame(new RectTransform(new Vector2(0.6f, 0.8f), bgBlocker.RectTransform, Anchor.Center)); + + var popupLayout = new GUILayoutGroup(new RectTransform(ToolBox.PaddingSizeParentRelative(popup.RectTransform, 0.95f), popup.RectTransform, Anchor.Center), isHorizontal: false); + + new GUITextBlock(new RectTransform(new Vector2(1.0f, 0.15f), popupLayout.RectTransform), TextManager.Get("talentresetheader"), font: GUIStyle.SubHeadingFont, textAlignment: Alignment.Center); + new GUITextBlock(new RectTransform(new Vector2(1.0f, hasResetTalentsBefore ? 0.25f : 0.5f), popupLayout.RectTransform), TextManager.Get("talentresetprompt"), wrap: true); + + if (hasResetTalentsBefore) + { + new GUITextBlock(new RectTransform(new Vector2(1.0f, 0.25f), popupLayout.RectTransform), + TextManager.GetWithVariable("talentresetpromptwarning", "[count]", talentResetCount.ToString()), wrap: true) + { + TextColor = GUIStyle.Red + }; + } + + var buttonLayout = new GUILayoutGroup(new RectTransform(new Vector2(1.0f, 0.35f), popupLayout.RectTransform), childAnchor: Anchor.CenterLeft, isHorizontal: true); + + var confirmButton = new GUIButton(new RectTransform(new Vector2(0.5f, 1.0f), buttonLayout.RectTransform), TextManager.Get("holdtoconfirm")) + { + RequireHold = true, + HoldDurationSeconds = 1.5f, + OnClicked = (button, o) => + { + if (character is null || characterInfo is null) { return false; } + + characterInfo.RefundTalents(); + selectedTalents.Clear(); + UpdateTalentInfo(); + bgBlocker.Visible = false; + return true; + } + }; + var denyButton = new GUIButton(new RectTransform(new Vector2(0.5f, 1.0f), buttonLayout.RectTransform), TextManager.Get("decidelater")) + { + RequireHold = false, + OnClicked = (button, userData) => + { + if (talentResetButton is not { } resetButton) { return false; } + startAnimation?.Invoke(popup.Rect, resetButton.Rect, 0.25f); + resetButton.Flash(GUIStyle.Green); + bgBlocker.Visible = false; + if (characterInfo != null) + { + characterInfo.ShowTalentResetPopupOnOpen = false; + } + return true; + } + }; + } + + private static StartAnimation CreatePopupAnimationHandler(GUIComponent parent) + { + bool drawAnimation = false; + + float animDur = 1f, + animTimer = 0f; + + RectangleF drawRect = RectangleF.Empty, + animStartRect = RectangleF.Empty, + animEndRect = RectangleF.Empty; + + void StartAnimation(RectangleF start, RectangleF end, float duration) + { + animStartRect = start; + animEndRect = end; + animTimer = 0; + animDur = duration; + drawRect = start; + drawAnimation = true; + } + + void OnDraw(SpriteBatch batch, GUICustomComponent component) + { + if (!drawAnimation) { return; } + + GUIComponentStyle style = GUIStyle.GetComponentStyle("GUIFrame"); + + style.Sprites[GUIComponent.ComponentState.None][0].Draw(batch, drawRect, Color.White); + } + + void OnUpdate(float f, GUICustomComponent component) + { + if (!drawAnimation) { return; } + + animTimer += f; + if (animTimer > animDur) + { + drawRect = animEndRect; + drawAnimation = false; + return; + } + + float lerp = animTimer / animDur; + + drawRect = new RectangleF( + MathHelper.Lerp(animStartRect.X, animEndRect.X, lerp), + MathHelper.Lerp(animStartRect.Y, animEndRect.Y, lerp), + MathHelper.Lerp(animStartRect.Width, animEndRect.Width, lerp), + MathHelper.Lerp(animStartRect.Height, animEndRect.Height, lerp)); + } + + new GUICustomComponent(new RectTransform(Vector2.One, parent.RectTransform), onDraw: OnDraw, onUpdate: OnUpdate) + { + IgnoreLayoutGroups = true, + CanBeFocused = false + }; + + return StartAnimation; + } + private void CreateTalentOption(GUIComponent parent, TalentSubTree subTree, int index, TalentOption talentOption, CharacterInfo info, int specializationCount) { int elementPadding = GUI.IntScale(8); @@ -679,6 +817,15 @@ namespace Barotrauma private bool ResetTalentSelection(GUIButton guiButton, object userData) { if (characterInfo is null) { return false; } + + int newTalentCount = selectedTalents.Count - characterInfo.GetUnlockedTalentsInTree().Count(); + // if we don't have talents selected, and we have points to refund, show the refund popup + if (characterInfo.TalentRefundPoints > 0 && newTalentCount == 0) + { + CreateTalentResetPopup(talentMainArea!); + return true; + } + selectedTalents = characterInfo.GetUnlockedTalentsInTree().ToHashSet(); UpdateTalentInfo(); return true; @@ -844,12 +991,31 @@ namespace Barotrauma } } + private static readonly LocalizedString refundText = TextManager.Get("refund"), + resetText = TextManager.Get("reset"); + public void Update() { if (characterInfo is null || talentResetButton is null || talentApplyButton is null) { return; } int talentCount = selectedTalents.Count - characterInfo.GetUnlockedTalentsInTree().Count(); - talentResetButton.Enabled = talentApplyButton.Enabled = talentCount > 0; + talentApplyButton.Enabled = character != null && talentCount > 0; + talentResetButton.Enabled = character != null && (talentCount > 0 || characterInfo.TalentRefundPoints > 0); + + if (talentCount == 0 && characterInfo.TalentRefundPoints > 0) + { + if (talentResetButton.FlashTimer <= 0.0f) + { + talentResetButton.Flash(GUIStyle.Orange); + } + + talentResetButton.Text = refundText; + } + else + { + talentResetButton.Text = resetText; + } + if (talentApplyButton.Enabled && talentApplyButton.FlashTimer <= 0.0f) { talentApplyButton.Flash(GUIStyle.Orange); @@ -893,6 +1059,22 @@ namespace Barotrauma return info.GetIdentifierUsingOriginalName() == ownCharacterInfo.GetIdentifierUsingOriginalName(); } + private static bool IsOnSameTeam(CharacterInfo? info) + { + if (info is null) { return false; } + + CharacterTeamType? ownCharacterTeam = Character.Controlled?.TeamID ?? GameMain.Client?.MyClient?.TeamID; + if (ownCharacterTeam is null) { return false; } + + return info.TeamID == ownCharacterTeam; + } + + private static bool IsSpectatingInMultiplayer() + { + if (GameMain.Client?.MyClient is not { } myClient) { return false; } + return myClient.Spectating; + } + public static bool CanManageTalents(CharacterInfo targetInfo) { // in singleplayer we can do whatever we want @@ -901,10 +1083,16 @@ namespace Barotrauma // always allow managing talents for own character if (IsOwnCharacter(targetInfo)) { return true; } + // disallow managing talents while spectating + if (IsSpectatingInMultiplayer()) { return false; } + // don't allow controlling non-bot characters if (targetInfo.Character is not { IsBot: true }) { return false; } - // lastly check if we have the permission to do this + // only allow managing talents for bots on the same team + if (!IsOnSameTeam(targetInfo)) { return false; } + + // lastly, check if we have the permission to do this return GameMain.Client is { } client && client.HasPermission(ClientPermissions.ManageBotTalents); } } diff --git a/Barotrauma/BarotraumaClient/ClientSource/GUI/UpgradeStore.cs b/Barotrauma/BarotraumaClient/ClientSource/GUI/UpgradeStore.cs index ea933efde..b57c52277 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,19 +776,29 @@ 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; } + bool hasSwappableItems = HasSwappableItems(category); + bool hasUpgradeModules = prefabs.Count > 0; + + customizeTabOpen = !hasUpgradeModules && hasSwappableItems; + customizeTabOpen = false; GUIComponent[] categoryFrames = GetFrames(category); @@ -799,9 +814,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 +823,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 +866,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 +1391,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 +1556,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 +1821,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 fbfaca6a9..062a0c393 100644 --- a/Barotrauma/BarotraumaClient/ClientSource/GameMain.cs +++ b/Barotrauma/BarotraumaClient/ClientSource/GameMain.cs @@ -120,6 +120,7 @@ namespace Barotrauma private readonly GameTime fixedTime; public Option ConnectCommand = Option.None(); + private string clientName; private static SpriteBatch spriteBatch; @@ -252,6 +253,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) { @@ -313,6 +324,8 @@ namespace Barotrauma GameSettings.SetCurrentConfig(config); } + int display = GameSettings.CurrentConfig.Graphics.Display; + GraphicsWidth = GameSettings.CurrentConfig.Graphics.Width; GraphicsHeight = GameSettings.CurrentConfig.Graphics.Height; @@ -340,7 +353,7 @@ namespace Barotrauma GraphicsDeviceManager.PreferredBackBufferFormat = SurfaceFormat.Color; GraphicsDeviceManager.PreferMultiSampling = false; GraphicsDeviceManager.SynchronizeWithVerticalRetrace = GameSettings.CurrentConfig.Graphics.VSync; - SetWindowMode(GameSettings.CurrentConfig.Graphics.DisplayMode); + SetWindowMode(GameSettings.CurrentConfig.Graphics.DisplayMode, display); defaultViewport = new Viewport(0, 0, GraphicsWidth, GraphicsHeight); @@ -353,8 +366,17 @@ namespace Barotrauma ResolutionChanged?.Invoke(); } - public void SetWindowMode(WindowMode windowMode) + public void SetWindowMode(WindowMode windowMode, int display) { + // We can't move the monitor while the window is fullscreen because of a restriction in SDL2, so as a workaround we switch to windowed mode first + var prevDisplayMode = WindowMode; + if (Window.TargetDisplay != display && prevDisplayMode != WindowMode.Windowed) + { + GraphicsDeviceManager.IsFullScreen = false; + GraphicsDeviceManager.ApplyChanges(); + } + Window.TargetDisplay = display; + WindowMode = windowMode; GraphicsDeviceManager.HardwareModeSwitch = windowMode != WindowMode.BorderlessWindowed; GraphicsDeviceManager.IsFullScreen = windowMode == WindowMode.Fullscreen || windowMode == WindowMode.BorderlessWindowed; @@ -723,7 +745,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); @@ -779,7 +801,7 @@ namespace Barotrauma { try { - SaveUtil.LoadGame(saveFiles.OrderBy(file => file.SaveTime).Last().FilePath); + SaveUtil.LoadGame(CampaignDataPath.CreateRegular(saveFiles.OrderBy(file => file.SaveTime).Last().FilePath)); } catch (Exception e) { @@ -804,19 +826,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(); } @@ -1145,7 +1176,7 @@ namespace Barotrauma } GameSession.Campaign?.End(); - SaveUtil.SaveGame(GameSession.SavePath); + SaveUtil.SaveGame(GameSession.DataPath); } if (Client != null) @@ -1162,11 +1193,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 5a6ae2c90..a6735757e 100644 --- a/Barotrauma/BarotraumaClient/ClientSource/GameSession/CrewManager.cs +++ b/Barotrauma/BarotraumaClient/ClientSource/GameSession/CrewManager.cs @@ -28,7 +28,7 @@ namespace Barotrauma public GUIComponent ReportButtonFrame { get; set; } private GUIFrame guiFrame; - private GUIFrame crewArea; + private GUILayoutGroup crewArea; private GUIListBox crewList; private float crewListOpenState; private bool _isCrewMenuOpen = true; @@ -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; @@ -93,12 +91,30 @@ namespace Barotrauma #region Crew Area - crewArea = new GUIFrame(HUDLayoutSettings.ToRectTransform(HUDLayoutSettings.CrewArea, guiFrame.RectTransform), style: null, color: Color.Transparent) + crewArea = new GUILayoutGroup(HUDLayoutSettings.ToRectTransform(HUDLayoutSettings.CrewArea, guiFrame.RectTransform), childAnchor: Anchor.TopCenter) { - CanBeFocused = false + Stretch = true }; crewArea.RectTransform.NonScaledSize = HUDLayoutSettings.CrewArea.Size; + for (int i = 0; i < 2; i++) + { + CharacterTeamType teamId = i == 0 ? CharacterTeamType.Team1 : CharacterTeamType.Team2; + var nameText = new GUITextBlock(new RectTransform(new Point(crewArea.Rect.Width - GUI.IntScale(10), GUI.IntScale(30)), crewArea.RectTransform), CombatMission.GetTeamName(teamId), textColor: CombatMission.GetTeamColor(teamId)) + { + ForceUpperCase = ForceUpperCase.Yes, + TextGetter = () => CombatMission.GetTeamName(teamId), + Visible = false, + IgnoreLayoutGroups = true, + UserData = teamId + }; + var teamIcon = new GUIImage(new RectTransform(Vector2.One, nameText.RectTransform, Anchor.CenterLeft, scaleBasis: ScaleBasis.BothHeight), style: i == 0 ? "CoalitionIcon" : "SeparatistIcon") + { + Color = nameText.TextColor + }; + nameText.Padding = new Vector4(teamIcon.Rect.Width + nameText.Padding.X, nameText.Padding.Y, nameText.Padding.Z, nameText.Padding.W); + } + // AbsoluteOffset is set in UpdateProjectSpecific based on crewListOpenState crewList = new GUIListBox(new RectTransform(Vector2.One, crewArea.RectTransform), style: null, isScrollBarOnDefaultSide: false) { @@ -177,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 @@ -290,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; @@ -562,9 +592,19 @@ namespace Barotrauma public bool CharacterClicked(GUIComponent component, object selection) { if (!AllowCharacterSwitch) { return false; } - if (!(selection is Character character) || character.IsDead || character.IsUnconscious) { return false; } + if (selection is not Character character || character.IsDead || character.IsUnconscious) { return false; } if (!character.IsOnPlayerTeam) { return false; } + if (GameMain.IsMultiplayer) + { + if (Character.Controlled == null) + { + Camera cam = Screen.Selected.Cam; + cam.Position = character.DrawPosition; + } + return true; + } + SelectCharacter(character); if (GUI.KeyboardDispatcher.Subscriber == crewList) { GUI.KeyboardDispatcher.Subscriber = null; } return true; @@ -631,10 +671,9 @@ namespace Barotrauma { if (crewList != this.crewList) { return; } if (draggedElementData is not Character) { return; } - if (!IsSinglePlayer) { return; } if (crewList.HasDraggedElementIndexChanged) { - UpdateCrewListIndices(); + if (IsSinglePlayer) { UpdateCrewListIndices(); } } else { @@ -645,10 +684,13 @@ namespace Barotrauma private void ResetCrewListIndex(Character c) { if (c?.Info == null) { return; } - c.Info.CrewListIndex = -1; - UpdateCrewListIndices(); + //default to the bottom of the list + c.Info.CrewListIndex = int.MaxValue; } + /// + /// Refresh the of the characters based on their order in the crew list + /// private void UpdateCrewListIndices() { if (crewList == null) { return; } @@ -661,20 +703,23 @@ namespace Barotrauma } } + /// + /// Order the crew list according to the characters' + /// private void SortCrewList() { if (crewList == null) { return; } crewList.Content.RectTransform.SortChildren((x, y) => { - var infoX = (x.GUIComponent.UserData as Character)?.Info?.CrewListIndex; - var infoY = (y.GUIComponent.UserData as Character)?.Info?.CrewListIndex; - if (infoX.HasValue) + int? index1 = (x.GUIComponent.UserData as Character)?.Info?.CrewListIndex; + int? index2 = (y.GUIComponent.UserData as Character)?.Info?.CrewListIndex; + if (index1.HasValue) { - return infoY.HasValue ? infoX.Value.CompareTo(infoY.Value) : -1; + return index2.HasValue ? index1.Value.CompareTo(index2.Value) : -1; } else { - return infoY.HasValue ? 1 : 0; + return index2.HasValue ? 1 : 0; } }); UpdateCrewListIndices(); @@ -1312,6 +1357,7 @@ namespace Barotrauma { if (ConversationAction.IsDialogOpen) { return; } if (!AllowCharacterSwitch) { return; } + if (character == null || character.Removed) { return; } //make the previously selected character wait in place for some time //(so they don't immediately start idling and walking away from their station) var aiController = Character.Controlled?.AIController; @@ -1323,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) @@ -1340,7 +1396,7 @@ namespace Barotrauma if (index > lastIndex) { index = 0; } if (index < 0) { index = lastIndex; } - if ((crewList.Content.GetChild(index)?.UserData as Character)?.IsOnPlayerTeam ?? false) + if (crewList.Content.GetChild(index)?.UserData is Character { IsOnPlayerTeam: true, Removed: false }) { return index; } @@ -1635,6 +1691,18 @@ namespace Barotrauma { crewArea.Visible = characters.Count > 0 && CharacterHealth.OpenHealthWindow == null; + CharacterTeamType myTeam = Character.Controlled?.TeamID ?? GameMain.Client?.MyClient?.TeamID ?? CharacterTeamType.Team1; + if (GameMain.GameSession?.GameMode is PvPMode) + { + var team1Text = crewArea.GetChildByUserData(CharacterTeamType.Team1); + team1Text.Visible = myTeam == CharacterTeamType.Team1; + team1Text.IgnoreLayoutGroups = !team1Text.Visible; + + var team2Text = crewArea.GetChildByUserData(CharacterTeamType.Team2); + team2Text.Visible = myTeam == CharacterTeamType.Team2; + team2Text.IgnoreLayoutGroups = !team2Text.Visible; + } + foreach (GUIComponent characterComponent in crewList.Content.Children) { if (characterComponent.UserData is Character character) @@ -1645,7 +1713,7 @@ namespace Barotrauma continue; } - characterComponent.Visible = Character.Controlled == null || Character.Controlled.TeamID == character.TeamID; + characterComponent.Visible = myTeam == character.TeamID; if (character.TeamID == CharacterTeamType.FriendlyNPC && Character.Controlled != null && (character.CurrentHull == Character.Controlled.CurrentHull || Vector2.DistanceSquared(Character.Controlled.WorldPosition, character.WorldPosition) < 500.0f * 500.0f)) { @@ -1873,7 +1941,7 @@ namespace Barotrauma { get { - if (GameMain.GameSession?.CrewManager == null) + if (GameMain.GameSession?.CrewManager == null || Screen.Selected is { IsEditor: true }) { return false; } @@ -2010,7 +2078,7 @@ namespace Barotrauma // Character context works differently to others as we still use the "basic" command interface, // but the order will be automatically assigned to this character isContextual = forceContextual; - if (entityContext is Character character) + if (entityContext is Character { Info: not null } character) { characterContext = character; itemContext = null; @@ -2098,7 +2166,7 @@ namespace Barotrauma CreateNodeConnectors(); if (Character.Controlled != null) { - Character.Controlled.dontFollowCursor = true; + Character.Controlled.FollowCursor = false; } HintManager.OnShowCommandInterface(); @@ -2242,7 +2310,7 @@ namespace Barotrauma returnNodeHotkey = expandNodeHotkey = Keys.None; if (Character.Controlled != null) { - Character.Controlled.dontFollowCursor = false; + Character.Controlled.FollowCursor = true; } } @@ -2511,7 +2579,7 @@ namespace Barotrauma // --> Create shortcut node for Steer order if (CanFitMoreNodes() && ShouldDelegateOrder("steer") && IsNonDuplicateOrderPrefab(OrderPrefab.Prefabs["steer"]) && subItems.Find(i => i.HasTag(Tags.NavTerminal) && i.IsPlayerTeamInteractable) is Item nav && characters.None(c => c.SelectedItem == nav) && - nav.GetComponent() is Steering steering && steering.Voltage > steering.MinVoltage) + nav.GetComponent() is Steering { HasPower: true } steering) { var order = new Order(OrderPrefab.Prefabs["steer"], steering.Item, steering); AddOrderNode(order); @@ -2625,9 +2693,9 @@ namespace Barotrauma bool IsNonDuplicateOrder(Order order) => IsNonDuplicateOrderPrefab(order.Prefab, order.Option); bool IsNonDuplicateOrderPrefab(OrderPrefab orderPrefab, Identifier option = default) { - return characterContext == null || (option.IsEmpty ? + return characterContext == null || (characterContext.CurrentOrders != null && (option.IsEmpty ? characterContext.CurrentOrders.None(oi => oi?.Identifier == orderPrefab?.Identifier) : - characterContext.CurrentOrders.None(oi => oi?.Identifier == orderPrefab?.Identifier && oi.Option == option)); + characterContext.CurrentOrders.None(oi => oi?.Identifier == orderPrefab?.Identifier && oi?.Option == option))); } void AddOrderNodeWithIdentifier(string identifier) { @@ -3000,7 +3068,7 @@ namespace Barotrauma { optionElement.OnSecondaryClicked = (button, _) => CreateAssignmentNodes(button); } - var colorMultiplier = characters.Any(c => c.CurrentOrders.Any(o => o != null && + var colorMultiplier = characters.Any(c => c.CurrentOrders != null && c.CurrentOrders.Any(o => o != null && o.Identifier == userData.Order.Identifier && o.TargetEntity == userData.Order.TargetEntity)) ? 0.5f : 1f; CreateNodeIcon(Vector2.One, optionElement.RectTransform, item.Prefab.MinimapIcon ?? order.SymbolSprite, order.Color * colorMultiplier, tooltip: item.Name); @@ -3654,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) @@ -3780,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 e34b44a4c..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; } @@ -395,13 +395,15 @@ namespace Barotrauma protected void TryEndRoundWithFuelCheck(Action onConfirm, Action onReturnToMapScreen) { + if (Submarine.MainSub == null) { return; } + Submarine.MainSub.CheckFuel(); bool lowFuel = Submarine.MainSub.Info.LowFuel; if (PendingSubmarineSwitch != null) { lowFuel = TransferItemsOnSubSwitch ? (lowFuel && PendingSubmarineSwitch.LowFuel) : PendingSubmarineSwitch.LowFuel; } - if (Level.IsLoadedFriendlyOutpost && lowFuel && CargoManager.PurchasedItems.None(i => i.Value.Any(pi => pi.ItemPrefab.Tags.Contains("reactorfuel")))) + if (Level.IsLoadedFriendlyOutpost && lowFuel && CargoManager.PurchasedItems.None(i => i.Value.Any(pi => pi.ItemPrefab.Tags.Contains(Tags.ReactorFuel)))) { var extraConfirmationBox = new GUIMessageBox(TextManager.Get("lowfuelheader"), diff --git a/Barotrauma/BarotraumaClient/ClientSource/GameSession/GameModes/MultiPlayerCampaign.cs b/Barotrauma/BarotraumaClient/ClientSource/GameSession/GameModes/MultiPlayerCampaign.cs index 7e6bc711f..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(); @@ -541,7 +542,7 @@ namespace Barotrauma { string savePath = SaveUtil.CreateSavePath(SaveUtil.SaveType.Multiplayer); - GameMain.GameSession = new GameSession(null, savePath, GameModePreset.MultiPlayerCampaign, CampaignSettings.Empty, mapSeed); + GameMain.GameSession = new GameSession(null, Option.None, CampaignDataPath.CreateRegular(savePath), GameModePreset.MultiPlayerCampaign, CampaignSettings.Empty, mapSeed); campaign = (MultiPlayerCampaign)GameMain.GameSession.GameMode; campaign.CampaignID = campaignID; GameMain.NetLobbyScreen.ToggleCampaignMode(true); @@ -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) @@ -1044,7 +1083,7 @@ namespace Barotrauma return false; } - public override void Save(XElement element) + public override void Save(XElement element, bool isSavingOnLoading) { //do nothing, the clients get the save files from the server } diff --git a/Barotrauma/BarotraumaClient/ClientSource/GameSession/GameModes/SinglePlayerCampaign.cs b/Barotrauma/BarotraumaClient/ClientSource/GameSession/GameModes/SinglePlayerCampaign.cs index f16ba4ad8..cdd4d140c 100644 --- a/Barotrauma/BarotraumaClient/ClientSource/GameSession/GameModes/SinglePlayerCampaign.cs +++ b/Barotrauma/BarotraumaClient/ClientSource/GameSession/GameModes/SinglePlayerCampaign.cs @@ -240,7 +240,7 @@ namespace Barotrauma if (!savedOnStart) { GUI.SetSavingIndicatorState(true); - SaveUtil.SaveGame(GameMain.GameSession.SavePath); + SaveUtil.SaveGame(GameMain.GameSession.DataPath, isSavingOnLoading: true); savedOnStart = true; } @@ -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; @@ -448,7 +448,7 @@ namespace Barotrauma if (success) { GameMain.GameSession.SubmarineInfo = new SubmarineInfo(GameMain.GameSession.Submarine); - SaveUtil.SaveGame(GameMain.GameSession.SavePath); + SaveUtil.SaveGame(GameMain.GameSession.DataPath); } else { @@ -479,7 +479,7 @@ namespace Barotrauma protected override void EndCampaignProjSpecific() { GameMain.GameSession.SubmarineInfo = new SubmarineInfo(GameMain.GameSession.Submarine); - SaveUtil.SaveGame(GameMain.GameSession.SavePath); + SaveUtil.SaveGame(GameMain.GameSession.DataPath); GameMain.CampaignEndScreen.Select(); GUI.DisableHUD = false; GameMain.CampaignEndScreen.OnFinished = () => @@ -672,7 +672,7 @@ namespace Barotrauma } } - public override void Save(XElement element) + public override void Save(XElement element, bool isSavingOnLoading) { XElement modeElement = new XElement("SinglePlayerCampaign", new XAttribute("purchasedlostshuttles", PurchasedLostShuttles), diff --git a/Barotrauma/BarotraumaClient/ClientSource/GameSession/GameModes/TestGameMode.cs b/Barotrauma/BarotraumaClient/ClientSource/GameSession/GameModes/TestGameMode.cs index c945b6103..9dc938aa5 100644 --- a/Barotrauma/BarotraumaClient/ClientSource/GameSession/GameModes/TestGameMode.cs +++ b/Barotrauma/BarotraumaClient/ClientSource/GameSession/GameModes/TestGameMode.cs @@ -7,7 +7,7 @@ using Barotrauma.Items.Components; namespace Barotrauma { - class TestGameMode : GameMode + partial class TestGameMode : GameMode { public Action OnRoundEnd; @@ -22,18 +22,6 @@ namespace Barotrauma private GUIButton createEventButton; - public TestGameMode(GameModePreset preset) : base(preset) - { - foreach (JobPrefab jobPrefab in JobPrefab.Prefabs.OrderBy(p => p.Identifier)) - { - for (int i = 0; i < jobPrefab.InitialCount; i++) - { - var variant = Rand.Range(0, jobPrefab.Variants); - CrewManager.AddCharacterInfo(new CharacterInfo(CharacterPrefab.HumanSpeciesName, jobOrJobPrefab: jobPrefab, variant: variant)); - } - } - } - public override void Start() { base.Start(); @@ -42,17 +30,24 @@ namespace Barotrauma foreach (Submarine submarine in Submarine.Loaded) { submarine.NeutralizeBallast(); - //normally the body would be made static during level generation, - //but in the test mode we load the outpost/wreck/beacon as if it was a normal sub and need to do this manually - if (submarine.Info.Type == SubmarineType.Outpost || - submarine.Info.Type == SubmarineType.OutpostModule || - submarine.Info.Type == SubmarineType.Wreck || - submarine.Info.Type == SubmarineType.BeaconStation) + switch (submarine.Info.Type) { - submarine.PhysicsBody.BodyType = FarseerPhysics.BodyType.Static; + case SubmarineType.Outpost: + case SubmarineType.OutpostModule: + case SubmarineType.Wreck: + case SubmarineType.BeaconStation: + //normally the body would be made static during level generation, + //but in the test mode we load the outpost/wreck/beacon as if it was a normal sub and need to do this manually + submarine.PhysicsBody.BodyType = FarseerPhysics.BodyType.Static; + if (submarine.Info.ShouldBeRuin) + { + submarine.Info.Type = SubmarineType.Ruin; + } + submarine.TeamID = submarine.Info.IsOutpost ? CharacterTeamType.FriendlyNPC : CharacterTeamType.None; + break; } } - + if (SpawnOutpost) { GenerateOutpost(Submarine.MainSub); diff --git a/Barotrauma/BarotraumaClient/ClientSource/GameSession/GameModes/Tutorials/Tutorial.cs b/Barotrauma/BarotraumaClient/ClientSource/GameSession/GameModes/Tutorials/Tutorial.cs index 66efaee87..3b6ac9488 100644 --- a/Barotrauma/BarotraumaClient/ClientSource/GameSession/GameModes/Tutorials/Tutorial.cs +++ b/Barotrauma/BarotraumaClient/ClientSource/GameSession/GameModes/Tutorials/Tutorial.cs @@ -86,7 +86,7 @@ namespace Barotrauma.Tutorials yield return CoroutineStatus.Running; - GameMain.GameSession = new GameSession(subInfo, GameModePreset.Tutorial, missionPrefabs: null); + GameMain.GameSession = new GameSession(subInfo, Option.None, GameModePreset.Tutorial, missionPrefabs: null); (GameMain.GameSession.GameMode as TutorialMode).Tutorial = this; if (generationParams is not null) @@ -138,7 +138,7 @@ namespace Barotrauma.Tutorials character = Character.Create(charInfo, wayPoint.WorldPosition, "", isRemotePlayer: false, hasAi: false); character.TeamID = CharacterTeamType.Team1; Character.Controlled = character; - character.GiveJobItems(null); + character.GiveJobItems(isPvPMode: false, null); var idCard = character.Inventory.FindItemByTag("identitycard".ToIdentifier()); if (idCard == null) diff --git a/Barotrauma/BarotraumaClient/ClientSource/GameSession/GameSession.cs b/Barotrauma/BarotraumaClient/ClientSource/GameSession/GameSession.cs index 728c8a7c7..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(); @@ -282,10 +286,10 @@ namespace Barotrauma public void SetRespawnInfo(string text, Color textColor, bool waitForNextRoundRespawn, bool hideButtons = false) { if (topLeftButtonGroup == null) { return; } - + bool permadeathMode = GameMain.NetworkMember?.ServerSettings is { RespawnMode: RespawnMode.Permadeath }; - bool ironmanMode = GameMain.NetworkMember is { ServerSettings: { RespawnMode: RespawnMode.Permadeath, IronmanMode: true } }; - + bool ironmanMode = GameMain.NetworkMember?.ServerSettings is { IronmanModeActive: true }; + bool hasRespawnOptions; if (permadeathMode) { @@ -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 d859a5af5..a308c988c 100644 --- a/Barotrauma/BarotraumaClient/ClientSource/GameSession/HintManager.cs +++ b/Barotrauma/BarotraumaClient/ClientSource/GameSession/HintManager.cs @@ -205,7 +205,7 @@ namespace Barotrauma if (Character.Controlled.SelectedItem.GetComponent() is Reactor reactor && reactor.PowerOn && Character.Controlled.SelectedItem.OwnInventory?.AllItems is IEnumerable containedItems && - containedItems.Count(i => i.HasTag(Tags.Fuel)) > 1) + containedItems.Count(i => i.HasTag(Tags.ReactorFuel)) > 1) { if (DisplayHint("onisinteracting.reactorwithextrarods".ToIdentifier())) { return; } } @@ -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/PvPMode.cs b/Barotrauma/BarotraumaClient/ClientSource/GameSession/PvPMode.cs new file mode 100644 index 000000000..80a1449ee --- /dev/null +++ b/Barotrauma/BarotraumaClient/ClientSource/GameSession/PvPMode.cs @@ -0,0 +1,84 @@ +using Microsoft.Xna.Framework; +namespace Barotrauma +{ + partial class PvPMode : MissionMode + { + private GUIComponent scoreContainer; + private readonly GUITextBlock[] scoreTexts = new GUITextBlock[2]; + private readonly GUITextBlock[] scoreTextShadows = new GUITextBlock[2]; + private readonly int[] prevScores = new int[2]; + + private void InitUI() + { + scoreContainer = new GUILayoutGroup(HUDLayoutSettings.ToRectTransform(HUDLayoutSettings.TutorialObjectiveListArea, GUI.Canvas), childAnchor: Anchor.TopRight) + { + CanBeFocused = false + }; + for (int i = 0; i < 2; i++) + { + var frame = new GUIFrame(new RectTransform(new Point(scoreContainer.Rect.Width, GUI.IntScale(80)), scoreContainer.RectTransform), style: null) + { + CanBeFocused = false + }; + new GUIImage(new RectTransform(Vector2.One, frame.RectTransform, Anchor.CenterRight, scaleBasis: ScaleBasis.BothHeight), style: i == 0 ? "CoalitionIcon" : "SeparatistIcon") + { + CanBeFocused = false + }; + scoreTextShadows[i] = new GUITextBlock(new RectTransform(Vector2.One, frame.RectTransform, Anchor.CenterRight, scaleBasis: ScaleBasis.BothHeight) { AbsoluteOffset = new Point(GUI.IntScale(38), GUI.IntScale(2)) }, + string.Empty, textColor: GUIStyle.TextColorDark, textAlignment: Alignment.CenterRight, font: GUIStyle.SubHeadingFont) + { + CanBeFocused = false + }; + scoreTexts[i] = new GUITextBlock(new RectTransform(Vector2.One, frame.RectTransform, Anchor.CenterRight, scaleBasis: ScaleBasis.BothHeight) { AbsoluteOffset = new Point(GUI.IntScale(40), 0) }, + string.Empty, textAlignment: Alignment.CenterRight, font: GUIStyle.SubHeadingFont) + { + CanBeFocused = false + }; + } + } + + public override void AddToGUIUpdateList() + { + base.AddToGUIUpdateList(); + + if (scoreContainer == null) { InitUI(); } + + scoreContainer.Visible = false; + foreach (var mission in Missions) + { + if (mission is CombatMission combatMission && combatMission.HasWinScore) + { + for (int i = 0; i < 2; i++) + { + var scoreText = scoreTexts[i]; + //one team very close to the win score, start flashing the score + if (combatMission.Scores[i] > combatMission.WinScore * 0.9f || + combatMission.Scores[i] == combatMission.WinScore - 1) + { + if (scoreText.Parent.FlashTimer <= 0.0f) + { + scoreText.Parent.Flash(GUIStyle.Orange); + scoreText.Pulsate(Vector2.One, Vector2.One * 1.2f, scoreText.Parent.FlashTimer); + } + } + if (prevScores[i] != combatMission.Scores[i] || scoreText.Text.IsNullOrEmpty()) + { + scoreText.Text = scoreTextShadows[i].Text = $"{combatMission.Scores[i]}/{combatMission.WinScore}"; + scoreText.Parent.Flash(GUIStyle.Green); + scoreText.Parent.GetAnyChild().Pulsate(Vector2.One, Vector2.One * 1.2f, scoreText.Parent.FlashTimer); + SoundPlayer.PlayUISound(GUISoundType.UIMessage); + } + scoreText.Parent.RectTransform.NonScaledSize = + new Point( + (int)(scoreText.TextSize.X + scoreText.Padding.X + scoreText.Padding.X) + scoreText.Parent.GetChild().Rect.Width + GUI.IntScale(10), + scoreText.Parent.Rect.Height); + scoreText.Parent.ForceLayoutRecalculation(); + prevScores[i] = combatMission.Scores[i]; + } + scoreContainer.Visible = true; + } + } + scoreContainer.AddToGUIUpdateList(); + } + } +} diff --git a/Barotrauma/BarotraumaClient/ClientSource/GameSession/RoundSummary.cs b/Barotrauma/BarotraumaClient/ClientSource/GameSession/RoundSummary.cs index 8ab663787..ceab34567 100644 --- a/Barotrauma/BarotraumaClient/ClientSource/GameSession/RoundSummary.cs +++ b/Barotrauma/BarotraumaClient/ClientSource/GameSession/RoundSummary.cs @@ -1,4 +1,5 @@ -using Microsoft.Xna.Framework; +using Barotrauma.Networking; +using Microsoft.Xna.Framework; using Microsoft.Xna.Framework.Graphics; using System; using System.Collections.Generic; @@ -13,11 +14,13 @@ namespace Barotrauma private float crewListAnimDelay = 0.25f; private float missionIconAnimDelay; - private const float jobColumnWidthPercentage = 0.11f; - private const float characterColumnWidthPercentage = 0.44f; - private const float statusColumnWidthPercentage = 0.45f; + 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; + private int jobColumnWidth, characterColumnWidth, statusColumnWidth, killColumnWidth, deathColumnWidth; private readonly List selectedMissions; private readonly Location startLocation, endLocation; @@ -109,6 +112,16 @@ namespace Barotrauma CombatMission.GetTeamName(CharacterTeamType.Team2), textAlignment: Alignment.TopLeft, font: GUIStyle.SubHeadingFont); crewHeader2.RectTransform.MinSize = new Point(0, GUI.IntScale(crewHeader2.Rect.Height * 2.0f)); CreateCrewList(crewContent2, gameSession.CrewManager.GetCharacterInfos().Where(c => c.TeamID == CharacterTeamType.Team2), traitorResults); + + if (CombatMission.Winner != CharacterTeamType.None) + { + new GUITextBlock(new RectTransform(new Vector2(1.0f, 0.0f), crewHeader.RectTransform), + TextManager.Get(CombatMission.Winner == CharacterTeamType.Team1 ? "pvpmode.victory" : "pvpmode.defeat"), textAlignment: Alignment.TopRight, font: GUIStyle.SubHeadingFont, + textColor: CombatMission.Winner == CharacterTeamType.Team1 ? GUIStyle.Green : GUIStyle.Red); + new GUITextBlock(new RectTransform(new Vector2(1.0f, 0.0f), crewHeader2.RectTransform), + TextManager.Get(CombatMission.Winner == CharacterTeamType.Team2 ? "pvpmode.victory" : "pvpmode.defeat"), textAlignment: Alignment.TopRight, font: GUIStyle.SubHeadingFont, + textColor: CombatMission.Winner == CharacterTeamType.Team2 ? GUIStyle.Green : GUIStyle.Red); + } } //header ------------------------------------------------------------------------------- @@ -238,11 +251,12 @@ namespace Barotrauma textContent, mission.Difficulty ?? 0, mission.Prefab.Icon, mission.Prefab.IconColor, + mission.GetDifficultyToolTipText(), out GUIImage missionIcon); if (selectedMissions.Contains(mission)) { - UpdateMissionStateIcon(mission.Completed, missionIcon, animDelay); + UpdateMissionStateIcon(mission is CombatMission combatMission ? CombatMission.IsInWinningTeam(GameMain.Client?.Character) : mission.Completed, missionIcon, animDelay); animDelay += 0.25f; } } @@ -429,13 +443,14 @@ namespace Barotrauma textContent, difficultyIconCount: 0, icon, GUIStyle.Red, + difficultyTooltipText: null, out GUIImage missionIcon); UpdateMissionStateIcon(traitorResults.VotedCorrectTraitor, missionIcon, iconAnimDelay); return content; } public static GUIComponent CreateMissionEntry(GUIComponent parent, LocalizedString header, List textContent, int difficultyIconCount, - Sprite icon, Color iconColor, out GUIImage missionIcon) + Sprite icon, Color iconColor, RichString difficultyTooltipText, out GUIImage missionIcon) { int spacing = GUI.IntScale(5); @@ -486,7 +501,8 @@ namespace Barotrauma { difficultyIndicatorGroup = new GUILayoutGroup(new RectTransform(new Point(missionTextGroup.Rect.Width, defaultLineHeight), parent: missionTextGroup.RectTransform), isHorizontal: true, childAnchor: Anchor.CenterLeft) { - AbsoluteSpacing = 1 + AbsoluteSpacing = 1, + CanBeFocused = true }; difficultyIndicatorGroup.RectTransform.MinSize = new Point(0, defaultLineHeight); var difficultyColor = Mission.GetDifficultyColor(difficultyIconCount); @@ -494,8 +510,8 @@ namespace Barotrauma { new GUIImage(new RectTransform(Vector2.One, difficultyIndicatorGroup.RectTransform, scaleBasis: ScaleBasis.Smallest), "DifficultyIndicator", scaleToFit: true) { - CanBeFocused = false, - Color = difficultyColor + Color = difficultyColor, + ToolTip = difficultyTooltipText }; } } @@ -567,7 +583,11 @@ namespace Barotrauma LocalizedString locationName = Submarine.MainSub is { AtEndExit: true } ? endLocation?.DisplayName : startLocation?.DisplayName; string textTag; - if (gameOver) + if (gameMode is PvPMode) + { + textTag = "RoundSummaryRoundHasEnded"; + } + else if (gameOver) { textTag = "RoundSummaryGameOver"; } @@ -596,7 +616,14 @@ namespace Barotrauma textTag = "RoundSummaryReturnToEmptyLocation"; break; default: - textTag = Submarine.MainSub.AtEndExit ? "RoundSummaryProgress" : "RoundSummaryReturn"; + if (Submarine.MainSub == null) + { + textTag = "RoundSummaryRoundHasEnded"; + } + else + { + textTag = Submarine.MainSub.AtEndExit ? "RoundSummaryProgress" : "RoundSummaryReturn"; + } break; } } @@ -628,26 +655,34 @@ namespace Barotrauma { var headerFrame = new GUILayoutGroup(new RectTransform(new Vector2(1.0f, 0.0f), parent.RectTransform, Anchor.TopCenter, minSize: new Point(0, (int)(30 * GUI.Scale))) { }, isHorizontal: true) { - AbsoluteSpacing = 2 + AbsoluteSpacing = 2, + Stretch = true }; - GUIButton jobButton = new GUIButton(new RectTransform(new Vector2(0f, 1f), headerFrame.RectTransform), TextManager.Get("tabmenu.job"), style: "GUIButtonSmallFreeScale"); - GUIButton characterButton = new GUIButton(new RectTransform(new Vector2(0f, 1f), headerFrame.RectTransform), TextManager.Get("name"), style: "GUIButtonSmallFreeScale"); - GUIButton statusButton = new GUIButton(new RectTransform(new Vector2(0f, 1f), headerFrame.RectTransform), TextManager.Get("label.statuslabel"), style: "GUIButtonSmallFreeScale"); + 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"); - float sizeMultiplier = 1.0f; - //sizeMultiplier = (headerFrame.Rect.Width - headerFrame.AbsoluteSpacing * (headerFrame.CountChildren - 1)) / (float)headerFrame.Rect.Width; + 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; + } + else + { + GUIButton statusButton = new GUIButton(new RectTransform(new Vector2(StatusColumnWidthPercentage, 1f), headerFrame.RectTransform), TextManager.Get("label.statuslabel"), style: "GUIButtonSmallFreeScale"); + statusColumnWidth = statusButton.Rect.Width; + } - jobButton.RectTransform.RelativeSize = new Vector2(jobColumnWidthPercentage * sizeMultiplier, 1f); - characterButton.RectTransform.RelativeSize = new Vector2(characterColumnWidthPercentage * sizeMultiplier, 1f); - statusButton.RectTransform.RelativeSize = new Vector2(statusColumnWidthPercentage * sizeMultiplier, 1f); - - jobButton.TextBlock.Font = characterButton.TextBlock.Font = statusButton.TextBlock.Font = GUIStyle.HotkeyFont; - jobButton.CanBeFocused = characterButton.CanBeFocused = statusButton.CanBeFocused = false; - jobButton.TextBlock.ForceUpperCase = characterButton.TextBlock.ForceUpperCase = statusButton.ForceUpperCase = ForceUpperCase.Yes; + foreach (var btn in headerFrame.GetAllChildren()) + { + btn.TextBlock.Font = GUIStyle.HotkeyFont; + btn.ForceUpperCase = ForceUpperCase.Yes; + btn.CanBeFocused = false; + } jobColumnWidth = jobButton.Rect.Width; characterColumnWidth = characterButton.Rect.Width; - statusColumnWidth = statusButton.Rect.Width; GUIListBox crewList = new GUIListBox(new RectTransform(Vector2.One, parent.RectTransform)) { @@ -658,8 +693,28 @@ namespace Barotrauma headerFrame.RectTransform.RelativeSize -= new Vector2(crewList.ScrollBar.RectTransform.RelativeSize.X, 0.0f); + killCounts.Clear(); + if (GameMain.NetworkMember != null) + { + foreach (CharacterInfo characterInfo in characterInfos) + { + if (characterInfo == null) { continue; } + Character character = characterInfo.Character; + Client ownerClient = GameMain.NetworkMember.ConnectedClients.FirstOrDefault(c => c.Character == character); + int killCount = 0, deathCount = 0; + foreach (var mission in selectedMissions) + { + if (mission is not CombatMission combatMission) { continue; } + killCount += ownerClient == null ? combatMission.GetBotKillCount(characterInfo) : combatMission.GetClientKillCount(ownerClient); + deathCount += ownerClient == null ? combatMission.GetBotDeathCount(characterInfo) : combatMission.GetClientDeathCount(ownerClient); + } + killCounts[characterInfo] = killCount; + deathCounts[characterInfo] = deathCount; + } + } + float delay = crewListAnimDelay; - foreach (CharacterInfo characterInfo in characterInfos) + foreach (CharacterInfo characterInfo in characterInfos.OrderByDescending(ci => killCounts.GetValueOrDefault(ci))) { if (characterInfo == null) { continue; } CreateCharacterElement(characterInfo, crewList, traitorResults, delay); @@ -670,6 +725,10 @@ namespace Barotrauma return crewList; } + private readonly Dictionary killCounts = new(); + private readonly Dictionary deathCounts = new(); + + private void CreateCharacterElement(CharacterInfo characterInfo, GUIListBox listBox, TraitorManager.TraitorResults? traitorResults, float animDelay) { GUIFrame frame = new GUIFrame(new RectTransform(new Point(listBox.Content.Rect.Width, GUI.IntScale(45)), listBox.Content.RectTransform), style: "ListBoxElement") @@ -701,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; @@ -741,8 +800,21 @@ namespace Barotrauma } } - GUITextBlock statusBlock = new GUITextBlock(new RectTransform(new Point(statusColumnWidth, paddedFrame.Rect.Height), paddedFrame.RectTransform), - ToolBox.LimitString(statusText.Value, GUIStyle.Font, characterColumnWidth), textAlignment: Alignment.Center, textColor: statusColor); + 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); + } + else + { + 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/Door.cs b/Barotrauma/BarotraumaClient/ClientSource/Items/Components/Door.cs index 5ea8a5265..d492bb83d 100644 --- a/Barotrauma/BarotraumaClient/ClientSource/Items/Components/Door.cs +++ b/Barotrauma/BarotraumaClient/ClientSource/Items/Components/Door.cs @@ -184,6 +184,13 @@ namespace Barotrauma.Items.Components { shakePos = Vector2.Zero; } + if (Character.Controlled is Character character && character.FocusedItem == item) + { + if ((IsFullyOpen || IsFullyClosed) && MathF.Abs(openState - lastOpenState) > 0) + { + CharacterHUD.RecreateHudTexts = true; + } + } } public void Draw(SpriteBatch spriteBatch, bool editing, float itemDepth = -1, Color? overrideColor = null) diff --git a/Barotrauma/BarotraumaClient/ClientSource/Items/Components/GeneticMaterial.cs b/Barotrauma/BarotraumaClient/ClientSource/Items/Components/GeneticMaterial.cs index 7d5981608..ac03954d2 100644 --- a/Barotrauma/BarotraumaClient/ClientSource/Items/Components/GeneticMaterial.cs +++ b/Barotrauma/BarotraumaClient/ClientSource/Items/Components/GeneticMaterial.cs @@ -14,6 +14,7 @@ namespace Barotrauma.Items.Components public override void AddTooltipInfo(ref LocalizedString name, ref LocalizedString description) { + bool mergedMaterialTainted = false; if (!materialName.IsNullOrEmpty() && item.ContainedItems.Count() > 0) { LocalizedString mergedMaterialName = materialName; @@ -22,11 +23,15 @@ namespace Barotrauma.Items.Components var containedMaterial = containedItem.GetComponent(); if (containedMaterial == null) { continue; } mergedMaterialName += ", " + containedMaterial.materialName; + if (containedMaterial.Tainted) + { + mergedMaterialTainted = true; + } } name = name.Replace(materialName, mergedMaterialName); } - if (Tainted) + if (Tainted || mergedMaterialTainted) { name = TextManager.GetWithVariable("entityname.taintedgeneticmaterial", "[geneticmaterialname]", name); } @@ -48,25 +53,46 @@ namespace Barotrauma.Items.Components description += '\n' + containedDescription; } } + + if (GameMain.DevMode && Tainted && selectedTaintedEffect != null) + { + description = $"{description}\n{selectedTaintedEffect.Name}: {selectedTaintedEffect.GetDescription(0f, AfflictionPrefab.Description.TargetType.OtherCharacter)}"; + } } public void ModifyDeconstructInfo(Deconstructor deconstructor, ref LocalizedString buttonText, ref LocalizedString infoText) { if (deconstructor.InputContainer.Inventory.AllItems.Count() == 2) { - var otherGeneticMaterial = - deconstructor.InputContainer.Inventory.AllItems.FirstOrDefault(it => it != item && it.Prefab == item.Prefab)?.GetComponent(); + var otherItem = deconstructor.InputContainer.Inventory.AllItems.FirstOrDefault(it => it != item); + if (otherItem == null) + { + return; + } + + var otherGeneticMaterial = otherItem.GetComponent(); if (otherGeneticMaterial == null) { - buttonText = TextManager.Get("researchstation.combine"); - infoText = TextManager.Get("researchstation.combine.infotext"); + return; } - else + + var combineRefineResult = GetCombineRefineResult(otherGeneticMaterial); + + if (combineRefineResult == CombineResult.None) + { + infoText = TextManager.Get("researchstation.novalidcombination"); + } + else if (combineRefineResult == CombineResult.Refined) { buttonText = TextManager.Get("researchstation.refine"); int taintedProbability = (int)(GetTaintedProbabilityOnRefine(otherGeneticMaterial, Character.Controlled) * 100); infoText = TextManager.GetWithVariable("researchstation.refine.infotext", "[taintedprobability]", taintedProbability.ToString()); } + else + { + buttonText = TextManager.Get("researchstation.combine"); + infoText = TextManager.Get("researchstation.combine.infotext"); + } } } diff --git a/Barotrauma/BarotraumaClient/ClientSource/Items/Components/Holdable/Holdable.cs b/Barotrauma/BarotraumaClient/ClientSource/Items/Components/Holdable/Holdable.cs index b24344c84..0d532b2c2 100644 --- a/Barotrauma/BarotraumaClient/ClientSource/Items/Components/Holdable/Holdable.cs +++ b/Barotrauma/BarotraumaClient/ClientSource/Items/Components/Holdable/Holdable.cs @@ -71,13 +71,13 @@ namespace Barotrauma.Items.Components } public override bool ValidateEventData(NetEntityEvent.IData data) - => TryExtractEventData(data, out _); + => TryExtractEventData(data, out _); public void ClientEventWrite(IWriteMessage msg, NetEntityEvent.IData extraData = null) { if (!attachable || body == null) { return; } - var eventData = ExtractEventData(extraData); + var eventData = ExtractEventData(extraData); Vector2 attachPos = eventData.AttachPos; msg.WriteSingle(attachPos.X); @@ -94,7 +94,9 @@ namespace Barotrauma.Items.Components bool shouldBeAttached = msg.ReadBoolean(); Vector2 simPosition = new Vector2(msg.ReadSingle(), msg.ReadSingle()); UInt16 submarineID = msg.ReadUInt16(); + UInt16 attacherID = msg.ReadUInt16(); Submarine sub = Entity.FindEntityByID(submarineID) as Submarine; + Character attacher = Entity.FindEntityByID(attacherID) as Character; if (shouldBeAttached) { @@ -104,6 +106,8 @@ namespace Barotrauma.Items.Components item.SetTransform(simPosition, 0.0f); item.Submarine = sub; AttachToWall(); + PlaySound(ActionType.OnUse, attacher); + ApplyStatusEffects(ActionType.OnUse, (float)Timing.Step, character: attacher, user: attacher); } } else diff --git a/Barotrauma/BarotraumaClient/ClientSource/Items/Components/Holdable/RangedWeapon.cs b/Barotrauma/BarotraumaClient/ClientSource/Items/Components/Holdable/RangedWeapon.cs index 352ba2c30..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; @@ -141,7 +134,7 @@ namespace Barotrauma.Items.Components { if (chargeSound != null) { - chargeSoundChannel = SoundPlayer.PlaySound(chargeSound.Sound, item.WorldPosition, chargeSound.Volume, chargeSound.Range, ignoreMuffling: chargeSound.IgnoreMuffling, freqMult: chargeSound.GetRandomFrequencyMultiplier()); + chargeSoundChannel = SoundPlayer.PlaySound(chargeSound, item.WorldPosition, hullGuess: item.CurrentHull); if (chargeSoundChannel != null) { chargeSoundChannel.Looping = true; } } } @@ -177,6 +170,8 @@ namespace Barotrauma.Items.Components //don't draw the crosshair if the item is in some other type of equip slot than hands (e.g. assault rifle in the bag slot) if (!character.HeldItems.Contains(item)) { return; } + base.DrawHUD(spriteBatch, character); + GUI.HideCursor = (crosshairSprite != null || crosshairPointerSprite != null) && GUI.MouseOn == null && !Inventory.IsMouseOnInventory && !GameMain.Instance.Paused; diff --git a/Barotrauma/BarotraumaClient/ClientSource/Items/Components/Holdable/Sprayer.cs b/Barotrauma/BarotraumaClient/ClientSource/Items/Components/Holdable/Sprayer.cs index 35f3edba3..cb72f3474 100644 --- a/Barotrauma/BarotraumaClient/ClientSource/Items/Components/Holdable/Sprayer.cs +++ b/Barotrauma/BarotraumaClient/ClientSource/Items/Components/Holdable/Sprayer.cs @@ -216,6 +216,7 @@ namespace Barotrauma.Items.Components public override void DrawHUD(SpriteBatch spriteBatch, Character character) { if (character == null || !character.IsKeyDown(InputType.Aim)) { return; } + base.DrawHUD(spriteBatch, character); GUI.HideCursor = targetSections.Count > 0; } diff --git a/Barotrauma/BarotraumaClient/ClientSource/Items/Components/ItemComponent.cs b/Barotrauma/BarotraumaClient/ClientSource/Items/Components/ItemComponent.cs index e9f8847e5..aa5e3883a 100644 --- a/Barotrauma/BarotraumaClient/ClientSource/Items/Components/ItemComponent.cs +++ b/Barotrauma/BarotraumaClient/ClientSource/Items/Components/ItemComponent.cs @@ -62,6 +62,10 @@ namespace Barotrauma.Items.Components private readonly Dictionary> sounds; private Dictionary soundSelectionModes; + /// + /// Starts the timer for delayed client-side corrections () - in other words, + /// the client will not attempt to read server updates for this component until the timer elapses. + /// protected float correctionTimer; public float IsActiveTimer; @@ -150,6 +154,17 @@ namespace Barotrauma.Items.Components public GUIFrame GuiFrame { get; set; } + /// + /// Overlay (just a non-interactable sprite) drawn when the item is selected, equipped or focused to via Controllers (e.g. when operating a turret via a periscope or a camera via a monitor). + /// + public Sprite HUDOverlay { get; set; } + + public float HUDOverlayAnimSpeed + { + get; + set; + } + private GUIDragHandle guiFrameDragHandle; private bool guiFrameUpdatePending; @@ -161,6 +176,13 @@ namespace Barotrauma.Items.Components set; } + [Serialize(true, IsPropertySaveable.No)] + public bool CloseByClickingOutsideGUIFrame + { + get; + set; + } + private ItemComponent linkToUIComponent; [Serialize("", IsPropertySaveable.No)] public string LinkUIToComponent @@ -261,7 +283,7 @@ namespace Barotrauma.Items.Components if (GameMain.Client?.MidRoundSyncing ?? false) { return; } //above the top boundary of the level (in an inactive respawn shuttle?) - if (item.Submarine != null && Level.Loaded != null && item.Submarine.WorldPosition.Y > Level.Loaded.Size.Y) + if (item.Submarine != null && item.Submarine.IsAboveLevel) { return; } @@ -294,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 @@ -340,7 +364,7 @@ namespace Barotrauma.Items.Components } else if (soundSelectionMode == SoundSelectionMode.Manual) { - index = Math.Clamp(ManuallySelectedSound, 0, matchingSounds.Count); + index = Math.Clamp(ManuallySelectedSound, 0, matchingSounds.Count - 1); } else { @@ -374,22 +398,20 @@ namespace Barotrauma.Items.Components float volume = GetSoundVolume(itemSound); if (volume <= 0.0001f) { return; } loopingSound = itemSound; - loopingSoundChannel = loopingSound.RoundSound.Sound.Play( - new Vector3(position.X, position.Y, 0.0f), - 0.01f, - freqMult: itemSound.RoundSound.GetRandomFrequencyMultiplier(), - muffle: SoundPlayer.ShouldMuffleSound(Character.Controlled, position, loopingSound.Range, Character.Controlled?.CurrentHull)); - loopingSoundChannel.Looping = true; - //TODO: tweak - loopingSoundChannel.Near = loopingSound.Range * 0.4f; - loopingSoundChannel.Far = loopingSound.Range; + loopingSoundChannel = SoundPlayer.PlaySound(loopingSound.RoundSound, position, volume: 0.01f, hullGuess: item.CurrentHull); + if (loopingSoundChannel != null) + { + loopingSoundChannel.Looping = true; + loopingSoundChannel.Near = loopingSound.Range * 0.4f; + loopingSoundChannel.Far = loopingSound.Range; + } } } else { float volume = GetSoundVolume(itemSound); if (volume <= 0.0001f) { return; } - var channel = SoundPlayer.PlaySound(itemSound.RoundSound.Sound, position, volume, itemSound.Range, itemSound.RoundSound.GetRandomFrequencyMultiplier(), item.CurrentHull, ignoreMuffling: itemSound.RoundSound.IgnoreMuffling); + var channel = SoundPlayer.PlaySound(itemSound.RoundSound, position, volume, hullGuess: item.CurrentHull); if (channel != null) { playingOneshotSoundChannels.Add(channel); } } } @@ -416,12 +438,23 @@ namespace Barotrauma.Items.Components if (sound == null) { return 0.0f; } if (sound.VolumeProperty == "") { return sound.VolumeMultiplier; } - if (SerializableProperties.TryGetValue(sound.VolumeProperty, out SerializableProperty property)) + SerializableProperty property = null; + ISerializableEntity targetEntity = null; + if (SerializableProperties.TryGetValue(sound.VolumeProperty, out property)) + { + targetEntity = this; + } + else if (Item.SerializableProperties.TryGetValue(sound.VolumeProperty, out property)) + { + targetEntity = Item; + } + + if (property != null) { float newVolume; try { - newVolume = property.GetFloatValue(this); + newVolume = property.GetFloatValue(targetEntity); } catch { @@ -470,7 +503,24 @@ namespace Barotrauma.Items.Components return linkToUIComponent; } - public virtual void DrawHUD(SpriteBatch spriteBatch, Character character) { } + public virtual void DrawHUD(SpriteBatch spriteBatch, Character character) + { + if (HUDOverlay != null) + { + Vector2 screenSize = new Vector2(GameMain.GraphicsWidth, GameMain.GraphicsHeight); + if (HUDOverlay is SpriteSheet spriteSheet) + { + spriteSheet.Draw(spriteBatch, + spriteIndex: (int)(Math.Floor(Timing.TotalTimeUnpaused * HUDOverlayAnimSpeed) % spriteSheet.FrameCount), + pos: screenSize / 2, color: Color.White, origin: HUDOverlay.Origin, rotate: 0, scale: screenSize / spriteSheet.FrameSize.ToVector2()); + } + else + { + HUDOverlay.Draw(spriteBatch, + pos: screenSize / 2, color: Color.White, origin: HUDOverlay.Origin, rotate: 0, scale: screenSize / HUDOverlay.size); + } + } + } public virtual void AddToGUIUpdateList(int order = 0) { @@ -513,6 +563,13 @@ namespace Barotrauma.Items.Components GuiFrameSource = subElement; ReloadGuiFrame(); break; + case "hudoverlayanimated": + HUDOverlay = new SpriteSheet(subElement); + HUDOverlayAnimSpeed = subElement.GetAttributeFloat("animspeed", 1.0f); + break; + case "hudoverlay": + HUDOverlay = new Sprite(subElement); + break; case "alternativelayout": AlternativeLayout = GUILayoutSettings.Load(subElement); break; @@ -687,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)) @@ -711,7 +771,11 @@ namespace Barotrauma.Items.Components /// protected virtual void CreateGUI() { } - //Starts a coroutine that will read the correct state of the component from the NetBuffer when correctionTimer reaches zero. + /// + /// Starts a coroutine that will read the correct state of the component from the NetBuffer when correctionTimer reaches zero. + /// Useful in cases where we a client is constantly adjusting some value, and we don't want state updates from the server to interfere with it + /// (e.g. setting the value back to what a client just set it to, when the client has already modified the value further). + /// protected void StartDelayedCorrection(IReadMessage buffer, float sendingTime, bool waitForMidRoundSync = false) { if (delayedCorrectionCoroutine != null) { CoroutineManager.StopCoroutines(delayedCorrectionCoroutine); } diff --git a/Barotrauma/BarotraumaClient/ClientSource/Items/Components/ItemContainer.cs b/Barotrauma/BarotraumaClient/ClientSource/Items/Components/ItemContainer.cs index e1658c4ef..353bb290b 100644 --- a/Barotrauma/BarotraumaClient/ClientSource/Items/Components/ItemContainer.cs +++ b/Barotrauma/BarotraumaClient/ClientSource/Items/Components/ItemContainer.cs @@ -458,54 +458,14 @@ namespace Barotrauma.Items.Components public void DrawContainedItems(SpriteBatch spriteBatch, float itemDepth, Color? overrideColor = null) { - Vector2 transformedItemPos = ItemPos * item.Scale; - Vector2 transformedItemInterval = ItemInterval * item.Scale; - Vector2 transformedItemIntervalHorizontal = new Vector2(transformedItemInterval.X, 0.0f); - Vector2 transformedItemIntervalVertical = new Vector2(0.0f, transformedItemInterval.Y); + var rootBody = item.RootContainer?.body ?? item.body; - if (item.body == null) - { - if (item.FlippedX) - { - transformedItemPos.X = -transformedItemPos.X; - transformedItemPos.X += item.Rect.Width; - transformedItemInterval.X = -transformedItemInterval.X; - transformedItemIntervalHorizontal.X = -transformedItemIntervalHorizontal.X; - } - if (item.FlippedY) - { - transformedItemPos.Y = -transformedItemPos.Y; - transformedItemPos.Y -= item.Rect.Height; - transformedItemInterval.Y = -transformedItemInterval.Y; - transformedItemIntervalVertical.Y = -transformedItemIntervalVertical.Y; - } - transformedItemPos += new Vector2(item.Rect.X, item.Rect.Y); - if (item.Submarine != null) { transformedItemPos += item.Submarine.DrawPosition; } - - if (Math.Abs(item.RotationRad) > 0.01f) - { - Matrix transform = Matrix.CreateRotationZ(-item.RotationRad); - transformedItemPos = Vector2.Transform(transformedItemPos - item.DrawPosition, transform) + item.DrawPosition; - transformedItemInterval = Vector2.Transform(transformedItemInterval, transform); - transformedItemIntervalHorizontal = Vector2.Transform(transformedItemIntervalHorizontal, transform); - transformedItemIntervalVertical = Vector2.Transform(transformedItemIntervalVertical, transform); - } - } - else - { - Matrix transform = Matrix.CreateRotationZ(item.body.DrawRotation); - if (item.body.Dir == -1.0f) - { - transformedItemPos.X = -transformedItemPos.X; - transformedItemInterval.X = -transformedItemInterval.X; - transformedItemIntervalHorizontal.X = -transformedItemIntervalHorizontal.X; - } - - transformedItemPos = Vector2.Transform(transformedItemPos, transform); - transformedItemInterval = Vector2.Transform(transformedItemInterval, transform); - transformedItemIntervalHorizontal = Vector2.Transform(transformedItemIntervalHorizontal, transform); - transformedItemPos += item.body.DrawPosition; - } + Vector2 transformedItemPos = GetContainedPosition( + drawPosition: true, + out Vector2 transformedItemIntervalHorizontal, + out Vector2 transformedItemIntervalVertical, + out bool flippedX, + out bool flippedY); Vector2 currentItemPos = transformedItemPos; @@ -514,30 +474,41 @@ 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; if (item.body != null) { Matrix transform = Matrix.CreateRotationZ(item.body.DrawRotation); - pos.X *= item.body.Dir; + pos.X *= rootBody.Dir; itemPos = Vector2.Transform(pos, transform) + item.body.DrawPosition; } else { itemPos = pos; // This code is aped based on above. Not tested. - if (item.FlippedX) + if (flippedX) { itemPos.X = -itemPos.X; itemPos.X += item.Rect.Width; } - if (item.FlippedY) + if (flippedY) { itemPos.Y = -itemPos.Y; itemPos.Y -= item.Rect.Height; @@ -555,15 +526,15 @@ namespace Barotrauma.Items.Components } } - if (AutoInteractWithContained) + if (CanAutoInteractWithContained(contained.Item) && Screen.Selected is not { IsEditor: true }) { contained.Item.IsHighlighted = item.IsHighlighted; item.IsHighlighted = false; } Vector2 origin = contained.Item.Sprite.Origin; - if (item.FlippedX) { origin.X = contained.Item.Sprite.SourceRect.Width - origin.X; } - if (item.FlippedY) { origin.Y = contained.Item.Sprite.SourceRect.Height - origin.Y; } + if (flippedX) { origin.X = contained.Item.Sprite.SourceRect.Width - origin.X; } + if (flippedY) { origin.Y = contained.Item.Sprite.SourceRect.Height - origin.Y; } float containedSpriteDepth = ContainedSpriteDepth < 0.0f ? contained.Item.Sprite.Depth : ContainedSpriteDepth; if (i < containedSpriteDepths.Length) @@ -571,19 +542,20 @@ 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) { spriteRotation = contained.Rotation; } - bool flipX = (item.body != null && item.body.Dir == -1) || item.FlippedX; + + bool flipX = rootBody is { Dir: -1 } || flippedX; if (flipX) { spriteEffects |= MathUtils.NearlyEqual(spriteRotation % 180, 90.0f) ? SpriteEffects.FlipVertically : SpriteEffects.FlipHorizontally; } - bool flipY = item.FlippedY; + bool flipY = flippedY; if (flipY) { spriteEffects |= MathUtils.NearlyEqual(spriteRotation % 180, 90.0f) ? SpriteEffects.FlipHorizontally : SpriteEffects.FlipVertically; @@ -598,7 +570,8 @@ namespace Barotrauma.Items.Components contained.Item.Scale, spriteEffects, depth: containedSpriteDepth); - contained.Item.DrawDecorativeSprites(spriteBatch, itemPos, flipX,flipY, (contained.Item.body == null ? 0.0f : contained.Item.body.DrawRotation), + + contained.Item.DrawDecorativeSprites(spriteBatch, itemPos, flipX, flipY, (contained.Item.body == null ? 0.0f : contained.Item.body.DrawRotation), containedSpriteDepth, overrideColor); foreach (ItemContainer ic in contained.Item.GetComponents()) @@ -608,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 += transformedItemInterval; - } } } diff --git a/Barotrauma/BarotraumaClient/ClientSource/Items/Components/ItemLabel.cs b/Barotrauma/BarotraumaClient/ClientSource/Items/Components/ItemLabel.cs index 61cf70993..f73d34554 100644 --- a/Barotrauma/BarotraumaClient/ClientSource/Items/Components/ItemLabel.cs +++ b/Barotrauma/BarotraumaClient/ClientSource/Items/Components/ItemLabel.cs @@ -41,7 +41,7 @@ namespace Barotrauma.Items.Components } private string text; - [Serialize("", IsPropertySaveable.Yes, translationTextTag: "Label.", description: "The text displayed in the label.", alwaysUseInstanceValues: true), Editable(100)] + [Serialize("", IsPropertySaveable.Yes, translationTextTag: "Label.", description: "The text displayed in the label.", alwaysUseInstanceValues: true), Editable(MaxLength = 100)] public string Text { get { return text; } 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 df20deb01..710dcb9f6 100644 --- a/Barotrauma/BarotraumaClient/ClientSource/Items/Components/Machines/Controller.cs +++ b/Barotrauma/BarotraumaClient/ClientSource/Items/Components/Machines/Controller.cs @@ -6,11 +6,11 @@ namespace Barotrauma.Items.Components { partial class Controller : ItemComponent { - private bool chatBoxOriginalState; private bool isHUDsHidden; public override void DrawHUD(SpriteBatch spriteBatch, Character character) { + base.DrawHUD(spriteBatch, character); if (focusTarget != null && character.ViewTarget == focusTarget) { foreach (ItemComponent ic in focusTarget.Components) @@ -23,48 +23,31 @@ namespace Barotrauma.Items.Components } } + public override void AddToGUIUpdateList(int order = 0) + { + base.AddToGUIUpdateList(order); + if (focusTarget != null && Character.Controlled.ViewTarget == focusTarget) + { + focusTarget.AddToGUIUpdateList(order); + } + } + 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/Deconstructor.cs b/Barotrauma/BarotraumaClient/ClientSource/Items/Components/Machines/Deconstructor.cs index 0fbbf4c00..24b0ec94b 100644 --- a/Barotrauma/BarotraumaClient/ClientSource/Items/Components/Machines/Deconstructor.cs +++ b/Barotrauma/BarotraumaClient/ClientSource/Items/Components/Machines/Deconstructor.cs @@ -154,12 +154,14 @@ namespace Barotrauma.Items.Components { infoArea.Text = TextManager.Get(InfoText).Fallback(InfoText); } + if (IsActive) { activateButton.Text = TextManager.Get("DeconstructorCancel"); infoArea.Text = string.Empty; return; } + bool outputsFound = false; foreach (var (inputItem, deconstructItem) in GetAvailableOutputs(checkRequiredOtherItems: true)) { @@ -174,27 +176,34 @@ namespace Barotrauma.Items.Components } inputItem.GetComponent()?.ModifyDeconstructInfo(this, ref buttonText, ref infoText); activateButton.Text = buttonText; - if (infoArea != null) - { - infoArea.Text = infoText; - } + infoArea.Text = infoText; + return; } } + + LocalizedString activateButtonText = TextManager.Get(ActivateButtonText); + activateButton.Enabled = outputsFound || !InputContainer.Inventory.IsEmpty(); + activateButton.Text = activateButtonText; + //no valid outputs found: check if we're missing some required items from the input slots and display a message about it if possible if (!outputsFound && infoArea != null) { foreach (var (inputItem, deconstructItem) in GetAvailableOutputs(checkRequiredOtherItems: false)) { + LocalizedString infoText = string.Empty; if (deconstructItem.RequiredOtherItem.Any() && !string.IsNullOrEmpty(deconstructItem.InfoTextOnOtherItemMissing)) { LocalizedString missingItemName = TextManager.Get("entityname." + deconstructItem.RequiredOtherItem.First()); - infoArea.Text = TextManager.GetWithVariable(deconstructItem.InfoTextOnOtherItemMissing, "[itemname]", missingItemName); + infoText = TextManager.GetWithVariable(deconstructItem.InfoTextOnOtherItemMissing, "[itemname]", missingItemName); } + + inputItem.GetComponent()?.ModifyDeconstructInfo(this, ref activateButtonText, ref infoText); + + activateButton.Text = activateButtonText; + infoArea.Text = infoText; } } - activateButton.Enabled = outputsFound || !InputContainer.Inventory.IsEmpty(); - activateButton.Text = TextManager.Get(ActivateButtonText); }; } @@ -415,7 +424,7 @@ namespace Barotrauma.Items.Components public override void UpdateHUDComponentSpecific(Character character, float deltaTime, Camera cam) { - inSufficientPowerWarning.Visible = IsActive && !hasPower; + inSufficientPowerWarning.Visible = IsActive && !HasPower; } private bool OnActivateButtonClicked(GUIButton button, object obj) diff --git a/Barotrauma/BarotraumaClient/ClientSource/Items/Components/Machines/Fabricator.cs b/Barotrauma/BarotraumaClient/ClientSource/Items/Components/Machines/Fabricator.cs index 0596825f6..f6dcd08c4 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,12 @@ 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; } + public override bool RecreateGUIOnResolutionChange => true; protected override void OnResolutionChanged() @@ -154,24 +175,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 +204,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 +296,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 +381,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 +453,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, @@ -372,17 +483,17 @@ namespace Barotrauma.Items.Components outputContainer.Inventory.RectTransform = outputInventoryHolder.RectTransform; } - private static LocalizedString GetRecipeNameAndAmount(FabricationRecipe fabricationRecipe) + private static RichString GetRecipeNameAndAmount(FabricationRecipe fabricationRecipe) { if (fabricationRecipe == null) { return ""; } if (fabricationRecipe.Amount > 1) { return TextManager.GetWithVariables("fabricationrecipenamewithamount", - ("[name]", fabricationRecipe.DisplayName), ("[amount]", fabricationRecipe.Amount.ToString())); + ("[name]", RichString.Rich(fabricationRecipe.DisplayName)), ("[amount]", fabricationRecipe.Amount.ToString())); } else { - return fabricationRecipe.DisplayName; + return RichString.Rich(fabricationRecipe.DisplayName); } } @@ -397,73 +508,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(); } @@ -757,6 +901,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 +918,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 +954,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 +976,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 +1013,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 +1031,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 +1097,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 +1114,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 +1157,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 +1183,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 +1192,6 @@ namespace Barotrauma.Items.Components }; new GUITextBlock(new RectTransform(new Vector2(1.0f, 0.0f), paddedReqFrame.RectTransform), TextManager.FormatCurrency(SelectedItem.RequiredMoney), font: GUIStyle.SmallFont); - } } @@ -1031,7 +1222,7 @@ namespace Barotrauma.Items.Components { if (selectedItem == null) { return false; } if (fabricatedItem == null && - !outputContainer.Inventory.CanBePut(selectedItem.TargetItem, selectedItem.OutCondition * selectedItem.TargetItem.Health)) + !outputContainer.Inventory.CanProbablyBePut(selectedItem.TargetItem, selectedItem.OutCondition * selectedItem.TargetItem.Health)) { outputSlot.Flash(GUIStyle.Red); return false; @@ -1101,8 +1292,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/MiniMap.cs b/Barotrauma/BarotraumaClient/ClientSource/Items/Components/Machines/MiniMap.cs index 1a1f2780e..fafa2ee3f 100644 --- a/Barotrauma/BarotraumaClient/ClientSource/Items/Components/Machines/MiniMap.cs +++ b/Barotrauma/BarotraumaClient/ClientSource/Items/Components/Machines/MiniMap.cs @@ -10,45 +10,27 @@ using Microsoft.Xna.Framework.Input; namespace Barotrauma.Items.Components { - internal readonly struct MiniMapGUIComponent + internal readonly record struct MiniMapGUIComponent(GUIComponent RectComponent, GUIComponent BorderComponent) { - public readonly GUIComponent RectComponent; - public readonly GUIComponent BorderComponent; - - public MiniMapGUIComponent(GUIComponent rectComponent) + public MiniMapGUIComponent(GUIComponent rectComponent) : this(rectComponent, rectComponent) { - RectComponent = rectComponent; - BorderComponent = rectComponent; } - - public MiniMapGUIComponent(GUIComponent frame, GUIComponent linkedHullComponent) - { - RectComponent = frame; - BorderComponent = linkedHullComponent; - } - + public void Deconstruct(out GUIComponent component, out GUIComponent borderComponent) { component = RectComponent; borderComponent = BorderComponent; } } - - internal readonly struct MiniMapSprite + + internal readonly record struct MiniMapSprite(Sprite? Sprite, Color Color) { - public readonly Sprite? Sprite; - public readonly Color Color; - - public MiniMapSprite(JobPrefab prefab) + public MiniMapSprite(JobPrefab prefab) : this(prefab.IconSmall, prefab.UIColor) { - Sprite = prefab.IconSmall; - Color = prefab.UIColor; } - - public MiniMapSprite(Order order) + + public MiniMapSprite(Order order) : this(order.SymbolSprite, order.Color) { - Sprite = order.SymbolSprite; - Color = order.Color; } } @@ -223,7 +205,27 @@ namespace Barotrauma.Items.Components NoPowerColor = MiniMapBaseColor * 0.1f, ElectricalBaseColor = GUIStyle.Orange, NoPowerElectricalColor = ElectricalBaseColor * 0.1f; - + + // If this is portable, only allow displaying data in the player sub (not enemy subs, ruins, wrecks or other unknown places) + private bool IsPortableItemAllowed + { + get + { + if (IsUsableOutsidePlayerSub) { return true; } + if (item.Submarine == null) { return false; } + if (item.GetComponent() is not Pickable handheldItem) { return true; } + // This will effectively make sure wherever we are, it belongs to the player + return handheldItem.Picker?.TeamID == item.Submarine.TeamID; + } + } + + [Serialize(false, IsPropertySaveable.No, description: "If this item is portable, should it be usable outside the player submarine?")] + public bool IsUsableOutsidePlayerSub + { + get; + set; + } + partial void InitProjSpecific() { hullDatas = new Dictionary(); @@ -425,22 +427,25 @@ namespace Barotrauma.Items.Components return false; } - private bool VisibleOnItemFinder(Item it) + private bool VisibleOnItemFinder(Item targetItem) { - if (it?.Submarine == null) { return false; } - if (item.Submarine == null || !item.Submarine.IsEntityFoundOnThisSub(it, includingConnectedSubs: true)) { return false; } - if (it.NonInteractable || it.IsHidden) { return false; } - if (it.GetComponent() == null) { return false; } + if (targetItem?.Submarine == null || item.Submarine == null) { return false; } - var holdable = it.GetComponent(); + if (!IsPortableItemAllowed) { return false; } + + if (!item.Submarine.IsEntityFoundOnThisSub(targetItem, includingConnectedSubs: true)) { return false; } + if (targetItem.NonInteractable || targetItem.IsHidden) { return false; } + if (targetItem.GetComponent() == null) { return false; } + + var holdable = targetItem.GetComponent(); if (holdable != null && holdable.Attached) { return false; } - var wire = it.GetComponent(); + var wire = targetItem.GetComponent(); if (wire != null && wire.Connections.Any(c => c != null)) { return false; } - if (it.Container?.GetComponent() is { DrawInventory: false } or { AllowAccess: false }) { return false; } + if (targetItem.Container?.GetComponent() is { DrawInventory: false } or { AllowAccess: false }) { return false; } - if (it.HasTag(Tags.TraitorMissionItem)) { return false; } + if (targetItem.HasTag(Tags.TraitorMissionItem)) { return false; } return true; } @@ -454,18 +459,24 @@ namespace Barotrauma.Items.Components searchAutoComplete?.AddToGUIUpdateList(order: order + 1); } } - - private void CreateHUD() + + private void ClearHUD() { subEntities.Clear(); - prevResolution = new Point(GameMain.GraphicsWidth, GameMain.GraphicsHeight); submarineContainer.ClearChildren(); + displayedSubs.Clear(); + } - if (item.Submarine is null) + private void RefreshHUD() + { + ClearHUD(); + + if (item.Submarine is null || !IsPortableItemAllowed) { - displayedSubs.Clear(); return; } + + prevResolution = new Point(GameMain.GraphicsWidth, GameMain.GraphicsHeight); scissorComponent = new GUIScissorComponent(new RectTransform(Vector2.One, submarineContainer.RectTransform, Anchor.Center)); miniMapContainer = new GUIFrame(new RectTransform(Vector2.One, scissorComponent.Content.RectTransform, Anchor.Center), style: null) { CanBeFocused = false }; @@ -574,18 +585,25 @@ namespace Barotrauma.Items.Components public override void UpdateHUDComponentSpecific(Character character, float deltaTime, Camera cam) { - //recreate HUD if the subs we should display have changed - if (item.Submarine == null && displayedSubs.Count > 0 || // item not inside a sub anymore, but display is still showing subs + // Refresh HUD (including possibly just clearing it away) if the subs we should display have changed + if (item.Submarine == null && displayedSubs.Count > 0 || // item not inside a sub anymore, but display is still showing subs item.Submarine is { } itemSub && ( - !displayedSubs.Contains(itemSub) || // current sub not displayed - itemSub.DockedTo.Where(s => s.TeamID == item.Submarine.TeamID).Any(s => !displayedSubs.Contains(s) && itemSub.ConnectedDockingPorts[s].IsLocked) || // some of the docked subs not displayed - displayedSubs.Any(s => s != itemSub && !itemSub.DockedTo.Contains(s)) // displaying a sub that shouldn't be displayed + // current sub not displayed + !displayedSubs.Contains(itemSub) || + // some of the docked subs not displayed + itemSub.DockedTo.Where(s => s.TeamID == item.Submarine.TeamID).Any(s => !displayedSubs.Contains(s) && itemSub.ConnectedDockingPorts[s].IsLocked) || + // displaying a sub that shouldn't be displayed + displayedSubs.Any(s => s != itemSub && !itemSub.DockedTo.Contains(s)) ) || - prevResolution.X != GameMain.GraphicsWidth || prevResolution.Y != GameMain.GraphicsHeight || // resolution changed - !submarineContainer.Children.Any()) // We lack a GUI + // If this item is portable and not in a player sub and using it otherwise is disallowed + !IsPortableItemAllowed || + // resolution changed + prevResolution.X != GameMain.GraphicsWidth || prevResolution.Y != GameMain.GraphicsHeight || + // We lack a GUI + !submarineContainer.Children.Any()) { - CreateHUD(); + RefreshHUD(); } //reset data if we haven't received anything in a while @@ -737,7 +755,7 @@ namespace Barotrauma.Items.Components return; } - if (Voltage < MinVoltage) + if (!HasPower) { Vector2 textSize = GUIStyle.Font.MeasureString(noPowerTip); Vector2 textPos = GuiFrame.Rect.Center.ToVector2(); @@ -747,7 +765,7 @@ namespace Barotrauma.Items.Components return; } - if (currentMode == MiniMapMode.HullStatus && item.Submarine != null) + if (currentMode == MiniMapMode.HullStatus && item.Submarine != null && IsPortableItemAllowed) { Rectangle prevScissorRect = spriteBatch.GraphicsDevice.ScissorRectangle; spriteBatch.End(); @@ -958,19 +976,19 @@ namespace Barotrauma.Items.Components MiniMapBlips = positions.ToImmutableHashSet(); - if (searchAutoComplete is null) { return; } - searchAutoComplete.Visible = false; + HideGUIComponent(searchAutoComplete); } private void UpdateHUDBack() { - if (item.Submarine == null) { return; } - - if (hullInfoFrame != null) { hullInfoFrame.Visible = false; } - reportFrame.Visible = false; - searchBarFrame.Visible = false; - electricalFrame.Visible = false; - miniMapFrame.Visible = false; + // Clear up mode-specific elements before checking if drawing should continue, so they'll be gone if not + HideModeSpecificFrames(); + + if (item.Submarine == null || !IsPortableItemAllowed) + { + ClearHUD(); + return; + } switch (currentMode) { @@ -988,7 +1006,24 @@ namespace Barotrauma.Items.Components break; } } - + + private void HideModeSpecificFrames() + { + HideGUIComponent(hullInfoFrame); + HideGUIComponent(reportFrame); + HideGUIComponent(searchBarFrame); + HideGUIComponent(electricalFrame); + HideGUIComponent(miniMapFrame); + } + + private static void HideGUIComponent(GUIComponent? component) + { + if (component != null) + { + component.Visible = false; + } + } + private void UpdateHullStatus() { bool canHoverOverHull = true; @@ -1007,7 +1042,7 @@ namespace Barotrauma.Items.Components child.Color = child.OutlineColor = NoPowerDoorColor; } - if (Voltage < MinVoltage) { continue; } + if (!HasPower) { continue; } child.Color = child.OutlineColor = DoorIndicatorColor; if (GUI.MouseOn == child) @@ -1037,7 +1072,7 @@ namespace Barotrauma.Items.Components } } - if (Voltage < MinVoltage) { continue; } + if (!HasPower) { continue; } hullDatas.TryGetValue(hull, out HullData? hullData); if (hullData is null) @@ -1187,7 +1222,7 @@ namespace Barotrauma.Items.Components component.Color = component.OutlineColor = NoPowerElectricalColor; } - if (Voltage < MinVoltage || !miniMapGuiComponent.RectComponent.Visible) { continue; } + if (!HasPower || !miniMapGuiComponent.RectComponent.Visible) { continue; } int durability = (int)(it.Condition / (it.MaxCondition / it.MaxRepairConditionMultiplier) * 100f); Color color = ToolBox.GradientLerp(durability / 100f, GUIStyle.Red, GUIStyle.Orange, GUIStyle.Green, GUIStyle.Green); @@ -1229,11 +1264,11 @@ namespace Barotrauma.Items.Components private void DrawHUDBack(SpriteBatch spriteBatch, GUICustomComponent container) { - if (item.Submarine == null) { return; } + if (item.Submarine == null || !IsPortableItemAllowed) { return; } - DrawSubmarine(spriteBatch); + DrawSubmarine(spriteBatch); - if (Voltage < MinVoltage) { return; } + if (!HasPower) { return; } Rectangle prevScissorRect = spriteBatch.GraphicsDevice.ScissorRectangle; spriteBatch.End(); spriteBatch.Begin(SpriteSortMode.Deferred, samplerState: GUI.SamplerState, rasterizerState: GameMain.ScissorTestEnable); diff --git a/Barotrauma/BarotraumaClient/ClientSource/Items/Components/Machines/Reactor.cs b/Barotrauma/BarotraumaClient/ClientSource/Items/Components/Machines/Reactor.cs index 43ec63ae5..ce3d63e73 100644 --- a/Barotrauma/BarotraumaClient/ClientSource/Items/Components/Machines/Reactor.cs +++ b/Barotrauma/BarotraumaClient/ClientSource/Items/Components/Machines/Reactor.cs @@ -475,11 +475,8 @@ namespace Barotrauma.Items.Components if (sound != null) { SoundPlayer.PlaySound( - sound.Sound, + sound, item.WorldPosition, - sound.Volume, - sound.Range, - freqMult: sound.GetRandomFrequencyMultiplier(), hullGuess: item.CurrentHull); } } diff --git a/Barotrauma/BarotraumaClient/ClientSource/Items/Components/Machines/Sonar.cs b/Barotrauma/BarotraumaClient/ClientSource/Items/Components/Machines/Sonar.cs index cb5115422..dc0d6dcbf 100644 --- a/Barotrauma/BarotraumaClient/ClientSource/Items/Components/Machines/Sonar.cs +++ b/Barotrauma/BarotraumaClient/ClientSource/Items/Components/Machines/Sonar.cs @@ -144,6 +144,7 @@ namespace Barotrauma.Items.Components private bool isConnectedToSteering; private static LocalizedString caveLabel; + private static LocalizedString enemyLabel; [Serialize(false, IsPropertySaveable.Yes)] @@ -164,6 +165,8 @@ namespace Barotrauma.Items.Components TextManager.Get("cave").Fallback( TextManager.Get("missiontype.nest")); + enemyLabel = TextManager.Get("enemysubmarine"); + foreach (var subElement in element.Elements()) { switch (subElement.Name.ToString().ToLowerInvariant()) @@ -216,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) @@ -1019,6 +1023,38 @@ namespace Barotrauma.Items.Components cave.StartPos.ToVector2(), transducerCenter, DisplayScale, center, DisplayRadius); } + + if (GameMain.NetworkMember is { } networkMember && GameMain.GameSession?.GameMode is PvPMode) + { + if (networkMember.ServerSettings.TrackOpponentInPvP + && Submarine.MainSubs[0] is { } coalitionSub + && Submarine.MainSubs[1] is { } separatistSub + && Character.Controlled is { } player) + { + Submarine whichSubToDraw = player.TeamID switch + { + CharacterTeamType.Team1 => separatistSub, + CharacterTeamType.Team2 => coalitionSub, + _ => null + }; + + if (whichSubToDraw != null) + { + DrawOffsetMarker(spriteBatch, + enemyLabel.Value, + Tags.Submarine, + Tags.Enemy, + whichSubToDraw.WorldPosition, + transducerCenter, + distanceThresholds: new Range(start: MetersToUnits(150), end: MetersToUnits(1600)), + offset: new Range(start: MetersToUnits(100), end: MetersToUnits(400)), + minOffset: MetersToUnits(10)); + + static float MetersToUnits(float m) + => m / Physics.DisplayToRealWorldRatio; + } + } + } } int missionIndex = 0; @@ -1042,7 +1078,8 @@ namespace Barotrauma.Items.Components } if (HasMineralScanner && UseMineralScanner && CurrentMode == Mode.Active && MineralClusters != null && - (item.CurrentHull == null || !DetectSubmarineWalls)) + (item.CurrentHull == null || !DetectSubmarineWalls) && + HasPower) { foreach (var c in MineralClusters) { @@ -1076,7 +1113,7 @@ namespace Barotrauma.Items.Components { if (!sub.ShowSonarMarker) { continue; } if (connectedSubs.Contains(sub)) { continue; } - if (Level.Loaded != null && sub.WorldPosition.Y > Level.Loaded.Size.Y) { continue; } + if (sub.IsAboveLevel) { continue; } if (item.Submarine != null || Character.Controlled != null) { @@ -1185,7 +1222,7 @@ namespace Barotrauma.Items.Components foreach (DockingPort dockingPort in DockingPort.List) { - if (Level.Loaded != null && dockingPort.Item.Submarine.WorldPosition.Y > Level.Loaded.Size.Y) { continue; } + if (dockingPort.Item.Submarine.IsAboveLevel) { continue; } if (dockingPort.Item.IsHidden) { continue; } if (dockingPort.Item.Submarine == null) { continue; } if (dockingPort.Item.Submarine.Info.IsWreck) { continue; } @@ -1198,8 +1235,8 @@ namespace Barotrauma.Items.Components //don't show the docking ports of the opposing team on the sonar if (item.Submarine != null && - item.Submarine != GameMain.NetworkMember?.RespawnManager?.RespawnShuttle && - dockingPort.Item.Submarine != GameMain.NetworkMember?.RespawnManager?.RespawnShuttle && + !item.Submarine.IsRespawnShuttle && + !dockingPort.Item.Submarine.IsRespawnShuttle && !dockingPort.Item.Submarine.Info.IsOutpost && !dockingPort.Item.Submarine.Info.IsBeacon) { @@ -1792,8 +1829,47 @@ namespace Barotrauma.Items.Components sonarBlip.Draw(spriteBatch, center + pos, color * 0.5f * blip.Alpha, sonarBlip.Origin, 0, scale, SpriteEffects.None, 0); } + /// + /// Used in DrawOffsetMarker to cache the randomized location of the marker + /// + private readonly Dictionary cachedLocations = new Dictionary(); + + private void DrawOffsetMarker(SpriteBatch spriteBatch, string label, Identifier iconIdentifier, Identifier targetIdentifier, Vector2 worldPosition, Vector2 transducerPosition, Range distanceThresholds, Range offset, float minOffset) + { + Vector2 pos; + + if (!cachedLocations.TryGetValue(targetIdentifier, out CachedLocation cachedLocation)) + { + cachedLocation = CreateCachedLocation(); + cachedLocations.Add(targetIdentifier, cachedLocation); + pos = cachedLocation.Location; + } + else + { + if (Timing.TotalTime > cachedLocation.RecalculationTime) + { + cachedLocation = CreateCachedLocation(); + cachedLocations[targetIdentifier] = cachedLocation; + } + + pos = cachedLocation.Location; + } + + DrawMarker(spriteBatch, label, iconIdentifier, targetIdentifier, pos, transducerPosition, DisplayScale, center, DisplayRadius); + + CachedLocation CreateCachedLocation() + { + float distance = Vector2.Distance(worldPosition, transducerPosition); + + float maxOffset = MathHelper.Lerp(offset.Start, offset.End, MathHelper.Clamp((distance - distanceThresholds.Start) / (distanceThresholds.End - distanceThresholds.Start), 0.0f, 1.0f)); + + Vector2 randomPos = Rand.Vector(Rand.Range(minOffset, maxOffset)); + return new CachedLocation(worldPosition + randomPos, Timing.TotalTime + Rand.Range(10.0f, 30.0f)); + } + } + private void DrawMarker(SpriteBatch spriteBatch, string label, Identifier iconIdentifier, object targetIdentifier, Vector2 worldPosition, Vector2 transducerPosition, float scale, Vector2 center, float radius, - bool onlyShowTextOnMouseOver = false) + bool onlyShowTextOnMouseOver = false) { float linearDist = Vector2.Distance(worldPosition, transducerPosition); float dist = linearDist; @@ -1903,25 +1979,6 @@ namespace Barotrauma.Items.Components 2, GUIStyle.SmallFont); } - protected override void RemoveComponentSpecific() - { - base.RemoveComponentSpecific(); - sonarBlip?.Remove(); - pingCircle?.Remove(); - directionalPingCircle?.Remove(); - screenOverlay?.Remove(); - screenBackground?.Remove(); - lineSprite?.Remove(); - - foreach (var t in targetIcons.Values) - { - t.Item1.Remove(); - } - targetIcons.Clear(); - - MineralClusters = null; - } - public void ClientEventWrite(IWriteMessage msg, NetEntityEvent.IData extraData = null) { msg.WriteBoolean(currentMode == Mode.Active); diff --git a/Barotrauma/BarotraumaClient/ClientSource/Items/Components/Machines/Steering.cs b/Barotrauma/BarotraumaClient/ClientSource/Items/Components/Machines/Steering.cs index 4d9aabff1..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; @@ -554,7 +572,7 @@ namespace Barotrauma.Items.Components int x = rect.X; int y = rect.Y; - if (Voltage < MinVoltage) { return; } + if (!HasPower) { return; } Rectangle velRect = new Rectangle(x + 20, y + 20, width - 40, height - 40); Vector2 steeringOrigin = steerArea.Rect.Center.ToVector2(); @@ -759,7 +777,7 @@ namespace Barotrauma.Items.Components dockingButton.Text = dockText; } - if (Voltage < MinVoltage) + if (!HasPower) { tipContainer.Visible = true; tipContainer.Text = noPowerTip; @@ -829,7 +847,7 @@ namespace Barotrauma.Items.Components } if (!AutoPilot && Character.DisableControls && GUI.KeyboardDispatcher.Subscriber == null) { - steeringAdjustSpeed = character == null ? DefaultSteeringAdjustSpeed : MathHelper.Lerp(0.2f, 1.0f, character.GetSkillLevel("helm") / 100.0f); + steeringAdjustSpeed = character == null ? DefaultSteeringAdjustSpeed : MathHelper.Lerp(0.2f, 1.0f, character.GetSkillLevel(Tags.HelmSkill) / 100.0f); Vector2 input = Vector2.Zero; if (PlayerInput.KeyDown(InputType.Left)) { input -= Vector2.UnitX; } if (PlayerInput.KeyDown(InputType.Right)) { input += Vector2.UnitX; } @@ -909,7 +927,7 @@ namespace Barotrauma.Items.Components if (targetPort.Docked || targetPort.Item.Submarine == null) { continue; } if (targetPort.Item.Submarine == controlledSub || targetPort.IsHorizontal != sourcePort.IsHorizontal) { continue; } if (targetPort.Item.Submarine.DockedTo?.Contains(sourcePort.Item.Submarine) ?? false) { continue; } - if (Level.Loaded != null && targetPort.Item.Submarine.WorldPosition.Y > Level.Loaded.Size.Y) { continue; } + if (targetPort.Item.Submarine.IsAboveLevel) { continue; } if (sourceDir == targetPort.GetDir()) { continue; } float dist = Vector2.DistanceSquared(sourcePort.Item.WorldPosition, targetPort.Item.WorldPosition); @@ -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 0b551f6bd..4aa4c0ed4 100644 --- a/Barotrauma/BarotraumaClient/ClientSource/Items/Components/Projectile.cs +++ b/Barotrauma/BarotraumaClient/ClientSource/Items/Components/Projectile.cs @@ -50,7 +50,7 @@ namespace Barotrauma.Items.Components Hull hull = Entity.FindEntityByID(hullID) as Hull; item.Submarine = submarine; item.CurrentHull = hull; - item.body.SetTransform(simPosition, item.body.Rotation); + item.body.SetTransformIgnoreContacts(simPosition, item.body.Rotation); switch (targetType) { @@ -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/RemoteController.cs b/Barotrauma/BarotraumaClient/ClientSource/Items/Components/RemoteController.cs index 31f951899..f573503f7 100644 --- a/Barotrauma/BarotraumaClient/ClientSource/Items/Components/RemoteController.cs +++ b/Barotrauma/BarotraumaClient/ClientSource/Items/Components/RemoteController.cs @@ -6,6 +6,7 @@ namespace Barotrauma.Items.Components { public override void DrawHUD(SpriteBatch spriteBatch, Character character) { + base.DrawHUD(spriteBatch, character); currentTarget?.DrawHUD(spriteBatch, Screen.Selected.Cam, character); } 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/Repairable.cs b/Barotrauma/BarotraumaClient/ClientSource/Items/Components/Repairable.cs index 5c790d576..e86923ef7 100644 --- a/Barotrauma/BarotraumaClient/ClientSource/Items/Components/Repairable.cs +++ b/Barotrauma/BarotraumaClient/ClientSource/Items/Components/Repairable.cs @@ -299,6 +299,8 @@ namespace Barotrauma.Items.Components public override void DrawHUD(SpriteBatch spriteBatch, Character character) { + base.DrawHUD(spriteBatch, character); + IsActive = true; float defaultMaxCondition = (item.MaxCondition / item.MaxRepairConditionMultiplier); diff --git a/Barotrauma/BarotraumaClient/ClientSource/Items/Components/Rope.cs b/Barotrauma/BarotraumaClient/ClientSource/Items/Components/Rope.cs index c6b5417c9..32a3d1528 100644 --- a/Barotrauma/BarotraumaClient/ClientSource/Items/Components/Rope.cs +++ b/Barotrauma/BarotraumaClient/ClientSource/Items/Components/Rope.cs @@ -52,9 +52,11 @@ namespace Barotrauma.Items.Components Vector2 sourcePos = GetSourcePos(); + //need to double the size because this is essentially just the radius, we need the diameter + // + some extra margin to be on the safe side return new Vector2( Math.Abs(target.DrawPosition.X - sourcePos.X), - Math.Abs(target.DrawPosition.Y - sourcePos.Y)) * 1.5f; + Math.Abs(target.DrawPosition.Y - sourcePos.Y)) * 2.2f; } } @@ -122,7 +124,7 @@ namespace Barotrauma.Items.Components { if (turret.BarrelSprite != null) { - startPos += new Vector2((float)Math.Cos(turret.Rotation), (float)Math.Sin(turret.Rotation)) * turret.BarrelSprite.size.Y * turret.BarrelSprite.RelativeOrigin.Y * item.Scale * 0.9f; + startPos += new Vector2((float)Math.Cos(turret.Rotation), (float)Math.Sin(turret.Rotation)) * turret.BarrelSprite.size.Y * turret.BarrelSprite.RelativeOrigin.Y * turret.Item.Scale * BarrelLengthMultiplier; } startPos -= turret.GetRecoilOffset(); } @@ -227,7 +229,7 @@ namespace Barotrauma.Items.Components { if (reelSoundChannel is not { IsPlaying: true }) { - reelSoundChannel = SoundPlayer.PlaySound(sound.Sound, position, sound.Volume, sound.Range, ignoreMuffling: sound.IgnoreMuffling, freqMult: sound.GetRandomFrequencyMultiplier()); + reelSoundChannel = SoundPlayer.PlaySound(sound, position); if (reelSoundChannel != null) { reelSoundChannel.Looping = true; @@ -242,7 +244,7 @@ namespace Barotrauma.Items.Components } else { - SoundPlayer.PlaySound(sound.Sound, position, sound.Volume, sound.Range, ignoreMuffling: sound.IgnoreMuffling, freqMult: sound.GetRandomFrequencyMultiplier()); + SoundPlayer.PlaySound(sound, position); } } diff --git a/Barotrauma/BarotraumaClient/ClientSource/Items/Components/Signal/ButtonTerminal.cs b/Barotrauma/BarotraumaClient/ClientSource/Items/Components/Signal/ButtonTerminal.cs index 80fbd44db..bea101f3b 100644 --- a/Barotrauma/BarotraumaClient/ClientSource/Items/Components/Signal/ButtonTerminal.cs +++ b/Barotrauma/BarotraumaClient/ClientSource/Items/Components/Signal/ButtonTerminal.cs @@ -3,7 +3,6 @@ using Barotrauma.Networking; using Microsoft.Xna.Framework; using System; using System.Linq; -using System.Xml.Linq; namespace Barotrauma.Items.Components { @@ -16,7 +15,7 @@ namespace Barotrauma.Items.Components partial void InitProjSpecific(ContentXElement element) { - terminalButtonStyles = new string[RequiredSignalCount]; + terminalButtonStyles = new string[requiredSignalCount]; int i = 0; foreach (var childElement in element.GetChildElements("TerminalButton")) { @@ -38,11 +37,11 @@ namespace Barotrauma.Items.Components }; paddedFrame.OnAddedToGUIUpdateList += (component) => { - bool buttonsEnabled = AllowUsingButtons; - foreach (var child in component.Children) + bool buttonsEnabled = IsActivated; + foreach (GUIComponent child in component.Children) { - if (!(child is GUIButton)) { continue; } - if (!(child.UserData is int)) { continue; } + if (child is not GUIButton) { continue; } + if (child.UserData is not int) { continue; } child.Enabled = buttonsEnabled; child.Children.ForEach(c => c.Enabled = buttonsEnabled); } @@ -59,7 +58,7 @@ namespace Barotrauma.Items.Components containerIndicator.OverrideState = itemsContained ? GUIComponent.ComponentState.Selected : GUIComponent.ComponentState.None; }; - float x = 1.0f / (1 + RequiredSignalCount); + float x = 1.0f / (1 + requiredSignalCount); float y = Math.Min((x * paddedFrame.Rect.Width) / paddedFrame.Rect.Height, 0.5f); Vector2 relativeSize = new Vector2(x, y); @@ -69,7 +68,7 @@ namespace Barotrauma.Items.Components containerIndicator = new GUIImage(new RectTransform(new Vector2(0.5f, 0.5f * (1.0f - y)), containerSection.RectTransform, anchor: Anchor.BottomCenter), style: "IndicatorLightRed", scaleToFit: true); - for (int i = 0; i < RequiredSignalCount; i++) + for (int i = 0; i < requiredSignalCount; i++) { var button = new GUIButton(new RectTransform(relativeSize, paddedFrame.RectTransform), style: null) { @@ -111,7 +110,7 @@ namespace Barotrauma.Items.Components public void ClientEventRead(IReadMessage msg, float sendingTime) { - SendSignal(msg.ReadRangedInteger(0, Signals.Length - 1), sender: null, isServerMessage: true); + SendSignal(msg.ReadRangedInteger(0, Signals.Length - 1), sender: null, ignoreState: true); } } } \ No newline at end of file diff --git a/Barotrauma/BarotraumaClient/ClientSource/Items/Components/Signal/CircuitBox.cs b/Barotrauma/BarotraumaClient/ClientSource/Items/Components/Signal/CircuitBox.cs index 329545f2d..9d8ba4214 100644 --- a/Barotrauma/BarotraumaClient/ClientSource/Items/Components/Signal/CircuitBox.cs +++ b/Barotrauma/BarotraumaClient/ClientSource/Items/Components/Signal/CircuitBox.cs @@ -1,4 +1,4 @@ -#nullable enable +#nullable enable using System; using System.Collections.Generic; @@ -129,7 +129,7 @@ namespace Barotrauma.Items.Components public void RemoveComponents(IReadOnlyCollection node) { - if (Locked) { return; } + if (IsLocked()) { return; } var ids = node.Select(static n => n.ID).ToImmutableArray(); if (GameMain.NetworkMember is null) @@ -146,7 +146,7 @@ namespace Barotrauma.Items.Components public void AddWire(CircuitBoxConnection one, CircuitBoxConnection two) { - if (Locked) { return; } + if (IsLocked()) { return; } if (GameMain.NetworkMember is null) { Connect(one, two, static delegate { }, CircuitBoxWire.SelectedWirePrefab); @@ -160,7 +160,7 @@ namespace Barotrauma.Items.Components public void RemoveWires(IReadOnlyCollection wires) { - if (Locked) { return; } + if (IsLocked()) { return; } var ids = wires.Select(static w => w.ID).ToImmutableArray(); if (GameMain.NetworkMember is null) { @@ -230,7 +230,7 @@ namespace Barotrauma.Items.Components public void MoveComponent(Vector2 moveAmount, IReadOnlyCollection moveables) { - if (Locked) { return; } + if (IsLocked()) { return; } var ids = ImmutableArray.CreateBuilder(); var ios = ImmutableArray.CreateBuilder(); var labelIds = ImmutableArray.CreateBuilder(); @@ -265,7 +265,7 @@ namespace Barotrauma.Items.Components public void AddComponent(ItemPrefab prefab, Vector2 pos) { - if (Locked) { return; } + if (IsLocked()) { return; } if (GameMain.NetworkMember is null) { ItemPrefab resource; @@ -292,7 +292,7 @@ namespace Barotrauma.Items.Components public void RenameLabel(CircuitBoxLabelNode label, Color color, NetLimitedString header, NetLimitedString body) { - if (Locked) { return; } + if (IsLocked()) { return; } if (GameMain.NetworkMember is null) { label.EditText(header, body); @@ -316,7 +316,7 @@ namespace Barotrauma.Items.Components public void ResizeNode(CircuitBoxNode node, CircuitBoxResizeDirection dir, Vector2 amount) { - if (Locked) { return; } + if (IsLocked()) { return; } var resize = node.ResizeBy(dir, amount); if (GameMain.NetworkMember is null) { @@ -341,7 +341,7 @@ namespace Barotrauma.Items.Components public void AddLabel(Vector2 pos) { - if (Locked) { return; } + if (IsLocked()) { return; } if (GameMain.NetworkMember is null) { AddLabelInternal(ICircuitBoxIdentifiable.FindFreeID(Labels), GUIStyle.Blue, pos, CircuitBoxLabelNode.DefaultHeaderText, NetLimitedString.Empty); @@ -353,7 +353,7 @@ namespace Barotrauma.Items.Components public void RemoveLabel(IReadOnlyCollection labels) { - if (Locked) { return; } + if (IsLocked()) { return; } if (!labels.Any()) { return; } var ids = labels.Select(static n => n.ID).ToImmutableArray(); diff --git a/Barotrauma/BarotraumaClient/ClientSource/Items/Components/Signal/CustomInterface.cs b/Barotrauma/BarotraumaClient/ClientSource/Items/Components/Signal/CustomInterface.cs index dc5b84592..2370fc843 100644 --- a/Barotrauma/BarotraumaClient/ClientSource/Items/Components/Signal/CustomInterface.cs +++ b/Barotrauma/BarotraumaClient/ClientSource/Items/Components/Signal/CustomInterface.cs @@ -12,7 +12,9 @@ namespace Barotrauma.Items.Components private readonly List uiElements = new List(); private GUILayoutGroup uiElementContainer; - private bool readingNetworkEvent; + private bool suppressNetworkEvents; + + private GUIComponent insufficientPowerWarning; private Point ElementMaxSize => new Point(uiElementContainer.Rect.Width, (int)(65 * GUI.yScale)); @@ -40,7 +42,7 @@ namespace Barotrauma.Items.Components float elementSize = Math.Min(1.0f / visibleElements.Count(), 1); foreach (CustomInterfaceElement ciElement in visibleElements) { - if (ciElement.HasPropertyName) + if (ciElement.InputType is CustomInterfaceElement.InputTypeOption.Number or CustomInterfaceElement.InputTypeOption.Text) { var layoutGroup = new GUILayoutGroup(new RectTransform(new Vector2(1.0f, elementSize), uiElementContainer.RectTransform), isHorizontal: true) { @@ -49,7 +51,7 @@ namespace Barotrauma.Items.Components }; new GUITextBlock(new RectTransform(new Vector2(0.5f, 1.0f), layoutGroup.RectTransform), TextManager.Get(ciElement.Label).Fallback(ciElement.Label)); - if (!ciElement.IsNumberInput) + if (ciElement.InputType is CustomInterfaceElement.InputTypeOption.Text) { var textBox = new GUITextBox(new RectTransform(new Vector2(0.5f, 1.0f), layoutGroup.RectTransform), ciElement.Signal, style: "GUITextBoxNoIcon") { @@ -68,7 +70,7 @@ namespace Barotrauma.Items.Components } else { - item.CreateClientEvent(this); + CreateClientEventWithCorrectionDelay(); } }; @@ -98,13 +100,10 @@ namespace Barotrauma.Items.Components ValueStep = numberInputStep, OnValueChanged = (ni) => { - if (GameMain.Client == null) + ValueChanged(ni.UserData as CustomInterfaceElement, ni.FloatValue); + if (!suppressNetworkEvents && GameMain.Client != null) { - ValueChanged(ni.UserData as CustomInterfaceElement, ni.FloatValue); - } - else if (!readingNetworkEvent) - { - item.CreateClientEvent(this); + CreateClientEventWithCorrectionDelay(); } } }; @@ -124,13 +123,10 @@ namespace Barotrauma.Items.Components ValueStep = numberInputStep, OnValueChanged = (ni) => { - if (GameMain.Client == null) + ValueChanged(ni.UserData as CustomInterfaceElement, ni.IntValue); + if (!suppressNetworkEvents && GameMain.Client != null) { - ValueChanged(ni.UserData as CustomInterfaceElement, ni.IntValue); - } - else if (!readingNetworkEvent) - { - item.CreateClientEvent(this); + CreateClientEventWithCorrectionDelay(); } } }; @@ -149,7 +145,7 @@ namespace Barotrauma.Items.Components } } } - else if (ciElement.ContinuousSignal) + else if (ciElement.InputType is CustomInterfaceElement.InputTypeOption.TickBox) { var tickBox = new GUITickBox(new RectTransform(new Vector2(1.0f, elementSize), uiElementContainer.RectTransform) { @@ -160,13 +156,10 @@ namespace Barotrauma.Items.Components }; tickBox.OnSelected += (tBox) => { - if (GameMain.Client == null) + TickBoxToggled(tBox.UserData as CustomInterfaceElement, tBox.Selected); + if (!suppressNetworkEvents && GameMain.Client != null) { - TickBoxToggled(tBox.UserData as CustomInterfaceElement, tBox.Selected); - } - else if (!readingNetworkEvent) - { - item.CreateClientEvent(this); + CreateClientEventWithCorrectionDelay(); } return true; }; @@ -175,7 +168,7 @@ namespace Barotrauma.Items.Components tickBox.RectTransform.MaxSize = new Point(int.MaxValue, int.MaxValue); uiElements.Add(tickBox); } - else + else if (ciElement.InputType is CustomInterfaceElement.InputTypeOption.Button) { var btn = new GUIButton(new RectTransform(new Vector2(1.0f, elementSize), uiElementContainer.RectTransform), TextManager.Get(ciElement.Label).Fallback(ciElement.Label), style: "DeviceButton") @@ -189,8 +182,10 @@ namespace Barotrauma.Items.Components { ButtonClicked(btnElement); } - else if (!readingNetworkEvent) + else if (!suppressNetworkEvents && GameMain.Client != null) { + //don't use CreateClientEventWithCorrectionDelay here, because buttons have no state, + //which means we don't need to worry about server updates interfering with client-side changes to the values in the interface item.CreateClientEvent(this, new EventData(btnElement)); } return true; @@ -203,6 +198,22 @@ namespace Barotrauma.Items.Components uiElements.Add(btn); } } + + if (ShowInsufficientPowerWarning) + { + insufficientPowerWarning = new GUITextBlock(new RectTransform(new Vector2(1.0f, 0.1f), GuiFrame.RectTransform, Anchor.BottomCenter, Pivot.TopCenter) { MinSize = new Point(0, GUI.IntScale(30)) }, + TextManager.Get("SteeringNoPowerTip"), font: GUIStyle.Font, wrap: true, style: "GUIToolTip", textAlignment: Alignment.Center) + { + AutoScaleHorizontal = true, + Visible = false + }; + } + + void CreateClientEventWithCorrectionDelay() + { + item.CreateClientEvent(this); + correctionTimer = CorrectionDelay; + } } public override void CreateEditingHUD(SerializableEntityEditor editor) @@ -253,7 +264,20 @@ namespace Barotrauma.Items.Components { if (uiElement.UserData is not CustomInterfaceElement element) { continue; } bool visible = Screen.Selected == GameMain.SubEditorScreen || element.StatusEffects.Any() || element.HasPropertyName || (element.Connection != null && element.Connection.Wires.Count > 0); - if (visible) { visibleElementCount++; } + if (visible) + { + visibleElementCount++; + if (element.GetValueInterval > 0.0f && correctionTimer <= 0.0f) + { + element.GetValueTimer -= deltaTime; + if (element.GetValueTimer <= 0.0f) + { + SetSignalToPropertyValue(element); + UpdateSignalProjSpecific(uiElement); + element.GetValueTimer = element.GetValueInterval; + } + } + } if (uiElement.Visible != visible) { uiElement.Visible = visible; @@ -274,6 +298,11 @@ namespace Barotrauma.Items.Components GuiFrame.Visible = visibleElementCount > 0; uiElementContainer.Recalculate(); } + + if (insufficientPowerWarning != null) + { + insufficientPowerWarning.Visible = item.GetComponents().Any(p => p.PowerConsumption > 0.0f && p.Voltage < p.MinVoltage); + } } partial void UpdateLabelsProjSpecific() @@ -300,7 +329,7 @@ namespace Barotrauma.Items.Components LocalizedString CreateLabelText(int elementIndex) { - var label = customInterfaceElementList[elementIndex].Label; + string label = customInterfaceElementList[elementIndex].Label; return string.IsNullOrWhiteSpace(label) ? TextManager.GetWithVariable("connection.signaloutx", "[num]", (elementIndex + 1).ToString()) : TextManager.Get(label).Fallback(label); @@ -336,22 +365,43 @@ namespace Barotrauma.Items.Components if (signals == null) { return; } for (int i = 0; i < signals.Length && i < uiElements.Count; i++) { - string signal = customInterfaceElementList[i].Signal; - if (uiElements[i] is GUITextBox tb) + UpdateSignalProjSpecific(uiElements[i]); + } + } + + private void UpdateSignalProjSpecific(GUIComponent uiElement) + { + if (uiElement.UserData is not CustomInterfaceElement element) { return; } + + suppressNetworkEvents = true; + + string signal = element.Signal; + if (uiElement is GUITextBox tb) + { + tb.Text = Screen.Selected is { IsEditor: true } ? + signal : + TextManager.Get(signal).Fallback(signal).Value; + } + else if (uiElement is GUINumberInput ni) + { + if (ni.InputType == NumberType.Int) { - tb.Text = Screen.Selected is { IsEditor: true } ? - signal : - TextManager.Get(signal).Fallback(signal).Value; - } - else if (uiElements[i] is GUINumberInput ni) - { - if (ni.InputType == NumberType.Int) + if (int.TryParse(signal, out int value)) { - int.TryParse(signal, out int value); ni.IntValue = value; } + else if (float.TryParse(signal, out float floatValue)) + { + ni.IntValue = (int)MathF.Round(floatValue); + } } } + else if (uiElement is GUITickBox tickBox) + { + tickBox.Selected = signal.Equals("true", StringComparison.OrdinalIgnoreCase); + } + + suppressNetworkEvents = false; } public void ClientEventWrite(IWriteMessage msg, NetEntityEvent.IData extraData = null) @@ -360,14 +410,9 @@ namespace Barotrauma.Items.Components for (int i = 0; i < customInterfaceElementList.Count; i++) { var element = customInterfaceElementList[i]; - if (element.HasPropertyName) + switch (element.InputType) { - if (!element.IsNumberInput) - { - msg.WriteString(((GUITextBox)uiElements[i]).Text); - } - else - { + case CustomInterfaceElement.InputTypeOption.Number: switch (element.NumberType) { case NumberType.Float: @@ -378,59 +423,82 @@ namespace Barotrauma.Items.Components msg.WriteString(((GUINumberInput)uiElements[i]).IntValue.ToString()); break; } - } - } - else if (element.ContinuousSignal) - { - msg.WriteBoolean(((GUITickBox)uiElements[i]).Selected); - } - else - { - msg.WriteBoolean(extraData is Item.ComponentStateEventData { ComponentData: EventData eventData } && eventData.BtnElement == customInterfaceElementList[i]); + break; + case CustomInterfaceElement.InputTypeOption.Text: + msg.WriteString(((GUITextBox)uiElements[i]).Text); + break; + case CustomInterfaceElement.InputTypeOption.TickBox: + msg.WriteBoolean(((GUITickBox)uiElements[i]).Selected); + break; + case CustomInterfaceElement.InputTypeOption.Button: + msg.WriteBoolean(extraData is Item.ComponentStateEventData { ComponentData: EventData eventData } && eventData.BtnElement == customInterfaceElementList[i]); + break; } } } public void ClientEventRead(IReadMessage msg, float sendingTime) { - readingNetworkEvent = true; + int msgStartPos = msg.BitPosition; + suppressNetworkEvents = true; try { + string[] stringValues = new string[customInterfaceElementList.Count]; + bool[] boolValues = new bool[customInterfaceElementList.Count]; for (int i = 0; i < customInterfaceElementList.Count; i++) { var element = customInterfaceElementList[i]; - if (element.HasPropertyName) + switch (element.InputType) { - string newValue = msg.ReadString(); - if (!element.IsNumberInput) - { - TextChanged(element, newValue); - } - else - { + case CustomInterfaceElement.InputTypeOption.Number: + case CustomInterfaceElement.InputTypeOption.Text: + stringValues[i] = msg.ReadString(); + break; + case CustomInterfaceElement.InputTypeOption.TickBox: + case CustomInterfaceElement.InputTypeOption.Button: + boolValues[i] = msg.ReadBoolean(); + break; + } + } + + if (correctionTimer > 0.0f) + { + int msgLength = msg.BitPosition - msgStartPos; + msg.BitPosition = msgStartPos; + StartDelayedCorrection(msg.ExtractBits(msgLength), sendingTime); + return; + } + + for (int i = 0; i < customInterfaceElementList.Count; i++) + { + var element = customInterfaceElementList[i]; + switch (element.InputType) + { + case CustomInterfaceElement.InputTypeOption.Number: switch (element.NumberType) { - case NumberType.Int when int.TryParse(newValue, out int value): + case NumberType.Int when int.TryParse(stringValues[i], out int value): ValueChanged(element, value); break; - case NumberType.Float when TryParseFloatInvariantCulture(newValue, out float value): + case NumberType.Float when TryParseFloatInvariantCulture(stringValues[i], out float value): ValueChanged(element, value); break; } - } - } - else - { - bool elementState = msg.ReadBoolean(); - if (element.ContinuousSignal) - { - ((GUITickBox)uiElements[i]).Selected = elementState; - TickBoxToggled(element, elementState); - } - else if (elementState) - { - ButtonClicked(element); - } + break; + case CustomInterfaceElement.InputTypeOption.Text: + TextChanged(element, stringValues[i]); + break; + case CustomInterfaceElement.InputTypeOption.TickBox: + bool tickBoxState = boolValues[i]; + ((GUITickBox)uiElements[i]).Selected = tickBoxState; + TickBoxToggled(element, tickBoxState); + break; + case CustomInterfaceElement.InputTypeOption.Button: + if (boolValues[i]) + { + ButtonClicked(element); + } + break; } } @@ -438,7 +506,7 @@ namespace Barotrauma.Items.Components } finally { - readingNetworkEvent = false; + suppressNetworkEvents = false; } } } diff --git a/Barotrauma/BarotraumaClient/ClientSource/Items/Components/Signal/Terminal.cs b/Barotrauma/BarotraumaClient/ClientSource/Items/Components/Signal/Terminal.cs index 21cad1ace..f890605ae 100644 --- a/Barotrauma/BarotraumaClient/ClientSource/Items/Components/Signal/Terminal.cs +++ b/Barotrauma/BarotraumaClient/ClientSource/Items/Components/Signal/Terminal.cs @@ -1,6 +1,7 @@ using Barotrauma.Extensions; using Barotrauma.Networking; using Microsoft.Xna.Framework; +using System.Collections.Generic; using System.Linq; using System.Xml.Linq; @@ -21,13 +22,16 @@ namespace Barotrauma.Items.Components private GUIListBox historyBox; private GUITextBlock fillerBlock; private GUITextBox inputBox; + private GUILayoutGroup layoutGroup; private bool shouldSelectInputBox; + private readonly List inputElements = new List(); + partial void InitProjSpecific(XElement element) { float marginMultiplier = element.GetAttributeFloat("marginmultiplier", 1.0f); - var layoutGroup = new GUILayoutGroup(new RectTransform(GuiFrame.Rect.Size - GUIStyle.ItemFrameMargin.Multiply(marginMultiplier), GuiFrame.RectTransform, Anchor.Center) { AbsoluteOffset = GUIStyle.ItemFrameOffset.Multiply(marginMultiplier) }) + layoutGroup = new GUILayoutGroup(new RectTransform(GuiFrame.Rect.Size - GUIStyle.ItemFrameMargin.Multiply(marginMultiplier), GuiFrame.RectTransform, Anchor.Center) { AbsoluteOffset = GUIStyle.ItemFrameOffset.Multiply(marginMultiplier) }) { ChildAnchor = Anchor.TopCenter, RelativeSpacing = 0.02f, @@ -39,43 +43,53 @@ namespace Barotrauma.Items.Components AutoHideScrollBar = this.AutoHideScrollbar }; - if (!Readonly) + inputElements.Add(CreateFillerBlock()); + inputElements.Add(new GUIFrame(new RectTransform(new Vector2(0.9f, 0.01f), layoutGroup.RectTransform), style: "HorizontalLine")); + + inputBox = new GUITextBox(new RectTransform(new Vector2(1, .1f), layoutGroup.RectTransform), textColor: TextColor) { - CreateFillerBlock(); - - new GUIFrame(new RectTransform(new Vector2(0.9f, 0.01f), layoutGroup.RectTransform), style: "HorizontalLine"); - - inputBox = new GUITextBox(new RectTransform(new Vector2(1, .1f), layoutGroup.RectTransform), textColor: TextColor) + MaxTextLength = MaxMessageLength, + OverflowClip = true, + OnEnterPressed = (GUITextBox textBox, string text) => { - MaxTextLength = MaxMessageLength, - OverflowClip = true, - OnEnterPressed = (GUITextBox textBox, string text) => + if (GameMain.NetworkMember == null) { - if (GameMain.NetworkMember == null) - { - SendOutput(text); - } - else - { - item.CreateClientEvent(this, new ClientEventData(text)); - } - textBox.Text = string.Empty; - return true; + SendOutput(text); } - }; - } + else + { + item.CreateClientEvent(this, new ClientEventData(text)); + } + textBox.Text = string.Empty; + return true; + } + }; + inputElements.Add(inputBox); + RefreshInputElements(); + } - layoutGroup.Recalculate(); + /// + /// Refreshes the visibility of the input box and the layout of the UI depending on whether the terminal is readonly or not. + /// + private void RefreshInputElements() + { + foreach (var inputElement in inputElements) + { + inputElement.Visible = !_readonly; + inputElement.IgnoreLayoutGroups = !inputElement.Visible; + } + layoutGroup?.Recalculate(); } // Create fillerBlock to cover historyBox so new values appear at the bottom of historyBox // This could be removed if GUIListBox supported aligning its children - public void CreateFillerBlock() + public GUIComponent CreateFillerBlock() { fillerBlock = new GUITextBlock(new RectTransform(new Vector2(1, 1), historyBox.Content.RectTransform, anchor: Anchor.TopCenter), string.Empty) { CanBeFocused = false }; + return fillerBlock; } private void SendOutput(string input) 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 9bafa3d1a..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)] - public float Width - { - get; - set; - } - public Vector2 DrawSize { get { return sectionExtents; } @@ -199,6 +192,8 @@ namespace Barotrauma.Items.Components return; } + if (Width * wireSprite.size.Y * Screen.Selected.Cam.Zoom < 1.0f) { return; } + Vector2 drawOffset = GetDrawOffset() + offset; float baseDepth = UseSpriteDepth ? item.SpriteDepth : wireSprite.Depth; diff --git a/Barotrauma/BarotraumaClient/ClientSource/Items/Components/StatusHUD.cs b/Barotrauma/BarotraumaClient/ClientSource/Items/Components/StatusHUD.cs index a47e99738..078f49ebd 100644 --- a/Barotrauma/BarotraumaClient/ClientSource/Items/Components/StatusHUD.cs +++ b/Barotrauma/BarotraumaClient/ClientSource/Items/Components/StatusHUD.cs @@ -175,6 +175,8 @@ namespace Barotrauma.Items.Components { if (character == null) { return; } + base.DrawHUD(spriteBatch, character); + if (OverlayColor.A > 0) { GUIStyle.UIGlow.Draw(spriteBatch, new Rectangle(0, 0, GameMain.GraphicsWidth, GameMain.GraphicsHeight), @@ -206,44 +208,51 @@ namespace Barotrauma.Items.Components if (ThermalGoggles) { - spriteBatch.End(); - GameMain.LightManager.SolidColorEffect.Parameters["color"].SetValue(Color.Red.ToVector4() * (0.3f + MathF.Sin(thermalEffectState) * 0.05f)); - GameMain.LightManager.SolidColorEffect.CurrentTechnique = GameMain.LightManager.SolidColorEffect.Techniques["SolidColorBlur"]; - GameMain.LightManager.SolidColorEffect.Parameters["blurDistance"].SetValue(0.01f + MathF.Sin(thermalEffectState) * 0.005f); - GameMain.LightManager.SolidColorEffect.CurrentTechnique.Passes[0].Apply(); - spriteBatch.Begin(SpriteSortMode.Deferred, BlendState.Additive, transformMatrix: Screen.Selected.Cam.Transform, effect: GameMain.LightManager.SolidColorEffect); - Entity refEntity = equipper; if (!isEquippable || refEntity == null) { refEntity = item; } + DrawThermalOverlay(spriteBatch, refEntity, character, OverlayColor, Range, thermalEffectState, ShowDeadCharacters); - foreach (Character c in Character.CharacterList) - { - if (c == character || !c.Enabled || c.Removed || c.Params.HideInThermalGoggles) { continue; } - if (!ShowDeadCharacters && c.IsDead) { continue; } - - float dist = Vector2.DistanceSquared(refEntity.WorldPosition, c.WorldPosition); - if (dist > Range * Range) { continue; } - - Sprite pingCircle = GUIStyle.UIThermalGlow.Value.Sprite; - foreach (Limb limb in c.AnimController.Limbs) - { - if (limb.Mass < 0.5f && limb != c.AnimController.MainLimb) { continue; } - float noise1 = PerlinNoise.GetPerlin((thermalEffectState + limb.Params.ID + c.ID) * 0.01f, (thermalEffectState + limb.Params.ID + c.ID) * 0.02f); - float noise2 = PerlinNoise.GetPerlin((thermalEffectState + limb.Params.ID + c.ID) * 0.01f, (thermalEffectState + limb.Params.ID + c.ID) * 0.008f); - Vector2 spriteScale = ConvertUnits.ToDisplayUnits(limb.body.GetSize()) / pingCircle.size * (noise1 * 0.5f + 2f); - Vector2 drawPos = new Vector2(limb.body.DrawPosition.X + (noise1 - 0.5f) * 100, -limb.body.DrawPosition.Y + (noise2 - 0.5f) * 100); - pingCircle.Draw(spriteBatch, drawPos, 0.0f, scale: Math.Max(spriteScale.X, spriteScale.Y)); - } - } - - spriteBatch.End(); - spriteBatch.Begin(SpriteSortMode.Deferred, BlendState.NonPremultiplied); } } + public static void DrawThermalOverlay(SpriteBatch spriteBatch, Entity refEntity, Character user, Color overlayColor, float range, float effectState, bool showDeadCharacters) + { + spriteBatch.End(); + float colorIntensityBase = 0.5f; //Multiplies the overlay color by this amount, the higher the value, the more bright/vibrant the color. + float colorIntensityVariance = 0.05f; //The variance of the pulse effect affecting the color's brightness/vibrance + GameMain.LightManager.SolidColorEffect.Parameters["color"].SetValue(overlayColor.ToVector4() * (colorIntensityBase + MathF.Sin(effectState) * colorIntensityVariance)); + GameMain.LightManager.SolidColorEffect.CurrentTechnique = GameMain.LightManager.SolidColorEffect.Techniques["SolidColorBlur"]; + GameMain.LightManager.SolidColorEffect.Parameters["blurDistance"].SetValue(0.01f + MathF.Sin(effectState) * 0.005f); + GameMain.LightManager.SolidColorEffect.CurrentTechnique.Passes[0].Apply(); + spriteBatch.Begin(SpriteSortMode.Deferred, BlendState.Additive, transformMatrix: Screen.Selected.Cam.Transform, effect: GameMain.LightManager.SolidColorEffect); + + foreach (Character c in Character.CharacterList) + { + if (c == user || !c.Enabled || c.Removed || c.Params.HideInThermalGoggles) { continue; } + if (!showDeadCharacters && c.IsDead) { continue; } + + float dist = Vector2.DistanceSquared(refEntity.WorldPosition, c.WorldPosition); + if (dist > range * range) { continue; } + + Sprite pingCircle = GUIStyle.UIThermalGlow.Value.Sprite; + foreach (Limb limb in c.AnimController.Limbs) + { + if (limb.Mass < 0.5f && limb != c.AnimController.MainLimb) { continue; } + float noise1 = PerlinNoise.GetPerlin((effectState + limb.Params.ID + c.ID) * 0.01f, (effectState + limb.Params.ID + c.ID) * 0.02f); + float noise2 = PerlinNoise.GetPerlin((effectState + limb.Params.ID + c.ID) * 0.01f, (effectState + limb.Params.ID + c.ID) * 0.008f); + Vector2 spriteScale = ConvertUnits.ToDisplayUnits(limb.body.GetSize()) / pingCircle.size * (noise1 * 0.5f + 2f); + Vector2 drawPos = new Vector2(limb.body.DrawPosition.X + (noise1 - 0.5f) * 100, -limb.body.DrawPosition.Y + (noise2 - 0.5f) * 100); + pingCircle.Draw(spriteBatch, drawPos, 0.0f, scale: Math.Max(spriteScale.X, spriteScale.Y)); + } + } + + spriteBatch.End(); + spriteBatch.Begin(SpriteSortMode.Deferred, BlendState.NonPremultiplied); + } + private void DrawCharacterInfo(SpriteBatch spriteBatch, Character target, float alpha = 1.0f) { Vector2 hudPos = GameMain.GameScreen.Cam.WorldToScreen(target.DrawPosition); @@ -273,7 +282,7 @@ namespace Barotrauma.Items.Components } else { - if (!target.CustomInteractHUDText.IsNullOrEmpty() && target.AllowCustomInteract) + if (target.ShouldShowCustomInteractText) { texts.Add(target.CustomInteractHUDText); textColors.Add(GUIStyle.Green); diff --git a/Barotrauma/BarotraumaClient/ClientSource/Items/Components/TriggerComponent.cs b/Barotrauma/BarotraumaClient/ClientSource/Items/Components/TriggerComponent.cs index 77fe1ba5a..aafc299b0 100644 --- a/Barotrauma/BarotraumaClient/ClientSource/Items/Components/TriggerComponent.cs +++ b/Barotrauma/BarotraumaClient/ClientSource/Items/Components/TriggerComponent.cs @@ -1,12 +1,28 @@ using Barotrauma.Networking; +using Microsoft.Xna.Framework; +using Microsoft.Xna.Framework.Graphics; +using System; namespace Barotrauma.Items.Components { - partial class TriggerComponent : ItemComponent, IServerSerializable + partial class TriggerComponent : ItemComponent, IServerSerializable, IDrawableComponent { + public Vector2 DrawSize => + Vector2.One * + (Radius > 0.0f ? Radius * 2 : Math.Max(Width, Height)); + + public void Draw(SpriteBatch spriteBatch, bool editing, float itemDepth = -1, Color? overrideColor = null) + { + if (editing) + { + PhysicsBody.DebugDraw(spriteBatch, Color.LightGray * 0.7f); + } + } + public void ClientEventRead(IReadMessage msg, float sendingTime) { CurrentForceFluctuation = msg.ReadRangedSingle(0.0f, 1.0f, 8); } + } } diff --git a/Barotrauma/BarotraumaClient/ClientSource/Items/Components/Turret.cs b/Barotrauma/BarotraumaClient/ClientSource/Items/Components/Turret.cs index 53117dd8f..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; } } @@ -227,14 +228,14 @@ namespace Barotrauma.Items.Components { if (moveSoundChannel == null && startMoveSound != null) { - moveSoundChannel = SoundPlayer.PlaySound(startMoveSound.Sound, item.WorldPosition, startMoveSound.Volume, startMoveSound.Range, ignoreMuffling: startMoveSound.IgnoreMuffling, freqMult: startMoveSound.GetRandomFrequencyMultiplier()); + moveSoundChannel = SoundPlayer.PlaySound(startMoveSound, item.WorldPosition, hullGuess: item.CurrentHull); } else if (moveSoundChannel == null || !moveSoundChannel.IsPlaying) { if (moveSound != null) { moveSoundChannel?.FadeOutAndDispose(); - moveSoundChannel = SoundPlayer.PlaySound(moveSound.Sound, item.WorldPosition, moveSound.Volume, moveSound.Range, ignoreMuffling: moveSound.IgnoreMuffling, freqMult: moveSound.GetRandomFrequencyMultiplier()); + moveSoundChannel = SoundPlayer.PlaySound(moveSound, item.WorldPosition, hullGuess: item.CurrentHull); if (moveSoundChannel != null) { moveSoundChannel.Looping = true;} } } @@ -246,7 +247,7 @@ namespace Barotrauma.Items.Components if (endMoveSound != null && moveSoundChannel.Sound != endMoveSound.Sound) { moveSoundChannel.FadeOutAndDispose(); - moveSoundChannel = SoundPlayer.PlaySound(endMoveSound.Sound, item.WorldPosition, endMoveSound.Volume, endMoveSound.Range, ignoreMuffling: endMoveSound.IgnoreMuffling, freqMult: endMoveSound.GetRandomFrequencyMultiplier()); + moveSoundChannel = SoundPlayer.PlaySound(endMoveSound, item.WorldPosition, hullGuess: item.CurrentHull); if (moveSoundChannel != null) { moveSoundChannel.Looping = false; } } else if (!moveSoundChannel.IsPlaying) @@ -275,7 +276,7 @@ namespace Barotrauma.Items.Components { if (chargeSound != null) { - chargeSoundChannel = SoundPlayer.PlaySound(chargeSound.Sound, item.WorldPosition, chargeSound.Volume, chargeSound.Range, ignoreMuffling: chargeSound.IgnoreMuffling, freqMult: chargeSound.GetRandomFrequencyMultiplier()); + chargeSoundChannel = SoundPlayer.PlaySound(chargeSound, item.WorldPosition, hullGuess: item.CurrentHull); if (chargeSoundChannel != null) { chargeSoundChannel.Looping = true; } } } @@ -385,17 +386,20 @@ namespace Barotrauma.Items.Components if (item.Condition > 0.0f || !HideBarrelWhenBroken) { - railSprite?.Draw(spriteBatch, + var currentRailSprite = item.Condition <= 0.0f && railSpriteBroken != null ? railSpriteBroken : railSprite; + var currentBarrelSprite = item.Condition <= 0.0f && barrelSpriteBroken != null ? barrelSpriteBroken : barrelSprite; + + currentRailSprite?.Draw(spriteBatch, drawPos, overrideColor ?? item.SpriteColor, Rotation + MathHelper.PiOver2, item.Scale, - SpriteEffects.None, item.SpriteDepth + (railSprite.Depth - item.Sprite.Depth)); + SpriteEffects.None, item.SpriteDepth + (currentRailSprite.Depth - item.Sprite.Depth)); - barrelSprite?.Draw(spriteBatch, + currentBarrelSprite?.Draw(spriteBatch, drawPos - GetRecoilOffset() * item.Scale, overrideColor ?? item.SpriteColor, Rotation + MathHelper.PiOver2, item.Scale, - SpriteEffects.None, item.SpriteDepth + (barrelSprite.Depth - item.Sprite.Depth)); + SpriteEffects.None, item.SpriteDepth + (currentBarrelSprite.Depth - item.Sprite.Depth)); float chargeRatio = currentChargeTime / MaxChargeTime; @@ -702,12 +706,14 @@ namespace Barotrauma.Items.Components public override void DrawHUD(SpriteBatch spriteBatch, Character character) { + base.DrawHUD(spriteBatch, character); + if (HudTint.A > 0) { GUI.DrawRectangle(spriteBatch, new Rectangle(0, 0, GameMain.GraphicsWidth, GameMain.GraphicsHeight), new Color(HudTint.R, HudTint.G, HudTint.B) * (HudTint.A / 255.0f), true); } - + GetAvailablePower(out float batteryCharge, out float batteryCapacity); List availableAmmo = new List(); diff --git a/Barotrauma/BarotraumaClient/ClientSource/Items/Inventory.cs b/Barotrauma/BarotraumaClient/ClientSource/Items/Inventory.cs index d756c442c..0c73cf49e 100644 --- a/Barotrauma/BarotraumaClient/ClientSource/Items/Inventory.cs +++ b/Barotrauma/BarotraumaClient/ClientSource/Items/Inventory.cs @@ -1359,7 +1359,7 @@ namespace Barotrauma if (selectedInventory.GetItemAt(slotIndex)?.OwnInventory?.Container is { } container && container.Inventory.CanBePut(item)) { - if (!container.AllowDragAndDrop || !container.AllowAccess) + if (!container.AllowDragAndDrop || !container.IsAccessible()) { allowCombine = false; } @@ -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 b626f62d4..e70235e79 100644 --- a/Barotrauma/BarotraumaClient/ClientSource/Items/Item.cs +++ b/Barotrauma/BarotraumaClient/ClientSource/Items/Item.cs @@ -130,7 +130,7 @@ namespace Barotrauma } } - public float GetDrawDepth() + public override float GetDrawDepth() { return GetDrawDepth(SpriteDepth + DrawDepthOffset, Sprite); } @@ -287,7 +287,7 @@ namespace Barotrauma } else { - int padding = 100; + int padding = 0; RectangleF boundingBox = GetTransformedQuad().BoundingAxisAlignedRectangle; Vector2 min = new Vector2(-boundingBox.Width / 2 - padding, -boundingBox.Height / 2 - padding); @@ -302,11 +302,11 @@ namespace Barotrauma } foreach (DecorativeSprite decorativeSprite in Prefab.DecorativeSprites) { - float scale = decorativeSprite.GetScale(spriteAnimState[decorativeSprite].RandomScaleFactor) * Scale; - min.X = Math.Min(-decorativeSprite.Sprite.size.X * decorativeSprite.Sprite.RelativeOrigin.X * scale, min.X); - min.Y = Math.Min(-decorativeSprite.Sprite.size.Y * (1.0f - decorativeSprite.Sprite.RelativeOrigin.Y) * scale, min.Y); - max.X = Math.Max(decorativeSprite.Sprite.size.X * (1.0f - decorativeSprite.Sprite.RelativeOrigin.X) * scale, max.X); - max.Y = Math.Max(decorativeSprite.Sprite.size.Y * decorativeSprite.Sprite.RelativeOrigin.Y * scale, max.Y); + Vector2 scale = decorativeSprite.GetScale(ref spriteAnimState[decorativeSprite].ScaleState, spriteAnimState[decorativeSprite].RandomScaleFactor) * Scale; + min.X = Math.Min(-decorativeSprite.Sprite.size.X * decorativeSprite.Sprite.RelativeOrigin.X * scale.X, min.X); + min.Y = Math.Min(-decorativeSprite.Sprite.size.Y * (1.0f - decorativeSprite.Sprite.RelativeOrigin.Y) * scale.Y, min.Y); + max.X = Math.Max(decorativeSprite.Sprite.size.X * (1.0f - decorativeSprite.Sprite.RelativeOrigin.X) * scale.X, max.X); + max.Y = Math.Max(decorativeSprite.Sprite.size.Y * decorativeSprite.Sprite.RelativeOrigin.Y * scale.Y, max.Y); } cachedVisibleExtents = extents = new Rectangle(min.ToPoint(), max.ToPoint()); } @@ -316,6 +316,9 @@ namespace Barotrauma if (worldPosition.X + extents.X > worldView.Right || worldPosition.X + extents.Width < worldView.X) { return false; } if (worldPosition.Y + extents.Height < worldView.Y - worldView.Height || worldPosition.Y + extents.Y > worldView.Y) { return false; } + if (extents.Width * Screen.Selected.Cam.Zoom < 1.0f) { return false; } + if (extents.Height * Screen.Selected.Cam.Zoom < 1.0f) { return false; } + return true; } @@ -405,7 +408,7 @@ namespace Barotrauma fadeInBrokenSprite.Sprite.effects ^= SpriteEffects; } - if (body == null) + if (body == null || body.BodyType == BodyType.Static) { if (Prefab.ResizeHorizontal || Prefab.ResizeVertical) { @@ -490,7 +493,7 @@ namespace Barotrauma } } var head = holdable.Picker.AnimController.GetLimb(LimbType.Head); - if (head != null) + if (head?.Sprite != null) { //ensure the holdable item is always drawn in front of the head no matter what the wearables or whatnot do with the sprite depths depth = @@ -523,8 +526,8 @@ namespace Barotrauma Vector2 offset = decorativeSprite.GetOffset(ref spriteAnimState[decorativeSprite].OffsetState, spriteAnimState[decorativeSprite].RandomOffsetMultiplier, -RotationRad) * Scale; if (flippedX && Prefab.CanSpriteFlipX) { offset.X = -offset.X; } if (flippedY && Prefab.CanSpriteFlipY) { offset.Y = -offset.Y; } - decorativeSprite.Sprite.Draw(spriteBatch, new Vector2(DrawPosition.X + offset.X, -(DrawPosition.Y + offset.Y)), color, - rotation, decorativeSprite.GetScale(spriteAnimState[decorativeSprite].RandomScaleFactor) * Scale, activeSprite.effects, + decorativeSprite.Sprite.Draw(spriteBatch, new Vector2(DrawPosition.X + offset.X, -(DrawPosition.Y + offset.Y)), color, decorativeSprite.Sprite.Origin, + rotation, decorativeSprite.GetScale(ref spriteAnimState[decorativeSprite].ScaleState, spriteAnimState[decorativeSprite].RandomScaleFactor) * Scale, activeSprite.effects, depth: depth + (decorativeSprite.Sprite.Depth - activeSprite.Depth)); } } @@ -673,14 +676,8 @@ 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(spriteAnimState[decorativeSprite].RandomScaleFactor) * Scale, spriteEffects, + -rotation + spriteRotation, decorativeSprite.GetScale(ref spriteAnimState[decorativeSprite].ScaleState, spriteAnimState[decorativeSprite].RandomScaleFactor) * Scale, spriteEffects, depth: depth + (decorativeSprite.Sprite.Depth - activeSprite.Depth)); } } @@ -795,6 +792,11 @@ namespace Barotrauma } } } + + foreach (var containedItem in ContainedItems) + { + containedItem.UpdateSpriteStates(deltaTime); + } } public override void UpdateEditing(Camera cam, float deltaTime) @@ -1069,12 +1071,18 @@ namespace Barotrauma foreach (RelatedItem relatedItem in requiredItems) { - //TODO: add to localization var textBlock = new GUITextBlock(new RectTransform(new Point(listBox.Content.Rect.Width, heightScaled)), - relatedItem.Type.ToString() + " required", font: GUIStyle.SmallFont) + TextManager.Get($"{relatedItem.Type}.required").Fallback($"{relatedItem.Type} required"), font: GUIStyle.SmallFont) { Padding = new Vector4(10.0f, 0.0f, 10.0f, 0.0f) }; + + var tooltip = TextManager.Get($"{relatedItem.Type}.required.tooltip").Fallback(LocalizedString.EmptyString); + if (!tooltip.IsNullOrWhiteSpace()) + { + textBlock.ToolTip = tooltip; + } + textBlock.RectTransform.IsFixedSize = true; componentEditor.AddCustomContent(textBlock, 1); @@ -1543,10 +1551,19 @@ namespace Barotrauma debugInitialHudPositions.Clear(); foreach (ItemComponent ic in activeHUDs) { - if (ic.GuiFrame == null || ic.AllowUIOverlap || ic.GetLinkUIToComponent() != null) { continue; } - if (!ignoreLocking && ic.LockGuiFramePosition) { continue; } - //if the frame covers nearly all of the screen, don't trying to prevent overlaps because it'd fail anyway - if (ic.GuiFrame.Rect.Width >= GameMain.GraphicsWidth * 0.9f && ic.GuiFrame.Rect.Height >= GameMain.GraphicsHeight * 0.9f) { continue; } + if (ic.GuiFrame == null || ic.GetLinkUIToComponent() != null) { continue; } + + bool nearlyCoversScreen = ic.GuiFrame.Rect.Width >= GameMain.GraphicsWidth * 0.9f && + ic.GuiFrame.Rect.Height >= GameMain.GraphicsHeight * 0.9f; + + // when we are not using overlap prevention, we still need to clamp the frame to the screen area to + // prevent frames becoming inaccessible outside the screen for example after a resolution change + if (ic.AllowUIOverlap || (!ignoreLocking && ic.LockGuiFramePosition) || nearlyCoversScreen) + { + ic.GuiFrame.ClampToArea(new Rectangle(0, 0, GameMain.GraphicsWidth, GameMain.GraphicsHeight)); + continue; + } + ic.GuiFrame.RectTransform.ScreenSpaceOffset = ic.GuiFrameOffset; elementsToMove.Add(ic.GuiFrame); debugInitialHudPositions.Add(ic.GuiFrame.Rect); @@ -1744,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(','); @@ -2074,6 +2091,17 @@ namespace Barotrauma } } break; + case EventType.SwapItem: + ushort newId = msg.ReadUInt16(); + uint prefabUintId = msg.ReadUInt32(); + ItemPrefab newPrefab = ItemPrefab.Prefabs.FirstOrDefault(p => p.UintIdentifier == prefabUintId); + if (newPrefab is null) + { + DebugConsole.ThrowError($"Error while reading {EventType.SwapItem} message: could not find an item prefab with the hash {prefabUintId}."); + break; + } + ReplaceFromNetwork(newPrefab, newId); + break; default: throw new Exception($"Malformed incoming item event: unsupported event type {eventType}"); } @@ -2171,6 +2199,18 @@ namespace Barotrauma } } + //if the item is outside the level, but not in a sub, it implies the item is inside a sub server-side but the client failed to properly move it + // -> let's correct that by finding the correct sub + if (Level.IsPositionAboveLevel(WorldPosition) && Submarine == null) + { + var newSub = Submarine.FindContainingInLocalCoordinates(ConvertUnits.ToDisplayUnits(body.SimPosition), inflate: 0.0f); + if (newSub != null) + { + Submarine = newSub; + FindHull(); + } + } + Vector2 displayPos = ConvertUnits.ToDisplayUnits(body.SimPosition); rect.X = (int)(displayPos.X - rect.Width / 2.0f); rect.Y = (int)(displayPos.Y + rect.Height / 2.0f); @@ -2249,7 +2289,13 @@ namespace Barotrauma if (!components.Contains(ic)) { return; } var eventData = new ComponentStateEventData(ic, extraData); - if (!ic.ValidateEventData(eventData)) { throw new Exception($"Component event creation failed: {typeof(T).Name}.{nameof(ItemComponent.ValidateEventData)} returned false"); } + if (!ic.ValidateEventData(eventData)) { + string errorMsg = + $"Client-side component event creation for the item \"{Prefab.Identifier}\" failed: {typeof(T).Name}.{nameof(ItemComponent.ValidateEventData)} returned false. " + + $"Data: {extraData?.GetType().ToString() ?? "null"}"; + GameAnalyticsManager.AddErrorEventOnce($"Item.CreateClientEvent:ValidateEventData:{Prefab.Identifier}", GameAnalyticsManager.ErrorSeverity.Error, errorMsg); + throw new Exception(errorMsg); + } GameMain.Client.CreateEntityEvent(this, eventData); } @@ -2329,15 +2375,15 @@ namespace Barotrauma ownerSheetIndex = (x, y); } - bool tagsChanged = msg.ReadBoolean(); + bool tagsChanged = msg.ReadBoolean(); string tags = ""; if (tagsChanged) { - HashSet addedTags = msg.ReadString().Split(',').ToIdentifiers().ToHashSet(); - HashSet removedTags = msg.ReadString().Split(',').ToIdentifiers().ToHashSet(); + HashSet addedTags = msg.ReadString().ToIdentifiers().ToHashSet(); + HashSet removedTags = msg.ReadString().ToIdentifiers().ToHashSet(); if (itemPrefab != null) { - tags = string.Join(',',itemPrefab.Tags.Where(t => !removedTags.Contains(t)).Concat(addedTags)); + tags = string.Join(',', itemPrefab.Tags.Where(t => !removedTags.Contains(t)).Union(addedTags)); } } @@ -2455,12 +2501,24 @@ namespace Barotrauma if (inventory != null) { - if (inventorySlotIndex >= 0 && inventorySlotIndex < 255 && - inventory.TryPutItem(item, inventorySlotIndex, false, false, null, false)) + if (inventorySlotIndex is >= 0 and < 255 && + !inventory.TryPutItem(item, inventorySlotIndex, allowSwapping: false, allowCombine: false, user: null, createNetworkEvent: false, ignoreCondition: true) && + inventory.IsSlotEmpty(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, null, item.AllowedSlots, 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/Gap.cs b/Barotrauma/BarotraumaClient/ClientSource/Map/Gap.cs index 419253352..470094abf 100644 --- a/Barotrauma/BarotraumaClient/ClientSource/Map/Gap.cs +++ b/Barotrauma/BarotraumaClient/ClientSource/Map/Gap.cs @@ -20,7 +20,8 @@ namespace Barotrauma public override bool IsVisible(Rectangle worldView) { - return Screen.Selected == GameMain.SubEditorScreen || GameMain.DebugDraw; + if (Screen.Selected != GameMain.SubEditorScreen && !GameMain.DebugDraw) { return false; } + return base.IsVisible(worldView); } public override void Draw(SpriteBatch sb, bool editing, bool back = true) @@ -95,9 +96,13 @@ namespace Barotrauma new Vector2(Math.Sign(targetHull.Rect.Center.X - rect.Center.X), 0.0f) : new Vector2(0.0f, Math.Sign((rect.Y - rect.Height / 2.0f) - (targetHull.Rect.Y - targetHull.Rect.Height / 2.0f))); - Vector2 arrowPos = new Vector2(WorldRect.Center.X, -(WorldRect.Y - WorldRect.Height / 2)); + Vector2 arrowPos = new Vector2(WorldRect.Center.X, WorldRect.Y - WorldRect.Height / 2); + if (Submarine != null) + { + arrowPos += (Submarine.DrawPosition - Submarine.Position); + } + arrowPos.Y = -arrowPos.Y; arrowPos += new Vector2(dir.X * (WorldRect.Width / 2), dir.Y * (WorldRect.Height / 2)); - bool invalidDir = false; if (dir == Vector2.Zero) { diff --git a/Barotrauma/BarotraumaClient/ClientSource/Map/Hull.cs b/Barotrauma/BarotraumaClient/ClientSource/Map/Hull.cs index 5841d8dc6..c4fb4318d 100644 --- a/Barotrauma/BarotraumaClient/ClientSource/Map/Hull.cs +++ b/Barotrauma/BarotraumaClient/ClientSource/Map/Hull.cs @@ -81,16 +81,12 @@ namespace Barotrauma public override bool IsVisible(Rectangle worldView) { if (BallastFlora != null) { return true; } - if (Screen.Selected != GameMain.SubEditorScreen && !GameMain.DebugDraw) { if (decals.Count == 0 && paintAmount < minimumPaintAmountToDraw) { return false; } - Rectangle worldRect = WorldRect; - if (worldRect.X > worldView.Right || worldRect.Right < worldView.X) { return false; } - if (worldRect.Y < worldView.Y - worldView.Height || worldRect.Y - worldRect.Height > worldView.Y) { return false; } } - return true; + return base.IsVisible(worldView); } public override bool IsMouseOn(Vector2 position) @@ -103,12 +99,31 @@ namespace Barotrauma private GUIComponent CreateEditingHUD(bool inGame = false) { + int heightScaled = GUI.IntScale(20); editingHUD = new GUIFrame(new RectTransform(new Vector2(0.3f, 0.25f), GUI.Canvas, Anchor.CenterRight) { MinSize = new Point(400, 0) }) { UserData = this }; GUIListBox listBox = new GUIListBox(new RectTransform(new Vector2(0.95f, 0.8f), editingHUD.RectTransform, Anchor.Center), style: null) { CanTakeKeyBoardFocus = false }; - new SerializableEntityEditor(listBox.Content.RectTransform, this, inGame, showName: true, titleFont: GUIStyle.LargeFont); + var hullEditor = new SerializableEntityEditor(listBox.Content.RectTransform, this, inGame, showName: true, titleFont: GUIStyle.LargeFont); + + if (!inGame) + { + if (Linkable) + { + var linkText = new GUITextBlock(new RectTransform(new Point(editingHUD.Rect.Width, heightScaled), isFixedSize: true), TextManager.Get("HoldToLink"), font: GUIStyle.SmallFont); + var hullLinkText = new GUITextBlock(new RectTransform(new Point(editingHUD.Rect.Width, heightScaled), isFixedSize: true), TextManager.Get("hulllinkinfo"), font: GUIStyle.SmallFont); + var itemsText = new GUITextBlock(new RectTransform(new Point(editingHUD.Rect.Width, heightScaled), isFixedSize: true), TextManager.Get("AllowedLinks"), font: GUIStyle.SmallFont); + LocalizedString allowedItems = AllowedLinks.None() ? TextManager.Get("None") : string.Join(", ", AllowedLinks); + itemsText.Text = TextManager.AddPunctuation(':', itemsText.Text, allowedItems); + hullEditor.AddCustomContent(linkText, 1); + hullEditor.AddCustomContent(hullLinkText, 2); + hullEditor.AddCustomContent(itemsText, 3); + linkText.TextColor = GUIStyle.Orange; + hullLinkText.TextColor = GUIStyle.Orange; + itemsText.TextColor = GUIStyle.Orange; + } + } PositionEditingHUD(); @@ -389,8 +404,8 @@ namespace Barotrauma //GUI.DrawRectangle(spriteBatch, new Rectangle((int)fs.LastExtinguishPos.X, (int)-fs.LastExtinguishPos.Y, 5,5), Color.Yellow, true); } - - GUI.DrawLine(spriteBatch, new Vector2(drawRect.X, -WorldSurface), new Vector2(drawRect.Right, -WorldSurface), Color.Cyan * 0.5f); + float worldSurface = surface + Submarine.DrawPosition.Y; + GUI.DrawLine(spriteBatch, new Vector2(drawRect.X, -worldSurface), new Vector2(drawRect.Right, -worldSurface), Color.Cyan * 0.5f); for (int i = 0; i < waveY.Length - 1; i++) { GUI.DrawLine(spriteBatch, @@ -563,8 +578,7 @@ namespace Barotrauma corners[4] = new Vector3(x, bottom, 0.0f); //bottom right corners[5] = new Vector3(x + width, bottom, 0.0f); - - Vector2[] uvCoords = new Vector2[4]; + for (int n = 0; n < 4; n++) { uvCoords[n] = Vector2.Transform(new Vector2(corners[n].X, -corners[n].Y), transform); 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/CaveGenerator.cs b/Barotrauma/BarotraumaClient/ClientSource/Map/Levels/CaveGenerator.cs index 9a071dfb8..1bb061414 100644 --- a/Barotrauma/BarotraumaClient/ClientSource/Map/Levels/CaveGenerator.cs +++ b/Barotrauma/BarotraumaClient/ClientSource/Map/Levels/CaveGenerator.cs @@ -9,26 +9,56 @@ namespace Barotrauma { static partial class CaveGenerator { - public static List GenerateWallVertices(List triangles, LevelGenerationParams generationParams, float zCoord) + public static List GenerateWallVertices(List triangles, Color color, float zCoord) { - var vertices = new List(); + var vertices = new List(); for (int i = 0; i < triangles.Count; i++) { foreach (Vector2 vertex in triangles[i]) { - Vector2 uvCoords = vertex / generationParams.WallTextureSize; - vertices.Add(new VertexPositionTexture(new Vector3(vertex, zCoord), uvCoords)); + vertices.Add(new VertexPositionColor(new Vector3(vertex, zCoord), color)); } } - return vertices; } - public static List GenerateWallEdgeVertices(List cells, Level level, float zCoord) + /// + /// Generates texture coordinates for the vertices based on their positions + /// + public static VertexPositionColorTexture[] ConvertToTextured(VertexPositionColor[] verts, float textureSize) { - float outWardThickness = level.GenerationParams.WallEdgeExpandOutwardsAmount; + VertexPositionColorTexture[] texturedVerts = new VertexPositionColorTexture[verts.Length]; + for (int i = 0; i < verts.Length; i++) + { + VertexPositionColor vertex = verts[i]; + texturedVerts[i] = new VertexPositionColorTexture(vertex.Position, vertex.Color, textureCoordinate: Vector2.Zero); + } + GenerateTextureCoordinates(texturedVerts, textureSize); + return texturedVerts; + } - List vertices = new List(); + /// + /// Generates texture coordinates for the vertices based on their positions + /// + public static void GenerateTextureCoordinates(VertexPositionColorTexture[] verts, float textureSize) + { + for (int i = 0; i < verts.Length; i++) + { + VertexPositionColorTexture vertex = verts[i]; + Vector2 uvCoords = new Vector2(vertex.Position.X, vertex.Position.Y) / textureSize; + verts[i] = new VertexPositionColorTexture(verts[i].Position, verts[i].Color, uvCoords); + } + } + + public static List GenerateWallEdgeVertices( + List cells, + float expandOutwards, float expandInwards, + Color outerColor, Color innerColor, + Level level, float zCoord, bool preventExpandThroughCell = false) + { + float outWardThickness = expandOutwards; + + List vertices = new List(); foreach (VoronoiCell cell in cells) { Vector2 minVert = cell.Edges[0].Point1; @@ -49,7 +79,10 @@ namespace Barotrauma { if (!edge.IsSolid) { continue; } - GraphEdge leftEdge = cell.Edges.Find(e => e != edge && (edge.Point1.NearlyEquals(e.Point1) || edge.Point1.NearlyEquals(e.Point2))); + //the left-side edge on this same cell + GraphEdge myLeftEdge = cell.Edges.Find(e => e != edge && (edge.Point1.NearlyEquals(e.Point1) || edge.Point1.NearlyEquals(e.Point2))); + //the left-side edge on either this cell, or the adjacent one if this is attached to another cell + GraphEdge leftEdge = myLeftEdge; var leftAdjacentCell = leftEdge?.AdjacentCell(cell); if (leftAdjacentCell != null) { @@ -57,7 +90,10 @@ namespace Barotrauma if (adjEdge != null) { leftEdge = adjEdge; } } - GraphEdge rightEdge = cell.Edges.Find(e => e != edge && (edge.Point2.NearlyEquals(e.Point1) || edge.Point2.NearlyEquals(e.Point2))); + //the right-side edge on this same cell + GraphEdge myRightEdge = cell.Edges.Find(e => e != edge && (edge.Point2.NearlyEquals(e.Point1) || edge.Point2.NearlyEquals(e.Point2))); + //the right-side edge on either this cell, or the adjacent one if this is attached to another cell + GraphEdge rightEdge = myRightEdge; var rightAdjacentCell = rightEdge?.AdjacentCell(cell); if (rightAdjacentCell != null) { @@ -67,18 +103,25 @@ namespace Barotrauma Vector2 leftNormal = Vector2.Zero, rightNormal = Vector2.Zero; - float inwardThickness1 = level.GenerationParams.WallEdgeExpandInwardsAmount; - float inwardThickness2 = level.GenerationParams.WallEdgeExpandInwardsAmount; + float inwardThickness1 = Math.Min(expandInwards, edge.Length); + float inwardThickness2 = inwardThickness1; if (leftEdge != null && !leftEdge.IsSolid) { + //the left-side edge is non-solid (an edge between two cells, not an actual solid wall edge) + // -> expand in the direction of that edge leftNormal = edge.Point1.NearlyEquals(leftEdge.Point1) ? Vector2.Normalize(leftEdge.Point2 - leftEdge.Point1) : Vector2.Normalize(leftEdge.Point1 - leftEdge.Point2); + //maximum expansion is half of the size of the edge (otherwise the expansions from different sides of the edge could overlap or even extend "through" the cell) + inwardThickness1 = Math.Min(inwardThickness1, leftEdge.Length / 2); } else if (leftEdge != null) { + //use the average of this edge's and the adjacent edge's normals leftNormal = -Vector2.Normalize(edge.GetNormal(cell) + leftEdge.GetNormal(leftAdjacentCell ?? cell)); if (!MathUtils.IsValid(leftNormal)) { leftNormal = -edge.GetNormal(cell); } + //maximum expansion is the length of the adjacent edge (more expansion causes the textures to distort) + inwardThickness1 = Math.Min(Math.Min(inwardThickness1, leftEdge.Length), myLeftEdge.Length); } else { @@ -109,11 +152,13 @@ namespace Barotrauma rightNormal = edge.Point2.NearlyEquals(rightEdge.Point1) ? Vector2.Normalize(rightEdge.Point2 - rightEdge.Point1) : Vector2.Normalize(rightEdge.Point1 - rightEdge.Point2); + inwardThickness2 = Math.Min(inwardThickness2, rightEdge.Length / 2); } else if (rightEdge != null) { rightNormal = -Vector2.Normalize(edge.GetNormal(cell) + rightEdge.GetNormal(rightAdjacentCell ?? cell)); if (!MathUtils.IsValid(rightNormal)) { rightNormal = -edge.GetNormal(cell); } + inwardThickness2 = Math.Min(Math.Min(inwardThickness2, rightEdge.Length), myRightEdge.Length); } else { @@ -150,10 +195,51 @@ namespace Barotrauma point1UV = point1UV / MathHelper.TwoPi * textureRepeatCount; point2UV = point2UV / MathHelper.TwoPi * textureRepeatCount; + //if calculating the UVs based on polar coordinates would result in stretching (using less than 10% of the texture for a wall the size of the texture) + //just calculate the UVs based on the length of the wall + //(this will mean the textures don't align at point2, but it doesn't seem that noticeable) + if ((point2UV - point1UV) * level.GenerationParams.WallEdgeTextureWidth < edge.Length * 0.1f) + { + point2UV = point1UV + edge.Length / 2 / level.GenerationParams.WallEdgeTextureWidth; + } + + //"extruding" inwards, need to make sure we don't make the edge poke through the cell from the other side + if (preventExpandThroughCell) + { + foreach (GraphEdge otherEdge in cell.Edges) + { + if (otherEdge == edge || Vector2.Dot(otherEdge.GetNormal(cell), edge.GetNormal(cell)) > 0) { continue; } + if (otherEdge != leftEdge) + { + inwardThickness1 = ClampThickness(otherEdge, edge.Point1, leftNormal, inwardThickness1); + } + if (otherEdge != rightEdge) + { + inwardThickness2 = ClampThickness(otherEdge, edge.Point2, rightNormal, inwardThickness2); + } + } + + static float ClampThickness(GraphEdge otherEdge, Vector2 thisPoint, Vector2 thisEdgeNormal, float currThickness) + { + if (MathUtils.GetLineIntersection( + thisPoint, thisPoint + thisEdgeNormal * currThickness, + otherEdge.Point1, otherEdge.Point2, areLinesInfinite: false, out Vector2 intersection1)) + { + return Math.Min(currThickness, Vector2.Distance(thisPoint, intersection1)); + } + return currThickness; + } + } + + //there needs to be some minimum amount of inward thickness, + //if the edge texture doesn't extend inside at all you can see through between the edge texture and the solid part of the cell + inwardThickness1 = Math.Max(inwardThickness1, Math.Min(100.0f, expandInwards)); + inwardThickness2 = Math.Max(inwardThickness2, Math.Min(100.0f, expandInwards)); + for (int i = 0; i < 2; i++) { Vector2[] verts = new Vector2[3]; - VertexPositionTexture[] vertPos = new VertexPositionTexture[3]; + VertexPositionColorTexture[] vertPos = new VertexPositionColorTexture[3]; if (i == 0) { @@ -161,9 +247,9 @@ namespace Barotrauma verts[1] = edge.Point2 - rightNormal * outWardThickness; verts[2] = edge.Point1 + leftNormal * inwardThickness1; - vertPos[0] = new VertexPositionTexture(new Vector3(verts[0], zCoord), new Vector2(point1UV, 0.0f)); - vertPos[1] = new VertexPositionTexture(new Vector3(verts[1], zCoord), new Vector2(point2UV, 0.0f)); - vertPos[2] = new VertexPositionTexture(new Vector3(verts[2], zCoord), new Vector2(point1UV, 1.0f)); + vertPos[0] = new VertexPositionColorTexture(new Vector3(verts[0], zCoord), outerColor, new Vector2(point1UV, 0.0f)); + vertPos[1] = new VertexPositionColorTexture(new Vector3(verts[1], zCoord), outerColor, new Vector2(point2UV, 0.0f)); + vertPos[2] = new VertexPositionColorTexture(new Vector3(verts[2], zCoord), innerColor, new Vector2(point1UV, 1.0f)); } else { @@ -172,9 +258,9 @@ namespace Barotrauma verts[1] = edge.Point2 - rightNormal * outWardThickness; verts[2] = edge.Point2 + rightNormal * inwardThickness2; - vertPos[0] = new VertexPositionTexture(new Vector3(verts[0], zCoord), new Vector2(point1UV, 1.0f)); - vertPos[1] = new VertexPositionTexture(new Vector3(verts[1], zCoord), new Vector2(point2UV, 0.0f)); - vertPos[2] = new VertexPositionTexture(new Vector3(verts[2], zCoord), new Vector2(point2UV, 1.0f)); + vertPos[0] = new VertexPositionColorTexture(new Vector3(verts[0], zCoord), innerColor, new Vector2(point1UV, 1.0f)); + vertPos[1] = new VertexPositionColorTexture(new Vector3(verts[1], zCoord), outerColor, new Vector2(point2UV, 0.0f)); + vertPos[2] = new VertexPositionColorTexture(new Vector3(verts[2], zCoord), innerColor, new Vector2(point2UV, 1.0f)); } vertices.AddRange(vertPos); } diff --git a/Barotrauma/BarotraumaClient/ClientSource/Map/Levels/Level.cs b/Barotrauma/BarotraumaClient/ClientSource/Map/Levels/Level.cs index 34caf829f..d3d2b8b53 100644 --- a/Barotrauma/BarotraumaClient/ClientSource/Map/Levels/Level.cs +++ b/Barotrauma/BarotraumaClient/ClientSource/Map/Levels/Level.cs @@ -47,62 +47,75 @@ namespace Barotrauma if (renderer == null) { return; } renderer.DrawDebugOverlay(spriteBatch, cam); - if (GameMain.DebugDraw && Screen.Selected.Cam.Zoom > 0.1f) + if (GameMain.DebugDraw) { - foreach (InterestingPosition pos in PositionsOfInterest) + if (Screen.Selected.Cam.Zoom > 0.1f) { - Color color = Color.Yellow; - if (pos.PositionType == PositionType.Cave || pos.PositionType == PositionType.AbyssCave) + foreach (InterestingPosition pos in PositionsOfInterest) { - color = Color.DarkOrange; - } - else if (pos.PositionType == PositionType.Ruin) - { - color = Color.LightGray; - } - if (!pos.IsValid) - { - color = Color.Red; - } + Color color = Color.Yellow; + if (pos.PositionType == PositionType.Cave || pos.PositionType == PositionType.AbyssCave) + { + color = Color.DarkOrange; + } + else if (pos.PositionType == PositionType.Ruin) + { + color = Color.LightGray; + } + if (!pos.IsValid) + { + color = Color.Red; + } - GUI.DrawRectangle(spriteBatch, new Vector2(pos.Position.X - 15.0f, -pos.Position.Y - 15.0f), new Vector2(30.0f, 30.0f), color, true); + GUI.DrawRectangle(spriteBatch, new Vector2(pos.Position.X - 15.0f, -pos.Position.Y - 15.0f), new Vector2(30.0f, 30.0f), color, true); + } + + foreach (RuinGeneration.Ruin ruin in Ruins) + { + Rectangle ruinArea = ruin.Area; + ruinArea.Y = -ruinArea.Y - ruinArea.Height; + + GUI.DrawRectangle(spriteBatch, ruinArea, Color.DarkSlateBlue, false, 0, 5); + } } - - foreach (RuinGeneration.Ruin ruin in Ruins) - { - Rectangle ruinArea = ruin.Area; - ruinArea.Y = -ruinArea.Y - ruinArea.Height; - - GUI.DrawRectangle(spriteBatch, ruinArea, Color.DarkSlateBlue, false, 0, 5); - } - - foreach (var positions in wreckPositions.Values) + + float zoomFactor = MathHelper.Lerp(20, 1, MathUtils.InverseLerp(Screen.Selected.Cam.MinZoom, Screen.Selected.Cam.DefaultZoom, Screen.Selected.Cam.Zoom)); + foreach ((string debugInfo, List positions) in positionHistory) { for (int i = 0; i < positions.Count; i++) { float t = (i + 1) / (float)positions.Count; - float multiplier = MathHelper.Lerp(0, 1, t); + float multiplier = MathHelper.Lerp(0.1f, 1, t); Color color = Color.Red * multiplier; var pos = positions[i]; pos.Y = -pos.Y; - var size = new Vector2(100); - GUI.DrawRectangle(spriteBatch, pos - size / 2, size, color, thickness: 10); + var size = new Vector2(200); + if (i == 0) + { + GUI.DrawRectangle(spriteBatch, pos - size, size * 2, Color.Red, thickness: 2 * zoomFactor); + GUI.DrawString(spriteBatch, pos - new Vector2(10, 20), debugInfo, Color.White, font: GUIStyle.LargeFont, forceUpperCase: ForceUpperCase.Yes); + } if (i < positions.Count - 1) { + if (i > 0) + { + GUI.DrawRectangle(spriteBatch, pos - size / 2, size, Color.Red, isFilled: true); + } var nextPos = positions[i + 1]; nextPos.Y = -nextPos.Y; - GUI.DrawLine(spriteBatch, pos, nextPos, color, width: 10); + GUI.DrawLine(spriteBatch, pos, nextPos, color, width: 4 * zoomFactor); } } } - foreach (var rects in blockedRects.Values) + foreach ((Submarine sub, List rects) in blockedRects) { - foreach (var rect in rects) + foreach (Rectangle t in rects) { - Rectangle newRect = rect; + Rectangle newRect = t; newRect.Y = -newRect.Y; - GUI.DrawRectangle(spriteBatch, newRect, Color.Red, thickness: 5); + GUI.DrawRectangle(spriteBatch, newRect, Color.Red * 0.1f, isFilled: true); + GUI.DrawString(spriteBatch, newRect.Center.ToVector2(), $"{sub.Info.Name}", Color.White, font: GUIStyle.LargeFont, forceUpperCase: ForceUpperCase.Yes); } } } 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 1d9f4ecd1..f4b48feb5 100644 --- a/Barotrauma/BarotraumaClient/ClientSource/Map/Levels/LevelObjects/LevelObjectManager.cs +++ b/Barotrauma/BarotraumaClient/ClientSource/Map/Levels/LevelObjects/LevelObjectManager.cs @@ -10,9 +10,11 @@ namespace Barotrauma { partial class LevelObjectManager { - private readonly List visibleObjectsBack = new List(); - private readonly List visibleObjectsMid = new List(); - private readonly List visibleObjectsFront = new List(); + // Pre-initialized to the max size, so that we don't have to resize the lists at runtime. TODO: Could the capacity (of some collections?) be lower? + private readonly List visibleObjectsBack = new List(MaxVisibleObjects); + private readonly List visibleObjectsMid = new List(MaxVisibleObjects); + private readonly List visibleObjectsFront = new List(MaxVisibleObjects); + private readonly HashSet allVisibleObjects = new HashSet(MaxVisibleObjects); private double NextRefreshTime; @@ -31,25 +33,41 @@ 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); } } - public IEnumerable GetVisibleObjects() + /// + /// Returns all visible objects, but not in order, because internally uses a HashSet. + /// + public IEnumerable GetAllVisibleObjects() { - return visibleObjectsBack.Union(visibleObjectsMid).Union(visibleObjectsFront); + allVisibleObjects.Clear(); + foreach (LevelObject obj in visibleObjectsBack) + { + allVisibleObjects.Add(obj); + } + foreach (LevelObject obj in visibleObjectsMid) + { + allVisibleObjects.Add(obj); + } + foreach (LevelObject obj in visibleObjectsFront) + { + allVisibleObjects.Add(obj); + } + return allVisibleObjects; } /// @@ -207,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 8b6a6b6fe..02a3d3834 100644 --- a/Barotrauma/BarotraumaClient/ClientSource/Map/Levels/LevelRenderer.cs +++ b/Barotrauma/BarotraumaClient/ClientSource/Map/Levels/LevelRenderer.cs @@ -1,4 +1,5 @@ -using Barotrauma.Extensions; +using Barotrauma.Extensions; +using Barotrauma.Particles; using Microsoft.Xna.Framework; using Microsoft.Xna.Framework.Graphics; using System; @@ -10,10 +11,25 @@ namespace Barotrauma { class LevelWallVertexBuffer : IDisposable { - public VertexBuffer WallEdgeBuffer, WallBuffer; + /// + /// Buffer for the vertices of the "actual" wall texture. + /// + public VertexBuffer WallBuffer; + + /// + /// Buffer for the vertices of the repeating edge texture drawn at the edges of the walls. + /// + public VertexBuffer WallEdgeBuffer; + + /// + /// Buffer for the vertices of the inner, non-textured black part of the wall. + /// + public VertexBuffer WallInnerBuffer; + public readonly Texture2D WallTexture, EdgeTexture; private VertexPositionColorTexture[] wallVertices; private VertexPositionColorTexture[] wallEdgeVertices; + private VertexPositionColor[] wallInnerVertices; public bool IsDisposed { @@ -21,7 +37,7 @@ namespace Barotrauma private set; } - public LevelWallVertexBuffer(VertexPositionTexture[] wallVertices, VertexPositionTexture[] wallEdgeVertices, Texture2D wallTexture, Texture2D edgeTexture, Color color) + public LevelWallVertexBuffer(VertexPositionColorTexture[] wallVertices, VertexPositionColorTexture[] wallEdgeVertices, VertexPositionColor[] wallInnerVertices, Texture2D wallTexture, Texture2D edgeTexture) { if (wallVertices.Length == 0) { @@ -31,32 +47,41 @@ namespace Barotrauma { throw new ArgumentException("Failed to instantiate a LevelWallVertexBuffer (no wall edge vertices)."); } - this.wallVertices = LevelRenderer.GetColoredVertices(wallVertices, color); + this.wallVertices = wallVertices; WallBuffer = new VertexBuffer(GameMain.Instance.GraphicsDevice, VertexPositionColorTexture.VertexDeclaration, wallVertices.Length, BufferUsage.WriteOnly); WallBuffer.SetData(this.wallVertices); WallTexture = wallTexture; - this.wallEdgeVertices = LevelRenderer.GetColoredVertices(wallEdgeVertices, color); + this.wallEdgeVertices = wallEdgeVertices; WallEdgeBuffer = new VertexBuffer(GameMain.Instance.GraphicsDevice, VertexPositionColorTexture.VertexDeclaration, wallEdgeVertices.Length, BufferUsage.WriteOnly); WallEdgeBuffer.SetData(this.wallEdgeVertices); EdgeTexture = edgeTexture; + + if (wallInnerVertices != null) + { + this.wallInnerVertices = wallInnerVertices; + WallInnerBuffer = new VertexBuffer(GameMain.Instance.GraphicsDevice, VertexPositionColor.VertexDeclaration, wallInnerVertices.Length, BufferUsage.WriteOnly); + WallInnerBuffer.SetData(this.wallInnerVertices); + } } - public void Append(VertexPositionTexture[] wallVertices, VertexPositionTexture[] wallEdgeVertices, Color color) + public void Append(VertexPositionColorTexture[] newWallVertices, VertexPositionColorTexture[] newWallEdgeVertices, VertexPositionColor[] newWallInnerVertices) { - WallBuffer.Dispose(); - WallBuffer = new VertexBuffer(GameMain.Instance.GraphicsDevice, VertexPositionColorTexture.VertexDeclaration, this.wallVertices.Length + wallVertices.Length, BufferUsage.WriteOnly); - int originalWallVertexCount = this.wallVertices.Length; - Array.Resize(ref this.wallVertices, originalWallVertexCount + wallVertices.Length); - Array.Copy(LevelRenderer.GetColoredVertices(wallVertices, color), 0, this.wallVertices, originalWallVertexCount, wallVertices.Length); - WallBuffer.SetData(this.wallVertices); + WallBuffer = Append(WallBuffer, ref wallVertices, newWallVertices, VertexPositionColorTexture.VertexDeclaration); + WallEdgeBuffer = Append(WallEdgeBuffer, ref wallEdgeVertices, newWallEdgeVertices, VertexPositionColorTexture.VertexDeclaration); + WallInnerBuffer = Append(WallInnerBuffer, ref wallInnerVertices, newWallInnerVertices, VertexPositionColor.VertexDeclaration); - WallEdgeBuffer.Dispose(); - WallEdgeBuffer = new VertexBuffer(GameMain.Instance.GraphicsDevice, VertexPositionColorTexture.VertexDeclaration, this.wallEdgeVertices.Length + wallEdgeVertices.Length, BufferUsage.WriteOnly); - int originalWallEdgeVertexCount = this.wallEdgeVertices.Length; - Array.Resize(ref this.wallEdgeVertices, originalWallEdgeVertexCount + wallEdgeVertices.Length); - Array.Copy(LevelRenderer.GetColoredVertices(wallEdgeVertices, color), 0, this.wallEdgeVertices, originalWallEdgeVertexCount, wallEdgeVertices.Length); - WallEdgeBuffer.SetData(this.wallEdgeVertices); + static VertexBuffer Append(VertexBuffer buffer, ref T[] currentVertices, T[] newVertices, VertexDeclaration vertexDeclaration) where T : struct, IVertexType + { + buffer?.Dispose(); + int originalVertexCount = currentVertices.Length; + int newBufferSize = originalVertexCount + newVertices.Length; + buffer = new VertexBuffer(GameMain.Instance.GraphicsDevice, vertexDeclaration, newBufferSize, BufferUsage.WriteOnly); + Array.Resize(ref currentVertices, newBufferSize); + Array.Copy(newVertices, 0, currentVertices, originalVertexCount, newVertices.Length); + buffer.SetData(currentVertices); + return buffer; + } } public void Dispose() @@ -69,7 +94,7 @@ namespace Barotrauma class LevelRenderer : IDisposable { - private static BasicEffect wallEdgeEffect, wallCenterEffect; + private static BasicEffect wallEdgeEffect, wallCenterEffect, wallInnerEffect; private Vector2 waterParticleOffset; private Vector2 waterParticleVelocity; @@ -128,7 +153,16 @@ namespace Barotrauma }; wallCenterEffect.CurrentTechnique = wallCenterEffect.Techniques["BasicEffect_Texture"]; } - + + if (wallInnerEffect == null) + { + wallInnerEffect = new BasicEffect(GameMain.Instance.GraphicsDevice) + { + VertexColorEnabled = true, + TextureEnabled = false + }; + wallInnerEffect.CurrentTechnique = wallInnerEffect.Techniques["BasicEffect_Texture"]; + } this.level = level; } @@ -162,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) @@ -183,7 +214,7 @@ namespace Barotrauma //calculate the sum of the forces of nearby level triggers //and use it to move the water texture and water distortion effect Vector2 currentWaterParticleVel = level.GenerationParams.WaterParticleVelocity; - foreach (LevelObject levelObject in level.LevelObjectManager.GetVisibleObjects()) + foreach (LevelObject levelObject in level.LevelObjectManager.GetAllVisibleObjects()) { if (levelObject.Triggers == null) { continue; } //use the largest water flow velocity of all the triggers @@ -224,22 +255,23 @@ namespace Barotrauma return verts; } - public void SetVertices(VertexPositionTexture[] wallVertices, VertexPositionTexture[] wallEdgeVertices, Texture2D wallTexture, Texture2D edgeTexture, Color color) + public void SetVertices(VertexPositionColorTexture[] wallVertices, VertexPositionColorTexture[] wallEdgeVertices, VertexPositionColor[] wallInnerVertices, Texture2D wallTexture, Texture2D edgeTexture) { var existingBuffer = vertexBuffers.Find(vb => vb.WallTexture == wallTexture && vb.EdgeTexture == edgeTexture); if (existingBuffer != null) { - existingBuffer.Append(wallVertices, wallEdgeVertices,color); + existingBuffer.Append(wallVertices, wallEdgeVertices, wallInnerVertices); } else { - vertexBuffers.Add(new LevelWallVertexBuffer(wallVertices, wallEdgeVertices, wallTexture, edgeTexture, color)); + vertexBuffers.Add(new LevelWallVertexBuffer(wallVertices, wallEdgeVertices, wallInnerVertices, wallTexture, edgeTexture)); } } public void DrawBackground(SpriteBatch spriteBatch, Camera cam, LevelObjectManager backgroundSpriteManager = null, - BackgroundCreatureManager backgroundCreatureManager = null) + BackgroundCreatureManager backgroundCreatureManager = null, + ParticleManager particleManager = null) { spriteBatch.Begin(SpriteSortMode.Deferred, BlendState.NonPremultiplied, SamplerState.LinearWrap); @@ -277,7 +309,7 @@ namespace Barotrauma spriteBatch.Begin(SpriteSortMode.Deferred, BlendState.NonPremultiplied, SamplerState.LinearWrap, DepthStencilState.DepthRead, null, null, - cam.Transform); + cam.Transform); backgroundSpriteManager?.DrawObjectsBack(spriteBatch, cam); if (cam.Zoom > 0.05f) @@ -321,6 +353,9 @@ namespace Barotrauma color: level.GenerationParams.WaterParticleColor * alpha, textureScale: new Vector2(texScale)); } } + + GameMain.ParticleManager?.Draw(spriteBatch, inWater: true, inSub: false, ParticleBlendState.AlphaBlend, background: true); + spriteBatch.End(); RenderWalls(GameMain.Instance.GraphicsDevice, cam); @@ -465,7 +500,8 @@ namespace Barotrauma var wallList = i == 0 ? level.ExtraWalls : level.UnsyncedExtraWalls; foreach (LevelWall wall in wallList) { - if (!(wall is DestructibleLevelWall destructibleWall) || destructibleWall.Destroyed) { continue; } + if (wall is not DestructibleLevelWall destructibleWall || destructibleWall.Destroyed) { continue; } + if (!wall.IsVisible(cam.WorldView)) { continue; } wallCenterEffect.Texture = level.GenerationParams.DestructibleWallSprite?.Texture ?? level.GenerationParams.WallSprite.Texture; wallCenterEffect.World = wall.GetTransform() * transformMatrix; @@ -491,15 +527,16 @@ namespace Barotrauma } } - wallEdgeEffect.Alpha = 1.0f; - wallCenterEffect.Alpha = 1.0f; - - wallCenterEffect.World = transformMatrix; - wallEdgeEffect.World = transformMatrix; + wallEdgeEffect.Alpha = wallInnerEffect.Alpha = wallCenterEffect.Alpha = 1.0f; + wallCenterEffect.World = wallInnerEffect.World = wallEdgeEffect.World = transformMatrix; //render static walls foreach (var vertexBuffer in vertexBuffers) { + wallInnerEffect.CurrentTechnique.Passes[0].Apply(); + graphicsDevice.SetVertexBuffer(vertexBuffer.WallInnerBuffer); + graphicsDevice.DrawPrimitives(PrimitiveType.TriangleList, 0, (int)Math.Floor(vertexBuffer.WallInnerBuffer.VertexCount / 3.0f)); + wallCenterEffect.Texture = vertexBuffer.WallTexture; wallCenterEffect.CurrentTechnique.Passes[0].Apply(); graphicsDevice.SetVertexBuffer(vertexBuffer.WallBuffer); @@ -521,6 +558,7 @@ namespace Barotrauma foreach (LevelWall wall in wallList) { if (wall is DestructibleLevelWall) { continue; } + if (!wall.IsVisible(cam.WorldView)) { continue; } //TODO: use LevelWallVertexBuffers for extra walls as well wallCenterEffect.World = wall.GetTransform() * transformMatrix; wallCenterEffect.Alpha = wall.Alpha; diff --git a/Barotrauma/BarotraumaClient/ClientSource/Map/Levels/LevelWall.cs b/Barotrauma/BarotraumaClient/ClientSource/Map/Levels/LevelWall.cs index 5130227b4..1d301e382 100644 --- a/Barotrauma/BarotraumaClient/ClientSource/Map/Levels/LevelWall.cs +++ b/Barotrauma/BarotraumaClient/ClientSource/Map/Levels/LevelWall.cs @@ -2,7 +2,6 @@ using Microsoft.Xna.Framework; using Microsoft.Xna.Framework.Graphics; using System; -using System.Collections.Generic; namespace Barotrauma { @@ -24,22 +23,47 @@ namespace Barotrauma Matrix.CreateTranslation(new Vector3(ConvertUnits.ToDisplayUnits(Body.Position), 0.0f)); } - public void SetWallVertices(VertexPositionTexture[] wallVertices, VertexPositionTexture[] wallEdgeVertices, Texture2D wallTexture, Texture2D edgeTexture, Color color) + public void SetWallVertices( + VertexPositionColorTexture[] wallVertices, VertexPositionColorTexture[] wallEdgeVertices, + Texture2D wallTexture, Texture2D edgeTexture) { if (VertexBuffer != null && !VertexBuffer.IsDisposed) { VertexBuffer.Dispose(); } - VertexBuffer = new LevelWallVertexBuffer(wallVertices, wallEdgeVertices, wallTexture, edgeTexture, color); + VertexBuffer = new LevelWallVertexBuffer(wallVertices, wallEdgeVertices, wallInnerVertices: null, wallTexture, edgeTexture); } public void GenerateVertices() { float zCoord = this is DestructibleLevelWall ? Rand.Range(0.9f, 1.0f) : 0.9f; - List wallVertices = CaveGenerator.GenerateWallVertices(triangles, level.GenerationParams, zCoord); + var nonTexturedWallVerts = + CaveGenerator.GenerateWallVertices(triangles, color, zCoord: 0.9f).ToArray(); + var wallVerts = CaveGenerator.ConvertToTextured(nonTexturedWallVerts, level.GenerationParams.WallTextureSize); SetWallVertices( - wallVertices.ToArray(), - CaveGenerator.GenerateWallEdgeVertices(Cells, level, zCoord).ToArray(), + wallVertices: wallVerts, + wallEdgeVertices: CaveGenerator.GenerateWallEdgeVertices(Cells, + level.GenerationParams.WallEdgeExpandOutwardsAmount, level.GenerationParams.WallEdgeExpandInwardsAmount, + outerColor: color, innerColor: color, + level, zCoord) + .ToArray(), level.GenerationParams.WallSprite.Texture, - level.GenerationParams.WallEdgeSprite.Texture, - color); + level.GenerationParams.WallEdgeSprite.Texture); + } + + public bool IsVisible(Rectangle worldView) + { + RectangleF worldViewInSimUnits = new RectangleF( + ConvertUnits.ToSimUnits(worldView.Location.ToVector2()), + ConvertUnits.ToSimUnits(worldView.Size.ToVector2())); + + foreach (var fixture in Body.FixtureList) + { + fixture.GetAABB(out var aabb, 0); + Vector2 lowerBound = aabb.LowerBound + Body.Position; + if (lowerBound.X > worldViewInSimUnits.Right || lowerBound.Y > worldViewInSimUnits.Y) { continue; } + Vector2 upperBound = aabb.UpperBound + Body.Position; + if (upperBound.X < worldViewInSimUnits.X || upperBound.Y < worldViewInSimUnits.Y - worldViewInSimUnits.Height) { continue; } + return true; + } + return false; } } } diff --git a/Barotrauma/BarotraumaClient/ClientSource/Map/Lights/ConvexHull.cs b/Barotrauma/BarotraumaClient/ClientSource/Map/Lights/ConvexHull.cs index 31c08ce71..7992ad5f9 100644 --- a/Barotrauma/BarotraumaClient/ClientSource/Map/Lights/ConvexHull.cs +++ b/Barotrauma/BarotraumaClient/ClientSource/Map/Lights/ConvexHull.cs @@ -1,5 +1,4 @@ -using Barotrauma.Items.Components; -using Microsoft.Xna.Framework; +using Microsoft.Xna.Framework; using Microsoft.Xna.Framework.Graphics; using System; using System.Collections.Generic; @@ -13,6 +12,7 @@ namespace Barotrauma.Lights public readonly Submarine Submarine; public HashSet IsHidden = new HashSet(); + public HashSet HasBeenVisible = new HashSet(); public readonly List List = new List(); public ConvexHullList(Submarine submarine) @@ -443,10 +443,10 @@ namespace Barotrauma.Lights public bool Intersects(Rectangle rect) { - if (!Enabled) return false; + if (!Enabled) { return false; } Rectangle transformedBounds = BoundingBox; - if (ParentEntity != null && ParentEntity.Submarine != null) + if (ParentEntity is { Submarine: not null }) { transformedBounds.X += (int)ParentEntity.Submarine.Position.X; transformedBounds.Y += (int)ParentEntity.Submarine.Position.Y; diff --git a/Barotrauma/BarotraumaClient/ClientSource/Map/Lights/LightManager.cs b/Barotrauma/BarotraumaClient/ClientSource/Map/Lights/LightManager.cs index 9bed09947..b65723a52 100644 --- a/Barotrauma/BarotraumaClient/ClientSource/Map/Lights/LightManager.cs +++ b/Barotrauma/BarotraumaClient/ClientSource/Map/Lights/LightManager.cs @@ -174,7 +174,7 @@ namespace Barotrauma.Lights } private readonly List activeLights = new List(capacity: 100); - private readonly List activeLightsWithLightVolume = new List(capacity: 100); + private readonly List activeShadowCastingLights = new List(capacity: 100); public static int ActiveLightCount { get; private set; } @@ -243,6 +243,7 @@ namespace Barotrauma.Lights } } + /// A render target that contains the structures that should obstruct lights in the background. If not given, damageable walls and hulls are rendered to obstruct the background lights. public void RenderLightMap(GraphicsDevice graphics, SpriteBatch spriteBatch, Camera cam, RenderTarget2D backgroundObstructor = null) { if (!LightingEnabled) { return; } @@ -273,13 +274,13 @@ 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; } //above the top boundary of the level (in an inactive respawn shuttle?) - if (Level.Loaded != null && light.WorldPosition.Y > Level.Loaded.Size.Y) { continue; } + if (Level.IsPositionAboveLevel(light.WorldPosition)) { continue; } float range = light.LightSourceParams.TextureRange; if (light.LightSprite != null) @@ -315,19 +316,20 @@ namespace Barotrauma.Lights } //find the lights with an active light volume - activeLightsWithLightVolume.Clear(); + activeShadowCastingLights.Clear(); foreach (var activeLight in activeLights) { + if (!activeLight.CastShadows) { continue; } if (activeLight.Range < 1.0f || activeLight.Color.A < 1 || activeLight.CurrentBrightness <= 0.0f) { continue; } - activeLightsWithLightVolume.Add(activeLight); + activeShadowCastingLights.Add(activeLight); } //remove some lights with a light volume if there's too many of them - if (activeLightsWithLightVolume.Count > GameSettings.CurrentConfig.Graphics.VisibleLightLimit && Screen.Selected is { IsEditor: false }) + if (activeShadowCastingLights.Count > GameSettings.CurrentConfig.Graphics.VisibleLightLimit && Screen.Selected is { IsEditor: false }) { - for (int i = GameSettings.CurrentConfig.Graphics.VisibleLightLimit; i < activeLightsWithLightVolume.Count; i++) + for (int i = GameSettings.CurrentConfig.Graphics.VisibleLightLimit; i < activeShadowCastingLights.Count; i++) { - activeLights.Remove(activeLightsWithLightVolume[i]); + activeLights.Remove(activeShadowCastingLights[i]); } } activeLights.Sort((l1, l2) => l1.LastRecalculationTime.CompareTo(l2.LastRecalculationTime)); @@ -410,11 +412,21 @@ namespace Barotrauma.Lights } spriteBatch.End(); - GameMain.GameScreen.DamageEffect.CurrentTechnique = GameMain.GameScreen.DamageEffect.Techniques["StencilShaderSolidColor"]; - GameMain.GameScreen.DamageEffect.Parameters["solidColor"].SetValue(Color.Black.ToVector4()); - spriteBatch.Begin(SpriteSortMode.Immediate, BlendState.NonPremultiplied, SamplerState.LinearWrap, transformMatrix: spriteBatchTransform, effect: GameMain.GameScreen.DamageEffect); - Submarine.DrawDamageable(spriteBatch, GameMain.GameScreen.DamageEffect); - spriteBatch.End(); + if (backgroundObstructor != null) + { + spriteBatch.Begin(SpriteSortMode.Immediate, BlendState.NonPremultiplied, SamplerState.LinearWrap, transformMatrix: Matrix.Identity, effect: GameMain.GameScreen.DamageEffect); + spriteBatch.Draw(backgroundObstructor, new Rectangle(0, 0, + (int)(GameMain.GraphicsWidth * currLightMapScale), (int)(GameMain.GraphicsHeight * currLightMapScale)), Color.Black); + spriteBatch.End(); + } + else + { + GameMain.GameScreen.DamageEffect.CurrentTechnique = GameMain.GameScreen.DamageEffect.Techniques["StencilShaderSolidColor"]; + GameMain.GameScreen.DamageEffect.Parameters["solidColor"].SetValue(Color.Black.ToVector4()); + spriteBatch.Begin(SpriteSortMode.Immediate, BlendState.NonPremultiplied, SamplerState.LinearWrap, transformMatrix: spriteBatchTransform, effect: GameMain.GameScreen.DamageEffect); + Submarine.DrawDamageable(spriteBatch, GameMain.GameScreen.DamageEffect); + spriteBatch.End(); + } graphics.BlendState = BlendState.Additive; @@ -679,11 +691,14 @@ namespace Barotrauma.Lights } return visibleHulls; } + + private static readonly List ShadowVertices = new List(500); + private static readonly List PenumbraVertices = new List(500); public void UpdateObstructVision(GraphicsDevice graphics, SpriteBatch spriteBatch, Camera cam, Vector2 lookAtPosition) { if ((!LosEnabled || LosMode == LosMode.None) && ObstructVisionAmount <= 0.0f) { return; } - if (ViewTarget == null) return; + if (ViewTarget == null) { return; } graphics.SetRenderTarget(LosTexture); @@ -766,11 +781,11 @@ namespace Barotrauma.Lights if (convexHulls != null) { - List shadowVerts = new List(); - List penumbraVerts = new List(); + ShadowVertices.Clear(); + PenumbraVertices.Clear(); foreach (ConvexHull convexHull in convexHulls) { - if (!convexHull.Enabled || !convexHull.Intersects(camView)) { continue; } + if (!convexHull.Intersects(camView)) { continue; } Vector2 relativeViewPos = pos; if (convexHull.ParentEntity?.Submarine != null) @@ -782,26 +797,26 @@ namespace Barotrauma.Lights for (int i = 0; i < convexHull.ShadowVertexCount; i++) { - shadowVerts.Add(convexHull.ShadowVertices[i]); + ShadowVertices.Add(convexHull.ShadowVertices[i]); } for (int i = 0; i < convexHull.PenumbraVertexCount; i++) { - penumbraVerts.Add(convexHull.PenumbraVertices[i]); + PenumbraVertices.Add(convexHull.PenumbraVertices[i]); } } - if (shadowVerts.Count > 0) + if (ShadowVertices.Count > 0) { ConvexHull.shadowEffect.World = shadowTransform; ConvexHull.shadowEffect.CurrentTechnique.Passes[0].Apply(); - graphics.DrawUserPrimitives(PrimitiveType.TriangleList, shadowVerts.ToArray(), 0, shadowVerts.Count / 3, VertexPositionColor.VertexDeclaration); + graphics.DrawUserPrimitives(PrimitiveType.TriangleList, ShadowVertices.ToArray(), 0, ShadowVertices.Count / 3, VertexPositionColor.VertexDeclaration); - if (penumbraVerts.Count > 0) + if (PenumbraVertices.Count > 0) { ConvexHull.penumbraEffect.World = shadowTransform; ConvexHull.penumbraEffect.CurrentTechnique.Passes[0].Apply(); - graphics.DrawUserPrimitives(PrimitiveType.TriangleList, penumbraVerts.ToArray(), 0, penumbraVerts.Count / 3, VertexPositionTexture.VertexDeclaration); + graphics.DrawUserPrimitives(PrimitiveType.TriangleList, PenumbraVertices.ToArray(), 0, PenumbraVertices.Count / 3, VertexPositionTexture.VertexDeclaration); } } } @@ -827,7 +842,7 @@ namespace Barotrauma.Lights public void ClearLights() { activeLights.Clear(); - activeLightsWithLightVolume.Clear(); + activeShadowCastingLights.Clear(); lights.Clear(); } } diff --git a/Barotrauma/BarotraumaClient/ClientSource/Map/Lights/LightSource.cs b/Barotrauma/BarotraumaClient/ClientSource/Map/Lights/LightSource.cs index 5e9a1f267..e37bd5193 100644 --- a/Barotrauma/BarotraumaClient/ClientSource/Map/Lights/LightSource.cs +++ b/Barotrauma/BarotraumaClient/ClientSource/Map/Lights/LightSource.cs @@ -1,4 +1,4 @@ -using Barotrauma.Extensions; +using Barotrauma.Extensions; using Microsoft.Xna.Framework; using Microsoft.Xna.Framework.Graphics; using System; @@ -245,7 +245,7 @@ namespace Barotrauma.Lights } set { - if (!needsRecalculation && value) + if (value) { foreach (ConvexHullList chList in convexHullsInRange) { @@ -450,6 +450,8 @@ namespace Barotrauma.Lights set; } + public Vector2 OffsetFromBody; + public DeformableSprite DeformableLightSprite { get; @@ -550,7 +552,6 @@ namespace Barotrauma.Lights chList.List.Clear(); foreach (var convexHull in fullChList.List) { - if (!convexHull.Enabled) { continue; } if (!MathUtils.CircleIntersectsRectangle(lightPos, TextureRange, convexHull.BoundingBox)) { continue; } if (lightSourceParams.Directional) { @@ -562,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; } @@ -572,6 +573,7 @@ namespace Barotrauma.Lights chList.List.Add(convexHull); } chList.IsHidden.RemoveWhere(ch => !chList.List.Contains(ch)); + chList.HasBeenVisible.RemoveWhere(ch => !chList.List.Contains(ch)); HullsUpToDate.Add(sub); } @@ -592,7 +594,17 @@ namespace Barotrauma.Lights private void CheckHullsInRange(Submarine sub) { //find the list of convexhulls that belong to the sub - ConvexHullList chList = convexHullsInRange.FirstOrDefault(chList => chList.Submarine == sub); + + // Performance-sensitive code, hence implemented without Linq. + ConvexHullList chList = null; + foreach (var chl in convexHullsInRange) + { + if (chl.Submarine == sub) + { + chList = chl; + break; + } + } //not found -> create one if (chList == null) @@ -604,7 +616,8 @@ namespace Barotrauma.Lights foreach (var ch in chList.List) { - if (ch.LastVertexChangeTime > LastRecalculationTime && !chList.IsHidden.Contains(ch)) + if (ch.LastVertexChangeTime > LastRecalculationTime && + (!chList.IsHidden.Contains(ch) || chList.HasBeenVisible.Contains(ch))) { NeedsRecalculation = true; break; @@ -712,8 +725,8 @@ namespace Barotrauma.Lights { foreach (ConvexHull hull in chList.List) { - if (hull.IsInvalid) { continue; } - if (!chList.IsHidden.Contains(hull)) + if (hull.IsInvalid || !hull.Enabled) { continue; } + if (!chList.IsHidden.Contains(hull) || chList.HasBeenVisible.Contains(hull)) { //find convexhull segments that are close enough and facing towards the light source lock (mutex) @@ -732,7 +745,17 @@ namespace Barotrauma.Lights } foreach (ConvexHull hull in chList.List) { - chList.IsHidden.Add(hull); + if (!hull.Enabled) + { + //if the hull is not enabled, we cannot determine if it's visible or hidden from the point of view of the light source + //so let's not mark it as hidden, but instead consider it as something that has been visible, so we know to recalculate if/when it becomes enabled again + chList.IsHidden.Remove(hull); + chList.HasBeenVisible.Add(hull); + continue; + } + + //mark convex hulls as hidden at this point, they're removed if we find any of the segments to be visible + chList.IsHidden.Add(hull); } } @@ -1411,14 +1434,7 @@ namespace Barotrauma.Lights { if (conditionals.None()) { return; } if (conditionalTarget == null) { return; } - if (logicalOperator == PropertyConditional.LogicalOperatorType.And) - { - Enabled = conditionals.All(c => c.Matches(conditionalTarget)); - } - else - { - Enabled = conditionals.Any(c => c.Matches(conditionalTarget)); - } + Enabled = PropertyConditional.CheckConditionals(conditionalTarget, conditionals, logicalOperator); } public void DebugDrawVertices(SpriteBatch spriteBatch) @@ -1501,6 +1517,7 @@ namespace Barotrauma.Lights foreach (var convexHullList in convexHullsInRange) { convexHullList.IsHidden.Remove(visibleConvexHull); + convexHullList.HasBeenVisible.Add(visibleConvexHull); } } diff --git a/Barotrauma/BarotraumaClient/ClientSource/Map/Map/Map.cs b/Barotrauma/BarotraumaClient/ClientSource/Map/Map/Map.cs index a29588def..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); @@ -923,31 +952,32 @@ namespace Barotrauma if (GameMain.DebugDraw) { - Vector2 dPos = pos; + //move the debug texts upwards so they don't go under the info panel that appears when highlighted + Vector2 dPos = pos + new Vector2(15, -100); if (location == HighlightedLocation) { - dPos.Y -= 80; - GUI.DrawString(spriteBatch, dPos + new Vector2(15, 32), "Faction: " + (location.Faction?.Prefab.Name ?? "none"), Color.White, Color.Black, font: GUIStyle.SubHeadingFont); - GUI.DrawString(spriteBatch, dPos + new Vector2(15, 50), "Secondary Faction: " + (location.SecondaryFaction?.Prefab.Name ?? "none"), Color.White, Color.Black, font: GUIStyle.SubHeadingFont); - dPos.Y += 48; + GUI.DrawString(spriteBatch, dPos, "Faction: " + (location.Faction?.Prefab.Name ?? "none"), Color.White, Color.Black, font: GUIStyle.SubHeadingFont); + GUI.DrawString(spriteBatch, dPos + new Vector2(0, 18), "Secondary Faction: " + (location.SecondaryFaction?.Prefab.Name ?? "none"), Color.White, Color.Black, font: GUIStyle.SubHeadingFont); + dPos.Y += 50; if (PlayerInput.KeyDown(Keys.LeftShift)) { - GUI.DrawString(spriteBatch, new Vector2(150,150), "Dist: " + + GUI.DrawString(spriteBatch, new Vector2(150, 150), "Dist: " + GetDistanceToClosestLocationOrConnection(CurrentLocation, int.MaxValue, loc => loc == location), Color.White, Color.Black, font: GUIStyle.SubHeadingFont); - } + GUI.DrawString(spriteBatch, dPos, $"Difficulty: {location.LevelData.Difficulty.FormatSingleDecimal()}", + ToolBox.GradientLerp(location.LevelData.Difficulty / 100.0f, GUIStyle.Blue, GUIStyle.Yellow, GUIStyle.Red), Color.Black * 0.8f, 4, font: GUIStyle.SmallFont); + + dPos.Y += 25; + GUI.DrawString(spriteBatch, dPos, $"Biome: {location.LevelData.Biome.DisplayName} ({location.LevelData.GenerationParams.Identifier})", Color.White, Color.Black, font: GUIStyle.SmallFont); } - dPos.Y += 48; - GUI.DrawString(spriteBatch, dPos, $"Difficulty: {location.LevelData.Difficulty.FormatSingleDecimal()}", Color.White, Color.Black * 0.8f, 4, font: GUIStyle.SmallFont); } } } DrawDecorativeHUD(spriteBatch, rect); - bool drawRadiationTooltip = true; - + bool drawRadiationTooltip = HighlightedLocation == null; if (tooltip != null) { GUIComponent.DrawToolTip(spriteBatch, tooltip.Value.tip, tooltip.Value.targetArea); @@ -1056,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)) { @@ -1196,7 +1226,9 @@ namespace Barotrauma Vector2 center = rectCenter + (connection.CenterPos + viewOffset) * zoom; if (viewArea.Contains(center) && connection.Biome != null) { - GUI.DrawString(spriteBatch, center, (connection.LevelData?.GenerationParams?.Identifier ?? connection.Biome.Identifier) + " (" + connection.Difficulty.FormatSingleDecimal() + ")", Color.White); + GUI.DrawString(spriteBatch, center - Vector2.UnitX * 50, + $"{(connection.LevelData?.GenerationParams?.Identifier ?? connection.Biome.Identifier)} ({connection.Difficulty.FormatSingleDecimal()})", + ToolBox.GradientLerp(connection.Difficulty / 100.0f, GUIStyle.Blue, GUIStyle.Yellow, GUIStyle.Red), backgroundColor: Color.Black * 0.7f, font: GUIStyle.SmallFont); } } 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/MapEntity.cs b/Barotrauma/BarotraumaClient/ClientSource/Map/MapEntity.cs index 6560a4ea1..38549dddf 100644 --- a/Barotrauma/BarotraumaClient/ClientSource/Map/MapEntity.cs +++ b/Barotrauma/BarotraumaClient/ClientSource/Map/MapEntity.cs @@ -81,6 +81,16 @@ namespace Barotrauma public virtual bool IsVisible(Rectangle worldView) { + Rectangle worldRect = WorldRect; + if (worldRect.X > worldView.Right || worldRect.Right < worldView.X) { return false; } + if (worldRect.Y < worldView.Y - worldView.Height || worldRect.Y - worldRect.Height > worldView.Y) { return false; } + //zoomed extremely far out -> no need to render + if (Screen.Selected.Cam.Zoom < 0.05f) { return false; } + if (worldRect.Width * Screen.Selected.Cam.Zoom < 1.0f || + worldRect.Height * Screen.Selected.Cam.Zoom < 1.0f) + { + return false; + } return true; } @@ -91,6 +101,8 @@ namespace Barotrauma public virtual void Draw(SpriteBatch spriteBatch, bool editing, bool back = true) { } + public virtual float GetDrawDepth() { return 0.0f; } + /// /// A method that modifies the draw depth to prevent z-fighting between entities with the same sprite depth /// @@ -305,6 +317,15 @@ namespace Barotrauma if (PlayerInput.IsCtrlDown()) { HashSet clones = Clone(SelectedList.ToList()).Where(c => c != null).ToHashSet(); + + if (clones.Count == 1) + { + if (clones.First() is WayPoint wayPoint && SelectedList.First() is WayPoint originalWaypoint && originalWaypoint.SpawnType == SpawnType.Path) + { + originalWaypoint.ConnectTo(wayPoint); + } + } + SelectedList = clones; SelectedList.ForEach(c => c.Move(moveAmount)); SubEditorScreen.StoreCommand(new AddOrDeleteCommand(new List(clones), false)); @@ -1068,6 +1089,7 @@ namespace Barotrauma } SubEditorScreen.StoreCommand(new AddOrDeleteCommand(clones, false, handleInventoryBehavior: false)); + if (Screen.Selected is SubEditorScreen subEditor) { subEditor.ReconstructLayers(); } } /// diff --git a/Barotrauma/BarotraumaClient/ClientSource/Map/RoundSound.cs b/Barotrauma/BarotraumaClient/ClientSource/Map/RoundSound.cs index ca438606f..ac2eeb854 100644 --- a/Barotrauma/BarotraumaClient/ClientSource/Map/RoundSound.cs +++ b/Barotrauma/BarotraumaClient/ClientSource/Map/RoundSound.cs @@ -16,6 +16,8 @@ namespace Barotrauma public readonly bool Stream; public readonly bool IgnoreMuffling; + public readonly bool MuteBackgroundMusic; + public readonly string? Filename; private RoundSound(ContentXElement element, Sound sound) @@ -26,6 +28,7 @@ namespace Barotrauma Range = element.GetAttributeFloat("range", 1000.0f); Volume = element.GetAttributeFloat("volume", 1.0f); IgnoreMuffling = element.GetAttributeBool("dontmuffle", false); + MuteBackgroundMusic = element.GetAttributeBool("MuteBackgroundMusic", false); FrequencyMultiplierRange = new Vector2(1.0f); string freqMultAttr = element.GetAttributeString("frequencymultiplier", element.GetAttributeString("frequency", "1.0")); @@ -73,9 +76,16 @@ namespace Barotrauma } Sound? existingSound = null; - if (roundSoundByPath.TryGetValue(filename.FullPath, out RoundSound? rs) && rs.Sound is { Disposed: false }) + if (roundSoundByPath.TryGetValue(filename.FullPath, out RoundSound? rs)) { - existingSound = rs.Sound; + if (rs.Sound is { Disposed: false }) + { + existingSound = rs.Sound; + } + else + { + roundSoundByPath.Remove(filename.FullPath); + } } if (existingSound is null) diff --git a/Barotrauma/BarotraumaClient/ClientSource/Map/Structure.cs b/Barotrauma/BarotraumaClient/ClientSource/Map/Structure.cs index 5f012c8a0..f995583ef 100644 --- a/Barotrauma/BarotraumaClient/ClientSource/Map/Structure.cs +++ b/Barotrauma/BarotraumaClient/ClientSource/Map/Structure.cs @@ -328,11 +328,11 @@ namespace Barotrauma Vector2 max = new Vector2(worldRect.Right, worldRect.Y + worldRect.Height); foreach (DecorativeSprite decorativeSprite in Prefab.DecorativeSprites) { - float scale = decorativeSprite.GetScale(spriteAnimState[decorativeSprite].RandomScaleFactor) * Scale; - min.X = Math.Min(worldPos.X - decorativeSprite.Sprite.size.X * decorativeSprite.Sprite.RelativeOrigin.X * scale, min.X); - max.X = Math.Max(worldPos.X + decorativeSprite.Sprite.size.X * (1.0f - decorativeSprite.Sprite.RelativeOrigin.X) * scale, max.X); - min.Y = Math.Min(worldPos.Y - decorativeSprite.Sprite.size.Y * (1.0f - decorativeSprite.Sprite.RelativeOrigin.Y) * scale, min.Y); - max.Y = Math.Max(worldPos.Y + decorativeSprite.Sprite.size.Y * decorativeSprite.Sprite.RelativeOrigin.Y * scale, max.Y); + Vector2 scale = decorativeSprite.GetScale(ref spriteAnimState[decorativeSprite].ScaleState, spriteAnimState[decorativeSprite].RandomScaleFactor) * Scale; + min.X = Math.Min(worldPos.X - decorativeSprite.Sprite.size.X * decorativeSprite.Sprite.RelativeOrigin.X * scale.X, min.X); + max.X = Math.Max(worldPos.X + decorativeSprite.Sprite.size.X * (1.0f - decorativeSprite.Sprite.RelativeOrigin.X) * scale.X, max.X); + min.Y = Math.Min(worldPos.Y - decorativeSprite.Sprite.size.Y * (1.0f - decorativeSprite.Sprite.RelativeOrigin.Y) * scale.Y, min.Y); + max.Y = Math.Max(worldPos.Y + decorativeSprite.Sprite.size.Y * decorativeSprite.Sprite.RelativeOrigin.Y * scale.Y, max.Y); } Vector2 offset = GetCollapseEffectOffset(); min += offset; @@ -341,6 +341,9 @@ namespace Barotrauma if (min.X > worldView.Right || max.X < worldView.X) { return false; } if (min.Y > worldView.Y || max.Y < worldView.Y - worldView.Height) { return false; } + Vector2 extents = max - min; + if (extents.X * Screen.Selected.Cam.Zoom < 1.0f) { return false; } + if (extents.Y * Screen.Selected.Cam.Zoom < 1.0f) { return false; } return true; } @@ -368,7 +371,7 @@ namespace Barotrauma return SpriteDepthOverrideIsSet ? SpriteOverrideDepth : Prefab.Sprite.Depth; } - public float GetDrawDepth() + public override float GetDrawDepth() { return GetDrawDepth(GetRealDepth(), Prefab.Sprite); } @@ -448,7 +451,7 @@ namespace Barotrauma MathUtils.PositiveModulo(-textureOffset.X, Prefab.BackgroundSprite.SourceRect.Width * TextureScale.X * Scale), MathUtils.PositiveModulo(-textureOffset.Y, Prefab.BackgroundSprite.SourceRect.Height * TextureScale.Y * Scale)); - float rotationRad = rotationForSprite(this.rotationRad, Prefab.BackgroundSprite); + float rotationRad = GetRotationForSprite(this.rotationRad, Prefab.BackgroundSprite); Prefab.BackgroundSprite.DrawTiled( spriteBatch, @@ -489,7 +492,7 @@ namespace Barotrauma advanceY = advanceY.FlipX(); } - float sectionSpriteRotationRad = rotationForSprite(this.rotationRad, Prefab.Sprite); + float sectionSpriteRotationRad = GetRotationForSprite(this.rotationRad, Prefab.Sprite); for (int i = 0; i < Sections.Length; i++) { @@ -498,16 +501,19 @@ namespace Barotrauma { float newCutoff = MathHelper.Lerp(0.0f, 0.65f, Sections[i].damage / MaxHealth); - if (Math.Abs(newCutoff - Submarine.DamageEffectCutoff) > 0.01f || color != Submarine.DamageEffectColor) + if (Math.Abs(newCutoff - Submarine.DamageEffectCutoff) > 0.05f) { + spriteBatch.End(); + spriteBatch.Begin(SpriteSortMode.BackToFront, + BlendState.NonPremultiplied, SamplerState.LinearWrap, + null, null, + damageEffect, + Screen.Selected.Cam.Transform); + damageEffect.Parameters["aCutoff"].SetValue(newCutoff); damageEffect.Parameters["cCutoff"].SetValue(newCutoff * 1.2f); - damageEffect.Parameters["inColor"].SetValue(color.ToVector4()); - damageEffect.CurrentTechnique.Passes[0].Apply(); - Submarine.DamageEffectCutoff = newCutoff; - Submarine.DamageEffectColor = color; } } if (!HasDamage && i == 0) @@ -560,15 +566,20 @@ namespace Barotrauma pos: drawPos.FlipY(), color: color, rotate: rotation, - scale: decorativeSprite.GetScale(spriteAnimState[decorativeSprite].RandomScaleFactor) * Scale, + origin: decorativeSprite.Sprite.Origin, + scale: decorativeSprite.GetScale(ref spriteAnimState[decorativeSprite].ScaleState, spriteAnimState[decorativeSprite].RandomScaleFactor) * Scale, spriteEffect: Prefab.Sprite.effects ^ SpriteEffects, depth: Math.Min(depth + (decorativeSprite.Sprite.Depth - Prefab.Sprite.Depth), 0.999f)); } } - static float rotationForSprite(float rotationRad, Sprite sprite) + static float GetRotationForSprite(float rotationRad, Sprite sprite) { - if (sprite.effects.HasFlag(SpriteEffects.FlipHorizontally) != sprite.effects.HasFlag(SpriteEffects.FlipVertically)) + // Use bitwise operations instead of HasFlag to avoid boxing, as this is performance-sensitive code. + bool flipHorizontally = (sprite.effects & SpriteEffects.FlipHorizontally) == SpriteEffects.FlipHorizontally; + bool flipVertically = (sprite.effects & SpriteEffects.FlipVertically) == SpriteEffects.FlipVertically; + + if (flipHorizontally != flipVertically) { rotationRad = -rotationRad; } @@ -600,6 +611,10 @@ namespace Barotrauma if (GetSection(i).damage > 0) { var textPos = SectionPosition(i, true); + if (Submarine != null) + { + textPos += (Submarine.DrawPosition - Submarine.Position); + } textPos.Y = -textPos.Y; GUI.DrawString(spriteBatch, textPos, "Damage: " + (int)((GetSection(i).damage / MaxHealth) * 100f) + "%", Color.Yellow); } 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 490077abb..be8ac5b7b 100644 --- a/Barotrauma/BarotraumaClient/ClientSource/Map/Submarine.cs +++ b/Barotrauma/BarotraumaClient/ClientSource/Map/Submarine.cs @@ -134,7 +134,7 @@ namespace Barotrauma foreach (Submarine sub in Loaded) { Rectangle worldBorders = sub.Borders; - worldBorders.Location += sub.WorldPosition.ToPoint(); + worldBorders.Location += (sub.DrawPosition + sub.HiddenSubPosition).ToPoint(); worldBorders.Y = -worldBorders.Y; GUI.DrawRectangle(spriteBatch, worldBorders, Color.White, false, 0, 5); @@ -159,46 +159,38 @@ namespace Barotrauma } public static float DamageEffectCutoff; - public static Color DamageEffectColor; - private static readonly List depthSortedDamageable = new List(); public static void DrawDamageable(SpriteBatch spriteBatch, Effect damageEffect, bool editing = false, Predicate predicate = null) { - var entitiesToRender = !editing && visibleEntities != null ? visibleEntities : MapEntity.MapEntityList; - - depthSortedDamageable.Clear(); - - //insertion sort according to draw depth - foreach (MapEntity e in entitiesToRender) + if (!editing && visibleEntities != null) { - if (e is Structure structure && structure.DrawDamageEffect) + foreach (MapEntity e in visibleEntities) { - if (predicate != null) + if (e is Structure structure && structure.DrawDamageEffect) { - if (!predicate(e)) { continue; } + if (predicate != null) + { + if (!predicate(structure)) { continue; } + } + structure.DrawDamage(spriteBatch, damageEffect, editing); } - float drawDepth = structure.GetDrawDepth(); - int i = 0; - while (i < depthSortedDamageable.Count) + } + } + else + { + foreach (Structure structure in Structure.WallList) + { + if (structure.DrawDamageEffect) { - float otherDrawDepth = depthSortedDamageable[i].GetDrawDepth(); - if (otherDrawDepth < drawDepth) { break; } - i++; + if (predicate != null) + { + if (!predicate(structure)) { continue; } + } + structure.DrawDamage(spriteBatch, damageEffect, editing); } - depthSortedDamageable.Insert(i, structure); } } - foreach (Structure s in depthSortedDamageable) - { - s.DrawDamage(spriteBatch, damageEffect, editing); - } - 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) @@ -506,6 +498,16 @@ namespace Barotrauma warnings.Add(SubEditorScreen.WarningType.WaterInHulls); Hull.ShowHulls = true; } + + if (Info.IsWreck) + { + Point vanillaBrainSize = new Point(204, 204); + if (WreckAI.GetPotentialBrainRooms(this, WreckAIConfig.GetRandom(), minSize: vanillaBrainSize).None()) + { + errorMsgs.Add(TextManager.Get("NoSuitableBrainRoomsWarning").Value); + warnings.Add(SubEditorScreen.WarningType.NoSuitableBrainRooms); + } + } if (!IsWarningSuppressed(SubEditorScreen.WarningType.NotEnoughContainers)) { @@ -656,6 +658,7 @@ namespace Barotrauma errorMsgs.Add(TextManager.GetWithVariables("InsufficientFreeConnectionsWarning", ("[doorcount]", doorLinks.ToString()), ("[freeconnectioncount]", (item.Connections[i].MaxWires - wireCount).ToString())).Value); + warnings.Add(SubEditorScreen.WarningType.InsufficientFreeConnectionsWarning); break; } } @@ -836,7 +839,7 @@ namespace Barotrauma subBody.PositionBuffer.Insert(index, posInfo); } } - + public void ClientEventRead(IReadMessage msg, float sendingTime) { Identifier layerIdentifier = msg.ReadIdentifier(); diff --git a/Barotrauma/BarotraumaClient/ClientSource/Map/SubmarinePreview.cs b/Barotrauma/BarotraumaClient/ClientSource/Map/SubmarinePreview.cs index f544b6dc3..c507940b9 100644 --- a/Barotrauma/BarotraumaClient/ClientSource/Map/SubmarinePreview.cs +++ b/Barotrauma/BarotraumaClient/ClientSource/Map/SubmarinePreview.cs @@ -572,8 +572,9 @@ namespace Barotrauma Vector2 offset = decorativeSprite.GetOffset(ref offsetState, Vector2.Zero) * scale; if (flippedX) { offset.X = -offset.X; } if (flippedY) { offset.Y = -offset.Y; } - decorativeSprite.Sprite.Draw(spriteRecorder, new Vector2(spritePos.X + offset.X, -(spritePos.Y + offset.Y)), color, - rotationRad + rot, decorativeSprite.GetScale(0f) * scale, prefab.Sprite.effects, + float throwAway = 0.0f; + decorativeSprite.Sprite.Draw(spriteRecorder, new Vector2(spritePos.X + offset.X, -(spritePos.Y + offset.Y)), color, decorativeSprite.Sprite.Origin, + rotationRad + rot, decorativeSprite.GetScale(ref throwAway, 0f) * scale, prefab.Sprite.effects, depth: Math.Min(depth + (decorativeSprite.Sprite.Depth - prefab.Sprite.Depth), 0.999f)); } } diff --git a/Barotrauma/BarotraumaClient/ClientSource/Map/WayPoint.cs b/Barotrauma/BarotraumaClient/ClientSource/Map/WayPoint.cs index 456dbcc0c..c8c38d34a 100644 --- a/Barotrauma/BarotraumaClient/ClientSource/Map/WayPoint.cs +++ b/Barotrauma/BarotraumaClient/ClientSource/Map/WayPoint.cs @@ -15,7 +15,8 @@ namespace Barotrauma public override bool IsVisible(Rectangle worldView) { - return Screen.Selected == GameMain.SubEditorScreen || GameMain.DebugDraw; + if (Screen.Selected != GameMain.SubEditorScreen && !GameMain.DebugDraw) { return false; } + return base.IsVisible(worldView); } public override bool SelectableInEditor @@ -45,6 +46,10 @@ namespace Barotrauma } if (IsHighlighted || IsHighlighted) { clr = Color.Lerp(clr, Color.White, 0.8f); } + if (Stairs is { Removed: true }) { Stairs = null; } + if (Ladders is { Item.Removed: true }) { Ladders = null; } + if (ConnectedGap is { Removed: true }) { ConnectedGap = null; } + int iconSize = spawnType == SpawnType.Path ? WaypointSize : SpawnPointSize; if (ConnectedDoor != null || Ladders != null || Stairs != null || SpawnType != SpawnType.Path) { @@ -92,6 +97,11 @@ namespace Barotrauma if (sprite != null) { float spriteScale = iconSize / (float)sprite.SourceRect.Width; + if (Ladders == null && ConnectedDoor == null && ConnectedGap != null) + { + clr = Color.White; + spriteScale *= 1.5f; + } sprite.Draw(spriteBatch, drawPos, clr, origin: sprite.size / 2, scale: spriteScale, depth: 0.001f); sprite2?.Draw(spriteBatch, drawPos + sprite.size * spriteScale * 0.5f, clr, origin: sprite2.size / 2, scale: spriteScale, depth: 0.001f); } @@ -100,27 +110,39 @@ namespace Barotrauma { AssignedJob.Icon.Draw(spriteBatch, drawPos, AssignedJob.UIColor, scale: iconSize / (float)AssignedJob.Icon.SourceRect.Width * 0.8f, depth: 0.0f); } - - foreach (MapEntity e in linkedTo) + + // alternate line drawing for when cloning the waypoint: line goes from current position to original position, where moving started + if (StartMovingPos != Vector2.Zero && SelectedList.Contains(this) && PlayerInput.IsCtrlDown()) { GUI.DrawLine(spriteBatch, drawPos, - new Vector2(e.DrawPosition.X, -e.DrawPosition.Y), + new Vector2(StartMovingPos.X, -StartMovingPos.Y), (IsTraversable ? GUIStyle.Green : Color.Gray) * 0.7f, width: 5, depth: 0.002f); } + else + { + foreach (MapEntity e in linkedTo) + { + GUI.DrawLine(spriteBatch, + drawPos, + new Vector2(e.DrawPosition.X, -e.DrawPosition.Y), + (IsTraversable ? GUIStyle.Green : Color.Gray) * 0.7f, width: 5, depth: 0.002f); + } + } + if (ConnectedGap != null) { GUI.DrawLine(spriteBatch, drawPos, new Vector2(ConnectedGap.DrawPosition.X, -ConnectedGap.DrawPosition.Y), - GUIStyle.Green * 0.5f, width: 1); + Color.White, width: 1); } if (Ladders != null) { GUI.DrawLine(spriteBatch, drawPos, new Vector2(Ladders.Item.DrawPosition.X, -Ladders.Item.DrawPosition.Y), - GUIStyle.Green * 0.5f, width: 1); + Color.White, width: 1); } var color = Color.WhiteSmoke; @@ -419,6 +441,7 @@ namespace Barotrauma jobDropDown.AddItem(TextManager.Get("Any"), null); foreach (JobPrefab jobPrefab in JobPrefab.Prefabs) { + if (jobPrefab.Name.IsNullOrWhiteSpace()) { continue; } jobDropDown.AddItem(jobPrefab.Name, jobPrefab); } jobDropDown.SelectItem(AssignedJob); @@ -432,7 +455,7 @@ namespace Barotrauma }; propertyBox.OnTextChanged += (textBox, text) => { - tags = text.Split(',').ToIdentifiers().ToHashSet(); + tags = text.ToIdentifiers().ToHashSet(); return true; }; propertyBox.OnEnterPressed += (textBox, text) => 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/FileTransfer/FileReceiver.cs b/Barotrauma/BarotraumaClient/ClientSource/Networking/FileTransfer/FileReceiver.cs index 1a9fb2c71..5bda4ceb1 100644 --- a/Barotrauma/BarotraumaClient/ClientSource/Networking/FileTransfer/FileReceiver.cs +++ b/Barotrauma/BarotraumaClient/ClientSource/Networking/FileTransfer/FileReceiver.cs @@ -260,7 +260,7 @@ namespace Barotrauma.Networking { try { - Directory.CreateDirectory(downloadFolder); + Directory.CreateDirectory(downloadFolder, catchUnauthorizedAccessExceptions: false); } catch (Exception e) { @@ -572,7 +572,7 @@ namespace Barotrauma.Networking { try { - File.Delete(transfer.FilePath); + File.Delete(transfer.FilePath, catchUnauthorizedAccessExceptions: false); } catch (Exception e) { diff --git a/Barotrauma/BarotraumaClient/ClientSource/Networking/GameClient.cs b/Barotrauma/BarotraumaClient/ClientSource/Networking/GameClient.cs index 3b43b5c1a..3f708532b 100644 --- a/Barotrauma/BarotraumaClient/ClientSource/Networking/GameClient.cs +++ b/Barotrauma/BarotraumaClient/ClientSource/Networking/GameClient.cs @@ -7,9 +7,11 @@ using Microsoft.Xna.Framework.Input; using System; using System.Collections.Generic; using System.Collections.Immutable; +using System.Globalization; using System.Linq; using System.Threading.Tasks; using System.Xml.Linq; +using Barotrauma.PerkBehaviors; namespace Barotrauma.Networking { @@ -38,11 +40,12 @@ namespace Barotrauma.Networking { if (string.IsNullOrEmpty(value)) { return; } Name = value; - nameId++; + ForceNameJobTeamUpdate(); } - public void ForceNameAndJobUpdate() + public void ForceNameJobTeamUpdate() { + // Deviously triggers SendLobbyUpdate() which causes the server to call GameServer.ClientReadLobby() nameId++; } @@ -137,13 +140,14 @@ namespace Barotrauma.Networking } } + public Client MyClient => ConnectedClients.FirstOrDefault(c => c.SessionId == SessionId); + public Option Ping { get { - Client selfClient = ConnectedClients.FirstOrDefault(c => c.SessionId == SessionId); - if (selfClient is null || selfClient.Ping == 0) { return Option.None(); } - return Option.Some(selfClient.Ping); + if (MyClient is null || MyClient.Ping == 0) { return Option.None(); } + return Option.Some(MyClient.Ping); } } @@ -485,14 +489,13 @@ namespace Barotrauma.Networking { if (VoipCapture.Instance.LastEnqueueAudio > DateTime.Now - new TimeSpan(0, 0, 0, 0, milliseconds: 100)) { - var myClient = ConnectedClients.Find(c => c.SessionId == SessionId); if (Screen.Selected == GameMain.NetLobbyScreen) { - GameMain.NetLobbyScreen.SetPlayerSpeaking(myClient); + GameMain.NetLobbyScreen.SetPlayerSpeaking(MyClient); } else { - GameMain.GameSession?.CrewManager?.SetClientSpeaking(myClient); + GameMain.GameSession?.CrewManager?.SetClientSpeaking(MyClient); } } } @@ -516,10 +519,12 @@ namespace Barotrauma.Networking { string errorMsg = "Error while reading a message from server. "; if (GameMain.Client == null) { errorMsg += "Client disposed."; } - AppendExceptionInfo(ref errorMsg, e); - GameAnalyticsManager.AddErrorEventOnce("GameClient.Update:CheckServerMessagesException" + e.TargetSite.ToString(), GameAnalyticsManager.ErrorSeverity.Error, errorMsg); - DebugConsole.ThrowError(errorMsg); - new GUIMessageBox(TextManager.Get("Error"), TextManager.GetWithVariables("MessageReadError", ("[message]", e.Message), ("[targetsite]", e.TargetSite.ToString()))) + AppendExceptionInfo(ref errorMsg, out Entity causingEntity, e); + + string targetSite = e.TargetSite?.ToString() ?? "unknown"; + GameAnalyticsManager.AddErrorEventOnce("GameClient.Update:CheckServerMessagesException" + targetSite, GameAnalyticsManager.ErrorSeverity.Error, errorMsg); + DebugConsole.ThrowError(errorMsg, contentPackage: causingEntity?.ContentPackage); + new GUIMessageBox(TextManager.Get("Error"), TextManager.GetWithVariables("MessageReadError", ("[message]", e.Message), ("[targetsite]", targetSite))) { DisplayInLoadingScreens = true }; @@ -579,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(); @@ -659,7 +667,7 @@ namespace Barotrauma.Networking catch (Exception e) { string errorMsg = "Error while reading an ingame update message from server."; - AppendExceptionInfo(ref errorMsg, e); + AppendExceptionInfo(ref errorMsg, out Entity causingEntity, e); GameAnalyticsManager.AddErrorEventOnce("GameClient.ReadDataMessage:ReadIngameUpdate", GameAnalyticsManager.ErrorSeverity.Error, errorMsg); throw; } @@ -689,6 +697,16 @@ namespace Barotrauma.Networking string subName = inc.ReadString(); string subHash = inc.ReadString(); + bool hasEnemySub = inc.ReadBoolean(); + + string enemySubName = subName; + string enemySubHash = subHash; + if (hasEnemySub) + { + enemySubName = inc.ReadString(); + enemySubHash = inc.ReadString(); + } + bool usingShuttle = inc.ReadBoolean(); string shuttleName = inc.ReadString(); string shuttleHash = inc.ReadString(); @@ -701,16 +719,18 @@ 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; if (campaign == null && campaignID == 0) { - readyToStart = GameMain.NetLobbyScreen.TrySelectSub(subName, subHash, GameMain.NetLobbyScreen.SubList) && - GameMain.NetLobbyScreen.TrySelectSub(shuttleName, shuttleHash, GameMain.NetLobbyScreen.ShuttleList.ListBox); + readyToStart = GameMain.NetLobbyScreen.TrySelectSub(subName, subHash, SelectedSubType.Sub, GameMain.NetLobbyScreen.SubList) && + GameMain.NetLobbyScreen.TrySelectSub(shuttleName, shuttleHash, SelectedSubType.Shuttle, GameMain.NetLobbyScreen.ShuttleList.ListBox); + + if (hasEnemySub && !GameMain.NetLobbyScreen.TrySelectSub(enemySubName, enemySubHash, SelectedSubType.EnemySub, GameMain.NetLobbyScreen.SubList)) + { + readyToStart = false; + } } else { @@ -720,30 +740,63 @@ 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")) { CoroutineManager.StartCoroutine(NetLobbyScreen.WaitForStartRound(startButton: null), "WaitForStartRound"); } break; + case ServerPacketHeader.WARN_STARTGAME: + DebugConsole.Log("Received WARN_STARTGAME packet."); + + RoundStartWarningData warningData = INetSerializableStruct.Read(inc); + var team1IncompatiblePerks = ToolBox.UintIdentifierArrayToPrefabCollection(DisembarkPerkPrefab.Prefabs, warningData.Team1IncompatiblePerks); + var team2IncompatiblePerks = ToolBox.UintIdentifierArrayToPrefabCollection(DisembarkPerkPrefab.Prefabs, warningData.Team2IncompatiblePerks); + + GameMain.NetLobbyScreen?.ShowStartRoundWarning(SerializableDateTime.UtcNow + TimeSpan.FromSeconds(warningData.RoundStartsAnywaysTimeInSeconds), warningData.Team1Sub, team1IncompatiblePerks, warningData.Team2Sub, team2IncompatiblePerks); + break; + case ServerPacketHeader.CANCEL_STARTGAME: + DebugConsole.Log("Received CANCEL_STARTGAME packet."); + GameMain.NetLobbyScreen?.CloseStartRoundWarning(); + if (GameMain.NetLobbyScreen?.ReadyToStartBox is { } readyToStartBox) + { + readyToStartBox.Selected = false; + SetReadyToStart(readyToStartBox); + } + 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: @@ -869,6 +922,9 @@ namespace Barotrauma.Networking case ServerPacketHeader.EVENTACTION: GameMain.GameSession?.EventManager.ClientRead(inc); break; + case ServerPacketHeader.SEND_BACKUP_INDICES: + GameMain.NetLobbyScreen?.CampaignSetupUI?.OnBackupIndicesReceived(inc); + break; } } @@ -887,10 +943,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); @@ -947,13 +1012,15 @@ namespace Barotrauma.Networking { if (Level.Loaded.EqualityCheckValues[stage] != levelEqualityCheckValues[stage]) { - string errorMsg = "Level equality check failed. The level generated at your end doesn't match the level generated by the server" + - " (client value " + stage + ": " + Level.Loaded.EqualityCheckValues[stage].ToString("X") + - ", server value " + stage + ": " + levelEqualityCheckValues[stage].ToString("X") + - ", level value count: " + levelEqualityCheckValues.Count + - ", seed: " + Level.Loaded.Seed + - ", sub: " + Submarine.MainSub.Info.Name + " (" + Submarine.MainSub.Info.MD5Hash.ShortRepresentation + ")" + - ", mirrored: " + Level.Loaded.Mirrored + "). Round init status: " + roundInitStatus + "." + campaignErrorInfo; + string errorMsg = "Level equality check failed. The level generated at your end doesn't match the level generated by the server, " + + $"(client value {stage}:{Level.Loaded.EqualityCheckValues[stage].ToString("X")}, " + + $"server value {stage}: {levelEqualityCheckValues[stage].ToString("X")}, " + + $"level value count: {levelEqualityCheckValues.Count}, " + + $"seed: {Level.Loaded.Seed}, " + + $"missions: {string.Join(", ", GameMain.GameSession.GameMode.Missions.Select(m => m.Prefab.Identifier))}, " + + $"sub: {(Submarine.MainSub == null ? "null" : (Submarine.MainSub.Info.Name + " (" + Submarine.MainSub.Info.MD5Hash.ShortRepresentation))}, " + + $"mirrored: {Level.Loaded.Mirrored}). Round init status: {roundInitStatus}." + + campaignErrorInfo; GameAnalyticsManager.AddErrorEventOnce("GameClient.StartGame:LevelsDontMatch" + Level.Loaded.Seed, GameAnalyticsManager.ErrorSeverity.Error, errorMsg); throw new Exception(errorMsg); } @@ -969,9 +1036,24 @@ namespace Barotrauma.Networking CrewManager.ClientReadActiveOrders(inc); } + if (inc.ReadBoolean()) + { + ApplyDisembarkPerk(); + } + roundInitStatus = RoundInitStatus.Started; } + private void ApplyDisembarkPerk() + { + var characters = GameSession.GetSessionCrewCharacters(CharacterType.Both); + + ImmutableArray team1Characters = characters.Where(static c => c.TeamID is CharacterTeamType.Team1).ToImmutableArray(), + team2Characters = characters.Where(static c => c.TeamID is CharacterTeamType.Team2).ToImmutableArray(); + + GameSession.GetPerks().ApplyAll(team1Characters, team2Characters); + } + /// /// Fires when the ClientPeer gets disconnected from the server. Does not necessarily mean the client is shutting down, we may still be able to reconnect. /// @@ -1349,6 +1431,8 @@ namespace Barotrauma.Networking private IEnumerable StartGame(IReadMessage inc) { + DebugConsole.Log($"Running {nameof(StartGame)} coroutine"); + Character?.Remove(); Character = null; HasSpawned = false; @@ -1384,6 +1468,7 @@ namespace Barotrauma.Networking { DebugConsole.ThrowError("Game mode \"" + modeIdentifier + "\" not found!"); roundInitStatus = RoundInitStatus.Interrupted; + startGameCoroutine = null; yield return CoroutineStatus.Failure; } @@ -1396,11 +1481,16 @@ namespace Barotrauma.Networking ServerSettings.LockAllDefaultWires = inc.ReadBoolean(); ServerSettings.AllowLinkingWifiToChat = inc.ReadBoolean(); ServerSettings.MaximumMoneyTransferRequest = inc.ReadInt32(); + ServerSettings.RespawnMode = (RespawnMode)inc.ReadByte(); bool usingShuttle = GameMain.NetLobbyScreen.UsingShuttle = inc.ReadBoolean(); 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); @@ -1415,25 +1505,47 @@ namespace Barotrauma.Networking { string levelSeed = inc.ReadString(); float levelDifficulty = inc.ReadSingle(); + Identifier biomeId = inc.ReadIdentifier(); string subName = inc.ReadString(); string subHash = inc.ReadString(); string shuttleName = inc.ReadString(); string shuttleHash = inc.ReadString(); + + bool hasEnemySub = inc.ReadBoolean(); + string enemySubName = subName; + string enemySubHash = subHash; + if (hasEnemySub) + { + enemySubName = inc.ReadString(); + enemySubHash = inc.ReadString(); + } + List missionHashes = new List(); int missionCount = inc.ReadByte(); for (int i = 0; i < missionCount; i++) { missionHashes.Add(inc.ReadUInt32()); } - if (!GameMain.NetLobbyScreen.TrySelectSub(subName, subHash, GameMain.NetLobbyScreen.SubList)) + if (!GameMain.NetLobbyScreen.TrySelectSub(subName, subHash, SelectedSubType.Sub, GameMain.NetLobbyScreen.SubList)) { roundInitStatus = RoundInitStatus.Interrupted; + startGameCoroutine = null; yield return CoroutineStatus.Success; } - if (!GameMain.NetLobbyScreen.TrySelectSub(shuttleName, shuttleHash, GameMain.NetLobbyScreen.ShuttleList.ListBox)) + if (hasEnemySub) + { + if (!GameMain.NetLobbyScreen.TrySelectSub(enemySubName, enemySubHash, SelectedSubType.EnemySub, GameMain.NetLobbyScreen.SubList)) + { + roundInitStatus = RoundInitStatus.Interrupted; + yield return CoroutineStatus.Success; + } + } + + if (!GameMain.NetLobbyScreen.TrySelectSub(shuttleName, shuttleHash, SelectedSubType.Shuttle, GameMain.NetLobbyScreen.ShuttleList.ListBox)) { roundInitStatus = RoundInitStatus.Interrupted; + startGameCoroutine = null; yield return CoroutineStatus.Success; } @@ -1463,6 +1575,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 || @@ -1475,24 +1588,28 @@ 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; } var selectedMissions = missionHashes.Select(i => MissionPrefab.Prefabs.Find(p => p.UintIdentifier == i)); - GameMain.GameSession = new GameSession(GameMain.NetLobbyScreen.SelectedSub, gameMode, missionPrefabs: selectedMissions); - GameMain.GameSession.StartRound(levelSeed, levelDifficulty); + var selectedEnemySub = hasEnemySub && GameMain.NetLobbyScreen.SelectedEnemySub is { } enemySub ? Option.Some(enemySub) : Option.None; + + GameMain.GameSession = new GameSession(GameMain.NetLobbyScreen.SelectedSub, selectedEnemySub, gameMode, missionPrefabs: selectedMissions); + GameMain.GameSession.StartRound(levelSeed, levelDifficulty, levelGenerationParams: null, forceBiome: biomeId); } 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(); @@ -1505,6 +1622,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; } @@ -1521,6 +1639,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; } @@ -1534,6 +1653,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; } @@ -1547,12 +1667,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; } @@ -1571,9 +1697,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 @@ -1595,6 +1728,12 @@ namespace Barotrauma.Networking { while (true) { + if (requestNewRoundStart) + { + RequestNewRoundStart(); + yield return CoroutineStatus.Success; + } + try { if (DateTime.Now > requestFinalizeTime) @@ -1659,17 +1798,21 @@ namespace Barotrauma.Networking { DebugConsole.ThrowError(roundInitStatus.ToString()); CoroutineManager.StartCoroutine(EndGame("")); + startGameCoroutine = null; yield return CoroutineStatus.Failure; } else { + startGameCoroutine = null; yield return CoroutineStatus.Success; } } - if (GameMain.GameSession.Submarine.Info.IsFileCorrupted) + if (GameMain.GameSession.Submarine != null && + 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; } @@ -1717,6 +1860,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) @@ -1759,12 +1913,15 @@ namespace Barotrauma.Networking refSub = Submarine.MainSubs[1]; } - // Enable characters near the main sub for the endCinematic - foreach (Character c in Character.CharacterList) + if (refSub != null) { - if (Vector2.DistanceSquared(refSub.WorldPosition, c.WorldPosition) < MathUtils.Pow2(c.Params.DisableDistance)) + // Enable characters near the main sub for the endCinematic + foreach (Character c in Character.CharacterList) { - c.Enabled = true; + if (Vector2.DistanceSquared(refSub.WorldPosition, c.WorldPosition) < MathUtils.Pow2(c.Params.DisableDistance)) + { + c.Enabled = true; + } } } @@ -1823,6 +1980,7 @@ namespace Barotrauma.Networking GameStarted = inc.ReadBoolean(); bool allowSpectating = inc.ReadBoolean(); + bool allowAFK = inc.ReadBoolean(); bool permadeathMode = inc.ReadBoolean(); bool ironmanMode = inc.ReadBoolean(); @@ -1842,7 +2000,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(); } } } } @@ -1851,6 +2009,8 @@ namespace Barotrauma.Networking { bool refreshCampaignUI = false; UInt16 listId = inc.ReadUInt16(); + GameMain.NetLobbyScreen.Team1Count = inc.ReadByte(); + GameMain.NetLobbyScreen.Team2Count = inc.ReadByte(); List tempClients = new List(); int clientCount = inc.ReadByte(); for (int i = 0; i < clientCount; i++) @@ -1883,27 +2043,37 @@ namespace Barotrauma.Networking existingClient.NameId = tc.NameId; existingClient.PreferredJob = tc.PreferredJob; existingClient.PreferredTeam = tc.PreferredTeam; + existingClient.TeamID = tc.TeamID; existingClient.Character = null; existingClient.Karma = tc.Karma; existingClient.Muted = tc.Muted; existingClient.InGame = tc.InGame; existingClient.IsOwner = tc.IsOwner; existingClient.IsDownloading = tc.IsDownloading; - GameMain.NetLobbyScreen.SetPlayerNameAndJobPreference(existingClient); + GameMain.NetLobbyScreen.SetPlayerNameAndJobPreference(existingClient); // refresh lobby player list in the local UI if (Screen.Selected != GameMain.NetLobbyScreen && tc.CharacterId > 0) { existingClient.CharacterID = tc.CharacterId; } if (existingClient.SessionId == SessionId) { + MultiplayerPreferences.Instance.TeamPreference = existingClient.PreferredTeam; + // If a team is already selected, make sure the UI reflects it + if (MultiplayerPreferences.Instance.TeamPreference != CharacterTeamType.None) + { + GameMain.NetLobbyScreen.TeamPreferenceListBox?.Select(MultiplayerPreferences.Instance.TeamPreference); + } + else + { + GameMain.NetLobbyScreen.RefreshPvpTeamSelectionButtons(); + } existingClient.SetPermissions(permissions, permittedConsoleCommands); if (!NetIdUtils.IdMoreRecent(nameId, tc.NameId)) { 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; } @@ -1950,6 +2120,7 @@ namespace Barotrauma.Networking Steam.SteamManager.UpdateLobby(ServerSettings); } + GameMain.NetLobbyScreen?.UpdateDisembarkPointListFromServerSettings(); } if (refreshCampaignUI) @@ -1960,6 +2131,7 @@ namespace Barotrauma.Networking campaign.CampaignUI?.HRManagerUI?.RefreshUI(); } } + } private bool initialUpdateReceived; @@ -1980,16 +2152,15 @@ namespace Barotrauma.Networking UInt16 updateID = inc.ReadUInt16(); + UInt16 settingsLen = inc.ReadUInt16(); byte[] settingsData = inc.ReadBytes(settingsLen); bool isInitialUpdate = inc.ReadBoolean(); + DebugConsole.Log($"Received {(isInitialUpdate ? "initial" : string.Empty)} lobby update ID: {updateID}, last ID: {GameMain.NetLobbyScreen.LastUpdateID}."); + if (isInitialUpdate) - { - if (GameSettings.CurrentConfig.VerboseLogging) - { - DebugConsole.NewMessage("Received initial lobby update, ID: " + updateID + ", last ID: " + GameMain.NetLobbyScreen.LastUpdateID, Color.Gray); - } + { ReadInitialUpdate(inc); initialUpdateReceived = true; } @@ -1997,6 +2168,15 @@ namespace Barotrauma.Networking string selectSubName = inc.ReadString(); string selectSubHash = inc.ReadString(); + bool usingEnemySub = inc.ReadBoolean(); + string selectEnemySubName = selectSubName; + string selectEnemySubHash = selectSubHash; + if (usingEnemySub) + { + selectEnemySubName = inc.ReadString(); + selectEnemySubHash = inc.ReadString(); + } + bool usingShuttle = inc.ReadBoolean(); string selectShuttleName = inc.ReadString(); string selectShuttleHash = inc.ReadString(); @@ -2007,11 +2187,18 @@ 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); - MissionType missionType = (MissionType)inc.ReadRangedInteger(0, (int)MissionType.All); + List missionTypes = new List(); + uint missionTypeCount = inc.ReadVariableUInt32(); + for (int i = 0; i < missionTypeCount; i++) + { + missionTypes.Add(inc.ReadIdentifier()); + } + int modeIndex = inc.ReadByte(); string levelSeed = inc.ReadString(); @@ -2048,12 +2235,19 @@ namespace Barotrauma.Networking ServerSettings.ServerLog.ServerName = ServerSettings.ServerName; GameMain.NetLobbyScreen.UsingShuttle = usingShuttle; - if (!allowSubVoting || GameMain.NetLobbyScreen.SelectedSub == null) { GameMain.NetLobbyScreen.TrySelectSub(selectSubName, selectSubHash, GameMain.NetLobbyScreen.SubList); } - GameMain.NetLobbyScreen.TrySelectSub(selectShuttleName, selectShuttleHash, GameMain.NetLobbyScreen.ShuttleList.ListBox); + if (!allowSubVoting || GameMain.NetLobbyScreen.SelectedSub == null) + { + GameMain.NetLobbyScreen.TrySelectSub(selectSubName, selectSubHash, SelectedSubType.Sub, GameMain.NetLobbyScreen.SubList); + if (usingEnemySub) + { + GameMain.NetLobbyScreen.TrySelectSub(selectEnemySubName, selectEnemySubHash, SelectedSubType.EnemySub, GameMain.NetLobbyScreen.SubList); + } + } + GameMain.NetLobbyScreen.TrySelectSub(selectShuttleName, selectShuttleHash, SelectedSubType.Shuttle, GameMain.NetLobbyScreen.ShuttleList.ListBox); GameMain.NetLobbyScreen.SetTraitorProbability(traitorProbability); GameMain.NetLobbyScreen.SetTraitorDangerLevel(traitorDangerLevel); - GameMain.NetLobbyScreen.SetMissionType(missionType); + GameMain.NetLobbyScreen.SetMissionTypes(missionTypes); GameMain.NetLobbyScreen.LevelSeed = levelSeed; GameMain.NetLobbyScreen.SelectMode(modeIndex); @@ -2071,6 +2265,7 @@ namespace Barotrauma.Networking } GameMain.NetLobbyScreen.SetAllowSpectating(allowSpectating); + GameMain.NetLobbyScreen.SetAllowAFK(allowAFK); GameMain.NetLobbyScreen.SetLevelDifficulty(levelDifficulty); GameMain.NetLobbyScreen.SetBotSpawnMode(botSpawnMode); GameMain.NetLobbyScreen.SetBotCount(botCount); @@ -2246,7 +2441,7 @@ namespace Barotrauma.Networking GameAnalyticsManager.AddErrorEventOnce("GameClient.ReadInGameUpdate", GameAnalyticsManager.ErrorSeverity.Critical, string.Join("\n", errorLines)); throw new Exception( - $"Exception thrown while reading segment {segment.Identifier} at position {segment.Pointer}." + + $"Exception thrown while reading a message of the type \"{segment.Identifier}\" at position {segment.Pointer}." + (prevSegments.Any() ? $" Previous segments: {string.Join(", ", prevSegments)}" : ""), ex); }); @@ -2263,6 +2458,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; @@ -2409,6 +2605,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) { @@ -2489,14 +2694,20 @@ namespace Barotrauma.Networking ((SubmarineInfo)c.UserData).MD5Hash.StringRepresentation == newSub.MD5Hash.StringRepresentation); if (subElement == null) { continue; } - Color newSubTextColor = new Color(subElement.GetChild().TextColor, 1.0f); - subElement.GetChild().TextColor = newSubTextColor; - - if (subElement.GetChildByUserData("classtext") is GUITextBlock classTextBlock) + //set the dimmed out submarine info back to normal and update texts + if (subElement.FindChild("nametext", recursive: true) is GUITextBlock nameTextBlock) + { + nameTextBlock.TextColor = new Color(nameTextBlock.TextColor, 1.0f); + } + if (subElement.FindChild("classtext", recursive: true) is GUITextBlock classTextBlock) { - Color newSubClassTextColor = new Color(classTextBlock.TextColor, 0.8f); classTextBlock.Text = TextManager.Get($"submarineclass.{newSub.SubmarineClass}"); - classTextBlock.TextColor = newSubClassTextColor; + classTextBlock.TextColor = new Color(classTextBlock.TextColor, 0.8f); + } + if (subElement.FindChild("pricetext", recursive: true) is GUITextBlock priceTextBlock) + { + priceTextBlock.Text = TextManager.GetWithVariable("currencyformat", "[credits]", string.Format(CultureInfo.InvariantCulture, "{0:N0}", newSub.Price)); + priceTextBlock.TextColor = new Color(priceTextBlock.TextColor, 0.8f); } subElement.UserData = newSub; @@ -2507,14 +2718,22 @@ namespace Barotrauma.Networking GameMain.NetLobbyScreen.FailedSelectedSub.Value.Name == newSub.Name && GameMain.NetLobbyScreen.FailedSelectedSub.Value.Hash == newSub.MD5Hash.StringRepresentation) { - GameMain.NetLobbyScreen.TrySelectSub(newSub.Name, newSub.MD5Hash.StringRepresentation, GameMain.NetLobbyScreen.SubList); + GameMain.NetLobbyScreen.TrySelectSub(newSub.Name, newSub.MD5Hash.StringRepresentation, SelectedSubType.Sub, GameMain.NetLobbyScreen.SubList); } if (GameMain.NetLobbyScreen.FailedSelectedShuttle.HasValue && GameMain.NetLobbyScreen.FailedSelectedShuttle.Value.Name == newSub.Name && GameMain.NetLobbyScreen.FailedSelectedShuttle.Value.Hash == newSub.MD5Hash.StringRepresentation) { - GameMain.NetLobbyScreen.TrySelectSub(newSub.Name, newSub.MD5Hash.StringRepresentation, GameMain.NetLobbyScreen.ShuttleList.ListBox); + GameMain.NetLobbyScreen.TrySelectSub(newSub.Name, newSub.MD5Hash.StringRepresentation, SelectedSubType.Shuttle, GameMain.NetLobbyScreen.ShuttleList.ListBox); + } + + if (GameMain.NetLobbyScreen.SelectedMode == GameModePreset.PvP && + GameMain.NetLobbyScreen.FailedSelectedEnemySub.HasValue && + GameMain.NetLobbyScreen.FailedSelectedEnemySub.Value.Name == newSub.Name && + GameMain.NetLobbyScreen.FailedSelectedEnemySub.Value.Hash == newSub.MD5Hash.StringRepresentation) + { + GameMain.NetLobbyScreen.TrySelectSub(newSub.Name, newSub.MD5Hash.StringRepresentation, SelectedSubType.EnemySub, GameMain.NetLobbyScreen.SubList); } NetLobbyScreen.FailedSubInfo failedCampaignSub = GameMain.NetLobbyScreen.FailedCampaignSubs.Find(s => s.Name == newSub.Name && s.Hash == newSub.MD5Hash.StringRepresentation); @@ -2547,20 +2766,20 @@ namespace Barotrauma.Networking if (GameMain.GameSession?.GameMode is not MultiPlayerCampaign campaign || campaign.CampaignID != campaignID) { string savePath = transfer.FilePath; - GameMain.GameSession = new GameSession(null, savePath, GameModePreset.MultiPlayerCampaign, CampaignSettings.Empty); + GameMain.GameSession = new GameSession(null, Option.None, CampaignDataPath.CreateRegular(savePath), GameModePreset.MultiPlayerCampaign, CampaignSettings.Empty); campaign = (MultiPlayerCampaign)GameMain.GameSession.GameMode; campaign.CampaignID = campaignID; GameMain.NetLobbyScreen.ToggleCampaignMode(true); } - GameMain.GameSession.SavePath = transfer.FilePath; + GameMain.GameSession.DataPath = CampaignDataPath.CreateRegular(transfer.FilePath); if (GameMain.GameSession.SubmarineInfo == null || campaign.Map == null) { string subPath = Path.Combine(SaveUtil.TempPath, gameSessionDocRoot.GetAttributeString("submarine", "")) + ".sub"; GameMain.GameSession.SubmarineInfo = new SubmarineInfo(subPath, ""); } - campaign.LoadState(GameMain.GameSession.SavePath); + campaign.LoadState(GameMain.GameSession.DataPath.LoadPath); GameMain.GameSession?.SubmarineInfo?.Reload(); GameMain.GameSession?.SubmarineInfo?.CheckSubsLeftBehind(); @@ -2577,7 +2796,7 @@ namespace Barotrauma.Networking GameMain.NetLobbyScreen.Select(); } - DebugConsole.Log("Campaign save received (" + GameMain.GameSession.SavePath + "), save ID " + campaign.LastSaveID); + DebugConsole.Log("Campaign save received (" + GameMain.GameSession.DataPath + "), save ID " + campaign.LastSaveID); //decrement campaign update IDs so the server will send us the latest data //(as there may have been campaign updates after the save file was created) foreach (MultiPlayerCampaign.NetFlags flag in Enum.GetValues(typeof(MultiPlayerCampaign.NetFlags))) @@ -2657,8 +2876,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; @@ -2858,14 +3081,14 @@ namespace Barotrauma.Networking /// /// Tell the server to select a submarine (permission required) /// - public void RequestSelectSub(SubmarineInfo sub, bool isShuttle) + public void RequestSelectSub(SubmarineInfo sub, SelectedSubType type) { if (!HasPermission(ClientPermissions.SelectSub) || sub == null) { return; } IWriteMessage msg = new WriteOnlyMessage(); msg.WriteByte((byte)ClientPacketHeader.SERVER_COMMAND); - msg.WriteUInt16((UInt16)ClientPermissions.SelectSub); - msg.WriteBoolean(isShuttle); msg.WritePadBits(); + msg.WriteUInt16((ushort)ClientPermissions.SelectSub); + msg.WriteByte((byte)type); msg.WriteString(sub.MD5Hash.StringRepresentation); ClientPeer.Send(msg, DeliveryMethod.Reliable); } @@ -2909,7 +3132,7 @@ namespace Barotrauma.Networking ClientPeer.Send(msg, DeliveryMethod.Reliable); } - public void SetupLoadCampaign(string saveName) + public void SetupLoadCampaign(string filePath, Option backupIndex) { if (ClientPeer == null) { return; } @@ -2920,15 +3143,27 @@ namespace Barotrauma.Networking msg.WriteByte((byte)ClientPacketHeader.CAMPAIGN_SETUP_INFO); msg.WriteBoolean(false); msg.WritePadBits(); - msg.WriteString(saveName); + msg.WriteString(filePath); + + if (backupIndex.TryUnwrap(out uint index)) + { + msg.WriteBoolean(true); + msg.WritePadBits(); + msg.WriteUInt32(index); + } + else + { + msg.WriteBoolean(false); + msg.WritePadBits(); + } ClientPeer.Send(msg, DeliveryMethod.Reliable); } /// - /// 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); @@ -2940,7 +3175,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 ? @@ -2952,23 +3211,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) @@ -3062,7 +3330,8 @@ namespace Barotrauma.Networking public bool EnterChatMessage(GUITextBox textBox, string message) { - textBox.TextColor = ChatMessage.MessageColor[(int)ChatMessageType.Default]; + var messageType = NetLobbyScreen.TeamChatSelected ? ChatMessageType.Team : ChatMessageType.Default; + textBox.TextColor = ChatMessage.MessageColor[(int)messageType]; if (string.IsNullOrWhiteSpace(message)) { @@ -3070,7 +3339,7 @@ namespace Barotrauma.Networking return false; } chatBox.ChatManager.Store(message); - SendChatMessage(message); + SendChatMessage(message, type: messageType); if (textBox.DeselectAfterMessage) { @@ -3559,9 +3828,9 @@ namespace Barotrauma.Networking { errorLines.Add("Submarine: " + GameMain.GameSession.Submarine.Info.Name); } - if (GameMain.NetworkMember?.RespawnManager?.RespawnShuttle != null) + if (GameMain.NetworkMember?.RespawnManager is { } respawnManager) { - errorLines.Add("Respawn shuttle: " + GameMain.NetworkMember.RespawnManager.RespawnShuttle.Info.Name); + errorLines.Add("Respawn shuttles: " + string.Join(", ", respawnManager.RespawnShuttles.Select(s => s.Info.Name))); } if (Level.Loaded != null) { @@ -3616,16 +3885,24 @@ namespace Barotrauma.Networking eventErrorWritten = true; } - private static void AppendExceptionInfo(ref string errorMsg, Exception e) + private static void AppendExceptionInfo(ref string errorMsg, out Entity causingEntity, Exception e) { if (!errorMsg.EndsWith("\n")) { errorMsg += "\n"; } + + Exception innerMostException = e.GetInnermost(); + causingEntity = GetCausingEntity(e); + + if (causingEntity != null) + { + errorMsg += "Entity: " + causingEntity + "\n"; + } errorMsg += e.Message + "\n"; - var innermostException = e.GetInnermost(); - if (innermostException != e) + + if (innerMostException != e) { // If available, only append the stacktrace of the innermost exception, // because that's the most important one to fix - errorMsg += "Inner exception: " + innermostException.Message + "\n" + innermostException.StackTrace.CleanupStackTrace(); + errorMsg += "Inner exception: " + innerMostException.Message + "\n" + innerMostException.StackTrace.CleanupStackTrace(); } else { @@ -3633,6 +3910,24 @@ namespace Barotrauma.Networking } } + /// + /// Checks if the exception or any of its inner exceptions are EntityEventExceptions, and returns the entity that caused the innermost EntityEventException. + /// + private static Entity GetCausingEntity(Exception e) + { + Entity causingEntity = null; + Exception currentException = e; + while (currentException != null) + { + if (currentException is EntityEventException entityEventException) + { + causingEntity = entityEventException.Entity; + } + currentException = currentException.InnerException; + } + return causingEntity; + } + #if DEBUG public void ForceTimeOut() { diff --git a/Barotrauma/BarotraumaClient/ClientSource/Networking/NetEntityEvent/ClientEntityEventManager.cs b/Barotrauma/BarotraumaClient/ClientSource/Networking/NetEntityEvent/ClientEntityEventManager.cs index c50dc2d86..d00026416 100644 --- a/Barotrauma/BarotraumaClient/ClientSource/Networking/NetEntityEvent/ClientEntityEventManager.cs +++ b/Barotrauma/BarotraumaClient/ClientSource/Networking/NetEntityEvent/ClientEntityEventManager.cs @@ -154,13 +154,25 @@ namespace Barotrauma.Networking //16 = entity ID, 8 = msg length if (msg.BitPosition + 16 + 8 > msg.LengthBits) { - string errorMsg = $"Error while reading a message from the server. Entity event data exceeds the size of the buffer (current position: {msg.BitPosition}, length: {msg.LengthBits})."; + UInt16 potentialEntityId = Entity.NullEntityID; + try + { + potentialEntityId = msg.ReadUInt16(); + } + catch + { + //failed to read the ID, do nothing (we would've just used it for the error message) + } + Entity targetEntity = Entity.FindEntityByID(potentialEntityId); + + string errorMsg = $"Error while reading a message from the server (entity: {targetEntity?.ToString() ?? "unknown"})."; + errorMsg += $" Entity event data exceeds the size of the buffer (current position: {msg.BitPosition}, length: {msg.LengthBits})."; errorMsg += "\nPrevious entities:"; for (int j = tempEntityList.Count - 1; j >= 0; j--) { errorMsg += "\n" + (tempEntityList[j] == null ? "NULL" : tempEntityList[j].ToString()); } - DebugConsole.ThrowError(errorMsg); + DebugConsole.ThrowError(errorMsg, contentPackage: targetEntity?.ContentPackage); return false; } @@ -172,7 +184,7 @@ namespace Barotrauma.Networking if (GameSettings.CurrentConfig.VerboseLogging) { DebugConsole.NewMessage("received msg " + thisEventID + " (null entity)", - Microsoft.Xna.Framework.Color.Orange); + Color.Orange); } tempEntityList.Add(null); if (thisEventID == (UInt16)(lastReceivedID + 1)) { lastReceivedID++; } @@ -187,7 +199,7 @@ namespace Barotrauma.Networking //skip the event if we've already received it or if the entity isn't found if (thisEventID != (UInt16)(lastReceivedID + 1) || entity == null) { - if (thisEventID != (UInt16) (lastReceivedID + 1)) + if (thisEventID != (UInt16)(lastReceivedID + 1)) { if (GameSettings.CurrentConfig.VerboseLogging) { @@ -195,7 +207,7 @@ namespace Barotrauma.Networking "Received msg " + thisEventID + " (waiting for " + (lastReceivedID + 1) + ")", NetIdUtils.IdMoreRecent(thisEventID, (UInt16)(lastReceivedID + 1)) ? GUIStyle.Red - : Microsoft.Xna.Framework.Color.Yellow); + : Color.Yellow); } } else if (entity == null) @@ -215,12 +227,18 @@ namespace Barotrauma.Networking if (GameSettings.CurrentConfig.VerboseLogging) { DebugConsole.NewMessage("received msg " + thisEventID + " (" + entity.ToString() + ")", - Microsoft.Xna.Framework.Color.Green); + Color.Green); } lastReceivedID++; - ReadEvent(msg, entity, sendingTime); - msg.ReadPadBits(); - + try + { + ReadEvent(msg, entity, sendingTime); + msg.ReadPadBits(); + } + catch (Exception exception) + { + throw new EntityEventException("Failed to read event." , entity as Entity, exception); + } if (msg.BitPosition != msgPosition + msgLength * 8) { var prevEntity = tempEntityList.Count >= 2 ? tempEntityList[tempEntityList.Count - 2] : null; @@ -231,7 +249,7 @@ namespace Barotrauma.Networking GameAnalyticsManager.AddErrorEventOnce("ClientEntityEventManager.Read:BitPosMismatch", GameAnalyticsManager.ErrorSeverity.Error, errorMsg); - throw new Exception(errorMsg); + throw new EntityEventException(errorMsg, entity as Entity); } } } diff --git a/Barotrauma/BarotraumaClient/ClientSource/Networking/Primitives/P2PSocket/SteamConnectSocket.cs b/Barotrauma/BarotraumaClient/ClientSource/Networking/Primitives/P2PSocket/SteamConnectSocket.cs index 47e3b69e5..aceb50a95 100644 --- a/Barotrauma/BarotraumaClient/ClientSource/Networking/Primitives/P2PSocket/SteamConnectSocket.cs +++ b/Barotrauma/BarotraumaClient/ClientSource/Networking/Primitives/P2PSocket/SteamConnectSocket.cs @@ -74,7 +74,16 @@ sealed class SteamConnectSocket : P2PSocket { if (!SteamManager.IsInitialized) { return Result.Failure(new Error(ErrorCode.SteamNotInitialized)); } - var connectionManager = Steamworks.SteamNetworkingSockets.ConnectRelay(endpoint.SteamId.Value); + ConnectionManager connectionManager; + try + { + connectionManager = Steamworks.SteamNetworkingSockets.ConnectRelay(endpoint.SteamId.Value); + } + catch (ArgumentException e) + { + DebugConsole.ThrowError("Failed to connect via SteamP2P. Are you logged in to Steam, is the same Steam account already connected to the server?", e); + return Result.Failure(new Error(ErrorCode.FailedToCreateSteamP2PSocket)); + } if (connectionManager is null) { return Result.Failure(new Error(ErrorCode.FailedToCreateSteamP2PSocket)); } connectionManager.SetEndpointAndCallbacks(endpoint, callbacks); diff --git a/Barotrauma/BarotraumaClient/ClientSource/Networking/Primitives/P2PSocket/SteamListenSocket.cs b/Barotrauma/BarotraumaClient/ClientSource/Networking/Primitives/P2PSocket/SteamListenSocket.cs index b1e7c8170..6ca70f22d 100644 --- a/Barotrauma/BarotraumaClient/ClientSource/Networking/Primitives/P2PSocket/SteamListenSocket.cs +++ b/Barotrauma/BarotraumaClient/ClientSource/Networking/Primitives/P2PSocket/SteamListenSocket.cs @@ -68,7 +68,7 @@ sealed class SteamListenSocket : P2PSocket public override void OnMessage(Steamworks.Data.Connection connection, Steamworks.Data.NetIdentity identity, IntPtr data, int size, long messageNum, long recvTime, int channel) { - if (!identity.IsSteamId) { return; } + if (!identity.IsSteamId || data == IntPtr.Zero) { return; } var endpoint = new SteamP2PEndpoint(new SteamId((Steamworks.SteamId)identity)); var dataArray = new byte[size]; diff --git a/Barotrauma/BarotraumaClient/ClientSource/Networking/Primitives/Peers/ClientPeer.cs b/Barotrauma/BarotraumaClient/ClientSource/Networking/Primitives/Peers/ClientPeer.cs index 5ecc23923..7b489b369 100644 --- a/Barotrauma/BarotraumaClient/ClientSource/Networking/Primitives/Peers/ClientPeer.cs +++ b/Barotrauma/BarotraumaClient/ClientSource/Networking/Primitives/Peers/ClientPeer.cs @@ -20,6 +20,8 @@ namespace Barotrauma.Networking public bool AllowModDownloads { get; private set; } = true; + public string AutomaticallyAttemptedPassword = string.Empty; + public readonly record struct Callbacks( Callbacks.MessageCallback OnMessageReceived, Callbacks.DisconnectCallback OnDisconnect, @@ -39,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; @@ -102,8 +107,12 @@ namespace Barotrauma.Networking TaskPool.Add($"{GetType().Name}.{nameof(GetAccountId)}", GetAccountId(), t => { - 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)) { Close(PeerDisconnectPacket.WithReason(DisconnectReason.AuthenticationFailed)); @@ -118,7 +127,7 @@ namespace Barotrauma.Networking var body = new ClientAuthTicketAndVersionPacket { - Name = GameMain.Client.Name, + Name = GameMain.Client?.Name ?? "Unknown", OwnerKey = ownerKey, AccountId = accountId, AuthTicket = authTicket, @@ -177,10 +186,16 @@ namespace Barotrauma.Networking var passwordPacket = INetSerializableStruct.Read(inc.Message); if (WaitingForPassword) { return; } - + passwordPacket.Salt.TryUnwrap(out passwordSalt); passwordPacket.RetriesLeft.TryUnwrap(out var retries); + if (!string.IsNullOrWhiteSpace(AutomaticallyAttemptedPassword)) + { + SendPassword(AutomaticallyAttemptedPassword); + return; + } + LocalizedString pwMsg = TextManager.Get("PasswordRequired"); passwordMsgBox?.Close(); diff --git a/Barotrauma/BarotraumaClient/ClientSource/Networking/Primitives/Peers/LidgrenClientPeer.cs b/Barotrauma/BarotraumaClient/ClientSource/Networking/Primitives/Peers/LidgrenClientPeer.cs index 563c5ecd1..c0656420b 100644 --- a/Barotrauma/BarotraumaClient/ClientSource/Networking/Primitives/Peers/LidgrenClientPeer.cs +++ b/Barotrauma/BarotraumaClient/ClientSource/Networking/Primitives/Peers/LidgrenClientPeer.cs @@ -224,10 +224,13 @@ namespace Barotrauma.Networking ToolBox.ThrowIfNull(netPeerConfiguration); #if DEBUG - netPeerConfiguration.SimulatedDuplicatesChance = GameMain.Client.SimulatedDuplicatesChance; - netPeerConfiguration.SimulatedMinimumLatency = GameMain.Client.SimulatedMinimumLatency; - netPeerConfiguration.SimulatedRandomLatency = GameMain.Client.SimulatedRandomLatency; - netPeerConfiguration.SimulatedLoss = GameMain.Client.SimulatedLoss; + if (GameMain.Client != null) + { + netPeerConfiguration.SimulatedDuplicatesChance = GameMain.Client.SimulatedDuplicatesChance; + netPeerConfiguration.SimulatedMinimumLatency = GameMain.Client.SimulatedMinimumLatency; + netPeerConfiguration.SimulatedRandomLatency = GameMain.Client.SimulatedRandomLatency; + netPeerConfiguration.SimulatedLoss = GameMain.Client.SimulatedLoss; + } #endif byte[] bufAux = msg.PrepareForSending(compressPastThreshold, out bool isCompressed, out _); diff --git a/Barotrauma/BarotraumaClient/ClientSource/Networking/RespawnManager.cs b/Barotrauma/BarotraumaClient/ClientSource/Networking/RespawnManager.cs index e4f182e02..e96674791 100644 --- a/Barotrauma/BarotraumaClient/ClientSource/Networking/RespawnManager.cs +++ b/Barotrauma/BarotraumaClient/ClientSource/Networking/RespawnManager.cs @@ -22,6 +22,12 @@ namespace Barotrauma.Networking get; private set; } + public DateTime ReturnTime { get; private set; } + public DateTime RespawnTime { get; private set; } + public State CurrentState { get; private set; } + public bool ReturnCountdownStarted { get; private set; } + public bool RespawnCountdownStarted { get; private set; } + public static void ShowDeathPromptIfNeeded(float delay = 1.0f) { if (UseDeathPrompt) @@ -30,13 +36,18 @@ namespace Barotrauma.Networking } } - partial void UpdateTransportingProjSpecific(float deltaTime) + partial void UpdateTransportingProjSpecific(TeamSpecificState teamSpecificState, float deltaTime) { - if (GameMain.Client?.Character == null || GameMain.Client.Character.Submarine != RespawnShuttle) { return; } - if (!ReturnCountdownStarted) { return; } + if (GameMain.Client?.Character == null || + GameMain.Client.Character.Submarine is not { IsRespawnShuttle: true } || + GameMain.Client.Character.TeamID != teamSpecificState.TeamID) + { + return; + } + if (!teamSpecificState.ReturnCountdownStarted) { return; } //show a warning when there's 20 seconds until the shuttle leaves - if ((ReturnTime - DateTime.Now).TotalSeconds < 20.0f && + if ((teamSpecificState.ReturnTime - DateTime.Now).TotalSeconds < 20.0f && (DateTime.Now - lastShuttleLeavingWarningTime).TotalSeconds > 30.0f) { lastShuttleLeavingWarningTime = DateTime.Now; @@ -46,43 +57,59 @@ namespace Barotrauma.Networking public void ClientEventRead(IReadMessage msg, float sendingTime) { - bool respawnPromptPending = false; - var newState = (State)msg.ReadRangedInteger(0, Enum.GetNames(typeof(State)).Length); - switch (newState) + var myTeamId = (CharacterTeamType)msg.ReadByte(); + foreach (var teamSpecificState in teamSpecificStates.Values) { - case State.Transporting: - ReturnCountdownStarted = msg.ReadBoolean(); - maxTransportTime = msg.ReadSingle(); - float transportTimeLeft = msg.ReadSingle(); + var teamId = (CharacterTeamType)msg.ReadByte(); - ReturnTime = DateTime.Now + new TimeSpan(0, 0, 0, 0, milliseconds: (int)(transportTimeLeft * 1000.0f)); - RespawnCountdownStarted = false; - if (CurrentState != newState) - { - CoroutineManager.StopCoroutines("forcepos"); - } - break; - case State.Waiting: - PendingRespawnCount = msg.ReadUInt16(); - RequiredRespawnCount = msg.ReadUInt16(); - respawnPromptPending = msg.ReadBoolean(); - RespawnCountdownStarted = msg.ReadBoolean(); - ResetShuttle(); - float newRespawnTime = msg.ReadSingle(); - RespawnTime = DateTime.Now + new TimeSpan(0, 0, 0, 0, milliseconds: (int)(newRespawnTime * 1000.0f)); - break; - case State.Returning: - RespawnCountdownStarted = false; - break; + bool respawnPromptPending = false; + bool clientHasChosenNewBotViaShuttle = false; + var newState = (State)msg.ReadRangedInteger(0, Enum.GetNames(typeof(State)).Length); + switch (newState) + { + case State.Transporting: + 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; + break; + } + teamSpecificState.CurrentState = newState; + + if (respawnPromptPending && !clientHasChosenNewBotViaShuttle) + { + GameMain.Client.HasSpawned = true; + DeathPrompt.Create(delay: 1.0f); + } + + if (teamId == myTeamId) + { + PendingRespawnCount = teamSpecificState.PendingRespawnCount; + RequiredRespawnCount = teamSpecificState.RequiredRespawnCount; + ReturnTime = teamSpecificState.ReturnTime; + RespawnTime = teamSpecificState.RespawnTime; + CurrentState = teamSpecificState.CurrentState; + ReturnCountdownStarted = teamSpecificState.ReturnCountdownStarted; + RespawnCountdownStarted = teamSpecificState.RespawnCountdownStarted; + } } - CurrentState = newState; - - if (respawnPromptPending) - { - GameMain.Client.HasSpawned = true; - DeathPrompt.Create(delay: 1.0f); - } - + msg.ReadPadBits(); } } diff --git a/Barotrauma/BarotraumaClient/ClientSource/Networking/ServerList/PingUtils.cs b/Barotrauma/BarotraumaClient/ClientSource/Networking/ServerList/PingUtils.cs index b2423ab37..9bfa06aaa 100644 --- a/Barotrauma/BarotraumaClient/ClientSource/Networking/ServerList/PingUtils.cs +++ b/Barotrauma/BarotraumaClient/ClientSource/Networking/ServerList/PingUtils.cs @@ -144,9 +144,9 @@ namespace Barotrauma.Networking var pingLocation = NetPingLocation.TryParseFromString(pingLocationStr); - if (pingLocation.HasValue && Steamworks.SteamNetworkingUtils.LocalPingLocation.HasValue) + if (pingLocation.HasValue) { - int ping = Steamworks.SteamNetworkingUtils.LocalPingLocation.Value.EstimatePingTo(pingLocation.Value); + int ping = Steamworks.SteamNetworkingUtils.EstimatePingTo(pingLocation.Value); if (ping < 0) { return Result.Failure(SteamLobbyPingError.PingEstimationFailed); } return Result.Success(ping); } diff --git a/Barotrauma/BarotraumaClient/ClientSource/Networking/ServerList/ServerInfo.cs b/Barotrauma/BarotraumaClient/ClientSource/Networking/ServerList/ServerInfo.cs index 1111afa09..0fa5a05b4 100644 --- a/Barotrauma/BarotraumaClient/ClientSource/Networking/ServerList/ServerInfo.cs +++ b/Barotrauma/BarotraumaClient/ClientSource/Networking/ServerList/ServerInfo.cs @@ -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/ServerSettings.cs b/Barotrauma/BarotraumaClient/ClientSource/Networking/ServerSettings.cs index b7252310c..082170271 100644 --- a/Barotrauma/BarotraumaClient/ClientSource/Networking/ServerSettings.cs +++ b/Barotrauma/BarotraumaClient/ClientSource/Networking/ServerSettings.cs @@ -1,6 +1,7 @@ using Microsoft.Xna.Framework; using System; using System.Collections.Generic; +using System.Collections.Immutable; using System.ComponentModel; using System.Linq; @@ -178,6 +179,11 @@ namespace Barotrauma.Networking extraCargoPanel.Visible = true; } } + + if (ReadPerks(incMsg)) + { + GameMain.NetLobbyScreen?.UpdateDisembarkPointListFromServerSettings(); + } } if (requiredFlags.HasFlag(NetFlags.HiddenSubs)) @@ -194,10 +200,41 @@ namespace Barotrauma.Networking } } + public static bool HasPermissionToChangePerks() + { + if (GameMain.Client.HasPermission(Networking.ClientPermissions.ManageSettings)) { return true; } + + bool isPvP = GameMain.NetLobbyScreen?.SelectedMode == GameModePreset.PvP; + bool hasSelectedTeam = MultiplayerPreferences.Instance.TeamPreference is CharacterTeamType.Team1 or CharacterTeamType.Team2; + var otherClients = GameMain.Client?.ConnectedClients.Where(static c => c.SessionId != GameMain.Client.SessionId).ToImmutableArray() ?? ImmutableArray.Empty; + + if (isPvP) + { + if (!hasSelectedTeam) { return false; } + + return !otherClients + .Where(static c => c.PreferredTeam == MultiplayerPreferences.Instance.TeamPreference) + .Any(static c => c.HasPermission(Networking.ClientPermissions.ManageSettings)); + } + else + { + return !otherClients.Any(static c => c.HasPermission(Networking.ClientPermissions.ManageSettings)); + } + } + + public void ClientAdminWritePerks() + { + IWriteMessage outMsg = new WriteOnlyMessage(); + + outMsg.WriteByte((byte)ClientPacketHeader.SERVER_SETTINGS_PERKS); + WritePerks(outMsg); + GameMain.Client?.ClientPeer?.Send(outMsg, DeliveryMethod.Reliable); + } + public void ClientAdminWrite( NetFlags dataToSend, - int? missionTypeOr = null, - int? missionTypeAnd = null, + Identifier addedMissionType = default, + Identifier removedMissionType = default, int traitorDangerLevel = 0) { if (!GameMain.Client.HasPermission(Networking.ClientPermissions.ManageSettings)) { return; } @@ -220,7 +257,7 @@ namespace Barotrauma.Networking outMsg.WriteUInt32(count); foreach (KeyValuePair prop in changedProperties) { - DebugConsole.NewMessage(prop.Value.Name.Value, Color.Lime); + DebugConsole.NewMessage($"Changed {prop.Value.Name.Value} to {prop.Value.GUIComponentValue}", Color.Lime); outMsg.WriteUInt32(prop.Key); prop.Value.Write(outMsg, prop.Value.GUIComponentValue); } @@ -237,8 +274,8 @@ namespace Barotrauma.Networking if (dataToSend.HasFlag(NetFlags.Misc)) { - outMsg.WriteRangedInteger(missionTypeOr ?? (int)Barotrauma.MissionType.None, 0, (int)Barotrauma.MissionType.All); - outMsg.WriteRangedInteger(missionTypeAnd ?? (int)Barotrauma.MissionType.All, 0, (int)Barotrauma.MissionType.All); + outMsg.WriteIdentifier(addedMissionType); + outMsg.WriteIdentifier(removedMissionType); outMsg.WriteByte((byte)(traitorDangerLevel + 1)); outMsg.WritePadBits(); } diff --git a/Barotrauma/BarotraumaClient/ClientSource/Networking/ServerSettingsUI.cs b/Barotrauma/BarotraumaClient/ClientSource/Networking/ServerSettingsUI.cs index f2d99e79d..46f9d88e1 100644 --- a/Barotrauma/BarotraumaClient/ClientSource/Networking/ServerSettingsUI.cs +++ b/Barotrauma/BarotraumaClient/ClientSource/Networking/ServerSettingsUI.cs @@ -418,8 +418,44 @@ namespace Barotrauma.Networking var randomizeLevelBox = new GUITickBox(new RectTransform(new Vector2(1.0f, 0.05f), listBox.Content.RectTransform), TextManager.Get("ServerSettingsRandomizeSeed")); AssignGUIComponent(nameof(RandomizeSeed), randomizeLevelBox); - //*********************************************** - + // ******* PVP ******************************** + NetLobbyScreen.CreateSubHeader("gamemode.pvp", listBox.Content); + + var teamSelectModeLabel = new GUITextBlock( + new RectTransform(new Vector2(1.0f, 0.05f), + listBox.Content.RectTransform), + TextManager.Get("TeamSelectionMode")); + teamSelectModeLabel.ToolTip = TextManager.Get("TeamSelectionMode.tooltip"); + var teamSelectionMode = new GUISelectionCarousel( + new RectTransform(new Vector2(0.5f, 0.6f), + teamSelectModeLabel.RectTransform, + Anchor.CenterRight)); + foreach (PvpTeamSelectionMode teamSelectionModeOption in Enum.GetValues(typeof(PvpTeamSelectionMode))) + { + var optionName = teamSelectionModeOption.ToString(); + teamSelectionMode.AddElement(teamSelectionModeOption, + TextManager.Get($"TeamSelectionMode.{optionName}"), + TextManager.Get($"TeamSelectionMode.{optionName}.tooltip")); + } + AssignGUIComponent(nameof(PvpTeamSelectionMode), teamSelectionMode); + + var autoBalanceThresholdLabel = new GUITextBlock( + new RectTransform(new Vector2(1.0f, 0.05f), + listBox.Content.RectTransform), + TextManager.Get("AutoBalanceThreshold")); + var autoBalanceThresholdTooltip = TextManager.Get("AutoBalanceThreshold.tooltip"); + autoBalanceThresholdLabel.ToolTip = autoBalanceThresholdTooltip; + var autoBalanceThreshold = new GUISelectionCarousel( + new RectTransform(new Vector2(0.5f, 0.6f), + autoBalanceThresholdLabel.RectTransform, + Anchor.CenterRight)); + autoBalanceThreshold.AddElement(0, TextManager.Get($"AutoBalanceThreshold.Off"), autoBalanceThresholdTooltip); + autoBalanceThreshold.AddElement(1, "1", autoBalanceThresholdTooltip); + autoBalanceThreshold.AddElement(2, "2", autoBalanceThresholdTooltip); + autoBalanceThreshold.AddElement(3, "3", autoBalanceThresholdTooltip); + AssignGUIComponent(nameof(PvpAutoBalanceThreshold), autoBalanceThreshold); + + // ******* GAMEPLAY *************************** NetLobbyScreen.CreateSubHeader("serversettingsroundstab", listBox.Content); var voiceChatEnabled = new GUITickBox(new RectTransform(new Vector2(1.0f, 0.05f), listBox.Content.RectTransform), @@ -429,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)); @@ -485,6 +527,9 @@ namespace Barotrauma.Networking AssignGUIComponent(nameof(NewCampaignDefaultSalary), defaultSalarySlider); defaultSalarySlider.OnMoved(defaultSalarySlider, defaultSalarySlider.BarScroll); + var pvpDisembarkPoints = NetLobbyScreen.CreateLabeledNumberInput(listBox.Content, "serversettingsdisembarkpoints", 0, 100, "serversettingsdisembarkpointstooltip"); + AssignGUIComponent(nameof(DisembarkPointAllowance), pvpDisembarkPoints); + //-------------------------------------------------------------------------------- // game settings //-------------------------------------------------------------------------------- diff --git a/Barotrauma/BarotraumaClient/ClientSource/Networking/Voip/VoipCapture.cs b/Barotrauma/BarotraumaClient/ClientSource/Networking/Voip/VoipCapture.cs index c3dbcca81..a87ba866d 100644 --- a/Barotrauma/BarotraumaClient/ClientSource/Networking/Voip/VoipCapture.cs +++ b/Barotrauma/BarotraumaClient/ClientSource/Networking/Voip/VoipCapture.cs @@ -202,6 +202,7 @@ namespace Barotrauma.Networking { DebugConsole.ThrowError("Capture device has been disconnected. You can select another available device in the settings."); Disconnected = true; + TryRefreshDevice(); break; } } @@ -320,7 +321,7 @@ namespace Barotrauma.Networking private Sound overrideSound; private int overridePos; - private short[] overrideBuf = new short[VoipConfig.BUFFER_SIZE]; + private readonly short[] overrideBuf = new short[VoipConfig.BUFFER_SIZE]; private void FillBuffer() { @@ -331,13 +332,13 @@ namespace Barotrauma.Networking { int sampleCount = overrideSound.FillStreamBuffer(overridePos, overrideBuf); overridePos += sampleCount * 2; - Array.Copy(overrideBuf, 0, uncompressedBuffer, totalSampleCount, sampleCount); + Array.Copy(overrideBuf, 0, uncompressedBuffer, totalSampleCount, Math.Min(sampleCount, uncompressedBuffer.Length - totalSampleCount)); totalSampleCount += sampleCount; if (sampleCount == 0) { overridePos = 0; - } + } } int sleepMs = VoipConfig.BUFFER_SIZE * 800 / VoipConfig.FREQUENCY; Thread.Sleep(sleepMs - 1); @@ -382,7 +383,14 @@ namespace Barotrauma.Networking } else { - overrideSound = GameMain.SoundManager.LoadSound(fileName, true); + try + { + overrideSound = GameMain.SoundManager.LoadSound(fileName, true); + } + catch (Exception e) + { + DebugConsole.ThrowError($"Failed to load the sound {fileName}.", e); + } } } @@ -394,5 +402,65 @@ namespace Barotrauma.Networking captureThread = null; if (captureDevice != IntPtr.Zero) { Alc.CaptureCloseDevice(captureDevice); } } + + public static void TryRefreshDevice() + { + DebugConsole.NewMessage("Refreshing audio capture device"); + + List deviceList = Alc.GetStringList(IntPtr.Zero, Alc.CaptureDeviceSpecifier).ToList(); + int alcError = Alc.GetError(IntPtr.Zero); + if (alcError != Alc.NoError) + { + DebugConsole.ThrowError("Failed to list available audio input devices: " + alcError.ToString()); + return; + } + + if (deviceList.Any()) + { + string device; + + if (deviceList.Find(n => n.Equals(GameSettings.CurrentConfig.Audio.VoiceCaptureDevice, StringComparison.OrdinalIgnoreCase)) + is string availablePreviousDevice) + { + DebugConsole.NewMessage($" Previous device choice available: {availablePreviousDevice}"); + device = availablePreviousDevice; + } + else + { + device = Alc.GetString(IntPtr.Zero, Alc.CaptureDefaultDeviceSpecifier); + DebugConsole.NewMessage($" Reverting to default device: {device}"); + } + + if (string.IsNullOrEmpty(device)) + { + device = deviceList[0]; + DebugConsole.NewMessage($" No default device found, resorting to first available device: {device}"); + } + + // Save the new device choice and generate a new voice capture instance with it + var currentConfig = GameSettings.CurrentConfig; + currentConfig.Audio.VoiceCaptureDevice = device; + GameSettings.SetCurrentConfig(currentConfig); + if (Instance is VoipCapture currentCaptureInstance) + { + currentCaptureInstance.Dispose(); + } + Create(GameSettings.CurrentConfig.Audio.VoiceCaptureDevice); + } + + // Didn't end up with any capture device, so let's disable voice capture for now + if (Instance == null) + { + DebugConsole.NewMessage($" No devices found, disabling"); + var currentConfig = GameSettings.CurrentConfig; + currentConfig.Audio.VoiceSetting = VoiceMode.Disabled; + GameSettings.SetCurrentConfig(currentConfig); + } + + if (GUI.SettingsMenuOpen) + { + SettingsMenu.Instance?.CreateAudioAndVCTab(true); + } + } } } 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/Networking/Voting.cs b/Barotrauma/BarotraumaClient/ClientSource/Networking/Voting.cs index 9ca9c34f9..b8fc09f6b 100644 --- a/Barotrauma/BarotraumaClient/ClientSource/Networking/Voting.cs +++ b/Barotrauma/BarotraumaClient/ClientSource/Networking/Voting.cs @@ -59,22 +59,77 @@ namespace Barotrauma switch (voteType) { case VoteType.Sub: - case VoteType.Mode: - GUIListBox listBox = (voteType == VoteType.Sub) ? - GameMain.NetLobbyScreen.SubList : GameMain.NetLobbyScreen.ModeList; + var subList = GameMain.NetLobbyScreen.SubList; - foreach (GUIComponent comp in listBox.Content.Children) + foreach (GUIComponent comp in subList.Content.Children) { - if (comp.FindChild("votes") is GUITextBlock voteText) { comp.RemoveChild(voteText); } + TryRemoveVoteText(comp); + + var container = comp.GetChild(); + var imageFrame = container.GetChild(); + var coalIcon = imageFrame.GetChildByUserData(NetLobbyScreen.CoalitionIconUserData); + var sepIcon = imageFrame.GetChildByUserData(NetLobbyScreen.SeparatistsIconUserData); + + coalIcon.Enabled = false; + sepIcon.Enabled = false; + + TryRemoveVoteText(coalIcon); + TryRemoveVoteText(sepIcon); + + static void TryRemoveVoteText(GUIComponent component) + { + if (component.FindChild("votes") is GUITextBlock foundText) + { + component.RemoveChild(foundText); + } + } } if (clients == null) { return; } - - IReadOnlyDictionary voteList = GetVoteCounts(voteType, clients); - foreach (KeyValuePair votable in voteList) + + bool isPvP = GameMain.NetLobbyScreen?.SelectedMode == GameModePreset.PvP; + + if (isPvP) { - SetVoteText(listBox, votable.Key, votable.Value); - } + var coalitionVoteList = GetVoteCounts(voteType, clients.Where(static c => c.PreferredTeam is CharacterTeamType.Team1)); + var separatistVoteList = GetVoteCounts(voteType, clients.Where(static c => c.PreferredTeam is CharacterTeamType.Team2)); + foreach (var (subInfo, amount) in coalitionVoteList) + { + SetSubVoteText(subList, subInfo, amount, CharacterTeamType.Team1); + } + + foreach (var (subInfo, amount) in separatistVoteList) + { + SetSubVoteText(subList, subInfo, amount, CharacterTeamType.Team2); + } + } + else + { + var subVoteList = GetVoteCounts(voteType, clients); + foreach (var (subInfo, amount) in subVoteList) + { + SetSubVoteText(subList, subInfo, amount, CharacterTeamType.None); + } + } + + break; + case VoteType.Mode: + var modeList = GameMain.NetLobbyScreen.ModeList; + foreach (GUIComponent comp in modeList.Content.Children) + { + if (comp.FindChild("votes") is GUITextBlock voteText) + { + comp.RemoveChild(voteText); + } + } + + if (clients == null) { return; } + + var modeVoteList = GetVoteCounts(voteType, clients); + foreach (var (preset, amount) in modeVoteList) + { + SetVoteText(modeList, preset, amount); + } break; case VoteType.StartRound: if (clients == null) { return; } @@ -90,12 +145,70 @@ namespace Barotrauma } } + private void SetSubVoteText(GUIListBox subListBox, SubmarineInfo userData, int votes, CharacterTeamType type) + { + GUIComponent subElement = subListBox.Content.GetChildByUserData(userData); + + if (subElement is null) + { + DebugConsole.ThrowError("Failed to find the submarine element in the listbox"); + return; + } + var (coalIcon, sepIcon) = GetPvPIcons(subElement); + + switch (type) + { + case CharacterTeamType.None: + { + SetVoteText(subListBox, userData, votes); + break; + } + case CharacterTeamType.Team1: + { + coalIcon.Enabled = votes > 0; + CreateSubmarineVoteText(coalIcon, votes); + break; + } + case CharacterTeamType.Team2: + { + sepIcon.Enabled = votes > 0; + CreateSubmarineVoteText(sepIcon, votes); + break; + } + default: + return; + } + + static void CreateSubmarineVoteText(GUIComponent parent, int votes) + { + if (parent is null) { return; } + var voteText = new GUITextBlock(new RectTransform(Vector2.One, parent.RectTransform, Anchor.TopLeft), $"{votes}", textAlignment: Alignment.Center) + { + Padding = Vector4.Zero, + UserData = "votes", + Shadow = true + }; + voteText.RectTransform.RelativeOffset = new Vector2(0.33f, 0.33f); + } + + static (GUIComponent CoalitionIcon, GUIComponent SeparatistsIcon) GetPvPIcons(GUIComponent child) + { + var container = child.GetChild(); + var imageFrame = container.GetChild(); + var coalIcon = imageFrame.GetChildByUserData(NetLobbyScreen.CoalitionIconUserData); + var sepIcon = imageFrame.GetChildByUserData(NetLobbyScreen.SeparatistsIconUserData); + + return (CoalitionIcon: coalIcon, SeparatistsIcon: sepIcon); + } + } + private void SetVoteText(GUIListBox listBox, object userData, int votes) { if (userData == null) { return; } foreach (GUIComponent comp in listBox.Content.Children) { if (comp.UserData != userData) { continue; } + if (comp.FindChild("votes") is not GUITextBlock voteText) { voteText = new GUITextBlock(new RectTransform(new Point(GUI.IntScale(30), comp.Rect.Height), comp.RectTransform, Anchor.CenterRight), @@ -197,28 +310,48 @@ namespace Barotrauma msg.WritePadBits(); return true; } - + public void ClientRead(IReadMessage inc) { GameMain.Client.ServerSettings.AllowSubVoting = inc.ReadBoolean(); if (GameMain.Client.ServerSettings.AllowSubVoting) { UpdateVoteTexts(null, VoteType.Sub); + bool isMultiSub = inc.ReadBoolean(); int votableCount = inc.ReadByte(); + + List serversubs = new List(); + if (GameMain.NetLobbyScreen?.SubList?.Content != null) + { + foreach (GUIComponent item in GameMain.NetLobbyScreen.SubList.Content.Children) + { + if (item.UserData is SubmarineInfo info) + { + serversubs.Add(info); + } + } + } + for (int i = 0; i < votableCount; i++) { int votes = inc.ReadByte(); string subName = inc.ReadString(); - List serversubs = new List(); - if (GameMain.NetLobbyScreen?.SubList?.Content != null) - { - foreach (GUIComponent item in GameMain.NetLobbyScreen.SubList.Content.Children) - { - if (item.UserData != null && item.UserData is SubmarineInfo) { serversubs.Add(item.UserData as SubmarineInfo); } - } - } + SubmarineInfo sub = serversubs.FirstOrDefault(s => s.Name == subName); - SetVoteText(GameMain.NetLobbyScreen.SubList, sub, votes); + SetSubVoteText(GameMain.NetLobbyScreen.SubList, sub, votes, isMultiSub ? CharacterTeamType.Team1 : CharacterTeamType.None); + } + + if (isMultiSub) + { + int separatistsCount = inc.ReadByte(); + for (int i = 0; i < separatistsCount; i++) + { + int votes = inc.ReadByte(); + string subName = inc.ReadString(); + + SubmarineInfo sub = serversubs.FirstOrDefault(s => s.Name == subName); + SetSubVoteText(GameMain.NetLobbyScreen.SubList, sub, votes, CharacterTeamType.Team2); + } } } GameMain.Client.ServerSettings.AllowModeVoting = inc.ReadBoolean(); diff --git a/Barotrauma/BarotraumaClient/ClientSource/Particles/Particle.cs b/Barotrauma/BarotraumaClient/ClientSource/Particles/Particle.cs index acea82954..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); @@ -72,7 +73,7 @@ namespace Barotrauma.Particles public float VelocityChangeMultiplier; - public bool DrawOnTop { get; private set; } + public ParticleDrawOrder DrawOrder { get; private set; } public ParticlePrefab.DrawTargetType DrawTarget { @@ -110,9 +111,12 @@ namespace Barotrauma.Particles { return debugName; } - public void Init(ParticlePrefab prefab, Vector2 position, Vector2 speed, float rotation, Hull hullGuess = null, bool drawOnTop = false, 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,10 +202,10 @@ namespace Barotrauma.Particles { this.rotation = MathUtils.VectorToAngle(new Vector2(velocity.X, -velocity.Y)); - prevRotation = rotation; + prevRotation = spawnRotation; } - DrawOnTop = drawOnTop; + DrawOrder = drawOrder; this.collisionIgnoreTimer = collisionIgnoreTimer; } @@ -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 2eac72fa2..4dd7c077c 100644 --- a/Barotrauma/BarotraumaClient/ClientSource/Particles/ParticleEmitter.cs +++ b/Barotrauma/BarotraumaClient/ClientSource/Particles/ParticleEmitter.cs @@ -8,6 +8,7 @@ namespace Barotrauma.Particles { class ParticleEmitterProperties : ISerializableEntity { + private const float MinValue = int.MinValue, MaxValue = int.MaxValue; @@ -98,8 +99,11 @@ namespace Barotrauma.Particles [Editable, Serialize(1f, IsPropertySaveable.Yes)] public float LifeTimeMultiplier { get; set; } - [Editable, Serialize(false, IsPropertySaveable.Yes)] - public bool DrawOnTop { 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; } [Serialize(0f, IsPropertySaveable.Yes)] public float Angle @@ -127,6 +131,12 @@ namespace Barotrauma.Particles public ParticleEmitterProperties(XElement element) { SerializableProperties = SerializableProperty.DeserializeProperties(this, element); + + //backwards compatibility + if (element.GetAttributeBool("drawontop", false)) + { + DrawOrder = ParticleDrawOrder.Foreground; + } } } @@ -215,7 +225,9 @@ namespace Barotrauma.Particles position += dir * Rand.Range(Prefab.Properties.DistanceMin, Prefab.Properties.DistanceMax); } - var particle = GameMain.ParticleManager.CreateParticle(particlePrefab, position, velocity, particleRotation, hullGuess, particlePrefab.DrawOnTop || Prefab.DrawOnTop, lifeTimeMultiplier: Prefab.Properties.LifeTimeMultiplier, tracerPoints: tracerPoints); + var particle = GameMain.ParticleManager.CreateParticle(particlePrefab, position, velocity, particleRotation, hullGuess, + particlePrefab.DrawOrder != ParticleDrawOrder.Default ? particlePrefab.DrawOrder : Prefab.DrawOrder, + lifeTimeMultiplier: Prefab.Properties.LifeTimeMultiplier, tracerPoints: Prefab.Properties.UseTracerPoints ? tracerPoints : null); if (particle != null) { @@ -286,7 +298,9 @@ namespace Barotrauma.Particles public readonly ContentPackage? ContentPackage; - public bool DrawOnTop => Properties.DrawOnTop || ParticlePrefab is { DrawOnTop: true }; + public ParticleDrawOrder DrawOrder => Properties.DrawOrder != ParticleDrawOrder.Default ? + Properties.DrawOrder : + (ParticlePrefab?.DrawOrder ?? ParticleDrawOrder.Default); public ParticleEmitterPrefab(ContentXElement element) { diff --git a/Barotrauma/BarotraumaClient/ClientSource/Particles/ParticleManager.cs b/Barotrauma/BarotraumaClient/ClientSource/Particles/ParticleManager.cs index 457755dfa..69fc90019 100644 --- a/Barotrauma/BarotraumaClient/ClientSource/Particles/ParticleManager.cs +++ b/Barotrauma/BarotraumaClient/ClientSource/Particles/ParticleManager.cs @@ -11,6 +11,13 @@ namespace Barotrauma.Particles AlphaBlend, Additive//, Distortion } + enum ParticleDrawOrder + { + Default, + Foreground, + Background + } + class ParticleManager { private const int MaxOutOfViewDist = 500; @@ -91,11 +98,14 @@ namespace Barotrauma.Particles return CreateParticle(prefab, position, velocity, rotation, hullGuess, collisionIgnoreTimer: collisionIgnoreTimer, tracerPoints:tracerPoints); } - public Particle CreateParticle(ParticlePrefab prefab, Vector2 position, Vector2 velocity, float rotation = 0.0f, Hull hullGuess = null, bool drawOnTop = false, float collisionIgnoreTimer = 0f, float lifeTimeMultiplier = 1f, Tuple tracerPoints = null) + public Particle CreateParticle(ParticlePrefab prefab, Vector2 position, Vector2 velocity, float rotation = 0.0f, Hull hullGuess = null, ParticleDrawOrder drawOrder = ParticleDrawOrder.Default, float collisionIgnoreTimer = 0f, float lifeTimeMultiplier = 1f, Tuple tracerPoints = null) { 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 || @@ -134,7 +144,7 @@ namespace Barotrauma.Particles if (particles[particleCount] == null) { particles[particleCount] = new Particle(); } Particle particle = particles[particleCount]; - particle.Init(prefab, position, velocity, rotation, hullGuess, drawOnTop, collisionIgnoreTimer, lifeTimeMultiplier, tracerPoints: tracerPoints); + particle.Init(prefab, position, velocity, rotation, hullGuess, drawOrder, collisionIgnoreTimer, lifeTimeMultiplier, tracerPoints: tracerPoints); particleCount++; particlesInCreationOrder.AddFirst(particle); @@ -213,7 +223,7 @@ namespace Barotrauma.Particles return activeParticles; } - public void Draw(SpriteBatch spriteBatch, bool inWater, bool? inSub, ParticleBlendState blendState) + public void Draw(SpriteBatch spriteBatch, bool inWater, bool? inSub, ParticleBlendState blendState, bool? background = false) { ParticlePrefab.DrawTargetType drawTarget = inWater ? ParticlePrefab.DrawTargetType.Water : ParticlePrefab.DrawTargetType.Air; @@ -225,12 +235,16 @@ namespace Barotrauma.Particles if (inSub.HasValue) { bool isOutside = particle.CurrentHull == null; - if (!particle.DrawOnTop && isOutside == inSub.Value) + if (particle.DrawOrder != ParticleDrawOrder.Foreground && isOutside == inSub.Value) { continue; } } - + if (background.HasValue) + { + bool isBackgroundParticle = particle.DrawOrder == ParticleDrawOrder.Background; + if (background.Value != isBackgroundParticle) { continue; } + } particle.Draw(spriteBatch); } } diff --git a/Barotrauma/BarotraumaClient/ClientSource/Particles/ParticlePrefab.cs b/Barotrauma/BarotraumaClient/ClientSource/Particles/ParticlePrefab.cs index c120eca23..77b3202cc 100644 --- a/Barotrauma/BarotraumaClient/ClientSource/Particles/ParticlePrefab.cs +++ b/Barotrauma/BarotraumaClient/ClientSource/Particles/ParticlePrefab.cs @@ -10,6 +10,7 @@ namespace Barotrauma.Particles { public static readonly PrefabCollection Prefabs = new PrefabCollection(); + [Flags] public enum DrawTargetType { Air = 1, Water = 2, Both = 3 } public readonly List Sprites; @@ -151,7 +152,7 @@ namespace Barotrauma.Particles public float Friction { get; private set; } [Editable(0.0f, 1.0f)] - [Serialize(0.5f, IsPropertySaveable.No, description: "How much of the particle's velocity is conserved when it collides with something, i.e. the \"bounciness\" of the particle. (1.0 = the particle stops completely).")] + [Serialize(0.5f, IsPropertySaveable.No, description: "How much of the particle's velocity is conserved when it collides with something, i.e. the \"bounciness\" of the particle. (0.0 = the particle stops completely).")] public float Restitution { get; private set; } //size ----------------------------------------- @@ -188,8 +189,8 @@ namespace Barotrauma.Particles [Editable, Serialize(DrawTargetType.Air, IsPropertySaveable.No, description: "Should the particle be rendered in air, water or both.")] public DrawTargetType DrawTarget { get; private set; } - [Editable, Serialize(false, IsPropertySaveable.No, description: "Should the particle be always rendered on top of entities?")] - public bool DrawOnTop { get; private set; } + [Editable, Serialize(ParticleDrawOrder.Default, IsPropertySaveable.No, description: "Should the particle be always forced to render on top of entities or behind everything?")] + public ParticleDrawOrder DrawOrder { get; private set; } [Editable, Serialize(false, IsPropertySaveable.No, description: "Draw the particle even when it's calculated to be outside of view (the formula doesn't take scales into account). ")] public bool DrawAlways { get; private set; } @@ -226,6 +227,16 @@ namespace Barotrauma.Particles SerializableProperties = SerializableProperty.DeserializeProperties(this, element); + //backwards compatibility + if (element.GetAttributeBool("drawontop", false)) + { + DrawOrder = ParticleDrawOrder.Foreground; + } + if (BlendState == ParticleBlendState.Additive && DrawOrder == ParticleDrawOrder.Background) + { + DebugConsole.AddWarning($"Error in particle prefab {Identifier}: additive particles cannot be rendered in the background."); + } + foreach (var subElement in element.Elements()) { switch (subElement.Name.ToString().ToLowerInvariant()) diff --git a/Barotrauma/BarotraumaClient/ClientSource/Physics/PhysicsBody.cs b/Barotrauma/BarotraumaClient/ClientSource/Physics/PhysicsBody.cs index e8445be17..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,13 +8,17 @@ namespace Barotrauma { partial class PhysicsBody { - private float bodyShapeTextureScale; - - private Texture2D bodyShapeTexture; - public Texture2D BodyShapeTexture - { - get { return bodyShapeTexture; } - } + /// + /// 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) { @@ -72,85 +75,54 @@ namespace Barotrauma if (drawOffset != Vector2.Zero) { Vector2 pos = ConvertUnits.ToDisplayUnits(FarseerBody.Position); - if (Submarine != null) pos += Submarine.DrawPosition; + if (Submarine != null) { pos += Submarine.DrawPosition; } GUI.DrawLine(spriteBatch, new Vector2(pos.X, -pos.Y), new Vector2(DrawPosition.X, -DrawPosition.Y), - Color.Cyan, 0, 5); + Color.Purple * 0.5f, 0, 5); } - if (bodyShapeTexture == null && IsValidShape(Radius, Height, Width)) + 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); + float width = ConvertUnits.ToDisplayUnits(Width); + switch (BodyShape) { case Shape.Rectangle: - { - float maxSize = Math.Max(ConvertUnits.ToDisplayUnits(Width), ConvertUnits.ToDisplayUnits(Height)); - if (maxSize > 128.0f) - { - bodyShapeTextureScale = 128.0f / maxSize; - } - else - { - bodyShapeTextureScale = 1.0f; - } - - bodyShapeTexture = GUI.CreateRectangle( - (int)ConvertUnits.ToDisplayUnits(Width * bodyShapeTextureScale), - (int)ConvertUnits.ToDisplayUnits(Height * bodyShapeTextureScale)); - break; - } + GUI.DrawRectangle(spriteBatch, position.FlipY(), new Vector2(width, height), new Vector2(width, height) / 2, -rotation, color); + break; case Shape.Capsule: + GUI.DrawCapsule(spriteBatch, position.FlipY(), height, radius, -rotation - MathHelper.PiOver2, color); + break; case Shape.HorizontalCapsule: - { - float maxSize = Math.Max(ConvertUnits.ToDisplayUnits(Radius), ConvertUnits.ToDisplayUnits(Math.Max(Height, Width))); - if (maxSize > 128.0f) - { - bodyShapeTextureScale = 128.0f / maxSize; - } - else - { - bodyShapeTextureScale = 1.0f; - } - - bodyShapeTexture = GUI.CreateCapsule( - (int)ConvertUnits.ToDisplayUnits(Radius * bodyShapeTextureScale), - (int)ConvertUnits.ToDisplayUnits(Math.Max(Height, Width) * bodyShapeTextureScale)); - break; - } + GUI.DrawCapsule(spriteBatch, position.FlipY(), width, radius, -rotation, color); + break; case Shape.Circle: - if (ConvertUnits.ToDisplayUnits(Radius) > 128.0f) - { - bodyShapeTextureScale = 128.0f / ConvertUnits.ToDisplayUnits(Radius); - } - else - { - bodyShapeTextureScale = 1.0f; - } - bodyShapeTexture = GUI.CreateCircle((int)ConvertUnits.ToDisplayUnits(Radius * bodyShapeTextureScale)); + GUI.DrawDonutSection(spriteBatch, position.FlipY(), new Range(radius - 0.5f, radius + 0.5f), MathHelper.TwoPi, color, 0, -rotation); break; default: throw new NotImplementedException(); } } - - float rot = -DrawRotation; - if (bodyShape == Shape.HorizontalCapsule) - { - rot -= MathHelper.PiOver2; - } - - if (bodyShapeTexture != null) - { - spriteBatch.Draw( - bodyShapeTexture, - new Vector2(DrawPosition.X, -DrawPosition.Y), - null, - color, - rot, - new Vector2(bodyShapeTexture.Width / 2, bodyShapeTexture.Height / 2), - 1.0f / bodyShapeTextureScale, SpriteEffects.None, 0.0f); - } } public PosInfo ClientRead(IReadMessage msg, float sendingTime, string parentDebugName) @@ -206,18 +178,10 @@ namespace Barotrauma return null; } - return lastProcessedNetworkState > sendingTime ? - null : - new PosInfo(newPosition, newRotation, newVelocity, newAngularVelocity, sendingTime); - } + if (lastProcessedNetworkState > sendingTime) { return null; } - partial void DisposeProjSpecific() - { - if (bodyShapeTexture != null) - { - bodyShapeTexture.Dispose(); - bodyShapeTexture = null; - } + LastServerState = new PosInfo(newPosition, newRotation, newVelocity, newAngularVelocity, sendingTime); + return LastServerState; } } } diff --git a/Barotrauma/BarotraumaClient/ClientSource/Screens/CampaignSetupUI/CampaignSetupUI.cs b/Barotrauma/BarotraumaClient/ClientSource/Screens/CampaignSetupUI/CampaignSetupUI.cs index b081a7449..cf804cdbf 100644 --- a/Barotrauma/BarotraumaClient/ClientSource/Screens/CampaignSetupUI/CampaignSetupUI.cs +++ b/Barotrauma/BarotraumaClient/ClientSource/Screens/CampaignSetupUI/CampaignSetupUI.cs @@ -5,6 +5,7 @@ using System; using System.Collections.Generic; using System.Collections.Immutable; using System.Linq; +using Barotrauma.Networking; namespace Barotrauma { @@ -19,7 +20,9 @@ namespace Barotrauma protected GUIButton loadGameButton; public Action StartNewGame; - public Action LoadGame; + + public delegate void LoadGameDelegate(string loadPath, Option backupIndex); + public LoadGameDelegate LoadGame; protected enum CategoryFilter { All = 0, Vanilla = 1, Custom = 2 } protected CategoryFilter subFilter = CategoryFilter.All; @@ -142,6 +145,7 @@ namespace Barotrauma public SettingValue OxygenMultiplier; public SettingValue FuelMultiplier; public SettingValue MissionRewardMultiplier; + public SettingValue ExperienceRewardMultiplier; public SettingValue ShopPriceMultiplier; public SettingValue ShipyardPriceMultiplier; public SettingValue RepairFailMultiplier; @@ -164,6 +168,7 @@ namespace Barotrauma OxygenMultiplier = OxygenMultiplier.GetValue(), FuelMultiplier = FuelMultiplier.GetValue(), MissionRewardMultiplier = MissionRewardMultiplier.GetValue(), + ExperienceRewardMultiplier = ExperienceRewardMultiplier.GetValue(), ShopPriceMultiplier = ShopPriceMultiplier.GetValue(), ShipyardPriceMultiplier = ShipyardPriceMultiplier.GetValue(), RepairFailMultiplier = RepairFailMultiplier.GetValue(), @@ -341,6 +346,19 @@ namespace Barotrauma verticalSize, OnValuesChanged); + // Experience reward multiplier + CampaignSettings.MultiplierSettings experienceMultiplierSettings = CampaignSettings.GetMultiplierSettings("ExperienceRewardMultiplier"); + SettingValue experienceMultiplier = CreateGUIFloatInputCarousel( + settingsList.Content, + TextManager.Get("campaignoption.experiencerewardmultiplier"), + TextManager.Get("campaignoption.experiencerewardmultiplier.tooltip"), + prevSettings.ExperienceRewardMultiplier, + valueStep: experienceMultiplierSettings.Step, + minValue: experienceMultiplierSettings.Min, + maxValue: experienceMultiplierSettings.Max, + verticalSize, + OnValuesChanged); + // Shop buying prices multiplier CampaignSettings.MultiplierSettings shopPriceMultiplierSettings = CampaignSettings.GetMultiplierSettings("ShopPriceMultiplier"); SettingValue shopPriceMultiplier = CreateGUIFloatInputCarousel( @@ -498,6 +516,7 @@ namespace Barotrauma oxygenMultiplier.SetValue(settings.OxygenMultiplier); fuelMultiplier.SetValue(settings.FuelMultiplier); rewardMultiplier.SetValue(settings.MissionRewardMultiplier); + experienceMultiplier.SetValue(settings.ExperienceRewardMultiplier); shopPriceMultiplier.SetValue(settings.ShopPriceMultiplier); shipyardPriceMultiplier.SetValue(settings.ShipyardPriceMultiplier); repairFailMultiplier.SetValue(settings.RepairFailMultiplier); @@ -527,6 +546,7 @@ namespace Barotrauma OxygenMultiplier = oxygenMultiplier, FuelMultiplier = fuelMultiplier, MissionRewardMultiplier = rewardMultiplier, + ExperienceRewardMultiplier = experienceMultiplier, ShopPriceMultiplier = shopPriceMultiplier, ShipyardPriceMultiplier = shipyardPriceMultiplier, RepairFailMultiplier = repairFailMultiplier, @@ -821,5 +841,119 @@ namespace Barotrauma return true; } + + protected void CreateBackupMenu(IEnumerable indexData, Action loadBackup) + { + var backupPopup = new GUIMessageBox("", "", new[] { TextManager.Get("Load"), TextManager.Get("Cancel") }, new Vector2(0.3f, 0.5f), minSize: new Point(500, 500)); + + GUILayoutGroup campaignSettingContent = new GUILayoutGroup(new RectTransform(new Vector2(1f, 0.8f), backupPopup.Content.RectTransform, Anchor.TopCenter)); + + GUIListBox backupList = new GUIListBox(new RectTransform(Vector2.One, campaignSettingContent.RectTransform)); + + bool isIronman = GameMain.NetworkMember?.ServerSettings is { IronmanModeActive: true }; + + if (!indexData.Any() || isIronman) + { + LocalizedString errorMsg = isIronman + ? TextManager.Get("ironmanmodebackupdisclaimer") + : TextManager.Get("nobackups"); + var errorBlock = new GUITextBlock(new RectTransform(Vector2.One, campaignSettingContent.RectTransform), errorMsg, font: GUIStyle.SubHeadingFont, textAlignment: Alignment.Center) + { + TextColor = GUIStyle.Red, + IgnoreLayoutGroups = true + }; + + if (errorBlock.Font.MeasureString(errorMsg).X > campaignSettingContent.Rect.Width) + { + errorBlock.Wrap = true; + errorBlock.SetTextPos(); + } + } + + if (!isIronman) + { + foreach (var data in indexData.OrderByDescending(static i => i.SaveTime)) + { + GUIFrame indexFrame = new GUIFrame( + new RectTransform(new Vector2(1.0f, 1f / SaveUtil.MaxBackupCount), backupList.Content.RectTransform), style: "ListBoxElement") + { + UserData = data + }; + + GUILayoutGroup indexLayout = new GUILayoutGroup( + new RectTransform(Vector2.One, indexFrame.RectTransform), + isHorizontal: true, + childAnchor: Anchor.CenterLeft) + { + RelativeSpacing = 0.05f, + Stretch = true + }; + + GUILayoutGroup leftLayout = new GUILayoutGroup(new RectTransform(new Vector2(0.5f, 0.8f), indexLayout.RectTransform), childAnchor: Anchor.TopCenter) + { + RelativeSpacing = 0.05f, + Stretch = true + }; + + LocalizedString locationName = data.LocationType.IsEmpty || data.LocationNameIdentifier.IsEmpty ? + TextManager.Get("unknown") : + Location.GetName(data.LocationType, data.LocationNameFormatIndex, data.LocationNameIdentifier); + + var locationNameBlock = new GUITextBlock(new RectTransform(new Vector2(1.0f, 0.5f), leftLayout.RectTransform), locationName, textAlignment: Alignment.CenterLeft) + { + TextColor = Color.White + }; + + new GUITextBlock(new RectTransform(new Vector2(1.0f, 0.5f), leftLayout.RectTransform), TextManager.Get($"savestate.{data.LevelType}"), textAlignment: Alignment.CenterLeft); + + + GUILayoutGroup rightLayout = new GUILayoutGroup(new RectTransform(new Vector2(0.5f, 0.8f), indexLayout.RectTransform), childAnchor: Anchor.TopCenter) + { + RelativeSpacing = 0.05f, + Stretch = true + }; + + TimeSpan difference = SerializableDateTime.UtcNow - data.SaveTime; + + double totalMinutes = difference.TotalMinutes; + + LocalizedString timeFormat = totalMinutes switch + { + < 1 => TextManager.Get("subeditor.savedjustnow"), + > 60 => TextManager.GetWithVariable("saveagehours", "[hours]", ((int)Math.Floor(difference.TotalHours)).ToString()), + _ => TextManager.GetWithVariable("subeditor.saveageminutes", "[minutes]", difference.Minutes.ToString()) + }; + + new GUITextBlock(new RectTransform(Vector2.One, rightLayout.RectTransform), timeFormat, textAlignment: Alignment.CenterRight); + + locationNameBlock.Text = ToolBox.LimitString(locationName, locationNameBlock.Font, locationNameBlock.Rect.Width); + } + } + + backupList.AfterSelected = (selected, _) => + { + // to my understanding, there's no way to unselect an item in a GUIListBox + // so no need to check if selected is null + backupPopup.Buttons[0].Enabled = true; + return true; + }; + + backupPopup.Buttons[1].OnClicked += (button, o) => + { + backupPopup.Close(); + return true; + }; + + backupPopup.Buttons[0].Enabled = false; + backupPopup.Buttons[0].OnClicked += (button, o) => + { + if (backupList.SelectedComponent?.UserData is not SaveUtil.BackupIndexData selectedIndexData) { return false; } + + backupPopup.Close(); + + loadBackup?.Invoke(selectedIndexData); + return true; + }; + } } } \ No newline at end of file diff --git a/Barotrauma/BarotraumaClient/ClientSource/Screens/CampaignSetupUI/MultiPlayerCampaignSetupUI.cs b/Barotrauma/BarotraumaClient/ClientSource/Screens/CampaignSetupUI/MultiPlayerCampaignSetupUI.cs index 4d153ba33..9e2528488 100644 --- a/Barotrauma/BarotraumaClient/ClientSource/Screens/CampaignSetupUI/MultiPlayerCampaignSetupUI.cs +++ b/Barotrauma/BarotraumaClient/ClientSource/Screens/CampaignSetupUI/MultiPlayerCampaignSetupUI.cs @@ -1,12 +1,16 @@ using Microsoft.Xna.Framework; using System; +using System.Collections; using System.Collections.Generic; using System.Globalization; +using System.Linq; +using Barotrauma.Networking; namespace Barotrauma { class MultiPlayerCampaignSetupUI : CampaignSetupUI { + private GUIButton rollbackSaveButton; private GUIButton deleteMpSaveButton; private int prevInitialMoney; @@ -232,34 +236,140 @@ namespace Barotrauma { if (saveList.SelectedData is not CampaignMode.SaveInfo saveInfo) { return false; } if (string.IsNullOrWhiteSpace(saveInfo.FilePath)) { return false; } - LoadGame?.Invoke(saveInfo.FilePath); - CoroutineManager.StartCoroutine(WaitForCampaignSetup(), "WaitForCampaignSetup"); + if (saveInfo.RespawnMode != RespawnMode.None && saveInfo.RespawnMode != GameMain.NetworkMember?.ServerSettings?.RespawnMode) + { + var msgBox = new GUIMessageBox(TextManager.Get("Warning"), + TextManager.GetWithVariables("RespawnModeMismatch", + ("[currentrespawnmode]", TextManager.Get($"respawnmode.{GameMain.NetworkMember?.ServerSettings?.RespawnMode}")), + ("[savedrespawnmode]", TextManager.Get($"respawnmode.{saveInfo.RespawnMode}"))), + new LocalizedString[] { TextManager.Get("RespawnModeMismatch.GoBack"), TextManager.Get("RespawnModeMismatch.LoadAnyway") }); + msgBox.Buttons[0].OnClicked = (button, obj) => + { + msgBox.Close(); + return true; + }; + msgBox.Buttons[1].OnClicked = (button, obj) => + { + msgBox.Close(); + LoadSaveGame(); + return true; + }; + return false; + } + + LoadSaveGame(); return true; + + void LoadSaveGame() + { + LoadGame?.Invoke(saveInfo.FilePath, backupIndex: Option.None); + CoroutineManager.StartCoroutine(WaitForCampaignSetup(), "WaitForCampaignSetup"); + } }, Enabled = false }; - deleteMpSaveButton = new GUIButton(new RectTransform(new Vector2(0.45f, 0.12f), loadGameContainer.RectTransform, Anchor.BottomLeft), - TextManager.Get("Delete"), style: "GUIButtonSmall") + + GUILayoutGroup leftButtonContainer = new GUILayoutGroup(new RectTransform(new Vector2(0.45f, 0.15f), loadGameContainer.RectTransform, Anchor.BottomLeft)) + { + RelativeSpacing = 0.05f, + Stretch = true + }; + + rollbackSaveButton = new GUIButton(new RectTransform(new Vector2(1f, 0.5f), leftButtonContainer.RectTransform), TextManager.Get("rollbackbutton"), style: "GUIButtonSmallFreeScale") + { + Visible = false, + ToolTip = TextManager.Get("backuptooltip"), + OnClicked = ViewBackupSaveMenu + }; + deleteMpSaveButton = new GUIButton(new RectTransform(new Vector2(1f, 0.5f), leftButtonContainer.RectTransform), + TextManager.Get("Delete"), style: "GUIButtonSmallFreeScale") { OnClicked = DeleteSave, Visible = false }; - } - - + } + + private bool ViewBackupSaveMenu(GUIButton button, object obj) + { + if (obj is not CampaignMode.SaveInfo saveInfo) { return false; } + if (string.IsNullOrWhiteSpace(saveInfo.FilePath)) { return false; } + + if (GameMain.Client.IsServerOwner) + { + CreateBackupMenu(SaveUtil.GetIndexData(saveInfo.FilePath), index => + { + LoadGame(saveInfo.FilePath, backupIndex: Option.Some(index.Index)); + }); + } + else + { + RequestBackupIndexData(saveInfo.FilePath); + } + return true; + } + + private const string PleaseWaitUserData = "PleaseWaitPopup"; + + private void RequestBackupIndexData(string savePath) + { + if (GameMain.Client == null) { return; } + + GUI.SetCursorWaiting(); + var msgBox = new GUIMessageBox(TextManager.Get("CampaignStartingPleaseWait"), TextManager.Get("CampaignStarting"), new[] { TextManager.Get("Cancel") }) + { + UserData = PleaseWaitUserData + }; + msgBox.Buttons[0].OnClicked = (btn, obj) => + { + GUI.ClearCursorWait(); + return true; + }; + + + IWriteMessage msg = new WriteOnlyMessage().WithHeader(ClientPacketHeader.REQUEST_BACKUP_INDICES); + msg.WriteString(savePath); + GameMain.Client?.ClientPeer?.Send(msg, DeliveryMethod.Reliable); + } + + public void OnBackupIndicesReceived(IReadMessage message) + { + GUI.ClearCursorWait(); + + foreach (GUIComponent component in GUIMessageBox.MessageBoxes.Where(static mb => mb.UserData is PleaseWaitUserData).ToArray()) + { + if (component is GUIMessageBox msgBox) + { + msgBox.Close(); + } + } + + string path = message.ReadString(); + var indexData = INetSerializableStruct.Read>(message); + CreateBackupMenu(indexData, selectedIndex => + { + LoadGame?.Invoke(path, backupIndex: Option.Some(selectedIndex.Index)); + }); + } + private bool SelectSaveFile(GUIComponent component, object obj) { if (obj is not CampaignMode.SaveInfo saveInfo) { return true; } string fileName = saveInfo.FilePath; loadGameButton.Enabled = true; + rollbackSaveButton.Visible = true; deleteMpSaveButton.Visible = deleteMpSaveButton.Enabled = GameMain.Client.IsServerOwner; - deleteMpSaveButton.Enabled = GameMain.GameSession?.SavePath != fileName; + rollbackSaveButton.Enabled = deleteMpSaveButton.Enabled = GameMain.GameSession?.DataPath.LoadPath != fileName; if (deleteMpSaveButton.Visible) { deleteMpSaveButton.UserData = saveInfo; } + + if (rollbackSaveButton.Visible) + { + rollbackSaveButton.UserData = saveInfo; + } return true; } } diff --git a/Barotrauma/BarotraumaClient/ClientSource/Screens/CampaignSetupUI/SinglePlayerCampaignSetupUI.cs b/Barotrauma/BarotraumaClient/ClientSource/Screens/CampaignSetupUI/SinglePlayerCampaignSetupUI.cs index f2706ca31..9cd40f9a1 100644 --- a/Barotrauma/BarotraumaClient/ClientSource/Screens/CampaignSetupUI/SinglePlayerCampaignSetupUI.cs +++ b/Barotrauma/BarotraumaClient/ClientSource/Screens/CampaignSetupUI/SinglePlayerCampaignSetupUI.cs @@ -291,7 +291,7 @@ namespace Barotrauma if (characterInfos.Count >= 3) { break; } } } - characterInfos.Sort((a, b) => Math.Sign(b.Job.MinKarma - a.Job.MinKarma)); + characterInfos.Sort((a, b) => Math.Sign(a.Job.CampaignSetupUIOrder - b.Job.CampaignSetupUIOrder)); characterInfoColumns.ClearChildren(); CharacterMenus?.ForEach(m => m.Dispose()); @@ -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); @@ -653,8 +653,7 @@ namespace Barotrauma { if (saveList.SelectedData is not CampaignMode.SaveInfo saveInfo) { return false; } if (string.IsNullOrWhiteSpace(saveInfo.FilePath)) { return false; } - LoadGame?.Invoke(saveInfo.FilePath); - + LoadGame?.Invoke(saveInfo.FilePath, backupIndex: Option.None); return true; }, Enabled = false @@ -685,6 +684,15 @@ namespace Barotrauma string mapseed = docRoot.GetAttributeString("mapseed", "unknown"); + Identifier locationNameIdentifier = docRoot.GetAttributeIdentifier("currentlocation", Identifier.Empty); + int locationNameFormatIndex = docRoot.GetAttributeInt("currentlocationnameformatindex", -1); + Identifier locationType = docRoot.GetAttributeIdentifier("locationtype", Identifier.Empty); + LevelData.LevelType levelType = docRoot.GetAttributeEnum("nextleveltype", LevelData.LevelType.LocationConnection); + + LocalizedString locationName = locationType.IsEmpty || locationNameIdentifier.IsEmpty ? + LocalizedString.EmptyString : + Location.GetName(locationType, locationNameFormatIndex, locationNameIdentifier); + var saveFileFrame = new GUIFrame( new RectTransform(new Vector2(0.45f, 0.6f), loadGameContainer.RectTransform, Anchor.TopRight) { @@ -708,6 +716,15 @@ namespace Barotrauma RelativeOffset = new Vector2(0, 0.1f) }); + if (!locationName.IsNullOrEmpty()) + { + new GUITextBlock(new RectTransform(new Vector2(1, 0), layoutGroup.RectTransform), + locationName, font: GUIStyle.SmallFont); + new GUITextBlock(new RectTransform(new Vector2(1, 0), layoutGroup.RectTransform), + TextManager.Get($"savestate.{levelType}"), font: GUIStyle.SmallFont); + //spacing + new GUIFrame(new RectTransform(new Vector2(0.0f, 0.05f), layoutGroup.RectTransform), style: null); + } new GUITextBlock(new RectTransform(new Vector2(1, 0), layoutGroup.RectTransform), $"{TextManager.Get("Submarine")} : {subName}", font: GUIStyle.SmallFont); new GUITextBlock(new RectTransform(new Vector2(1, 0), layoutGroup.RectTransform), @@ -715,15 +732,40 @@ namespace Barotrauma new GUITextBlock(new RectTransform(new Vector2(1, 0), layoutGroup.RectTransform), $"{TextManager.Get("MapSeed")} : {mapseed}", font: GUIStyle.SmallFont); - new GUIButton(new RectTransform(new Vector2(0.4f, 0.15f), saveFileFrame.RectTransform, Anchor.BottomCenter) + GUILayoutGroup buttonContainer = new GUILayoutGroup(new RectTransform(new Vector2(0.85f, 0.15f), saveFileFrame.RectTransform, Anchor.BottomCenter) { RelativeOffset = new Vector2(0, 0.1f) - }, TextManager.Get("Delete"), style: "GUIButtonSmall") + }, isHorizontal: true) + { + RelativeSpacing = 0.05f, + Stretch = true + }; + + new GUIButton(new RectTransform(new Vector2(0.5f, 1f), buttonContainer.RectTransform, Anchor.CenterLeft), TextManager.Get("Delete"), style: "GUIButtonSmall") { UserData = saveInfo, OnClicked = DeleteSave }; + new GUIButton(new RectTransform(new Vector2(0.5f, 1f), buttonContainer.RectTransform, Anchor.CenterLeft), TextManager.Get("rollbackbutton"), style: "GUIButtonSmall") + { + UserData = saveInfo, + ToolTip = TextManager.Get("backuptooltip"), + OnClicked = ViewBackupMenu + }; + + return true; + } + + private bool ViewBackupMenu(GUIButton btn, object obj) + { + if (obj is not CampaignMode.SaveInfo saveInfo) { return false; } + + var indexData = SaveUtil.GetIndexData(saveInfo.FilePath); + CreateBackupMenu(indexData, index => + { + LoadGame(saveInfo.FilePath, Option.Some(index.Index)); + }); return true; } diff --git a/Barotrauma/BarotraumaClient/ClientSource/Screens/CampaignUI.cs b/Barotrauma/BarotraumaClient/ClientSource/Screens/CampaignUI.cs index fb27297d7..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(); @@ -443,20 +443,22 @@ namespace Barotrauma GUILayoutGroup difficultyIndicatorGroup = null; if (mission.Difficulty.HasValue) { - difficultyIndicatorGroup = new GUILayoutGroup(new RectTransform(Vector2.One * 0.9f, missionName.RectTransform, anchor: Anchor.CenterRight, scaleBasis: ScaleBasis.Smallest) { AbsoluteOffset = new Point((int)missionName.Padding.Z, 0) }, + difficultyIndicatorGroup = new GUILayoutGroup(new RectTransform(new Vector2(0.5f, 0.9f), missionName.RectTransform, anchor: Anchor.CenterRight) { AbsoluteOffset = new Point((int)missionName.Padding.Z, 0) }, isHorizontal: true, childAnchor: Anchor.CenterRight) { AbsoluteSpacing = 1, - UserData = "difficulty" + UserData = "difficulty", }; + difficultyIndicatorGroup.SetAsFirstChild(); var difficultyColor = mission.GetDifficultyColor(); for (int i = 0; i < mission.Difficulty; i++) { - new GUIImage(new RectTransform(Vector2.One, difficultyIndicatorGroup.RectTransform, scaleBasis: ScaleBasis.Smallest) { IsFixedSize = true }, "DifficultyIndicator", scaleToFit: true) + new GUIImage(new RectTransform(Vector2.One * 0.9f, difficultyIndicatorGroup.RectTransform, scaleBasis: ScaleBasis.Smallest) { IsFixedSize = true }, "DifficultyIndicator", scaleToFit: true) { Color = difficultyColor, SelectedColor = difficultyColor, - HoverColor = difficultyColor + HoverColor = difficultyColor, + ToolTip = mission.GetDifficultyToolTipText() }; } } diff --git a/Barotrauma/BarotraumaClient/ClientSource/Screens/CharacterEditor/CharacterEditorScreen.cs b/Barotrauma/BarotraumaClient/ClientSource/Screens/CharacterEditor/CharacterEditorScreen.cs index 426910cd8..6456d3594 100644 --- a/Barotrauma/BarotraumaClient/ClientSource/Screens/CharacterEditor/CharacterEditorScreen.cs +++ b/Barotrauma/BarotraumaClient/ClientSource/Screens/CharacterEditor/CharacterEditorScreen.cs @@ -6,6 +6,7 @@ using System.Collections.Generic; using System.Linq; using System.Xml.Linq; using Barotrauma.Extensions; +using Barotrauma.Sounds; using FarseerPhysics; using FarseerPhysics.Dynamics; #if DEBUG @@ -115,7 +116,7 @@ namespace Barotrauma.CharacterEditor { base.Select(); - GameMain.SoundManager.SetCategoryGainMultiplier("waterambience", 0.0f, 0); + GameMain.SoundManager.SetCategoryGainMultiplier(SoundManager.SoundCategoryWaterAmbience, 0.0f, 0); GUI.ForceMouseOn(null); if (Submarine.MainSub == null) @@ -236,7 +237,7 @@ namespace Barotrauma.CharacterEditor protected override void DeselectEditorSpecific() { SoundPlayer.OverrideMusicType = Identifier.Empty; - GameMain.SoundManager.SetCategoryGainMultiplier("waterambience", GameSettings.CurrentConfig.Audio.SoundVolume, 0); + GameMain.SoundManager.SetCategoryGainMultiplier(SoundManager.SoundCategoryWaterAmbience, GameSettings.CurrentConfig.Audio.SoundVolume, 0); GUI.ForceMouseOn(null); if (isEndlessRunner) { @@ -746,6 +747,12 @@ namespace Barotrauma.CharacterEditor minorModesToggle?.UpdateOpenState((float)deltaTime, new Vector2(-minorModesPanel.Rect.Width - leftArea.RectTransform.AbsoluteOffset.X, 0), minorModesPanel.RectTransform); modesToggle?.UpdateOpenState((float)deltaTime, new Vector2(-modesPanel.Rect.Width - leftArea.RectTransform.AbsoluteOffset.X, 0), modesPanel.RectTransform); buttonsPanelToggle?.UpdateOpenState((float)deltaTime, new Vector2(-buttonsPanel.Rect.Width - leftArea.RectTransform.AbsoluteOffset.X, 0), buttonsPanel.RectTransform); + totalMassText.Text = GetTotalMassText(); + } + + private LocalizedString GetTotalMassText() + { + return TextManager.GetWithVariable($"{screenTextTag}totalmass", "[mass]", character?.AnimController?.Mass.FormatZeroDecimal() ?? "0"); } public CursorState GetMouseCursorState() @@ -1089,9 +1096,12 @@ namespace Barotrauma.CharacterEditor } else if (PlayerInput.PrimaryMouseButtonClicked()) { - jointStartLimb = GetClosestLimbOnSpritesheet(PlayerInput.MousePosition, l => selectedLimbs.Contains(l) && !l.Hidden); - anchor1Pos = GetLimbSpritesheetRect(jointStartLimb).Center.ToVector2() - PlayerInput.MousePosition; - jointCreationMode = JointCreationMode.Create; + jointStartLimb = GetClosestLimbOnSpritesheet(PlayerInput.MousePosition, l => selectedLimbs.Contains(l)); + if (jointStartLimb != null) + { + anchor1Pos = GetLimbSpritesheetRect(jointStartLimb).Center.ToVector2() - PlayerInput.MousePosition; + jointCreationMode = JointCreationMode.Create; + } } } else @@ -1110,8 +1120,11 @@ namespace Barotrauma.CharacterEditor else if (PlayerInput.PrimaryMouseButtonClicked()) { jointStartLimb = GetClosestLimbOnRagdoll(PlayerInput.MousePosition, l => selectedLimbs.Contains(l) && !l.Hidden); - anchor1Pos = ConvertUnits.ToDisplayUnits(jointStartLimb.body.FarseerBody.GetLocalPoint(ScreenToSim(PlayerInput.MousePosition))); - jointCreationMode = JointCreationMode.Create; + if (jointStartLimb != null) + { + anchor1Pos = ConvertUnits.ToDisplayUnits(jointStartLimb.body.FarseerBody.GetLocalPoint(ScreenToSim(PlayerInput.MousePosition))); + jointCreationMode = JointCreationMode.Create; + } } } } @@ -1494,10 +1507,10 @@ namespace Barotrauma.CharacterEditor { DebugConsole.NewMessage(GetCharacterEditorTranslation("TryingToSpawnCharacter").Replace("[config]", speciesName.ToString()), Color.HotPink); OnPreSpawn(); - bool dontFollowCursor = true; + bool followCursor = false; if (character != null) { - dontFollowCursor = character.dontFollowCursor; + followCursor = character.FollowCursor; RagdollParams.ClearHistory(); CurrentAnimation.ClearHistory(); if (!character.Removed) @@ -1510,7 +1523,7 @@ namespace Barotrauma.CharacterEditor { var characterInfo = new CharacterInfo(speciesName, jobOrJobPrefab: JobPrefab.Prefabs[selectedJob.Value]); character = Character.Create(speciesName, spawnPosition, ToolBox.RandomSeed(8), characterInfo, hasAi: false, ragdoll: ragdoll); - character.GiveJobItems(); + character.GiveJobItems(isPvPMode: false); HideWearables(); if (displayWearables) { @@ -1525,7 +1538,7 @@ namespace Barotrauma.CharacterEditor } if (character != null) { - character.dontFollowCursor = dontFollowCursor; + character.FollowCursor = followCursor; } if (character == null) { @@ -1693,8 +1706,8 @@ namespace Barotrauma.CharacterEditor } else { - config.SetAttributeValue("speciesname", name, StringComparison.OrdinalIgnoreCase); - config.SetAttributeValue("humanoid", isHumanoid, StringComparison.OrdinalIgnoreCase); + config.TrySetAttributeValue("speciesname", name); + config.TrySetAttributeValue("humanoid", isHumanoid); var ragdollElement = config.GetChildElement("ragdolls"); if (ragdollElement == null) { @@ -1758,7 +1771,7 @@ namespace Barotrauma.CharacterEditor // Ragdoll RagdollParams.ClearCache(); - string ragdollPath = RagdollParams.GetDefaultFile(name, contentPackage); + string ragdollPath = RagdollParams.GetDefaultFile(name); RagdollParams ragdollParams = isHumanoid ? RagdollParams.CreateDefault(ragdollPath, name, ragdoll) : RagdollParams.CreateDefault(ragdollPath, name, ragdoll); @@ -1777,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); @@ -1805,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); } } @@ -1845,6 +1858,7 @@ namespace Barotrauma.CharacterEditor private GUILayoutGroup rightArea, leftArea; private GUIFrame centerArea; + private GUITextBlock totalMassText; private GUIFrame characterSelectionPanel; private GUIFrame fileEditPanel; private GUIFrame modesPanel; @@ -1920,16 +1934,13 @@ namespace Barotrauma.CharacterEditor centerArea = new GUIFrame(new RectTransform(new Vector2(0.5f, 0.95f), parent: Frame.RectTransform, anchor: Anchor.TopRight) { AbsoluteOffset = new Point((int)(rightArea.RectTransform.ScaledSize.X + rightArea.RectTransform.RelativeOffset.X * rightArea.RectTransform.Parent.ScaledSize.X + (int)(20 * GUI.xScale)), (int)(20 * GUI.yScale)) - }, style: null) { CanBeFocused = false }; leftArea = new GUILayoutGroup(new RectTransform(new Vector2(0.15f, 0.95f), parent: Frame.RectTransform, anchor: Anchor.CenterLeft), childAnchor: Anchor.BottomLeft) { RelativeSpacing = 0.02f }; - Vector2 toggleSize = new Vector2(1.0f, 0.03f); - CreateFileEditPanel(); CreateOptionsPanel(toggleSize); CreateCharacterSelectionPanel(); @@ -1941,12 +1952,11 @@ namespace Barotrauma.CharacterEditor optionsPanel.RectTransform.MinSize = new Point(0, (int)(optionsPanel.GetChild().RectTransform.Children.Sum(c => c.Rect.Height) / innerScale.Y)); rightArea.Recalculate(); } - CreateButtonsPanel(); CreateModesPanel(toggleSize); CreateMinorModesPanel(toggleSize); - CreateContextualControls(); + totalMassText = new GUITextBlock(new RectTransform(new Vector2(1.0f, 0.01f), rightArea.RectTransform), GetTotalMassText()); } private void CreateMinorModesPanel(Vector2 toggleSize) @@ -2209,10 +2219,10 @@ namespace Barotrauma.CharacterEditor }; new GUITickBox(new RectTransform(toggleSize, layoutGroup.RectTransform), GetCharacterEditorTranslation("FollowCursor")) { - Selected = !character.dontFollowCursor, + Selected = character.FollowCursor, OnSelected = box => { - character.dontFollowCursor = !box.Selected; + character.FollowCursor = box.Selected; return true; } }; @@ -2710,7 +2720,7 @@ namespace Barotrauma.CharacterEditor }, elementCount: 8, style: null); jobDropDown.ListBox.Color = new Color(jobDropDown.ListBox.Color.R, jobDropDown.ListBox.Color.G, jobDropDown.ListBox.Color.B, byte.MaxValue); jobDropDown.AddItem("None"); - JobPrefab.Prefabs.ForEach(j => jobDropDown.AddItem(j.Name, j.Identifier)); + JobPrefab.Prefabs.Where(j => !j.HiddenJob).ForEach(j => jobDropDown.AddItem(j.Name, j.Identifier)); jobDropDown.SelectItem(selectedJob); jobDropDown.OnSelected = (component, data) => { @@ -3112,7 +3122,7 @@ namespace Barotrauma.CharacterEditor { CharacterParams.Reset(true); AnimParams.ForEach(p => p.Reset(true)); - character.AnimController.ResetRagdoll(forceReload: true); + character.AnimController.ResetRagdoll(); RecreateRagdoll(); jointCreationMode = JointCreationMode.None; isDrawingLimb = false; @@ -3566,20 +3576,23 @@ namespace Barotrauma.CharacterEditor int offsetX = spriteSheetOffsetX; int offsetY = spriteSheetOffsetY; Rectangle rect = Rectangle.Empty; - for (int i = 0; i < Textures.Count; i++) + if (Textures != null) { - if (limb.ActiveSprite.FilePath != texturePaths[i]) + for (int i = 0; i < Textures.Count; i++) { - offsetY += (int)(Textures[i].Height * spriteSheetZoom); - } - else - { - rect = limb.ActiveSprite.SourceRect; - rect.Size = rect.MultiplySize(spriteSheetZoom); - rect.Location = rect.Location.Multiply(spriteSheetZoom); - rect.X += offsetX; - rect.Y += offsetY; - break; + if (limb.ActiveSprite.FilePath != texturePaths[i]) + { + offsetY += (int)(Textures[i].Height * spriteSheetZoom); + } + else + { + rect = limb.ActiveSprite.SourceRect; + rect.Size = rect.MultiplySize(spriteSheetZoom); + rect.Location = rect.Location.Multiply(spriteSheetZoom); + rect.X += offsetX; + rect.Y += offsetY; + break; + } } } return rect; diff --git a/Barotrauma/BarotraumaClient/ClientSource/Screens/CharacterEditor/Wizard.cs b/Barotrauma/BarotraumaClient/ClientSource/Screens/CharacterEditor/Wizard.cs index 5fb552402..d782ca3f0 100644 --- a/Barotrauma/BarotraumaClient/ClientSource/Screens/CharacterEditor/Wizard.cs +++ b/Barotrauma/BarotraumaClient/ClientSource/Screens/CharacterEditor/Wizard.cs @@ -300,7 +300,7 @@ namespace Barotrauma.CharacterEditor string destinationDir = Path.GetDirectoryName(destinationPath); if (!Directory.Exists(destinationDir)) { - Directory.CreateDirectory(destinationDir); + Directory.CreateDirectory(destinationDir, catchUnauthorizedAccessExceptions: true); } if (!File.Exists(destinationPath)) diff --git a/Barotrauma/BarotraumaClient/ClientSource/Screens/EventEditor/EventEditorScreen.cs b/Barotrauma/BarotraumaClient/ClientSource/Screens/EventEditor/EventEditorScreen.cs index 138112575..ed6a1db23 100644 --- a/Barotrauma/BarotraumaClient/ClientSource/Screens/EventEditor/EventEditorScreen.cs +++ b/Barotrauma/BarotraumaClient/ClientSource/Screens/EventEditor/EventEditorScreen.cs @@ -198,7 +198,7 @@ namespace Barotrauma msgBox.Close(); string path = Path.Combine(exportPath, $"{nameInput.Text}.xml"); - File.WriteAllText(path, save.ToString()); + File.WriteAllText(path, save.ToString(), catchUnauthorizedAccessExceptions: false); AskForConfirmation(TextManager.Get("EventEditor.OpenTextHeader"), TextManager.Get("EventEditor.OpenTextBody"), () => { ToolBox.OpenFileWithShell(path); @@ -942,7 +942,7 @@ namespace Barotrauma return false; } - GameSession gameSession = new GameSession(subInfo, "", GameModePreset.TestMode, CampaignSettings.Empty, null); + GameSession gameSession = new GameSession(subInfo, Option.None, CampaignDataPath.Empty, GameModePreset.TestMode, CampaignSettings.Empty, null); TestGameMode gameMode = ((TestGameMode?)gameSession.GameMode) ?? throw new InvalidCastException(); gameMode.SpawnOutpost = true; diff --git a/Barotrauma/BarotraumaClient/ClientSource/Screens/GameScreen.cs b/Barotrauma/BarotraumaClient/ClientSource/Screens/GameScreen.cs index c9e0d8f5b..7eeb3526e 100644 --- a/Barotrauma/BarotraumaClient/ClientSource/Screens/GameScreen.cs +++ b/Barotrauma/BarotraumaClient/ClientSource/Screens/GameScreen.cs @@ -5,6 +5,7 @@ using Microsoft.Xna.Framework.Graphics; using System; using System.Diagnostics; using System.Linq; +using System.Transactions; namespace Barotrauma { @@ -15,6 +16,8 @@ namespace Barotrauma private RenderTarget2D renderTargetWater; private RenderTarget2D renderTargetFinal; + private RenderTarget2D renderTargetDamageable; + public readonly Effect DamageEffect; private readonly Texture2D damageStencil; private readonly Texture2D distortTexture; @@ -65,6 +68,7 @@ namespace Barotrauma renderTargetBackground = new RenderTarget2D(graphics, GameMain.GraphicsWidth, GameMain.GraphicsHeight); renderTargetWater = new RenderTarget2D(graphics, GameMain.GraphicsWidth, GameMain.GraphicsHeight); renderTargetFinal = new RenderTarget2D(graphics, GameMain.GraphicsWidth, GameMain.GraphicsHeight, false, SurfaceFormat.Color, DepthFormat.None); + renderTargetDamageable = new RenderTarget2D(graphics, GameMain.GraphicsWidth, GameMain.GraphicsHeight, false, SurfaceFormat.Color, DepthFormat.None); } public override void AddToGUIUpdateList() @@ -133,19 +137,7 @@ namespace Barotrauma if (Character.Controlled == null && !GUI.DisableHUD) { - for (int i = 0; i < Submarine.MainSubs.Length; i++) - { - if (Submarine.MainSubs[i] == null) continue; - if (Level.Loaded != null && Submarine.MainSubs[i].WorldPosition.Y < Level.MaxEntityDepth) { continue; } - - Vector2 position = Submarine.MainSubs[i].SubBody != null ? Submarine.MainSubs[i].WorldPosition : Submarine.MainSubs[i].HiddenSubPosition; - - Color indicatorColor = i == 0 ? Color.LightBlue * 0.5f : GUIStyle.Red * 0.5f; - GUI.DrawIndicator( - spriteBatch, position, cam, - Math.Max(Submarine.MainSub.Borders.Width, Submarine.MainSub.Borders.Height), - GUIStyle.SubmarineLocationIcon.Value.Sprite, indicatorColor); - } + DrawPositionIndicators(spriteBatch); } if (!GUI.DisableHUD) @@ -164,7 +156,118 @@ namespace Barotrauma GameMain.PerformanceCounter.AddElapsedTicks("Draw:HUD", sw.ElapsedTicks); sw.Restart(); } + + private void DrawPositionIndicators(SpriteBatch spriteBatch) + { + Sprite subLocationSprite = GUIStyle.SubLocationIcon.Value?.Sprite; + Sprite shuttleSprite = GUIStyle.ShuttleIcon.Value?.Sprite; + Sprite wreckSprite = GUIStyle.WreckIcon.Value?.Sprite; + Sprite caveSprite = GUIStyle.CaveIcon.Value?.Sprite; + Sprite outpostSprite = GUIStyle.OutpostIcon.Value?.Sprite; + Sprite ruinSprite = GUIStyle.RuinIcon.Value?.Sprite; + Sprite enemySprite = GUIStyle.EnemyIcon.Value?.Sprite; + Sprite corpseSprite = GUIStyle.CorpseIcon.Value?.Sprite; + Sprite beaconSprite = GUIStyle.BeaconIcon.Value?.Sprite; + + for (int i = 0; i < Submarine.MainSubs.Length; i++) + { + if (Submarine.MainSubs[i] == null) { continue; } + if (Level.Loaded != null && Submarine.MainSubs[i].WorldPosition.Y < Level.MaxEntityDepth) { continue; } + + Vector2 position = Submarine.MainSubs[i].SubBody != null ? Submarine.MainSubs[i].WorldPosition : Submarine.MainSubs[i].HiddenSubPosition; + + Color indicatorColor = i == 0 ? Color.LightBlue * 0.5f : GUIStyle.Red * 0.5f; + Sprite displaySprite = Submarine.MainSubs[i].Info.HasTag(SubmarineTag.Shuttle) ? shuttleSprite : subLocationSprite; + if (displaySprite != null) + { + GUI.DrawIndicator( + spriteBatch, position, cam, + Math.Max(Submarine.MainSubs[i].Borders.Width, Submarine.MainSubs[i].Borders.Height), + displaySprite, indicatorColor); + } + } + + if (!GameMain.DevMode) { return;} + + if (Level.Loaded != null) + { + foreach (Level.Cave cave in Level.Loaded.Caves) + { + Vector2 position = cave.StartPos.ToVector2(); + + Color indicatorColor = Color.Yellow * 0.5f; + if (caveSprite != null) + { + GUI.DrawIndicator( + spriteBatch, position, cam, hideDist: 3000f, + caveSprite, indicatorColor); + } + } + } + + foreach (Submarine submarine in Submarine.Loaded) + { + if (Submarine.MainSubs.Contains(submarine)) { continue; } + + Vector2 position = submarine.WorldPosition; + Color teamColorIndicator = submarine.TeamID switch + { + CharacterTeamType.Team1 => Color.LightBlue * 0.5f, + CharacterTeamType.Team2 => GUIStyle.Red * 0.5f, + CharacterTeamType.FriendlyNPC => GUIStyle.Yellow * 0.5f, + _ => Color.Green * 0.5f + }; + + Color indicatorColor = submarine.Info.Type switch + { + SubmarineType.Outpost => Color.LightGreen, + SubmarineType.Wreck => Color.SaddleBrown, + SubmarineType.BeaconStation => Color.Azure, + SubmarineType.Ruin => Color.Purple, + _ => teamColorIndicator + }; + + Sprite displaySprite = submarine.Info.Type switch + { + SubmarineType.Outpost => outpostSprite, + SubmarineType.Wreck => wreckSprite, + SubmarineType.BeaconStation => beaconSprite, + SubmarineType.Ruin => ruinSprite, + _ => subLocationSprite + }; + + // use a little dimmer color for transports + if (submarine.Info.SubmarineClass == SubmarineClass.Transport) { indicatorColor *= 0.75f; } + + if (displaySprite != null) + { + GUI.DrawIndicator( + spriteBatch, position, cam, hideDist: Math.Max(submarine.Borders.Width, submarine.Borders.Height), + displaySprite, indicatorColor); + } + } + + // markers for all enemies and corpses + foreach (Character character in Character.CharacterList) + { + Vector2 position = character.WorldPosition; + Color indicatorColor = Color.DarkRed * 0.5f; + if (character.IsDead) { indicatorColor = Color.DarkGray * 0.5f; } + + if (character.TeamID != CharacterTeamType.None) { continue;} + + Sprite displaySprite = character.IsDead ? corpseSprite : enemySprite; + + if (displaySprite != null) + { + GUI.DrawIndicator( + spriteBatch, position, cam, hideDist: 3000f, + displaySprite, indicatorColor); + } + } + } + public void DrawMap(GraphicsDevice graphics, SpriteBatch spriteBatch, double deltaTime) { foreach (Submarine sub in Submarine.Loaded) @@ -203,7 +306,7 @@ namespace Barotrauma //Draw background structures and wall background sprites //(= the background texture that's revealed when a wall is destroyed) into the background render target //These will be visible through the LOS effect. - spriteBatch.Begin(SpriteSortMode.BackToFront, BlendState.NonPremultiplied, null, DepthStencilState.None, null, null, cam.Transform); + spriteBatch.Begin(SpriteSortMode.BackToFront, BlendState.NonPremultiplied, depthStencilState: DepthStencilState.None, transformMatrix: cam.Transform); Submarine.DrawBack(spriteBatch, false, e => e is Structure s && (e.SpriteDepth >= 0.9f || s.Prefab.BackgroundSprite != null) && !IsFromOutpostDrawnBehindSubs(e)); Submarine.DrawPaintedColors(spriteBatch, false); spriteBatch.End(); @@ -212,8 +315,25 @@ namespace Barotrauma GameMain.PerformanceCounter.AddElapsedTicks("Draw:Map:BackStructures", sw.ElapsedTicks); sw.Restart(); + 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(); + GameMain.PerformanceCounter.AddElapsedTicks("Draw:Map:FrontDamageable", sw.ElapsedTicks); + sw.Restart(); + graphics.SetRenderTarget(null); - GameMain.LightManager.RenderLightMap(graphics, spriteBatch, cam, renderTarget); + GameMain.LightManager.RenderLightMap(graphics, spriteBatch, cam, renderTargetDamageable); sw.Stop(); GameMain.PerformanceCounter.AddElapsedTicks("Draw:Map:Lighting", sw.ElapsedTicks); @@ -231,24 +351,24 @@ namespace Barotrauma Level.Loaded.DrawBack(graphics, spriteBatch, cam); } - spriteBatch.Begin(SpriteSortMode.BackToFront, BlendState.NonPremultiplied, null, DepthStencilState.None, null, null, cam.Transform); + spriteBatch.Begin(SpriteSortMode.BackToFront, BlendState.NonPremultiplied, depthStencilState: DepthStencilState.None, transformMatrix: cam.Transform); Submarine.DrawBack(spriteBatch, false, e => e is Structure s && (e.SpriteDepth >= 0.9f || s.Prefab.BackgroundSprite != null) && IsFromOutpostDrawnBehindSubs(e)); spriteBatch.End(); //draw alpha blended particles that are in water and behind subs #if LINUX || OSX - spriteBatch.Begin(SpriteSortMode.Deferred, BlendState.NonPremultiplied, null, DepthStencilState.None, null, null, cam.Transform); + spriteBatch.Begin(SpriteSortMode.Deferred, BlendState.NonPremultiplied, depthStencilState: DepthStencilState.None, transformMatrix: cam.Transform); #else - spriteBatch.Begin(SpriteSortMode.Deferred, BlendState.NonPremultiplied, null, DepthStencilState.None, null, null, cam.Transform); + spriteBatch.Begin(SpriteSortMode.Deferred, BlendState.NonPremultiplied, depthStencilState: DepthStencilState.None, transformMatrix: cam.Transform); #endif GameMain.ParticleManager.Draw(spriteBatch, true, false, Particles.ParticleBlendState.AlphaBlend); spriteBatch.End(); //draw additive particles that are in water and behind subs - spriteBatch.Begin(SpriteSortMode.Deferred, BlendState.Additive, null, DepthStencilState.None, null, null, cam.Transform); + spriteBatch.Begin(SpriteSortMode.Deferred, BlendState.Additive, depthStencilState: DepthStencilState.None, transformMatrix: cam.Transform); GameMain.ParticleManager.Draw(spriteBatch, true, false, Particles.ParticleBlendState.Additive); spriteBatch.End(); - spriteBatch.Begin(SpriteSortMode.Deferred, BlendState.NonPremultiplied, null, DepthStencilState.None); + spriteBatch.Begin(SpriteSortMode.Deferred, BlendState.NonPremultiplied, depthStencilState: DepthStencilState.None); spriteBatch.Draw(renderTarget, new Rectangle(0, 0, GameMain.GraphicsWidth, GameMain.GraphicsHeight), Color.White); spriteBatch.End(); @@ -267,8 +387,8 @@ namespace Barotrauma GraphicsQuad.Render(); //Draw the rest of the structures, characters and front structures - spriteBatch.Begin(SpriteSortMode.BackToFront, BlendState.NonPremultiplied, null, DepthStencilState.None, null, null, cam.Transform); - Submarine.DrawBack(spriteBatch, false, e => !(e is Structure) || e.SpriteDepth < 0.9f); + spriteBatch.Begin(SpriteSortMode.BackToFront, BlendState.NonPremultiplied, depthStencilState: DepthStencilState.None, transformMatrix: cam.Transform); + Submarine.DrawBack(spriteBatch, false, e => e is not Structure || e.SpriteDepth < 0.9f); DrawCharacters(deformed: false, firstPass: true); spriteBatch.End(); @@ -276,7 +396,7 @@ namespace Barotrauma GameMain.PerformanceCounter.AddElapsedTicks("Draw:Map:BackCharactersItems", sw.ElapsedTicks); sw.Restart(); - spriteBatch.Begin(SpriteSortMode.Deferred, BlendState.NonPremultiplied, null, DepthStencilState.None, null, null, cam.Transform); + spriteBatch.Begin(SpriteSortMode.Deferred, BlendState.NonPremultiplied, depthStencilState: DepthStencilState.None, transformMatrix: cam.Transform); DrawCharacters(deformed: true, firstPass: true); DrawCharacters(deformed: true, firstPass: false); DrawCharacters(deformed: false, firstPass: false); @@ -321,19 +441,19 @@ namespace Barotrauma GraphicsQuad.Render(); //draw alpha blended particles that are inside a sub - spriteBatch.Begin(SpriteSortMode.Deferred, BlendState.NonPremultiplied, null, DepthStencilState.DepthRead, null, null, cam.Transform); + spriteBatch.Begin(SpriteSortMode.Deferred, BlendState.NonPremultiplied, depthStencilState: DepthStencilState.DepthRead, transformMatrix: cam.Transform); GameMain.ParticleManager.Draw(spriteBatch, true, true, Particles.ParticleBlendState.AlphaBlend); spriteBatch.End(); graphics.SetRenderTarget(renderTarget); //draw alpha blended particles that are not in water - spriteBatch.Begin(SpriteSortMode.Deferred, BlendState.NonPremultiplied, null, DepthStencilState.DepthRead, null, null, cam.Transform); + spriteBatch.Begin(SpriteSortMode.Deferred, BlendState.NonPremultiplied, depthStencilState: DepthStencilState.DepthRead, transformMatrix: cam.Transform); GameMain.ParticleManager.Draw(spriteBatch, false, null, Particles.ParticleBlendState.AlphaBlend); spriteBatch.End(); //draw additive particles that are not in water - spriteBatch.Begin(SpriteSortMode.Deferred, BlendState.Additive, null, DepthStencilState.None, null, null, cam.Transform); + spriteBatch.Begin(SpriteSortMode.Deferred, BlendState.Additive, depthStencilState: DepthStencilState.None, transformMatrix: cam.Transform); GameMain.ParticleManager.Draw(spriteBatch, false, null, Particles.ParticleBlendState.Additive); spriteBatch.End(); @@ -350,20 +470,10 @@ namespace Barotrauma GameMain.PerformanceCounter.AddElapsedTicks("Draw:Map:FrontParticles", sw.ElapsedTicks); sw.Restart(); - DamageEffect.CurrentTechnique = DamageEffect.Techniques["StencilShader"]; - spriteBatch.Begin(SpriteSortMode.Immediate, - BlendState.NonPremultiplied, SamplerState.LinearWrap, - null, null, - DamageEffect, - cam.Transform); - Submarine.DrawDamageable(spriteBatch, DamageEffect, false); - spriteBatch.End(); + GraphicsQuad.UseBasicEffect(renderTargetDamageable); + GraphicsQuad.Render(); - sw.Stop(); - GameMain.PerformanceCounter.AddElapsedTicks("Draw:Map:FrontDamageable", sw.ElapsedTicks); - sw.Restart(); - - spriteBatch.Begin(SpriteSortMode.BackToFront, BlendState.NonPremultiplied, null, DepthStencilState.None, null, null, cam.Transform); + spriteBatch.Begin(SpriteSortMode.BackToFront, BlendState.NonPremultiplied, depthStencilState: DepthStencilState.None, transformMatrix: cam.Transform); Submarine.DrawFront(spriteBatch, false, null); spriteBatch.End(); @@ -372,7 +482,7 @@ namespace Barotrauma sw.Restart(); //draw additive particles that are inside a sub - spriteBatch.Begin(SpriteSortMode.Deferred, BlendState.Additive, null, DepthStencilState.Default, null, null, cam.Transform); + spriteBatch.Begin(SpriteSortMode.Deferred, BlendState.Additive, depthStencilState: DepthStencilState.Default, transformMatrix: cam.Transform); GameMain.ParticleManager.Draw(spriteBatch, true, true, Particles.ParticleBlendState.Additive); foreach (var discharger in Items.Components.ElectricalDischarger.List) { @@ -388,7 +498,7 @@ namespace Barotrauma GraphicsQuad.Render(); } - spriteBatch.Begin(SpriteSortMode.Deferred, BlendState.NonPremultiplied, SamplerState.LinearWrap, DepthStencilState.None, null, null, cam.Transform); + spriteBatch.Begin(SpriteSortMode.Deferred, BlendState.NonPremultiplied, SamplerState.LinearWrap, depthStencilState: DepthStencilState.None, transformMatrix: cam.Transform); foreach (Character c in Character.CharacterList) { c.DrawFront(spriteBatch, cam); diff --git a/Barotrauma/BarotraumaClient/ClientSource/Screens/LevelEditorScreen.cs b/Barotrauma/BarotraumaClient/ClientSource/Screens/LevelEditorScreen.cs index 5b9394728..2cb36e846 100644 --- a/Barotrauma/BarotraumaClient/ClientSource/Screens/LevelEditorScreen.cs +++ b/Barotrauma/BarotraumaClient/ClientSource/Screens/LevelEditorScreen.cs @@ -7,6 +7,7 @@ using System; using System.Collections.Generic; using System.Linq; using System.Xml.Linq; +using Microsoft.Xna.Framework.Input; #if DEBUG using System.IO; using System.Xml; @@ -20,23 +21,29 @@ namespace Barotrauma { public override Camera Cam { get; } - private readonly GUIFrame leftPanel, rightPanel, bottomPanel, topPanel; + 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 readonly GUIListBox paramsList, ruinParamsList, caveParamsList, outpostParamsList, levelObjectList; - private readonly GUIListBox editorContainer; + private GUIListBox paramsList, ruinParamsList, caveParamsList, outpostParamsList, levelObjectList, backgroundCreatureList; + private GUIListBox editorContainer; - private readonly GUIButton spriteEditDoneButton; + private GUIButton spriteEditDoneButton; - private readonly GUITextBox seedBox; + private GUITextBox seedBox; - private readonly GUITickBox lightingEnabled, cursorLightEnabled, allowInvalidOutpost, mirrorLevel; + private GUITickBox lightingEnabled, cursorLightEnabled, allowInvalidOutpost, mirrorLevel; - private readonly GUIDropDown selectedSubDropDown; - private readonly GUIDropDown selectedBeaconStationDropdown; - private readonly GUIDropDown selectedWreckDropdown; + private GUIDropDown selectedSubDropDown; + private GUIDropDown selectedBeaconStationDropdown; + private GUIDropDown selectedWreckDropdown; + private GUINumberInput forceDifficultyInput; private Sprite editingSprite; @@ -45,15 +52,34 @@ namespace Barotrauma private readonly Color[] tunnelDebugColors = new Color[] { Color.White, Color.Cyan, Color.LightGreen, Color.Red, Color.LightYellow, Color.LightSeaGreen }; private LevelData currentLevelData; - - public LevelEditorScreen() + + private void RefreshUI(bool forceCreate = false) { - Cam = new Camera() + if (forceCreate) { - MinZoom = 0.01f, - MaxZoom = 1.0f - }; - + CreateUI(); + } + + GUI.PreventPauseMenuToggle = false; + pointerLightSource = new LightSource(Vector2.Zero, 1000.0f, Color.White, submarine: null); + GameMain.LightManager.AddLight(pointerLightSource); + topPanel.ClearChildren(); + new SerializableEntityEditor(topPanel.RectTransform, pointerLightSource.LightSourceParams, false, true); + + editingSprite = null; + UpdateParamsList(); + UpdateRuinParamsList(); + 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) }); var paddedLeftPanel = new GUILayoutGroup(new RectTransform(new Vector2(0.9f, 0.95f), leftPanel.RectTransform, Anchor.CenterLeft) { RelativeOffset = new Vector2(0.02f, 0.0f) }) { @@ -71,7 +97,9 @@ 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; }; @@ -83,7 +111,30 @@ namespace Barotrauma }; ruinParamsList.OnSelected += (GUIComponent component, object obj) => { - CreateOutpostGenerationParamsEditor(obj as OutpostGenerationParams); + if (selectedRuinGenerationParams == obj) + { + // need to wait a frame before deselecting or the highlight on the list item gets left on + CoroutineManager.StartCoroutine(DeselectRuinParams()); + + IEnumerable DeselectRuinParams() + { + if (Screen.Selected != this) + { + yield break; + } + + yield return null; + selectedRuinGenerationParams = null; + CreateOutpostGenerationParamsEditor(null); + ruinParamsList.Deselect(); + } + } + else + { + selectedRuinGenerationParams = obj as RuinGenerationParams; + CreateOutpostGenerationParamsEditor(selectedRuinGenerationParams); + } + return true; }; @@ -108,7 +159,8 @@ namespace Barotrauma }; outpostParamsList.OnSelected += (GUIComponent component, object obj) => { - CreateOutpostGenerationParamsEditor(obj as OutpostGenerationParams); + selectedOutpostGenerationParams = obj as OutpostGenerationParams; + CreateOutpostGenerationParamsEditor(selectedOutpostGenerationParams); return true; }; @@ -218,15 +270,31 @@ namespace Barotrauma selectedWreckDropdown.AddItem(wreck.DisplayName, userData: wreck); } wreckDropDownContainer.RectTransform.MinSize = new Point(0, selectedWreckDropdown.RectTransform.MinSize.Y); + + var forceDifficultyContainer = new GUILayoutGroup(new RectTransform(new Vector2(1.0f, 0.02f), paddedRightPanel.RectTransform), isHorizontal: true); + new GUITextBlock(new RectTransform(new Vector2(0.5f, 1.0f), forceDifficultyContainer.RectTransform), TextManager.Get("leveldifficulty")); + forceDifficultyInput = new GUINumberInput(new RectTransform(new Vector2(0.5f, 1.0f), forceDifficultyContainer.RectTransform), NumberType.Float) + { + MinValueFloat = 0, + MaxValueFloat = 100, + FloatValue = Level.ForcedDifficulty ?? selectedParams?.MinLevelDifficulty ?? 0f, + OnValueChanged = (numberInput) => + { + if (Level.ForcedDifficulty == null) { return; } + Level.ForcedDifficulty = numberInput.FloatValue; + } + }; + forceDifficultyContainer.RectTransform.MinSize = new Point(0, forceDifficultyInput.RectTransform.MinSize.Y); + + var tickBoxContainer = new GUILayoutGroup(new RectTransform(new Vector2(1.0f, 0.04f), paddedRightPanel.RectTransform), isHorizontal: true); + mirrorLevel = new GUITickBox(new RectTransform(new Vector2(0.5f, 0.02f), tickBoxContainer.RectTransform), TextManager.Get("mirrorentityx")); - mirrorLevel = new GUITickBox(new RectTransform(new Vector2(1.0f, 0.02f), paddedRightPanel.RectTransform), TextManager.Get("mirrorentityx")); - - allowInvalidOutpost = new GUITickBox(new RectTransform(new Vector2(1.0f, 0.025f), paddedRightPanel.RectTransform), + allowInvalidOutpost = new GUITickBox(new RectTransform(new Vector2(0.5f, 0.025f), tickBoxContainer.RectTransform), TextManager.Get("leveleditor.allowinvalidoutpost")) { ToolTip = TextManager.Get("leveleditor.allowinvalidoutpost.tooltip") }; - + new GUIButton(new RectTransform(new Vector2(1.0f, 0.05f), paddedRightPanel.RectTransform), TextManager.Get("leveleditor.generate")) { @@ -240,13 +308,15 @@ namespace Barotrauma Submarine.MainSub = new Submarine(subInfo); } GameMain.LightManager.ClearLights(); - currentLevelData = LevelData.CreateRandom(seedBox.Text, generationParams: selectedParams); + currentLevelData = LevelData.CreateRandom(seedBox.Text, difficulty: forceDifficultyInput.FloatValue, generationParams: selectedParams); currentLevelData.ForceOutpostGenerationParams = outpostParamsList.SelectedData as OutpostGenerationParams; currentLevelData.ForceBeaconStation = selectedBeaconStationDropdown.SelectedData as SubmarineInfo; currentLevelData.ForceWreck = selectedWreckDropdown.SelectedData as SubmarineInfo; + currentLevelData.ForceRuinGenerationParams = selectedRuinGenerationParams; 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) { @@ -272,7 +342,6 @@ namespace Barotrauma } }; - new GUIButton(new RectTransform(new Vector2(1.0f, 0.05f), paddedRightPanel.RectTransform), TextManager.Get("leveleditor.test")) { @@ -300,8 +369,8 @@ namespace Barotrauma subInfo ??= SubmarineInfo.SavedSubmarines.GetRandomUnsynced(s => s.IsPlayer && !s.HasTag(SubmarineTag.Shuttle) && !nonPlayerFiles.Any(f => f.Path == s.FilePath)); - GameSession gameSession = new GameSession(subInfo, "", GameModePreset.TestMode, CampaignSettings.Empty, null); - gameSession.StartRound(Level.Loaded.LevelData); + GameSession gameSession = new GameSession(subInfo, Option.None, CampaignDataPath.Empty, GameModePreset.TestMode, CampaignSettings.Empty, null); + gameSession.StartRound(Level.Loaded.LevelData, mirrorLevel.Selected); (gameSession.GameMode as TestGameMode).OnRoundEnd = () => { GameMain.LevelEditorScreen.Select(); @@ -323,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 @@ -337,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")) { @@ -349,6 +465,19 @@ 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() + { + Cam = new Camera() + { + MinZoom = 0.01f, + MaxZoom = 1.0f + }; + + RefreshUI(forceCreate: true); } public void TestLevelGenerationForErrors(int amountOfLevelsToGenerate) @@ -394,28 +523,13 @@ namespace Barotrauma yield return CoroutineStatus.Running; } } - - } - - - + public override void Select() { base.Select(); - - GUI.PreventPauseMenuToggle = false; - pointerLightSource = new LightSource(Vector2.Zero, 1000.0f, Color.White, submarine: null); - GameMain.LightManager.AddLight(pointerLightSource); - topPanel.ClearChildren(); - new SerializableEntityEditor(topPanel.RectTransform, pointerLightSource.LightSourceParams, false, true); - - editingSprite = null; - UpdateParamsList(); - UpdateRuinParamsList(); - UpdateCaveParamsList(); - UpdateOutpostParamsList(); - UpdateLevelObjectsList(); + + RefreshUI(forceCreate: false); } protected override void DeselectEditorSpecific() @@ -464,7 +578,7 @@ namespace Barotrauma foreach (RuinGenerationParams genParams in RuinGenerationParams.RuinParams.OrderBy(p => p.Identifier)) { new GUITextBlock(new RectTransform(new Vector2(1.0f, 0.05f), ruinParamsList.Content.RectTransform) { MinSize = new Point(0, 20) }, - genParams.Name) + genParams.Identifier.Value) { Padding = Vector4.Zero, UserData = genParams @@ -502,9 +616,13 @@ namespace Barotrauma new Vector2(relWidth, relWidth * ((float)levelObjectList.Content.Rect.Width / levelObjectList.Content.Rect.Height)), levelObjectList.Content.RectTransform) { MinSize = new Point(0, 60) }, style: "ListBoxElementSquare") { - UserData = levelObjPrefab + UserData = levelObjPrefab, + ToolTip = levelObjPrefab.Name + }; + var paddedFrame = new GUIFrame(new RectTransform(new Vector2(0.9f, 0.9f), frame.RectTransform, Anchor.Center), style: null) + { + CanBeFocused = false }; - 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(levelObjPrefab.Name, GUIStyle.SmallFont, paddedFrame.Rect.Width), textAlignment: Alignment.Center, font: GUIStyle.SmallFont) @@ -523,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(); @@ -556,6 +712,7 @@ namespace Barotrauma private void CreateOutpostGenerationParamsEditor(OutpostGenerationParams outpostGenerationParams) { editorContainer.ClearChildren(); + if (outpostGenerationParams == null) { return; } var outpostParamsEditor = new SerializableEntityEditor(editorContainer.Content.RectTransform, outpostGenerationParams, false, true, elementHeight: 20); // location type ------------------------- @@ -584,7 +741,7 @@ namespace Barotrauma locationTypeDropDown.SelectItem("any"); } - locationTypeDropDown.OnSelected += (_, __) => + locationTypeDropDown.AfterSelected += (_, __) => { outpostGenerationParams.SetAllowedLocationTypes(locationTypeDropDown.SelectedDataMultiple.Cast()); locationTypeDropDown.Text = ToolBox.LimitString(locationTypeDropDown.Text, locationTypeDropDown.Font, locationTypeDropDown.Rect.Width); @@ -596,29 +753,29 @@ namespace Barotrauma // module count ------------------------- - var moduleLabel = new GUITextBlock(new RectTransform(new Point(editorContainer.Content.Rect.Width, (int)(70 * GUI.Scale))), TextManager.Get("submarinetype.outpostmodules"), font: GUIStyle.SubHeadingFont); - outpostParamsEditor.AddCustomContent(moduleLabel, 100); - foreach (var moduleCount in outpostGenerationParams.ModuleCounts) { - var moduleCountGroup = new GUILayoutGroup(new RectTransform(new Point(editorContainer.Content.Rect.Width, (int)(25 * GUI.Scale))), isHorizontal: true, childAnchor: Anchor.CenterLeft); - new GUITextBlock(new RectTransform(new Vector2(0.5f, 1f), moduleCountGroup.RectTransform), TextManager.Capitalize(moduleCount.Identifier.Value), textAlignment: Alignment.CenterLeft); - new GUINumberInput(new RectTransform(new Vector2(0.5f, 1f), moduleCountGroup.RectTransform), NumberType.Int) + var editor = new SerializableEntityEditor(editorContainer.Content.RectTransform, moduleCount, inGame: false, showName: true, elementHeight: 20, titleFont: GUIStyle.Font); + foreach (var componentList in editor.Fields.Values) { - MinValueInt = 0, - MaxValueInt = 100, - IntValue = moduleCount.Count, - OnValueChanged = (numInput) => + foreach (var component in componentList) { - outpostGenerationParams.SetModuleCount(moduleCount.Identifier, numInput.IntValue); - if (numInput.IntValue == 0) + if (component is GUINumberInput numberInput) { - outpostParamsList.Select(outpostParamsList.SelectedData); + numberInput.OnValueChanged += (numInput) => + { + if (moduleCount.Count == 0) + { + //refresh to remove this module count from the editor + outpostParamsList.Select(outpostParamsList.SelectedData); + } + }; } } - }; - moduleCountGroup.RectTransform.MinSize = new Point(moduleCountGroup.Rect.Width, moduleCountGroup.RectTransform.Children.Max(c => c.MinSize.Y)); - outpostParamsEditor.AddCustomContent(moduleCountGroup, 100); + } + editor.RectTransform.MaxSize = new Point(int.MaxValue, editor.Rect.Height); + outpostParamsEditor.AddCustomContent(editor, 100); + editor.Recalculate(); } // add module count ------------------------- @@ -648,7 +805,7 @@ namespace Barotrauma }; addModuleCountGroup.RectTransform.MinSize = new Point(addModuleCountGroup.Rect.Width, addModuleCountGroup.RectTransform.Children.Max(c => c.MinSize.Y)); outpostParamsEditor.AddCustomContent(addModuleCountGroup, 100); - + outpostParamsEditor.Recalculate(); } private void CreateLevelObjectEditor(LevelObjectPrefab levelObjectPrefab) @@ -736,7 +893,7 @@ namespace Barotrauma dropdown.AddItem(objPrefab.Name, objPrefab); if (childObj.AllowedNames.Contains(objPrefab.Name)) { dropdown.SelectItem(objPrefab); } } - dropdown.OnSelected = (selected, obj) => + dropdown.AfterSelected = (selected, obj) => { childObj.AllowedNames = dropdown.SelectedDataMultiple.Select(d => ((LevelObjectPrefab)d).Name).ToList(); return true; @@ -817,9 +974,22 @@ namespace Barotrauma { var levelObj = levelObjFrame.UserData as LevelObjectPrefab; float commonness = levelObj.GetCommonness(levelData); - levelObjFrame.Color = commonness > 0.0f ? GUIStyle.Green * 0.4f : Color.Transparent; - levelObjFrame.SelectedColor = commonness > 0.0f ? GUIStyle.Green * 0.6f : Color.White * 0.5f; - levelObjFrame.HoverColor = commonness > 0.0f ? GUIStyle.Green * 0.7f : Color.White * 0.6f; + + Color color = GUIStyle.Green; + + if (commonness > 0.0f && levelData?.GenerationParams != null) + { + if (levelObj.MinSurfaceWidth > levelData.GenerationParams.CellSubdivisionLength && + levelObj.SpawnPos.HasFlag(LevelObjectPrefab.SpawnPosType.Wall)) + { + color = Color.Orange; + levelObjFrame.ToolTip = $"Potential issue: the level walls in \"{levelData.GenerationParams.Identifier}\" are set to be subdivided every {levelData.GenerationParams.CellSubdivisionLength} pixels, but the level object requires wall segments of at least {levelObj.MinSurfaceWidth} px. The object may be rarer than intended (or fail to spawn at all) in the level."; + } + } + + levelObjFrame.Color = commonness > 0.0f ? color * 0.4f : Color.Transparent; + levelObjFrame.SelectedColor = commonness > 0.0f ? color * 0.6f : Color.White * 0.5f; + levelObjFrame.HoverColor = commonness > 0.0f ? color * 0.7f : Color.White * 0.6f; levelObjFrame.GetAnyChild().Color = commonness > 0.0f ? Color.White : Color.DarkGray; if (commonness <= 0.0f) @@ -837,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(); @@ -965,11 +1228,17 @@ namespace Barotrauma spriteBatch.Begin(SpriteSortMode.Deferred, samplerState: GUI.SamplerState, rasterizerState: GameMain.ScissorTestEnable); if (Level.Loaded != null) { - float crushDepthScreen = Cam.WorldToScreen(new Vector2(0.0f, -Level.Loaded.CrushDepth)).Y; - if (crushDepthScreen > 0.0f && crushDepthScreen < GameMain.GraphicsHeight) + float hullUpgradePrcIncrease = UpgradePrefab.CrushDepthUpgradePrc / 100f; + for (int upgradeLevel = 0; upgradeLevel <= UpgradePrefab.IncreaseWallHealthMaxLevel; upgradeLevel++) { - GUI.DrawLine(spriteBatch, new Vector2(0, crushDepthScreen), new Vector2(GameMain.GraphicsWidth, crushDepthScreen), GUIStyle.Red * 0.25f, width: 5); - GUI.DrawString(spriteBatch, new Vector2(GameMain.GraphicsWidth / 2, crushDepthScreen), "Crush depth", GUIStyle.Red, backgroundColor: Color.Black); + float upgradeLevelCrushDepth = Level.DefaultRealWorldCrushDepth + (Level.DefaultRealWorldCrushDepth * upgradeLevel * hullUpgradePrcIncrease); + float subCrushDepth = (upgradeLevelCrushDepth / Physics.DisplayToRealWorldRatio) - Level.Loaded.LevelData.InitialDepth; + string labelText = $"Crush depth (upgrade level {upgradeLevel})"; + if (upgradeLevel == 0) + { + labelText = $"Crush depth (no upgrade)"; + } + DrawCrushDepth(subCrushDepth, labelText, Color.Red); } float abyssStartScreen = Cam.WorldToScreen(new Vector2(0.0f, Level.Loaded.AbyssArea.Bottom)).Y; @@ -987,11 +1256,25 @@ namespace Barotrauma } GUI.Draw(Cam, spriteBatch); spriteBatch.End(); + + void DrawCrushDepth(float crushDepth, string labelText, Color color) + { + float crushDepthScreen = Cam.WorldToScreen(new Vector2(0.0f, -crushDepth)).Y; + if (crushDepthScreen > 0.0f && crushDepthScreen < GameMain.GraphicsHeight) + { + GUI.DrawLine(spriteBatch, new Vector2(0, crushDepthScreen), new Vector2(GameMain.GraphicsWidth, crushDepthScreen), color * 0.25f, width: 5); + GUI.DrawString(spriteBatch, new Vector2(GameMain.GraphicsWidth / 2, crushDepthScreen), labelText, GUIStyle.Red, backgroundColor: Color.Black); + } + } } - 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) @@ -1016,6 +1299,19 @@ namespace Barotrauma { GameMain.SpriteEditorScreen.Update(deltaTime); } + + // in case forced difficulty was changed by console command or such + if (Level.ForcedDifficulty != null && MathHelper.Distance((float)Level.ForcedDifficulty, forceDifficultyInput.FloatValue) > 0.001f) + { + forceDifficultyInput.FloatValue = (float)Level.ForcedDifficulty; + } + +#if DEBUG + if (PlayerInput.KeyDown(Keys.LeftControl) && PlayerInput.KeyHit(Keys.R)) + { + RefreshUI(forceCreate: true); + } +#endif } private void SerializeAll() diff --git a/Barotrauma/BarotraumaClient/ClientSource/Screens/MainMenuScreen/MainMenuScreen.cs b/Barotrauma/BarotraumaClient/ClientSource/Screens/MainMenuScreen/MainMenuScreen.cs index 5a62f17fc..228053291 100644 --- a/Barotrauma/BarotraumaClient/ClientSource/Screens/MainMenuScreen/MainMenuScreen.cs +++ b/Barotrauma/BarotraumaClient/ClientSource/Screens/MainMenuScreen/MainMenuScreen.cs @@ -512,6 +512,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 var minButtonSize = new Point(120, 20); var maxButtonSize = new Point(480, 80); @@ -906,6 +922,7 @@ namespace Barotrauma } var gamesession = new GameSession( selectedSub, + Option.None, GameModePreset.DevSandbox, missionPrefabs: null); //(gamesession.GameMode as SinglePlayerCampaign).GenerateMap(ToolBox.RandomSeed(8)); @@ -928,6 +945,7 @@ namespace Barotrauma DebugConsole.ThrowError("Failed to find the job \"" + job + "\"!"); } gamesession.CrewManager.AddCharacterInfo(characterInfo); + characterInfo.SetNameBasedOnJob(); } gamesession.CrewManager.InitSinglePlayerRound(); } @@ -1251,12 +1269,12 @@ namespace Barotrauma if (!Directory.Exists(SaveUtil.TempPath)) { - Directory.CreateDirectory(SaveUtil.TempPath); + Directory.CreateDirectory(SaveUtil.TempPath, catchUnauthorizedAccessExceptions: true); } try { - File.Copy(selectedSub.FilePath, Path.Combine(SaveUtil.TempPath, selectedSub.Name + ".sub"), true); + File.Copy(selectedSub.FilePath, Path.Combine(SaveUtil.TempPath, selectedSub.Name + ".sub"), overwrite: true, catchUnauthorizedAccessExceptions: false); } catch (System.IO.IOException e) { @@ -1270,7 +1288,7 @@ namespace Barotrauma selectedSub = new SubmarineInfo(Path.Combine(SaveUtil.TempPath, selectedSub.Name + ".sub")); - GameMain.GameSession = new GameSession(selectedSub, savePath, GameModePreset.SinglePlayerCampaign, settings, mapSeed); + GameMain.GameSession = new GameSession(selectedSub, Option.None, CampaignDataPath.CreateRegular(savePath), GameModePreset.SinglePlayerCampaign, settings, mapSeed); GameMain.GameSession.CrewManager.ClearCharacterInfos(); foreach (var characterInfo in campaignSetupUI.CharacterMenus.Select(m => m.CharacterInfo)) { @@ -1279,22 +1297,25 @@ namespace Barotrauma ((SinglePlayerCampaign)GameMain.GameSession.GameMode).LoadNewLevel(); } - private void LoadGame(string saveFile) + private void LoadGame(string path, Option backupIndex) { - if (string.IsNullOrWhiteSpace(saveFile)) return; + if (string.IsNullOrWhiteSpace(path)) return; try { - SaveUtil.LoadGame(saveFile); + CampaignDataPath dataPath = + backupIndex.TryUnwrap(out uint index) + ? new CampaignDataPath(loadPath: SaveUtil.GetBackupPath(path, index), path) + : CampaignDataPath.CreateRegular(path); + + SaveUtil.LoadGame(dataPath); } catch (Exception e) { - DebugConsole.ThrowError("Loading save \"" + saveFile + "\" failed", e); + DebugConsole.ThrowError("Loading save \"" + path + "\" failed", e); + GameMain.GameSession = null; return; } - - //TODO - //GameMain.LobbyScreen.Select(); } #region UI Methods @@ -1327,7 +1348,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); diff --git a/Barotrauma/BarotraumaClient/ClientSource/Screens/NetLobbyScreen.cs b/Barotrauma/BarotraumaClient/ClientSource/Screens/NetLobbyScreen.cs index ba7fc25c6..ee2702b79 100644 --- a/Barotrauma/BarotraumaClient/ClientSource/Screens/NetLobbyScreen.cs +++ b/Barotrauma/BarotraumaClient/ClientSource/Screens/NetLobbyScreen.cs @@ -1,11 +1,11 @@ using Barotrauma.Extensions; using Barotrauma.Networking; -using Barotrauma.Steam; using Microsoft.Xna.Framework; using Microsoft.Xna.Framework.Graphics; using System; using System.Collections.Generic; using System.Collections.Immutable; +using System.Diagnostics.CodeAnalysis; using System.Globalization; using System.Linq; @@ -14,13 +14,17 @@ namespace Barotrauma partial class NetLobbyScreen : Screen { private GUIListBox chatBox; + private GUILayoutGroup chatRow; private GUIButton serverLogReverseButton; private GUIListBox serverLogBox, serverLogFilterTicks; private GUIComponent jobVariantTooltip; private GUIComponent playStyleIconContainer; - + + private GUIDropDown chatSelector; + public static bool TeamChatSelected = false; + private GUITextBox chatInput; private GUITextBox serverLogFilter; public GUITextBox ChatInput @@ -39,6 +43,9 @@ namespace Barotrauma private GUIScrollBar traitorProbabilitySlider; private GUILayoutGroup traitorDangerGroup; + private GUIDropDown outpostDropdown; + private bool outpostDropdownUpToDate; + public GUIFrame MissionTypeFrame { get; private set; } public GUIFrame CampaignSetupFrame { get; private set; } public GUIFrame CampaignFrame { get; private set; } @@ -47,7 +54,7 @@ namespace Barotrauma private GUITickBox[] missionTypeTickBoxes; private GUIListBox missionTypeList; - + public GUITextBox LevelSeedBox { get; private set; } private GUIButton joinOnGoingRoundButton; @@ -60,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; @@ -99,8 +107,14 @@ namespace Barotrauma private readonly List permadeathDisabledRespawnSettings = new List(); private readonly List ironmanDisabledRespawnSettings = new List(); private readonly List campaignDisabledElements = new List(); + private readonly List campaignHiddenElements = new List(); + private readonly List pvpOnlyElements = new(); + private readonly List disembarkPerkSettings = new(); + 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; } @@ -184,16 +198,29 @@ namespace Barotrauma ?? Array.Empty(); public GUIListBox PlayerList; + + public int Team1Count; + public int Team2Count; public GUITextBox CharacterNameBox { get; private set; } public GUIListBox TeamPreferenceListBox { get; private set; } + private GUITextBlock pvpTeamChoiceTeam1; + private GUITextBlock pvpTeamChoiceMiddleButton; + private GUITextBlock pvpTeamChoiceTeam2; + + 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; } - public SubmarineInfo SelectedSub => SubList.SelectedData as SubmarineInfo; + [AllowNull, MaybeNull] + public SubmarineInfo SelectedSub; + + [AllowNull, MaybeNull] + public SubmarineInfo SelectedEnemySub; public SubmarineInfo SelectedShuttle => ShuttleList.SelectedData as SubmarineInfo; @@ -201,7 +228,7 @@ namespace Barotrauma public bool UsingShuttle { - get { return shuttleTickBox.Selected && !PermadeathMode; } + get { return shuttleTickBox.Selected; } set { shuttleTickBox.Selected = value; } } @@ -210,34 +237,27 @@ namespace Barotrauma get { return ModeList.SelectedData as GameModePreset; } } - public MissionType MissionType + public IEnumerable MissionTypes { get { - MissionType retVal = MissionType.None; - int index = 0; - foreach (MissionType type in Enum.GetValues(typeof(MissionType))) - { - if (type == MissionType.None || type == MissionType.All) { continue; } - - if (missionTypeTickBoxes[index].Selected) - { - retVal = (MissionType)((int)retVal | (int)type); - } - - index++; - } - - return retVal; + return missionTypeTickBoxes.Where(t => t.Selected).Select(t => (Identifier)t.UserData); } set { - int index = 0; - foreach (MissionType type in Enum.GetValues(typeof(MissionType))) + bool changed = false; + foreach (var missionTypeTickBox in missionTypeTickBoxes) { - if (type == MissionType.None || type == MissionType.All) { continue; } - missionTypeTickBoxes[index].Selected = ((int)type & (int)value) != 0; - index++; + bool prevSelected = missionTypeTickBox.Selected; + missionTypeTickBox.Selected = value.Contains((Identifier)missionTypeTickBox.UserData); + if (prevSelected != missionTypeTickBox.Selected) + { + changed = true; + } + } + if (changed) + { + RefreshOutpostDropdown(); } } } @@ -682,11 +702,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; } @@ -710,69 +730,228 @@ namespace Barotrauma }; clientDisabledElements.Add(missionTypeList); - var missionTypes = (MissionType[])Enum.GetValues(typeof(MissionType)); - missionTypeTickBoxes = new GUITickBox[missionTypes.Length - 2]; - int index = 0; - for (int i = 0; i < missionTypes.Length; i++) - { - var missionType = missionTypes[i]; - if (missionType == MissionType.None || missionType == MissionType.All) { continue; } + List missionTypes = MissionPrefab.GetAllMultiplayerSelectableMissionTypes().ToList(); + missionTypeTickBoxes = new GUITickBox[missionTypes.Count]; + int index = 0; + foreach (var missionType in missionTypes.OrderBy(t => TextManager.Get("MissionType." + t.Value).Value)) + { GUIFrame frame = new GUIFrame(new RectTransform(new Vector2(1.0f, 0.05f), missionTypeList.Content.RectTransform) { MinSize = new Point(0, GUI.IntScale(30)) }, style: null) { UserData = missionType, }; - if (MissionPrefab.HiddenMissionClasses.Contains(missionType)) - { - missionTypeTickBoxes[index] = new GUITickBox(new RectTransform(Vector2.One, frame.RectTransform), string.Empty) - { - UserData = (int)missionType, - Visible = false, - CanBeFocused = false - }; - } - else - { - missionTypeTickBoxes[index] = new GUITickBox(new RectTransform(Vector2.One, frame.RectTransform), + missionTypeTickBoxes[index] = new GUITickBox(new RectTransform(Vector2.One, frame.RectTransform), TextManager.Get("MissionType." + missionType.ToString())) + { + UserData = missionType, + ToolTip = TextManager.Get("MissionTypeDescription." + missionType.ToString()), + OnSelected = (tickbox) => { - UserData = (int)missionType, - ToolTip = TextManager.Get("MissionTypeDescription." + missionType.ToString()), - OnSelected = (tickbox) => + RefreshOutpostDropdown(); + if (tickbox.Selected) { - int missionTypeOr = tickbox.Selected ? (int)tickbox.UserData : (int)MissionType.None; - int missionTypeAnd = (int)MissionType.All & (!tickbox.Selected ? (~(int)tickbox.UserData) : (int)MissionType.All); - GameMain.Client?.ServerSettings.ClientAdminWrite(ServerSettings.NetFlags.Misc, (int)missionTypeOr, (int)missionTypeAnd); - return true; + GameMain.Client?.ServerSettings.ClientAdminWrite(ServerSettings.NetFlags.Misc, addedMissionType: (Identifier)tickbox.UserData); } - }; - frame.RectTransform.MinSize = missionTypeTickBoxes[index].RectTransform.MinSize; - } + else + { + GameMain.Client?.ServerSettings.ClientAdminWrite(ServerSettings.NetFlags.Misc, removedMissionType: (Identifier)tickbox.UserData); + } + return true; + } + }; + frame.RectTransform.MinSize = missionTypeTickBoxes[index].RectTransform.MinSize; index++; } clientDisabledElements.AddRange(missionTypeTickBoxes); return gameModeSpecificFrame; } + + private GUIFrame gameModeSettingsContent; + private GUILayoutGroup gameModeSettingsLayout; private GUIComponent CreateGameModeSettingsPanel(GUIComponent parent) { //------------------------------------------------------------------ // settings panel //------------------------------------------------------------------ - - GUILayoutGroup settingsLayout = new GUILayoutGroup(new RectTransform(Vector2.One, parent.RectTransform)) + + gameModeSettingsLayout = new GUILayoutGroup(new RectTransform(Vector2.One, parent.RectTransform)) { Stretch = true }; - CreateSubHeader("GameModeSettings", settingsLayout); + CreateSubHeader("GameModeSettings", gameModeSettingsLayout); - var settingsContent = new GUIListBox(new RectTransform(Vector2.One, settingsLayout.RectTransform)).Content; + gameModeSettingsContent = new GUIListBox(new RectTransform(Vector2.One, gameModeSettingsLayout.RectTransform)).Content; + + var winScoreHeader = new GUITextBlock(new RectTransform(new Vector2(1.0f, 0.0f), gameModeSettingsContent.RectTransform), TextManager.Get("ServerSettingsWinScorePvP")) + { + CanBeFocused = false + }; + clientDisabledElements.Add(winScoreHeader); + pvpOnlyElements.Add(winScoreHeader); + + var winScoreContainer = CreateLabeledSlider(gameModeSettingsContent, headerTag: string.Empty, valueLabelTag: string.Empty, tooltipTag: "ServerSettingsWinScorePvPTooltip", + out var winScorePvPSlider, out var winScorePvPSliderLabel); + winScorePvPSlider.Range = new Vector2(10, 1000); + winScorePvPSlider.StepValue = 10; + winScorePvPSlider.OnMoved = (scrollBar, _) => + { + if (scrollBar.UserData is not GUITextBlock text) { return false; } + text.Text = TextManager.GetWithVariable("ServerSettingsWinScoreValuePvP", "[value]", ((int)Math.Round(scrollBar.BarScrollValue, digits: 0)).ToString()); + return true; + }; + winScorePvPSlider.OnReleased = (scrollBar, _) => + { + GameMain.Client?.ServerSettings.ClientAdminWrite(ServerSettings.NetFlags.Properties); + return true; + }; + + AssignComponentToServerSetting(winScorePvPSlider, nameof(ServerSettings.WinScorePvP)); + winScorePvPSlider.OnMoved(winScorePvPSlider, winScorePvPSlider.BarScroll); + clientDisabledElements.AddRange(winScoreContainer.GetAllChildren()); + pvpOnlyElements.Add(winScoreContainer); + + //(pvp) stun resistance ------------------------------------------------- + var sliderContainer = CreateLabeledSlider(gameModeSettingsContent, headerTag: string.Empty, valueLabelTag: "gamemodesettings.stunresistance", tooltipTag: "gamemodesettings.stunresistancetooltip", + out var slider, out var sliderLabel); + LocalizedString stunResistLabel = sliderLabel.Text; + slider.Step = 0.1f; + slider.Range = new Vector2(0.0f, 1.0f); + slider.OnReleased = (scrollbar, value) => + { + GameMain.Client?.ServerSettings.ClientAdminWrite(ServerSettings.NetFlags.Properties); + return true; + }; + slider.OnMoved = (GUIScrollBar scrollBar, float barScroll) => + { + ((GUITextBlock)scrollBar.UserData).Text = stunResistLabel.Replace("[percentage]", ((int)MathUtils.Round(scrollBar.BarScrollValue * 100.0f, 10.0f)).ToString()); + return true; + }; + AssignComponentToServerSetting(slider, nameof(ServerSettings.PvPStunResist)); + slider.OnMoved(slider, slider.BarScroll); + clientDisabledElements.AddRange(sliderContainer.GetAllChildren()); + pvpOnlyElements.Add(sliderContainer); + + //(pvp) mark enemy location toggle -------------------------------------- + var markApproximateEnemyLocationToggle = new GUITickBox(new RectTransform(new Vector2(0.4f, 0.06f), gameModeSettingsContent.RectTransform), + TextManager.Get("ServerSettingsTrackOpponentInPvP")) + { + ToolTip = TextManager.Get("gamemodesettings.markenemylocationtooltip"), + Selected = GameMain.Client != null && GameMain.Client.ServerSettings.TrackOpponentInPvP, + OnSelected = (tt) => + { + GameMain.Client?.ServerSettings.ClientAdminWrite(ServerSettings.NetFlags.Properties); + return true; + } + }; + AssignComponentToServerSetting(markApproximateEnemyLocationToggle, nameof(ServerSettings.TrackOpponentInPvP)); + clientDisabledElements.Add(markApproximateEnemyLocationToggle); + pvpOnlyElements.Add(markApproximateEnemyLocationToggle); + + //make the header use the height of the tickboxes to get the layout to be a little more uniform + winScoreHeader.RectTransform.MinSize = new Point(0, markApproximateEnemyLocationToggle.RectTransform.MinSize.Y); + + //(pvp) spawn monsters tickbox ----------------------------------------- + var spawnMonstersTickbox = new GUITickBox(new RectTransform(Vector2.One, gameModeSettingsContent.RectTransform), TextManager.Get("gamemodesettings.spawnmonsters")) + { + ToolTip = TextManager.Get("gamemodesettings.spawnmonsterstooltip"), + Selected = GameMain.Client != null && GameMain.Client.ServerSettings.PvPSpawnMonsters, + OnSelected = (GUITickBox box) => + { + GameMain.Client?.ServerSettings.ClientAdminWrite(ServerSettings.NetFlags.Properties); + return true; + } + }; + AssignComponentToServerSetting(spawnMonstersTickbox, nameof(ServerSettings.PvPSpawnMonsters)); + clientDisabledElements.Add(spawnMonstersTickbox); + pvpOnlyElements.Add(spawnMonstersTickbox); + + //(pvp) spawn wrecks tickbox ------------------------------------------- + var spawnWrecksTickbox = new GUITickBox(new RectTransform(Vector2.One, gameModeSettingsContent.RectTransform), TextManager.Get("gamemodesettings.spawnwrecks")) + { + ToolTip = TextManager.Get("gamemodesettings.spawnwreckstooltip"), + Selected = GameMain.Client != null && GameMain.Client.ServerSettings.PvPSpawnWrecks, + OnSelected = (GUITickBox box) => + { + GameMain.Client?.ServerSettings.ClientAdminWrite(ServerSettings.NetFlags.Properties); + return true; + } + }; + AssignComponentToServerSetting(spawnWrecksTickbox, nameof(ServerSettings.PvPSpawnWrecks)); + clientDisabledElements.Add(spawnWrecksTickbox); + pvpOnlyElements.Add(spawnWrecksTickbox); + + // outpost ----------------------------------------------------------------------------- + GUILayoutGroup outpostHolder = new GUILayoutGroup(new RectTransform(new Vector2(1.0f, 0.05f), gameModeSettingsContent.RectTransform), isHorizontal: true) + { + Visible = false, + Stretch = true + }; + var outpostLabel = new GUITextBlock(new RectTransform(new Vector2(0.5f, 1.0f), outpostHolder.RectTransform), TextManager.Get("gamemodesettings.outpost"), wrap: true); + outpostDropdown = new GUIDropDown(new RectTransform(new Vector2(0.5f, 1.0f), outpostHolder.RectTransform), elementCount: 6, listBoxScale: 2.0f) + { + ToolTip = TextManager.Get("gamemodesettings.outposttooltip"), + AfterSelected = (component, obj) => + { + //don't register selecting the outpost until we've refreshed the available outposts, + //otherwise a client may request selecting "nothing" just because there's nothing in the list yet + if (outpostDropdownUpToDate && obj != null) + { + GameMain.Client?.ServerSettings.ClientAdminWrite(ServerSettings.NetFlags.Properties); + } + return true; + } + }; + outpostDropdown.ListBox.RectTransform.SetPosition(Anchor.BottomLeft, Pivot.TopLeft); + //do this before adding the contents, otherwise they get disabled too (and we just want to disable the dropdown itself) + clientDisabledElements.AddRange(outpostHolder.GetAllChildren()); + outpostDropdown.AddItem(TextManager.Get("random"), "Random".ToIdentifier()); + foreach (var submarineInfo in SubmarineInfo.SavedSubmarines.DistinctBy(s => s.Name)) + { + outpostDropdown.AddItem(submarineInfo.DisplayName, userData: submarineInfo.Name.ToIdentifier(), toolTip: submarineInfo.Description); + } + + AssignComponentToServerSetting(outpostDropdown, nameof(ServerSettings.SelectedOutpostName)); + outpostHolder.RectTransform.MinSize = new Point(0, outpostDropdown.RectTransform.MinSize.Y); + + campaignHiddenElements.Add(outpostHolder); + + // biome ----------------------------------------------------------------------------- + GUILayoutGroup biomeHolder = new GUILayoutGroup(new RectTransform(new Vector2(1.0f, 0.05f), gameModeSettingsContent.RectTransform), isHorizontal: true) + { + Stretch = true + }; + var biomeLabel = new GUITextBlock(new RectTransform(new Vector2(0.5f, 1.0f), biomeHolder.RectTransform), TextManager.Get("biome"), wrap: true); + var biomeDropdown = new GUIDropDown(new RectTransform(new Vector2(0.5f, 1.0f), biomeHolder.RectTransform), elementCount: 6, listBoxScale: 2.0f) + { + AfterSelected = (component, obj) => + { + if (obj != null) + { + GameMain.Client?.ServerSettings.ClientAdminWrite(ServerSettings.NetFlags.Properties); + } + return true; + } + }; + biomeDropdown.ListBox.RectTransform.SetPosition(Anchor.BottomLeft, Pivot.TopLeft); + //do this before adding the contents, otherwise they get disabled too (and we just want to disable the dropdown itself) + clientDisabledElements.AddRange(biomeHolder.GetAllChildren()); + biomeDropdown.AddItem(TextManager.Get("random"), "Random".ToIdentifier()); + foreach (var biome in Biome.Prefabs.OrderBy(b => b.MinDifficulty)) + { + if (biome.IsEndBiome) { continue; } + biomeDropdown.AddItem(biome.DisplayName, biome.Identifier); + } + AssignComponentToServerSetting(biomeDropdown, nameof(ServerSettings.Biome)); + biomeHolder.RectTransform.MinSize = new Point(0, biomeDropdown.RectTransform.MinSize.Y); + + campaignHiddenElements.Add(biomeHolder); //seed ------------------------------------------------------------------ - var seedLabel = new GUITextBlock(new RectTransform(new Vector2(1.0f, 0.1f), settingsContent.RectTransform), TextManager.Get("LevelSeed")) + var seedLabel = new GUITextBlock(new RectTransform(new Vector2(1.0f, 0.1f), gameModeSettingsContent.RectTransform), TextManager.Get("LevelSeed")) { CanBeFocused = false }; @@ -789,7 +968,7 @@ namespace Barotrauma //level difficulty ------------------------------------------------------------------ - var levelDifficultyHolder = CreateLabeledSlider(settingsContent, "LevelDifficulty", "", "LevelDifficultyExplanation", out levelDifficultySlider, out var difficultySliderLabel, + var levelDifficultyHolder = CreateLabeledSlider(gameModeSettingsContent, "LevelDifficulty", "", "LevelDifficultyExplanation", out levelDifficultySlider, out var difficultySliderLabel, step: 0.01f, range: new Vector2(0.0f, 100.0f)); levelDifficultySlider.OnReleased = (scrollbar, value) => { @@ -810,9 +989,9 @@ namespace Barotrauma clientDisabledElements.AddRange(levelDifficultyHolder.GetAllChildren()); //bot count ------------------------------------------------------------------ - CreateSubHeader("BotSettings", settingsContent); + CreateSubHeader("BotSettings", gameModeSettingsContent); - var botCountSettingHolder = new GUILayoutGroup(new RectTransform(new Vector2(1.0f, 0.1f), settingsContent.RectTransform), isHorizontal: true, childAnchor: Anchor.CenterLeft) { Stretch = true }; + var botCountSettingHolder = new GUILayoutGroup(new RectTransform(new Vector2(1.0f, 0.1f), gameModeSettingsContent.RectTransform), isHorizontal: true, childAnchor: Anchor.CenterLeft) { Stretch = true }; new GUITextBlock(new RectTransform(new Vector2(0.7f, 0.0f), botCountSettingHolder.RectTransform), TextManager.Get("BotCount"), wrap: true); var botCountSelection = new GUISelectionCarousel(new RectTransform(new Vector2(0.5f, 1.0f), botCountSettingHolder.RectTransform)); for (int i = 0; i <= NetConfig.MaxPlayers; i++) @@ -823,7 +1002,7 @@ namespace Barotrauma clientDisabledElements.AddRange(botCountSettingHolder.GetAllChildren()); botSettingsElements.Add(botCountSelection); - var botSpawnModeSettingHolder = new GUILayoutGroup(new RectTransform(new Vector2(1.0f, 0.1f), settingsContent.RectTransform), isHorizontal: true, childAnchor: Anchor.CenterLeft) { Stretch = true }; + var botSpawnModeSettingHolder = new GUILayoutGroup(new RectTransform(new Vector2(1.0f, 0.1f), gameModeSettingsContent.RectTransform), isHorizontal: true, childAnchor: Anchor.CenterLeft) { Stretch = true }; new GUITextBlock(new RectTransform(new Vector2(0.7f, 0.0f), botSpawnModeSettingHolder.RectTransform), TextManager.Get("BotSpawnMode"), wrap: true); var botSpawnModeSelection = new GUISelectionCarousel(new RectTransform(new Vector2(0.5f, 1.0f), botSpawnModeSettingHolder.RectTransform)); foreach (var botSpawnMode in Enum.GetValues(typeof(BotSpawnMode)).Cast()) @@ -843,14 +1022,14 @@ namespace Barotrauma //traitor probability ------------------------------------------------------------------ - CreateSubHeader("TraitorSettings", settingsContent); + CreateSubHeader("TraitorSettings", gameModeSettingsContent); //spacing - new GUIFrame(new RectTransform(new Point(1, GUI.IntScale(5)), settingsContent.RectTransform), style: null); + new GUIFrame(new RectTransform(new Point(1, GUI.IntScale(5)), gameModeSettingsContent.RectTransform), style: null); //the probability slider is a traitor element, but we don't add it to traitorElements //because we don't want to disable it when sliding it to 0 (need to be able to slide it back!) - var traitorProbabilityHolder = CreateLabeledSlider(settingsContent, "traitor.probability", "", "traitor.probability.tooltip", + var traitorProbabilityHolder = CreateLabeledSlider(gameModeSettingsContent, "traitor.probability", "", "traitor.probability.tooltip", out traitorProbabilitySlider, out var traitorProbabilityText, step: 0.01f, range: new Vector2(0.0f, 1.0f)); traitorProbabilitySlider.OnMoved = (scrollbar, value) => @@ -869,7 +1048,7 @@ namespace Barotrauma traitorElements.Clear(); clientDisabledElements.AddRange(traitorProbabilityHolder.GetAllChildren()); - var traitorDangerHolder = new GUILayoutGroup(new RectTransform(new Vector2(1.0f, 0.1f), settingsContent.RectTransform), isHorizontal: true, childAnchor: Anchor.CenterLeft) + var traitorDangerHolder = new GUILayoutGroup(new RectTransform(new Vector2(1.0f, 0.1f), gameModeSettingsContent.RectTransform), isHorizontal: true, childAnchor: Anchor.CenterLeft) { Stretch = true }; @@ -924,7 +1103,7 @@ namespace Barotrauma traitorElements.AddRange(traitorDangerGroup.Children); traitorElements.AddRange(traitorDangerButtons); - var traitorsMinPlayerCountHolder = new GUILayoutGroup(new RectTransform(new Vector2(1.0f, 0.1f), settingsContent.RectTransform), isHorizontal: true, childAnchor: Anchor.CenterLeft) { Stretch = true }; + var traitorsMinPlayerCountHolder = new GUILayoutGroup(new RectTransform(new Vector2(1.0f, 0.1f), gameModeSettingsContent.RectTransform), isHorizontal: true, childAnchor: Anchor.CenterLeft) { Stretch = true }; new GUITextBlock(new RectTransform(new Vector2(0.7f, 0.0f), traitorsMinPlayerCountHolder.RectTransform), TextManager.Get("ServerSettingsTraitorsMinPlayerCount"), wrap: true) { ToolTip = TextManager.Get("ServerSettingsTraitorsMinPlayerCountToolTip") @@ -946,7 +1125,36 @@ namespace Barotrauma } } - return settingsContent; + return gameModeSettingsContent; + } + + private GUIButton upgradesTabButton, + respawnTabButton; + + private void SelectRespawnTab() + => SelectTabShared(buttonToEnable: respawnTabButton, + buttonToDisable: upgradesTabButton, + elementsToEnable: respawnSettings, + elementsToDisable: disembarkPerkSettings); + + private void SelectUpgradesTab() + => SelectTabShared(buttonToEnable: upgradesTabButton, + buttonToDisable: respawnTabButton, + elementsToEnable: disembarkPerkSettings, + elementsToDisable: respawnSettings); + + private void SelectTabShared(GUIButton buttonToEnable, + GUIButton buttonToDisable, + ICollection elementsToEnable, + ICollection elementsToDisable) + + { + if (buttonToEnable is null || buttonToDisable is null) { return; } + + buttonToDisable.Selected = false; + buttonToEnable.Selected = true; + foreach (var element in elementsToDisable) { element.Visible = element.Enabled = false; } + foreach (var element in elementsToEnable) { element.Visible = element.Enabled = true; } } private GUIComponent CreateGeneralSettingsPanel(GUIComponent parent) @@ -955,20 +1163,47 @@ namespace Barotrauma // settings panel //------------------------------------------------------------------ - GUILayoutGroup settingsLayout = new GUILayoutGroup(new RectTransform(Vector2.One, parent.RectTransform)) + GUILayoutGroup mainContainer = new GUILayoutGroup(new RectTransform(Vector2.One, parent.RectTransform)); + + GUILayoutGroup tabContainer = new GUILayoutGroup(new RectTransform(new Vector2(1f, 0.066f), mainContainer.RectTransform), isHorizontal: true) { + RelativeSpacing = 0.02f, Stretch = true }; - var respawnSettingsHeader = CreateSubHeader("RespawnSettings", settingsLayout); - var settingsContent = new GUIListBox(new RectTransform(Vector2.One, settingsLayout.RectTransform)).Content; + respawnTabButton = new GUIButton(new RectTransform(new Vector2(0.5f, 1.0f), tabContainer.RectTransform), TextManager.Get("respawnsettings"), style: "GUITabButton") { Selected = true }; + upgradesTabButton = new GUIButton(new RectTransform(new Vector2(0.5f, 1.0f), tabContainer.RectTransform), TextManager.Get("disembarkpointsettings"), style: "GUITabButton"); + + respawnTabButton.OnClicked = (button, _) => + { + SelectRespawnTab(); + return true; + }; + + upgradesTabButton.OnClicked = (button, _) => + { + SelectUpgradesTab(); + return true; + }; + + + GUIFrame mainFrame = new GUIFrame(new RectTransform(new Vector2(1f, 1.0f - tabContainer.RectTransform.RelativeSize.Y), mainContainer.RectTransform), style: null); + + GUILayoutGroup settingsLayout = new GUILayoutGroup(new RectTransform(Vector2.One, mainFrame.RectTransform)); + + var settingsList = new GUIListBox(new RectTransform(Vector2.One, settingsLayout.RectTransform)); + respawnSettings.Add(settingsLayout); + + CreateDisembarkPointPanel(mainFrame); + + var settingsContent = settingsList.Content; // ------------------------------------------------------------------ var respawnModeHolder = new GUILayoutGroup(new RectTransform(new Vector2(1.0f, 0.1f), settingsContent.RectTransform), isHorizontal: true, childAnchor: Anchor.CenterLeft) { Stretch = true }; respawnModeLabel = new GUITextBlock(new RectTransform(new Vector2(0.4f, 0.0f), respawnModeHolder.RectTransform), TextManager.Get("RespawnMode"), wrap: true); respawnModeSelection = new GUISelectionCarousel(new RectTransform(new Vector2(0.6f, 1.0f), respawnModeHolder.RectTransform)); - foreach (var respawnMode in Enum.GetValues(typeof(RespawnMode)).Cast()) + foreach (var respawnMode in Enum.GetValues(typeof(RespawnMode)).Cast().Where(rm => rm != RespawnMode.None)) { respawnModeSelection.AddElement(respawnMode, TextManager.Get($"respawnmode.{respawnMode}"), TextManager.Get($"respawnmode.{respawnMode}.tooltip")); } @@ -985,7 +1220,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); @@ -1010,7 +1245,10 @@ namespace Barotrauma { OnSelected = (component, obj) => { - GameMain.Client?.RequestSelectSub(obj as SubmarineInfo, isShuttle: true); + SubmarineInfo subInfo = (SubmarineInfo)obj; + ShuttleList.Text = subInfo.DisplayName; + ShuttleList.ToolTip = subInfo.Description; + SelectShuttle(subInfo); return true; } }; @@ -1186,6 +1424,342 @@ namespace Barotrauma return settingsContent; } + private GUIListBox disembarkPerkSettingList; + private GUIComponent disembarkPerkDisabledDisclaimer; + private GUIComponent noPerksAvailableDisclaimer; + private GUITextBlock disembarkPerkFooterText; + + /// + /// Used to prevent disembarkPerkSettingList.AfterSelected from firing when the server settings are updated. + /// + private bool isUpdatingPerks; + + public void CreateDisembarkPointPanel(GUIComponent parent) + { + GUILayoutGroup settingsLayout = new GUILayoutGroup(new RectTransform(Vector2.One, parent.RectTransform)) + { + Stretch = true, + Visible = false, + }; + + var settingsList = new GUIListBox(new RectTransform(Vector2.One, settingsLayout.RectTransform)) + { + SelectMultiple = true, + DisabledColor = Color.White * 0.1f + }; + + disembarkPerkSettingList = settingsList; + + noPerksAvailableDisclaimer = new GUIFrame(new RectTransform(Vector2.One, settingsLayout.RectTransform), style: "GUIBackgroundBlocker") + { + Visible = false, + IgnoreLayoutGroups = true + }; + + new GUITextBlock(new RectTransform(Vector2.One, noPerksAvailableDisclaimer.RectTransform), TextManager.Get("noperksavailable"), textAlignment: Alignment.Center, font: GUIStyle.SubHeadingFont, wrap: true) + { + TextColor = GUIStyle.Red, + Shadow = true, + }; + + disembarkPerkDisabledDisclaimer = new GUIFrame(new RectTransform(Vector2.One, settingsLayout.RectTransform), style: "GUIBackgroundBlocker") + { + IgnoreLayoutGroups = true, + }; + var disclaimerLayout = new GUILayoutGroup(new RectTransform(Vector2.One, disembarkPerkDisabledDisclaimer.RectTransform)); + + new GUITextBlock(new RectTransform(new Vector2(1f, 0.3f), disclaimerLayout.RectTransform), TextManager.Get("disembarkpointselectteam"), textAlignment: Alignment.BottomCenter, font: GUIStyle.LargeFont) + { + TextColor = GUIStyle.Red + }; + + var teamSelectLayout = new GUILayoutGroup(new RectTransform(new Vector2(1f, 0.7f), disclaimerLayout.RectTransform), isHorizontal: true); + CreateTeamDisclaimerButtons(teamSelectLayout); + + disembarkPerkFooterText = new GUITextBlock(new RectTransform(new Vector2(1.0f, 0.055f), settingsLayout.RectTransform) { MinSize = new Point(0, GUI.IntScale(28)) }, + string.Empty, font: GUIStyle.SubHeadingFont, textAlignment: Alignment.CenterRight, textColor: GUIStyle.TextColorBright, color: Color.Black * 0.8f, style: null) + { + Padding = new Vector4(10, 0, 10, 0) * GUI.Scale + }; + UpdatePerkFooterText(settingsList); + + settingsList.AfterSelected = (component, o) => + { + if (GameMain.Client?.ServerSettings is not { } settings) { return false; } + + UpdatePerkFooterText(settingsList); + + if (isUpdatingPerks) { return false; } + + bool canChangePerks = ServerSettings.HasPermissionToChangePerks(); + + if (!canChangePerks) { return false; } + + switch (MultiplayerPreferences.Instance.TeamPreference) + { + case CharacterTeamType.Team2: + { + settings.SelectedSeparatistsPerks = PerksFromSelectedElements(); + break; + } + default: + { + settings.SelectedCoalitionPerks = PerksFromSelectedElements(); + break; + } + + Identifier[] PerksFromSelectedElements() + { + var list = settingsList.AllSelected.Select(static c => ((DisembarkPerkPrefab)c.UserData)).ToList(); + + bool potentiallyHasOrphanedPerks = true; + + do + { + potentiallyHasOrphanedPerks = false; + if (list.None()) { break; } + + list.ForEachMod(perk => + { + if (perk.Prerequisite.IsEmpty) { return; } + + if (list.All(p => p.Identifier != perk.Prerequisite)) + { + list.Remove(perk); + potentiallyHasOrphanedPerks = true; + } + }); + } while (potentiallyHasOrphanedPerks); + + return list.Select(static p => p.Identifier).ToArray(); + } + } + + settings.ClientAdminWritePerks(); + + return true; + }; + + disembarkPerkSettings.Add(settingsLayout); + + Identifier disembarkPerkCategory = Identifier.Empty; + + foreach (var disembarkPerkPrefab in DisembarkPerkPrefab.Prefabs + .OrderBy(static p => p.SortCategory) + .ThenBy(static p => p.SortKey) + .ThenBy(static p => p.Cost)) + { + if (disembarkPerkCategory != disembarkPerkPrefab.SortCategory) + { + disembarkPerkCategory = disembarkPerkPrefab.SortCategory; + + if (!disembarkPerkCategory.IsEmpty) + { + GUIFrame categoryFrame = new GUIFrame(new RectTransform(new Vector2(1.0f, 0.15f), settingsList.Content.RectTransform), style: null) + { + CanBeFocused = false + }; + + new GUITextBlock(new RectTransform(Vector2.One, categoryFrame.RectTransform), TextManager.Get($"perkcategory.{disembarkPerkPrefab.SortCategory}"), font: GUIStyle.SubHeadingFont, textAlignment: Alignment.Center); + } + } + + GUIFrame frame = new GUIFrame(new RectTransform(new Vector2(1.0f, 0.1f), settingsList.Content.RectTransform), style: "ListBoxElement") + { + UserData = disembarkPerkPrefab, + ToolTip = disembarkPerkPrefab.Description + }; + GUILayoutGroup prefabLayout = new GUILayoutGroup(new RectTransform(Vector2.One, frame.RectTransform), isHorizontal: true) + { + Stretch = true + }; + + var perkLabel = new GUITextBlock(new RectTransform(new Vector2(0.8f, 1.0f), prefabLayout.RectTransform), disembarkPerkPrefab.Name, textAlignment: Alignment.CenterLeft) + { + DisabledTextColor = Color.White * 0.1f, + DisabledColor = Color.White * 0.1f, + CanBeFocused = false, + }; + + perkLabel.Text = ToolBox.LimitString(perkLabel.Text, perkLabel.Font, perkLabel.Rect.Width); + + var costLabel = new GUITextBlock(new RectTransform(new Vector2(0.2f, 1.0f), prefabLayout.RectTransform), disembarkPerkPrefab.Cost.ToString(), textAlignment: Alignment.Right) + { + DisabledTextColor = Color.White * 0.1f, + DisabledColor = Color.White * 0.1f, + CanBeFocused = false, + }; + } + + GameMain.Client?.OnPermissionChanged?.RegisterOverwriteExisting(nameof(CreateDisembarkPointPanel).ToIdentifier(), _ => + { + UpdateDisembarkPointListFromServerSettings(); + }); + + void CreateTeamDisclaimerButtons(GUILayoutGroup buttonParent) + { + var team1Button = new GUIButton(new RectTransform(new Vector2(0.5f, 0.5f), buttonParent.RectTransform), style: "CoalitionButton") + { + OnClicked = (button, obj) => + { + TeamPreferenceListBox?.Select(CharacterTeamType.Team1); + return true; + } + }; + + var team2Button = new GUIButton(new RectTransform(new Vector2(0.5f, 0.5f), buttonParent.RectTransform), style: "SeparatistButton") + { + OnClicked = (button, obj) => + { + TeamPreferenceListBox?.Select(CharacterTeamType.Team2); + return true; + } + }; + } + } + + private void UpdatePerkFooterText(GUIListBox box) + { + int pointsLeft = GameMain.NetworkMember?.ServerSettings?.DisembarkPointAllowance ?? -1; + bool ignorePerksThatCantApplyWithoutSub = GameSession.ShouldIgnorePerksThatCanNotApplyWithoutSubmarine(SelectedMode, MissionTypes); + + foreach (GUIComponent child in box.Content.Children) + { + if (box.AllSelected.Contains(child) && child.UserData is DisembarkPerkPrefab perkPrefab) + { + if (ignorePerksThatCantApplyWithoutSub && perkPrefab.PerkBehaviors.Any(static b => !b.CanApplyWithoutSubmarine())) + { + continue; + } + pointsLeft -= perkPrefab.Cost; + } + } + + disembarkPerkFooterText.Text = TextManager.GetWithVariable("disembarkpointleft", "[amount]", pointsLeft.ToString()); + + disembarkPerkFooterText.TextColor = + pointsLeft < 0 + ? GUIStyle.Red + : GUIStyle.TextColorBright; + } + + public void UpdateDisembarkPointListFromServerSettings() + { + if (disembarkPerkSettingList is null || disembarkPerkDisabledDisclaimer is null || disembarkPerkFooterText is null) { return; } + + CharacterTeamType teamPreference = MultiplayerPreferences.Instance.TeamPreference; + + bool hasTeamPreference = teamPreference is (CharacterTeamType.Team1 or CharacterTeamType.Team2); + + if (SelectedMode != GameModePreset.PvP) + { + teamPreference = CharacterTeamType.Team1; + hasTeamPreference = true; + } + + disembarkPerkDisabledDisclaimer.Visible = !hasTeamPreference; + disembarkPerkFooterText.Visible = hasTeamPreference; + + SetEnabled(hasTeamPreference); + + bool canManagePerks = ServerSettings.HasPermissionToChangePerks(); + + if (!canManagePerks) + { + SetEnabled(false); + } + + isUpdatingPerks = true; + + bool hasAvailablePerks = false; + if (GameMain.Client?.ServerSettings is { } settings) + { + Identifier[] selectedPerks = teamPreference switch + { + CharacterTeamType.Team1 => settings.SelectedCoalitionPerks, + CharacterTeamType.Team2 => settings.SelectedSeparatistsPerks, + _ => Array.Empty() + }; + + bool ignorePerksThatCantApplyWithoutSub = GameSession.ShouldIgnorePerksThatCanNotApplyWithoutSubmarine(SelectedMode, MissionTypes); + disembarkPerkSettingList.Deselect(); + foreach (GUIComponent child in disembarkPerkSettingList.Content.Children) + { + if (child.UserData is not DisembarkPerkPrefab perkPrefab) { continue; } + bool shouldSelect = selectedPerks.Contains(perkPrefab.Identifier); + + bool hasPrerequisite = !perkPrefab.Prerequisite.IsEmpty; + bool isMutuallyExclusivePerkSelected = selectedPerks.Any(p => perkPrefab.MutuallyExclusivePerks.Contains(p)); + TogglePerkElement(enabled: true); + + if (shouldSelect) + { + disembarkPerkSettingList.Select(child.UserData, force: GUIListBox.Force.Yes, GUIListBox.AutoScroll.Disabled); + } + + if (hasPrerequisite) + { + bool enabled = selectedPerks.Contains(perkPrefab.Prerequisite); + TogglePerkElement(enabled); + } + + if (isMutuallyExclusivePerkSelected) + { + TogglePerkElement(enabled: false); + } + + if (ignorePerksThatCantApplyWithoutSub) + { + if (perkPrefab.PerkBehaviors.Any(static b => !b.CanApplyWithoutSubmarine())) + { + TogglePerkElement(enabled: false); + } + } + + if (child.Enabled) + { + hasAvailablePerks = true; + } + + void TogglePerkElement(bool enabled) + { + child.Enabled = enabled; + foreach (GUITextBlock text in child.GetAllChildren()) + { + text.Enabled = enabled; + } + } + } + } + + noPerksAvailableDisclaimer.Visible = !hasAvailablePerks; + if (!hasAvailablePerks) + { + disembarkPerkDisabledDisclaimer.Visible = false; + } + + UpdatePerkFooterText(disembarkPerkSettingList); + isUpdatingPerks = false; + + void SetEnabled(bool enabled) + { + disembarkPerkSettingList.Enabled = enabled; + foreach (GUIComponent child in disembarkPerkSettingList.Content.Children) + { + //child.Enabled = enabled; + foreach (GUITextBlock block in child.GetAllChildren()) + { + block.Enabled = enabled; + } + } + } + } + + public static void SelectShuttle(SubmarineInfo info) + { + GameMain.Client?.RequestSelectSub(info, SelectedSubType.Shuttle); + } + public static GUITextBlock CreateSubHeader(string textTag, GUIComponent parent, string toolTipTag = null) { var header = new GUITextBlock(new RectTransform(new Vector2(1.0f, 0.055f), parent.RectTransform) { MinSize = new Point(0, GUI.IntScale(28)) }, @@ -1200,16 +1774,24 @@ namespace Barotrauma return header; } - public static GUIComponent CreateLabeledSlider(GUIComponent parent, string headerTag, string valueLabelTag, string tooltipTag, out GUIScrollBar slider, out GUITextBlock label, float? step = null, Vector2? range = null) + public static GUIComponent CreateLabeledSlider(GUIComponent parent, string headerTag, string valueLabelTag, string tooltipTag, + out GUIScrollBar slider, out GUITextBlock label, float? step = null, Vector2? range = null) + { + return CreateLabeledSlider(parent, headerTag, valueLabelTag, tooltipTag, out slider, out label, out GUITextBlock _, step, range); + } + + public static GUIComponent CreateLabeledSlider(GUIComponent parent, string headerTag, string valueLabelTag, string tooltipTag, + out GUIScrollBar slider, out GUITextBlock label, out GUITextBlock header, float? step = null, Vector2? range = null) { GUILayoutGroup verticalLayout = null; + header = null; if (!headerTag.IsNullOrEmpty()) { verticalLayout = new GUILayoutGroup(new RectTransform(new Vector2(1.0f, 0.0f), parent.RectTransform), isHorizontal: false) { Stretch = true }; - var header = new GUITextBlock(new RectTransform(new Vector2(1.0f, 0.5f), verticalLayout.RectTransform), + header = new GUITextBlock(new RectTransform(new Vector2(1.0f, 0.5f), verticalLayout.RectTransform), TextManager.Get(headerTag), textAlignment: Alignment.CenterLeft) { CanBeFocused = false @@ -1313,13 +1895,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)) { @@ -1418,21 +2012,12 @@ namespace Barotrauma }; // Chat input - - var chatRow = new GUILayoutGroup(new RectTransform(new Vector2(1.0f, 0.07f), socialHolder.RectTransform), + chatRow = new GUILayoutGroup(new RectTransform(new Vector2(1.0f, 0.07f), socialHolder.RectTransform), isHorizontal: true, childAnchor: Anchor.CenterLeft) { Stretch = true }; - - chatInput = new GUITextBox(new RectTransform(new Vector2(0.95f, 1.0f), chatRow.RectTransform)) - { - MaxTextLength = ChatMessage.MaxLength, - Font = GUIStyle.SmallFont, - DeselectAfterMessage = false - }; - - micIcon = new GUIImage(new RectTransform(new Vector2(0.05f, 1.0f), chatRow.RectTransform), style: "GUIMicrophoneUnavailable"); + RefreshChatrow(); serverLogHolder = new GUILayoutGroup(new RectTransform(Vector2.One, logHolderBottom.RectTransform, Anchor.Center)) { @@ -1559,6 +2144,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")) @@ -1566,6 +2170,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); @@ -1580,6 +2192,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)); @@ -1610,11 +2223,16 @@ namespace Barotrauma GUI.ClearCursorWait(); } + public const string PleaseWaitPopupUserData = "PleaseWaitPopup"; + public static IEnumerable WaitForStartRound(GUIButton startButton) { GUI.SetCursorWaiting(); LocalizedString headerText = TextManager.Get("RoundStartingPleaseWait"); - var msgBox = new GUIMessageBox(headerText, TextManager.Get("RoundStarting"), Array.Empty()); + var msgBox = new GUIMessageBox(headerText, TextManager.Get("RoundStarting"), Array.Empty()) + { + UserData = PleaseWaitPopupUserData + }; if (startButton != null) { @@ -1639,6 +2257,7 @@ namespace Barotrauma public override void Deselect() { + GameMain.Client?.OnPermissionChanged.TryDeregister(nameof(CreateDisembarkPointPanel).ToIdentifier()); SaveAppearance(); chatInput.Deselect(); CampaignCharacterDiscarded = false; @@ -1663,14 +2282,8 @@ namespace Barotrauma changesPendingText?.Parent?.RemoveChild(changesPendingText); changesPendingText = null; - - chatInput.Select(); - chatInput.OnEnterPressed = GameMain.Client.EnterChatMessage; - chatInput.OnTextChanged += GameMain.Client.TypingChatMessage; - chatInput.OnDeselected += (sender, key) => - { - GameMain.Client?.ChatBox.ChatManager.Clear(); - }; + + RefreshChatrow(); //disable/hide elements the clients are not supposed to use/see clientDisabledElements.ForEach(c => c.Enabled = false); @@ -1678,9 +2291,11 @@ namespace Barotrauma RefreshEnabledElements(); + createPendingChangesText = false; + TabMenu.PendingChanges = false; + if (GameMain.Client != null) { - ChatManager.RegisterKeys(chatInput, GameMain.Client.ChatBox.ChatManager); joinOnGoingRoundButton.Visible = GameMain.Client.GameStarted; ReadyToStartBox.Selected = false; GameMain.Client.SetReadyToStart(ReadyToStartBox); @@ -1693,8 +2308,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; } @@ -1728,7 +2353,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); @@ -1801,6 +2426,7 @@ namespace Barotrauma if (campaignCharacterInfo != newCampaignCharacterInfo) { campaignCharacterInfo = newCampaignCharacterInfo; + SaveAppearance(); UpdatePlayerFrame(campaignCharacterInfo, false); } } @@ -1826,7 +2452,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; } @@ -1837,7 +2463,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; } @@ -1999,7 +2625,7 @@ namespace Barotrauma TextManager.Get("deceased"), textAlignment: Alignment.Center, font: GUIStyle.LargeFont); - if (GameMain.Client?.ServerSettings is { IronmanMode: true }) + if (GameMain.Client?.ServerSettings is { IronmanModeActive: true }) { new GUITextBlock( new RectTransform(new Vector2(1.0f, 0.0f), parent.RectTransform), @@ -2073,7 +2699,7 @@ namespace Barotrauma TeamPreferenceListBox.UpdateDimensions(); Color team1Color = new Color(0, 110, 150, 255); - var team1Option = new GUITextBlock(new RectTransform(new Vector2(0.3f, 1.0f), TeamPreferenceListBox.Content.RectTransform), TextManager.Get("teampreference.team1"), textAlignment: Alignment.Center, style: null) + pvpTeamChoiceTeam1 = new GUITextBlock(new RectTransform(new Vector2(0.3f, 1.0f), TeamPreferenceListBox.Content.RectTransform), TextManager.Get("teampreference.team1"), textAlignment: Alignment.Center, style: null) { UserData = CharacterTeamType.Team1, CanBeFocused = true, @@ -2084,11 +2710,13 @@ namespace Barotrauma OutlineColor = team1Color, TextColor = Color.White, HoverTextColor = Color.White, - SelectedTextColor = Color.White + SelectedTextColor = Color.White, + DisabledColor = team1Color * 0.25f, + DisabledTextColor = Color.Gray, }; Color noPreferenceColor = new Color(100, 100, 100, 255); - var noPreferenceOption = new GUITextBlock(new RectTransform(new Vector2(0.4f, 1.0f), TeamPreferenceListBox.Content.RectTransform), TextManager.Get("teampreference.nopreference"), textAlignment: Alignment.Center, style: null) + pvpTeamChoiceMiddleButton = new GUITextBlock(new RectTransform(new Vector2(0.4f, 1.0f), TeamPreferenceListBox.Content.RectTransform), "", textAlignment: Alignment.Center, style: null) { UserData = CharacterTeamType.None, CanBeFocused = true, @@ -2099,11 +2727,11 @@ namespace Barotrauma OutlineColor = noPreferenceColor, TextColor = Color.White, HoverTextColor = Color.White, - SelectedTextColor = Color.White + SelectedTextColor = Color.White, }; Color team2Color = new Color(150, 110, 0, 255); - var team2Option = new GUITextBlock(new RectTransform(new Vector2(0.3f, 1.0f), TeamPreferenceListBox.Content.RectTransform), TextManager.Get("teampreference.team2"), textAlignment: Alignment.Center, style: null) + pvpTeamChoiceTeam2 = new GUITextBlock(new RectTransform(new Vector2(0.3f, 1.0f), TeamPreferenceListBox.Content.RectTransform), TextManager.Get("teampreference.team2"), textAlignment: Alignment.Center, style: null) { UserData = CharacterTeamType.Team2, CanBeFocused = true, @@ -2114,24 +2742,84 @@ namespace Barotrauma OutlineColor = team2Color, TextColor = Color.White, HoverTextColor = Color.White, - SelectedTextColor = Color.White + SelectedTextColor = Color.White, + DisabledColor = team2Color * 0.25f, + DisabledTextColor = Color.Gray, }; - TeamPreferenceListBox.Select(MultiplayerPreferences.Instance.TeamPreference); + var prevTeamSelection = MultiplayerPreferences.Instance.TeamPreference; + ResetPvpTeamSelection(); + + // Handle special case: middle button in Player Choice mode should pick a random team, if possible TeamPreferenceListBox.OnSelected += (component, obj) => { - if ((CharacterTeamType)obj == MultiplayerPreferences.Instance.TeamPreference) { return true; } + CharacterTeamType newTeamPreference = (CharacterTeamType)obj; + if (newTeamPreference == CharacterTeamType.None + && GameMain.Client?.ServerSettings?.PvpTeamSelectionMode == PvpTeamSelectionMode.PlayerChoice) + { + TeamPreferenceListBox.Select(Rand.Value() < 0.5 ? CharacterTeamType.Team1 : CharacterTeamType.Team2); + var teamColor = (CharacterTeamType)TeamPreferenceListBox.SelectedData == CharacterTeamType.Team1 ? team1Color : team2Color; + TeamPreferenceListBox.SelectedComponent.Flash(teamColor, useRectangleFlash: true, flashDuration: 1.0f); + return true; + } + return false; // Allow the next delegate to handle other cases + }; + + // Handle everything else + TeamPreferenceListBox.OnSelected += (component, obj) => + { + CharacterTeamType newTeamPreference = (CharacterTeamType)obj; + + if (newTeamPreference == CharacterTeamType.None + && GameMain.Client?.ServerSettings?.PvpTeamSelectionMode == PvpTeamSelectionMode.PlayerChoice) { return false; } // Already handled by delegate above - MultiplayerPreferences.Instance.TeamPreference = (CharacterTeamType)obj; - GameMain.Client.ForceNameAndJobUpdate(); - GameSettings.SaveCurrentConfig(); + var oldPreference = MultiplayerPreferences.Instance.TeamPreference; + + MultiplayerPreferences.Instance.TeamPreference = newTeamPreference; + + UpdateSelectedSub(newTeamPreference); + if (newTeamPreference != oldPreference) + { + GameMain.Client?.ForceNameJobTeamUpdate(); + GameSettings.SaveCurrentConfig(); + } + RefreshPvpTeamSelectionButtons(); + UpdateDisembarkPointListFromServerSettings(); + //need to update job preferences and close the selection frame + //because the team selection might affect the uniform sprite and the loadouts + UpdateJobPreferences(GameMain.Client?.CharacterInfo ?? Character.Controlled?.Info); + JobSelectionFrame = null; + RefreshChatrow(); // to enable/disable team chat according to current selection return true; }; + + if (prevTeamSelection != CharacterTeamType.None) + { + TeamPreferenceListBox.Select(prevTeamSelection); + } } } + public void UpdateSelectedSub(CharacterTeamType preference) + { + bool votingEnabled = GameMain.NetworkMember.ServerSettings.SubSelectionMode == SelectionMode.Vote; + SubList.OnSelected -= VotableClicked; + switch (preference) + { + case CharacterTeamType.Team1 or CharacterTeamType.None when SelectedSub is { } selectedSub: + TrySelectSub(selectedSub.Name, selectedSub.MD5Hash.StringRepresentation, SelectedSubType.Sub, SubList, showPreview: false); + if (!votingEnabled) { SubList.Select(selectedSub, autoScroll: GUIListBox.AutoScroll.Disabled); } + break; + case CharacterTeamType.Team2 when SelectedEnemySub is { } selectedEnemySub: + TrySelectSub(selectedEnemySub.Name, selectedEnemySub.MD5Hash.StringRepresentation, SelectedSubType.EnemySub, SubList, showPreview: false); + if (!votingEnabled) { SubList.Select(selectedEnemySub, autoScroll: GUIListBox.AutoScroll.Disabled); } + break; + } + SubList.OnSelected += VotableClicked; + } + public void TryDiscardCampaignCharacter(Action onYes) { var confirmation = new GUIMessageBox(TextManager.Get("NewCampaignCharacterHeader"), TextManager.Get("NewCampaignCharacterText"), @@ -2151,6 +2839,9 @@ namespace Barotrauma { if (!createPendingChangesText || changesPendingText != null || playerInfoContent == null) { return; } + //remove the previous one + changesPendingText?.Parent?.RemoveChild(changesPendingText); + changesPendingText = new GUIFrame(new RectTransform(new Vector2(1.0f, 0.065f), playerInfoContent.RectTransform, Anchor.BottomCenter, Pivot.TopCenter) { RelativeOffset = new Vector2(0f, -0.03f) }, style: "OuterGlow") { @@ -2177,7 +2868,7 @@ namespace Barotrauma }; } - private void CreateJobVariantTooltip(JobPrefab jobPrefab, int variant, GUIComponent parentSlot) + private void CreateJobVariantTooltip(JobPrefab jobPrefab, CharacterTeamType team, int variant, bool isPvPMode, GUIComponent parentSlot) { jobVariantTooltip = new GUIFrame(new RectTransform(new Point((int)(400 * GUI.Scale), (int)(180 * GUI.Scale)), GUI.Canvas, pivot: Pivot.BottomRight), style: "GUIToolTip") @@ -2194,16 +2885,15 @@ 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.PreviewItems[variant] - .Where(it => it.ShowPreview) - .Select(it => it.ItemIdentifier) + var itemIdentifiers = jobPrefab.GetJobItems(variant, it => it.ShowPreview) + .Select(it => it.GetItemIdentifier(team, isPvPMode)) .Distinct(); int itemsPerRow = 5; int rows = (int)Math.Max(Math.Ceiling(itemIdentifiers.Count() / (float)itemsPerRow), 1); new GUICustomComponent(new RectTransform(new Vector2(1.0f, 0.4f * rows), content.RectTransform, Anchor.BottomCenter), - onDraw: (sb, component) => { DrawJobVariantItems(sb, component, new JobVariant(jobPrefab, variant), itemsPerRow); }); + onDraw: (sb, component) => { DrawJobVariantItems(sb, component, new JobVariant(jobPrefab, variant), team, isPvPMode, itemsPerRow); }); jobVariantTooltip.RectTransform.MinSize = new Point(0, content.RectTransform.Children.Sum(c => c.Rect.Height + content.AbsoluteSpacing)); } @@ -2245,12 +2935,72 @@ namespace Barotrauma new GUITextBlock(new RectTransform(Vector2.One, playerInfoContent.RectTransform, Anchor.Center), TextManager.Get("PlayingAsSpectator"), textAlignment: Alignment.Center); + + if (SelectedMode == GameModePreset.PvP) + { + // In PvP mode, becoming a spectator should reset any existing team selection + ResetPvpTeamSelection(); + } } else { UpdatePlayerFrame(campaignCharacterInfo, allowEditing: campaignCharacterInfo == null); } } + + public void RefreshPvpTeamSelectionButtons() + { + if (pvpTeamChoiceMiddleButton == null || pvpTeamChoiceTeam1 == null || pvpTeamChoiceTeam2 == null) + { + return; + } + + ServerSettings serverSettings = GameMain.Client.ServerSettings; + + CharacterTeamType currentTeam = MultiplayerPreferences.Instance.TeamPreference; + bool pvpPlayerChoiceMode = serverSettings.PvpTeamSelectionMode == PvpTeamSelectionMode.PlayerChoice; + + pvpTeamChoiceMiddleButton.Text = TextManager.Get(pvpPlayerChoiceMode ? "PvP.PickRandom" : "teampreference.nopreference"); + if (pvpPlayerChoiceMode && serverSettings.PvpAutoBalanceThreshold > 0) + { + pvpTeamChoiceTeam1.Enabled = currentTeam == CharacterTeamType.Team1 || CanJoinTeam1(); + pvpTeamChoiceTeam2.Enabled = currentTeam == CharacterTeamType.Team2 || CanJoinTeam2(); + pvpTeamChoiceTeam1.ToolTip = !pvpTeamChoiceTeam1.Enabled ? TextManager.Get("PvP.TeamDisabledBecauseBalance") : null; + pvpTeamChoiceTeam2.ToolTip = !pvpTeamChoiceTeam2.Enabled ? TextManager.Get("PvP.TeamDisabledBecauseBalance") : null; + pvpTeamChoiceMiddleButton.Enabled = CanJoinTeam1() && CanJoinTeam2(); + } + else + { + pvpTeamChoiceTeam1.Enabled = true; + pvpTeamChoiceTeam2.Enabled = true; + pvpTeamChoiceTeam1.ToolTip = null; + pvpTeamChoiceTeam2.ToolTip = null; + pvpTeamChoiceMiddleButton.Enabled = true; + } + + bool CanJoinTeam1() + { + int newTeam1Count = Team1Count + (currentTeam == CharacterTeamType.Team1 ? 0 : 1); + int newTeam2Count = Team2Count - (currentTeam == CharacterTeamType.Team2 ? 1 : 0); + return newTeam1Count - newTeam2Count <= serverSettings.PvpAutoBalanceThreshold; + } + + bool CanJoinTeam2() + { + int newTeam2Count = Team2Count + (currentTeam == CharacterTeamType.Team2 ? 0 : 1); + int newTeam1Count = Team1Count - (currentTeam == CharacterTeamType.Team1 ? 1 : 0); + return newTeam2Count - newTeam1Count <= serverSettings.PvpAutoBalanceThreshold; + } + } + + public void ResetPvpTeamSelection() + { + TeamPreferenceListBox?.Deselect(); + MultiplayerPreferences.Instance.TeamPreference = CharacterTeamType.None; + RefreshPvpTeamSelectionButtons(); + RefreshChatrow(); + GameMain.Client.ForceNameJobTeamUpdate(); + } public void SetAllowSpectating(bool allowSpectating) { @@ -2268,15 +3018,76 @@ 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; autoRestartTimer = timer; } - public void SetMissionType(MissionType missionType) + public void SetMissionTypes(IEnumerable missionTypes) { - MissionType = missionType; + MissionTypes = missionTypes; + } + + private void RefreshOutpostDropdown() + { + outpostDropdown.Parent.Visible = MissionTypeFrame.Visible; + if (!outpostDropdown.Parent.Visible) { return; } + + outpostDropdownUpToDate = false; + + Identifier prevSelected = GameMain.NetworkMember?.ServerSettings.SelectedOutpostName ?? Identifier.Empty; + + outpostDropdown.ClearChildren(); + outpostDropdown.AddItem(TextManager.Get("Random"), "Random".ToIdentifier()); + HashSet validOutpostTagsForMissions = new HashSet(); + + IEnumerable suitableMissionClasses = + SelectedMode == GameModePreset.PvP ? + MissionPrefab.PvPMissionClasses.Values : + MissionPrefab.CoOpMissionClasses.Values; + foreach (var missionType in MissionTypes) + { + foreach (var missionPrefab in MissionPrefab.Prefabs) + { + if (!suitableMissionClasses.Contains(missionPrefab.MissionClass)) { continue; } + if (missionPrefab.Type != missionType || missionPrefab.SingleplayerOnly) { continue; } + if (!missionPrefab.AllowOutpostSelectionFromTag.IsEmpty) + { + validOutpostTagsForMissions.Add(missionPrefab.AllowOutpostSelectionFromTag); + } + } + } + if (validOutpostTagsForMissions.Any()) + { + foreach (var submarineInfo in SubmarineInfo.SavedSubmarines.DistinctBy(s => s.Name)) + { + if (submarineInfo.Type == SubmarineType.Outpost && + validOutpostTagsForMissions.Any(tag => submarineInfo.OutpostTags.Contains(tag))) + { + outpostDropdown.AddItem(submarineInfo.DisplayName, userData: submarineInfo.Name.ToIdentifier(), toolTip: submarineInfo.Description); + } + } + outpostDropdown.ListBox.Select(prevSelected); + GameMain.Client.ServerSettings.AssignGUIComponent(nameof(ServerSettings.SelectedOutpostName), outpostDropdown); + } + else + { + outpostDropdown.Parent.Visible = false; + //remove assignment, we shouldn't try selecting the outpost when there's none to select + GameMain.Client.ServerSettings.AssignGUIComponent(nameof(ServerSettings.SelectedOutpostName), null); + } + outpostDropdownUpToDate = true; } public void UpdateSubList(GUIComponent subList, IEnumerable submarines) @@ -2313,10 +3124,30 @@ namespace Barotrauma UserData = sub }; - int buttonSize = (int)(frame.Rect.Height * 0.8f); - var subTextBlock = new GUITextBlock(new RectTransform(new Vector2(0.8f, 1.0f), frame.RectTransform, Anchor.CenterLeft), + var frameLayout = new GUILayoutGroup(new RectTransform(new Vector2(0.75f, 1f), frame.RectTransform), isHorizontal: true); + + 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) + { + 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 }; @@ -2382,10 +3213,11 @@ namespace Barotrauma } else { - var infoContainer = new GUILayoutGroup(new RectTransform(new Vector2(0.5f, 1.0f), parent.RectTransform, Anchor.CenterRight) { AbsoluteOffset = new Point(GUI.IntScale(20), 0) }, isHorizontal: false); + var infoContainer = new GUILayoutGroup(new RectTransform(new Vector2(0.25f, 1.0f), parent.RectTransform, Anchor.CenterRight) { AbsoluteOffset = new Point(GUI.IntScale(20), 0) }, isHorizontal: false); new GUITextBlock(new RectTransform(new Vector2(1.0f, 0.5f), infoContainer.RectTransform), TextManager.GetWithVariable("currencyformat", "[credits]", string.Format(CultureInfo.InvariantCulture, "{0:N0}", sub.Price)), textAlignment: Alignment.BottomRight, font: GUIStyle.SmallFont) { + Padding = Vector4.Zero, UserData = "pricetext", TextColor = subTextBlock.TextColor * 0.8f, CanBeFocused = false @@ -2393,6 +3225,7 @@ namespace Barotrauma new GUITextBlock(new RectTransform(new Vector2(1.0f, 0.5f), infoContainer.RectTransform), TextManager.Get($"submarineclass.{sub.SubmarineClass}"), textAlignment: Alignment.TopRight, font: GUIStyle.SmallFont) { + Padding = Vector4.Zero, UserData = "classtext", TextColor = subTextBlock.TextColor * 0.8f, ToolTip = subTextBlock.ToolTip, @@ -2408,9 +3241,33 @@ namespace Barotrauma VoteType voteType; if (component.Parent == GameMain.NetLobbyScreen.SubList.Content) { + if (SelectedMode == GameModePreset.PvP && MultiplayerPreferences.Instance.TeamPreference is not (CharacterTeamType.Team1 or CharacterTeamType.Team2)) + { + // we are in PvP but don't have a team selected, so we can't select a sub + // and also highlight the team selection list + + foreach (GUIComponent child in TeamPreferenceListBox.Content.Children) + { + if (child.UserData is CharacterTeamType.None) { continue; } + child.Flash(GUIStyle.Red, useRectangleFlash: true, flashDuration: 1f); + } + + return false; + } if (!GameMain.Client.ServerSettings.AllowSubVoting) { - var selectedSub = component.UserData as SubmarineInfo; + var selectedSub = (SubmarineInfo)component.UserData; + var type = SelectedMode != GameModePreset.PvP + ? SelectedSubType.Sub + : MultiplayerPreferences.Instance.TeamPreference switch + { + CharacterTeamType.None or CharacterTeamType.Team1 + => SelectedSubType.Sub, + CharacterTeamType.Team2 + => SelectedSubType.EnemySub, + _ => throw new NotImplementedException() + }; + if (SelectedMode == GameModePreset.MultiPlayerCampaign && CampaignSetupUI != null) { if (selectedSub.Price > CampaignSettings.CurrentSettings.InitialMoney) @@ -2433,7 +3290,7 @@ namespace Barotrauma msgBox.Buttons[0].OnClicked = msgBox.Close; msgBox.Buttons[0].OnClicked += (button, obj) => { - GameMain.Client.RequestSelectSub(obj as SubmarineInfo, isShuttle: false); + GameMain.Client.RequestSelectSub(obj as SubmarineInfo, type); return true; }; msgBox.Buttons[1].OnClicked = msgBox.Close; @@ -2441,7 +3298,7 @@ namespace Barotrauma } else if (GameMain.Client.HasPermission(ClientPermissions.SelectSub)) { - GameMain.Client.RequestSelectSub(selectedSub, isShuttle: false); + GameMain.Client.RequestSelectSub(selectedSub, type); return true; } return false; @@ -3192,7 +4049,7 @@ namespace Barotrauma JobInfoFrame?.AddToGUIUpdateList(); CharacterAppearanceCustomizationMenu?.AddToGUIUpdateList(); - JobSelectionFrame?.AddToGUIUpdateList(); + JobSelectionFrame?.AddToGUIUpdateList(order: 1); } public override void Update(double deltaTime) @@ -3246,11 +4103,15 @@ namespace Barotrauma JobSelectionFrame.Visible = false; } - if (GUI.MouseOn?.UserData is JobVariant jobPrefab && GUI.MouseOn.Style?.Name == "JobVariantButton") + if (GUI.MouseOn?.UserData is JobVariant jobPrefab && + GUI.MouseOn.Style?.Name == "JobVariantButton" && + GUI.MouseOn.Parent != null) { - if (jobVariantTooltip?.UserData is not JobVariant prevVisibleVariant || prevVisibleVariant.Prefab != jobPrefab.Prefab || prevVisibleVariant.Variant != jobPrefab.Variant) + if (jobVariantTooltip?.UserData is not JobVariant prevVisibleVariant || + prevVisibleVariant.Prefab != jobPrefab.Prefab || + prevVisibleVariant.Variant != jobPrefab.Variant) { - CreateJobVariantTooltip(jobPrefab.Prefab, jobPrefab.Variant, GUI.MouseOn.Parent); + CreateJobVariantTooltip(jobPrefab.Prefab, TeamPreference, jobPrefab.Variant, isPvPMode: SelectedMode == GameModePreset.PvP, GUI.MouseOn.Parent); } } if (jobVariantTooltip != null) @@ -3334,11 +4195,11 @@ namespace Barotrauma } } - private static void DrawJobVariantItems(SpriteBatch spriteBatch, GUICustomComponent component, JobVariant jobPrefab, int itemsPerRow) + private static void DrawJobVariantItems(SpriteBatch spriteBatch, GUICustomComponent component, JobVariant jobVariant, CharacterTeamType team, bool isPvPMode, int itemsPerRow) { - var itemIdentifiers = jobPrefab.Prefab.PreviewItems[jobPrefab.Variant] - .Where(it => it.ShowPreview) - .Select(it => it.ItemIdentifier) + var allJobItems = jobVariant.Prefab.GetJobItems(jobVariant.Variant, it => it.ShowPreview); + var itemIdentifiers = allJobItems + .Select(it => it.GetItemIdentifier(team, isPvPMode)) .Distinct(); Point slotSize = new Point(component.Rect.Height); @@ -3360,7 +4221,7 @@ namespace Barotrauma LocalizedString tooltip = null; foreach (Identifier itemIdentifier in itemIdentifiers) { - if (!(MapEntityPrefab.Find(null, identifier: itemIdentifier, showErrorMessages: false) is ItemPrefab itemPrefab)) { continue; } + if (MapEntityPrefab.FindByIdentifier(identifier: itemIdentifier) is not ItemPrefab itemPrefab) { continue; } int row = (int)Math.Floor(i / (float)slotCountPerRow); int slotsPerThisRow = Math.Min((slotCount - row * slotCountPerRow), slotCountPerRow); @@ -3377,7 +4238,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.PreviewItems[jobPrefab.Variant].Count(it => it.ShowPreview && it.ItemIdentifier == itemIdentifier); + int count = allJobItems.Where(it => it.GetItemIdentifier(team, isPvPMode) == itemIdentifier).Sum(it => it.Amount); if (count > 1) { string itemCountText = "x" + count; @@ -3405,9 +4266,20 @@ namespace Barotrauma { chatBox.RemoveChild(chatBox.Content.Children.First()); } - + + LocalizedString displayedChatRow = ChatMessage.GetTimeStamp(); + if (message.Type == ChatMessageType.Private) + { + displayedChatRow += TextManager.Get("PrivateMessageTag") + " "; + } + else if (message.Type == ChatMessageType.Team) + { + displayedChatRow += TextManager.Get("PvP.ChatMode.Team.ChatPrefixTag") + " "; + } + displayedChatRow += message.TextWithSender; + GUITextBlock msg = new GUITextBlock(new RectTransform(new Vector2(1.0f, 0.0f), chatBox.Content.RectTransform), - text: RichString.Rich(ChatMessage.GetTimeStamp() + (message.Type == ChatMessageType.Private ? TextManager.Get("PrivateMessageTag") + " " : "") + message.TextWithSender), + text: RichString.Rich(displayedChatRow), textColor: message.Color, color: ((chatBox.CountChildren % 2) == 0) ? Color.Transparent : Color.Black * 0.1f, wrap: true, font: GUIStyle.SmallFont) @@ -3556,57 +4428,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) => @@ -3617,7 +4535,10 @@ namespace Barotrauma }; itemsInRow++; - var images = AddJobSpritesToGUIComponent(jobButton, jobPrefab.Prefab, selectedByPlayer: false); + var images = AddJobSpritesToGUIComponent(jobButton, jobPrefab.Prefab, + team: TeamPreference, + isPvPMode: SelectedMode == GameModePreset.PvP, + selectedByPlayer: false); if (images != null && images.Length > 1) { jobPrefab.Variant = Math.Min(jobPrefab.Variant, images.Length); @@ -3625,23 +4546,17 @@ namespace Barotrauma GUIButton currSelected = null; for (int variantIndex = 0; variantIndex < images.Length; variantIndex++) { - foreach (GUIImage image in images[variantIndex]) - { - image.Visible = currVisible == variantIndex; - } + images[variantIndex].Visible = currVisible == variantIndex; var variantButton = CreateJobVariantButton(jobPrefab, variantIndex, images.Length, jobButton); variantButton.OnClicked = (btn, obj) => { if (currSelected != null) { currSelected.Selected = false; } - int k = ((JobVariant)obj).Variant; + int selectedVariantIndex = ((JobVariant)obj).Variant; btn.Parent.UserData = obj; - for (int j = 0; j < images.Length; j++) + for (int i = 0; i < images.Length; i++) { - foreach (GUIImage image in images[j]) - { - image.Visible = k == j; - } + images[i].Visible = selectedVariantIndex == i; } currSelected = btn; currSelected.Selected = true; @@ -3664,36 +4579,28 @@ namespace Barotrauma return true; } - private static GUIImage[][] AddJobSpritesToGUIComponent(GUIComponent parent, JobPrefab jobPrefab, bool selectedByPlayer) + private static GUIImage[] AddJobSpritesToGUIComponent(GUIComponent parent, JobPrefab jobPrefab, CharacterTeamType team, bool isPvPMode, bool selectedByPlayer) { GUIFrame innerFrame = null; - List outfitPreviews = jobPrefab.GetJobOutfitSprites(CharacterPrefab.HumanPrefab.CharacterInfoPrefab, useInventoryIcon: true, out var maxDimensions); + List outfitPreviews = jobPrefab.GetJobOutfitSprites(team, isPvPMode).ToList(); innerFrame = new GUIFrame(new RectTransform(Vector2.One * 0.85f, parent.RectTransform, Anchor.Center), style: null) { CanBeFocused = false }; - GUIImage[][] retVal = Array.Empty(); + GUIImage[] retVal = new GUIImage[outfitPreviews.Count]; if (outfitPreviews != null && outfitPreviews.Any()) { - retVal = new GUIImage[outfitPreviews.Count][]; for (int i = 0; i < outfitPreviews.Count; i++) { - JobPrefab.OutfitPreview outfitPreview = outfitPreviews[i]; - retVal[i] = new GUIImage[outfitPreview.Sprites.Count]; - for (int j = 0; j < outfitPreview.Sprites.Count; j++) + Sprite outfitPreview = outfitPreviews[i]; + float aspectRatio = outfitPreview.size.Y / outfitPreview.size.X; + retVal[i] = new GUIImage(new RectTransform(new Vector2(0.7f / aspectRatio, 0.7f), innerFrame.RectTransform, Anchor.Center), outfitPreview, scaleToFit: true) { - Sprite sprite = outfitPreview.Sprites[j].sprite; - Vector2 drawOffset = outfitPreview.Sprites[j].drawOffset; - float aspectRatio = outfitPreview.Dimensions.Y / outfitPreview.Dimensions.X; - retVal[i][j] = new GUIImage(new RectTransform(new Vector2(0.7f / aspectRatio, 0.7f), innerFrame.RectTransform, Anchor.Center) - { RelativeOffset = drawOffset / outfitPreview.Dimensions }, sprite, scaleToFit: true) - { - PressedColor = Color.White, - CanBeFocused = false - }; - } + PressedColor = Color.White, + CanBeFocused = false + }; } } @@ -3744,7 +4651,8 @@ namespace Barotrauma { SaveAppearance(); UpdatePlayerFrame(null); - GameMain.Client.ConnectedClients.ForEach(c => SetPlayerNameAndJobPreference(c)); + GameMain.Client.ConnectedClients.ForEach(SetPlayerNameAndJobPreference); + ResetPvpTeamSelection(); } if (SelectedMode != GameModePreset.MultiPlayerCampaign && GameMain.GameSession?.GameMode is CampaignMode && Selected == this) @@ -3755,6 +4663,7 @@ namespace Barotrauma respawnModeSelection.Refresh(); // not all respawn modes are compatible with all game modes RefreshGameModeContent(); RefreshEnabledElements(); + UpdateDisembarkPointListFromServerSettings(); } public void HighlightMode(int modeIndex) @@ -3768,23 +4677,58 @@ namespace Barotrauma private void RefreshMissionTypes() { + IEnumerable suitableMissionClasses; + if (SelectedMode == GameModePreset.Mission) + { + suitableMissionClasses = MissionPrefab.CoOpMissionClasses.Values; + } + else if (SelectedMode == GameModePreset.PvP) + { + suitableMissionClasses = MissionPrefab.PvPMissionClasses.Values; + } + else + { + return; + } for (int i = 0; i < missionTypeTickBoxes.Length; i++) { - MissionType missionType = (MissionType)(int)missionTypeTickBoxes[i].UserData; - if (MissionPrefab.HiddenMissionClasses.Contains(missionType)) + Identifier missionType = (Identifier)missionTypeTickBoxes[i].UserData; + missionTypeTickBoxes[i].Parent.Visible = + MissionPrefab.Prefabs.Any(p => p.Type == missionType && suitableMissionClasses.Contains(p.MissionClass)); + } + } + + private void RefreshGameModeSettingsContent() + { + foreach (var element in campaignHiddenElements) + { + SetElementVisible(element, SelectedMode != GameModePreset.MultiPlayerCampaign && + SelectedMode != GameModePreset.SinglePlayerCampaign); + } + foreach (var element in pvpOnlyElements) + { + SetElementVisible(element, SelectedMode == GameModePreset.PvP); + } + + if (respawnTabButton != null && upgradesTabButton != null) + { + if (SelectedMode == GameModePreset.MultiPlayerCampaign) { - missionTypeTickBoxes[i].Parent.Visible = false; - continue; + SelectRespawnTab(); + respawnTabButton.Enabled = upgradesTabButton.Enabled = false; } - if (SelectedMode == GameModePreset.Mission) + else { - missionTypeTickBoxes[i].Parent.Visible = MissionPrefab.CoOpMissionClasses.ContainsKey(missionType); - } - else if (SelectedMode == GameModePreset.PvP) - { - missionTypeTickBoxes[i].Parent.Visible = MissionPrefab.PvPMissionClasses.ContainsKey(missionType); + respawnTabButton.Enabled = upgradesTabButton.Enabled = true; } } + + static void SetElementVisible(GUIComponent element, bool enabled) + { + element.Visible = enabled; + } + + gameModeSettingsLayout.Recalculate(); } private void RefreshGameModeContent() @@ -3808,6 +4752,33 @@ namespace Barotrauma }); autoRestartBox.Parent.Visible = true; + + UpdateDisembarkPointListFromServerSettings(); + + bool isPvP = SelectedMode == GameModePreset.PvP; + foreach (GUIComponent child in SubList.Content.Children) + { + var container = child.GetChild(); + + var imageFrame = container.GetChild(); + + var coalIcon = imageFrame.GetChildByUserData(CoalitionIconUserData); + var sepIcon = imageFrame.GetChildByUserData(SeparatistsIconUserData); + coalIcon.Visible = isPvP; + sepIcon.Visible = isPvP; + + if (GameMain.NetworkMember.ServerSettings.SubSelectionMode != SelectionMode.Vote) + { + coalIcon.Enabled = sepIcon.Enabled = false; + if (child.UserData is not SubmarineInfo info) { continue; } + if (SelectedSub == info) { coalIcon.Enabled = true; } + if (SelectedEnemySub == info) { sepIcon.Enabled = true; } + } + } + + UpdateSelectedSub(isPvP ? MultiplayerPreferences.Instance.TeamPreference : CharacterTeamType.None); + + RefreshGameModeSettingsContent(); if (SelectedMode == GameModePreset.Mission || SelectedMode == GameModePreset.PvP) { MissionTypeFrame.Visible = true; @@ -3871,26 +4842,106 @@ namespace Barotrauma ReadyToStartBox.Parent.Visible = !GameMain.Client.GameStarted; RefreshStartButtonVisibility(); + RefreshOutpostDropdown(); } 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) - StartButton.Visible = - !GameMain.Client.GameStarted && + StartButton.Visible = + !GameMain.Client.GameStarted && !CampaignSetupUI.LoadGameMenuVisible && (GameMain.Client.HasPermission(ClientPermissions.ManageRound) || GameMain.Client.HasPermission(ClientPermissions.ManageCampaign)); } 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 = + StartButton.Visible = (SelectedMode != GameModePreset.MultiPlayerCampaign || campaignActive) && !GameMain.Client.GameStarted && GameMain.Client.HasPermission(ClientPermissions.ManageRound); } + + StartButton.Enabled = true; + if (GameSession.ShouldApplyDisembarkPoints(SelectedMode)) + { + StartButton.Enabled = GameSession.ValidatedDisembarkPoints(SelectedMode, MissionTypes); + + StartButton.ToolTip = + !StartButton.Enabled + ? 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() + { + chatRow.ClearChildren(); + + // Team chat only makes sense when in a team (in "player preference" team selection mode, team assignments only happen at round start) + if (SelectedMode == GameModePreset.PvP && GameMain.Client?.ServerSettings?.PvpTeamSelectionMode == PvpTeamSelectionMode.PlayerChoice + && MultiplayerPreferences.Instance.TeamPreference != CharacterTeamType.None) + { + var chatSelectorRT = new RectTransform(new Vector2(0.25f, 1.0f), chatRow.RectTransform, Anchor.CenterLeft); + chatSelector = new GUIDropDown(chatSelectorRT, elementCount: 2) + { + OnSelected = (_, userdata) => + { + TeamChatSelected = (bool)userdata; + return true; + } + }; + chatSelector.AddItem(TextManager.Get($"PvP.ChatMode.Team"), userData: true, color: ChatMessage.MessageColor[(int)ChatMessageType.Team]); + chatSelector.AddItem(TextManager.Get($"PvP.ChatMode.All"), userData: false, color: ChatMessage.MessageColor[(int)ChatMessageType.Default]); + chatSelector.SelectItem(TeamChatSelected); + } + else + { + TeamChatSelected = false; + } + + if (chatInput != null) + { + chatInput.RectTransform.Parent = chatRow.RectTransform; + } + else + { + chatInput = new GUITextBox(new RectTransform(new Vector2(0.75f, 1.0f), chatRow.RectTransform, Anchor.CenterRight)) + { + MaxTextLength = ChatMessage.MaxLength, + Font = GUIStyle.SmallFont, + DeselectAfterMessage = false + }; + + micIcon = new GUIImage(new RectTransform(new Vector2(0.05f, 1.0f), chatRow.RectTransform), style: "GUIMicrophoneUnavailable"); + chatInput.Select(); + } + + //this needs to be done even if we're using the existing chatinput instance instead of creating a new one, + //because the client might not have existed when the input box was first created + if (GameMain.Client != null) + { + chatInput.ResetDelegates(); + chatInput.OnEnterPressed = GameMain.Client.EnterChatMessage; + chatInput.OnTextChanged += GameMain.Client.TypingChatMessage; + chatInput.OnDeselected += (sender, key) => + { + GameMain.Client?.ChatBox.ChatManager.Clear(); + }; + ChatManager.RegisterKeys(chatInput, GameMain.Client.ChatBox.ChatManager); + } + + chatRow.Recalculate(); } public void ToggleCampaignMode(bool enabled) @@ -3948,7 +4999,7 @@ namespace Barotrauma { if (button.UserData is not JobVariant jobPrefab) { return false; } - JobInfoFrame = jobPrefab.Prefab.CreateInfoFrame(out GUIComponent buttonContainer); + JobInfoFrame = jobPrefab.Prefab.CreateInfoFrame(isPvP: SelectedMode == GameModePreset.PvP, out GUIComponent buttonContainer); GUIButton closeButton = new GUIButton(new RectTransform(new Vector2(0.25f, 0.05f), buttonContainer.RectTransform, Anchor.BottomRight), TextManager.Get("Close")) { @@ -3989,15 +5040,15 @@ namespace Barotrauma slot.CanBeFocused = !disableNext; if (slot.UserData is JobVariant jobPrefab) { - var images = AddJobSpritesToGUIComponent(slot, jobPrefab.Prefab, selectedByPlayer: true); + var images = AddJobSpritesToGUIComponent(slot, jobPrefab.Prefab, + team: TeamPreference, + isPvPMode: SelectedMode == GameModePreset.PvP, + selectedByPlayer: true); for (int variantIndex = 0; variantIndex < images.Length; variantIndex++) { - foreach (GUIImage image in images[variantIndex]) - { - //jobPreferenceSprites.Add(image.Sprite); - int selectedVariantIndex = Math.Min(jobPrefab.Variant, images.Length); - image.Visible = images.Length == 1 || selectedVariantIndex == variantIndex; - } + int selectedVariantIndex = Math.Min(jobPrefab.Variant, images.Length); + images[variantIndex].Visible = images.Length == 1 || selectedVariantIndex == variantIndex; + if (images.Length > 1) { var variantButton = CreateJobVariantButton(jobPrefab, variantIndex, images.Length, slot); @@ -4060,7 +5111,7 @@ namespace Barotrauma disableNext = true; } } - GameMain.Client.ForceNameAndJobUpdate(); + GameMain.Client.ForceNameJobTeamUpdate(); if (!MultiplayerPreferences.Instance.AreJobPreferencesEqual(jobPreferences)) { @@ -4078,7 +5129,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) }, @@ -4121,12 +5172,13 @@ namespace Barotrauma } public FailedSubInfo? FailedSelectedSub; + public FailedSubInfo? FailedSelectedEnemySub; public FailedSubInfo? FailedSelectedShuttle; public List FailedCampaignSubs = new List(); public List FailedOwnedSubs = new List(); - public bool TrySelectSub(string subName, string md5Hash, GUIListBox subList) + public bool TrySelectSub(string subName, string md5Hash, SelectedSubType type, GUIListBox subList, bool showPreview = true) { UpdateSubVisibility(); if (GameMain.Client == null) { return false; } @@ -4144,13 +5196,34 @@ namespace Barotrauma //matching sub found and already selected, all good if (sub != null) { - if (subList == SubList) + if (subList == SubList && showPreview) { - CreateSubPreview(sub); + if (type is not SelectedSubType.EnemySub || MultiplayerPreferences.Instance.TeamPreference == CharacterTeamType.Team2) + { + CreateSubPreview(sub); + } } - if (subList.SelectedData is SubmarineInfo selectedSub && selectedSub.MD5Hash?.StringRepresentation == md5Hash && Barotrauma.IO.File.Exists(sub.FilePath)) + SubmarineInfo selectedSub = type switch { + SelectedSubType.Sub => SelectedSub, + SelectedSubType.EnemySub => SelectedEnemySub, + SelectedSubType.Shuttle => SelectedShuttle, + _ => null + }; + + if (selectedSub != null && selectedSub.MD5Hash?.StringRepresentation == md5Hash && Barotrauma.IO.File.Exists(sub.FilePath)) + { + //ensure the selected sub matches the correct submarineInfo instance (which may have been just downloaded from the server) + switch (type) + { + case SelectedSubType.Sub: + SelectedSub = sub; + break; + case SelectedSubType.EnemySub: + SelectedEnemySub = sub; + break; + } return true; } } @@ -4173,17 +5246,39 @@ namespace Barotrauma else { subList.OnSelected -= VotableClicked; - subList.Select(sub, GUIListBox.Force.Yes); + + var preference = MultiplayerPreferences.Instance.TeamPreference; + switch (type) + { + case SelectedSubType.Sub: + if (preference is CharacterTeamType.Team1 or CharacterTeamType.None) + { + subList.Select(sub); + } + SelectedSub = sub; + break; + case SelectedSubType.EnemySub: + if (preference is CharacterTeamType.Team2) + { + subList.Select(sub); + } + SelectedEnemySub = sub; + break; + } subList.OnSelected += VotableClicked; } - if (subList == SubList) + switch (type) { - FailedSelectedSub = null; - } - else - { - FailedSelectedShuttle = null; + case SelectedSubType.Sub: + FailedSelectedSub = null; + break; + case SelectedSubType.EnemySub: + FailedSelectedEnemySub = null; + break; + case SelectedSubType.Shuttle: + FailedSelectedShuttle = null; + break; } //hashes match, all good @@ -4196,13 +5291,17 @@ namespace Barotrauma //------------------------------------------------------------------------------------- //if we get to this point, a matching sub was not found or it has an incorrect MD5 hash - if (subList == SubList) + switch (type) { - FailedSelectedSub = new FailedSubInfo(subName, md5Hash); - } - else - { - FailedSelectedShuttle = new FailedSubInfo(subName, md5Hash); + case SelectedSubType.Sub: + FailedSelectedSub = new FailedSubInfo(subName, md5Hash); + break; + case SelectedSubType.EnemySub: + FailedSelectedEnemySub = new FailedSubInfo(subName, md5Hash); + break; + case SelectedSubType.Shuttle: + FailedSelectedShuttle = new FailedSubInfo(subName, md5Hash); + break; } LocalizedString errorMsg = ""; @@ -4295,6 +5394,9 @@ namespace Barotrauma } private readonly List visibilityMenuOrder = new List(); + public const string SeparatistsIconUserData = "separatistsIcon"; + public const string CoalitionIconUserData = "coalitionIcon"; + private void CreateSubmarineVisibilityMenu() { var messageBox = new GUIMessageBox(TextManager.Get("SubmarineVisibility"), "", @@ -4402,6 +5504,8 @@ namespace Barotrauma var subName = new GUITextBlock(new RectTransform(new Vector2(0.6f, 1.0f), frameContent.RectTransform), text: sub.DisplayName) { + UserData = "nametext", + ToolTip = sub.Description, CanBeFocused = false }; @@ -4529,6 +5633,7 @@ namespace Barotrauma public void UpdateSubVisibility() { + if (GameMain.Client == null) { return; } foreach (GUIComponent child in SubList.Content.Children) { if (child.UserData is not SubmarineInfo sub) { continue; } @@ -4543,5 +5648,91 @@ namespace Barotrauma { CampaignCharacterDiscarded = false; } + + private const string RoundStartWarningBoxUserData = "RoundStartWarningBox"; + + public void ShowStartRoundWarning(SerializableDateTime waitUntilTime, string team1SubName, ImmutableArray team1IncompatiblePerks, string team2SubName, ImmutableArray team2IncompatiblePerks) + { + DateTime startTime = DateTime.UtcNow; + TimeSpan differenceFromStart = waitUntilTime.ToUtcValue() - startTime; + + StopWaitingForStartRound(); + GUIMessageBox.MessageBoxes.OfType().ForEachMod(static mod => + { + if (mod.UserData is PleaseWaitPopupUserData) + { + mod.Close(); + } + }); + + var messageBox = new GUIMessageBox(TextManager.Get("warning"), TextManager.Get("startgamewarning"), Array.Empty(), relativeSize: new Vector2(0.3f / GUI.AspectRatioAdjustment, 0.4f), minSize: new Point(400, 300)) + { + UserData = RoundStartWarningBoxUserData + }; + + GUILayoutGroup contentLayout = new GUILayoutGroup(new RectTransform(new Vector2(1f, 0.7f), messageBox.Content.RectTransform, Anchor.BottomCenter), isHorizontal: false); + + GUIListBox errorList = new GUIListBox(new RectTransform(new Vector2(1.0f, 0.7f), contentLayout.RectTransform)); + + foreach (DisembarkPerkPrefab perk in team1IncompatiblePerks) + { + new GUITextBlock(new RectTransform(new Vector2(1.0f, 0.33f), errorList.Content.RectTransform), FormatWarning(perk, team1SubName)); + } + + foreach (DisembarkPerkPrefab perk in team2IncompatiblePerks) + { + new GUITextBlock(new RectTransform(new Vector2(1.0f, 0.33f), errorList.Content.RectTransform), FormatWarning(perk, team2SubName)); + } + + GUIProgressBar progress = new GUIProgressBar(new RectTransform(new Vector2(1f, 0.15f), contentLayout.RectTransform), 0.0f, GUIStyle.Orange); + GUITextBlock progressText = new GUITextBlock(new RectTransform(Vector2.One, progress.RectTransform), TextManager.GetWithVariable("startggamewarningprogress", "[seconds]", ((int)differenceFromStart.TotalSeconds).ToString()), textAlignment: Alignment.Center) + { + Shadow = true, + TextColor = Color.White + }; + + new GUICustomComponent(new RectTransform(Vector2.Zero, progress.RectTransform), + onDraw: static (batch, component) => { }, + onUpdate: (f, component) => + { + TimeSpan difference = waitUntilTime.ToUtcValue() - DateTime.UtcNow; + float seconds = (float)difference.TotalSeconds; + + progress.BarSize = seconds / (float)differenceFromStart.TotalSeconds; + + progressText.Text = TextManager.GetWithVariable("startggamewarningprogress", "[seconds]", ((int)seconds).ToString()); + }); + + GUILayoutGroup buttonLayout = new GUILayoutGroup(new RectTransform(new Vector2(1.0f, 0.25f), contentLayout.RectTransform), childAnchor: Anchor.BottomCenter); + GUIButton cancelButton = new GUIButton(new RectTransform(new Vector2(0.5f, 1f), buttonLayout.RectTransform), TextManager.Get("Cancel")); + + + cancelButton.OnClicked += (button, userData) => + { + IWriteMessage msg = new WriteOnlyMessage().WithHeader(ClientPacketHeader.RESPONSE_CANCEL_STARTGAME); + GameMain.Client?.ClientPeer?.Send(msg, DeliveryMethod.Reliable); + messageBox.Close(); + return true; + }; + + static LocalizedString FormatWarning(DisembarkPerkPrefab prefab, string subName) + { + return TextManager.GetWithVariables("startgamewarningformat", + ("[category]", TextManager.Get($"perkcategory.{prefab.SortCategory}")), + ("[perk]", prefab.Name), + ("[submarine]", subName)); + } + } + + public void CloseStartRoundWarning() + { + GUIMessageBox.MessageBoxes.OfType().ForEachMod(static mod => + { + if (mod.UserData is RoundStartWarningBoxUserData) + { + mod.Close(); + } + }); + } } } diff --git a/Barotrauma/BarotraumaClient/ClientSource/Screens/ServerListScreen/ServerListScreen.cs b/Barotrauma/BarotraumaClient/ClientSource/Screens/ServerListScreen/ServerListScreen.cs index 4b3ff25ff..08bc32e59 100644 --- a/Barotrauma/BarotraumaClient/ClientSource/Screens/ServerListScreen/ServerListScreen.cs +++ b/Barotrauma/BarotraumaClient/ClientSource/Screens/ServerListScreen/ServerListScreen.cs @@ -465,33 +465,38 @@ namespace Barotrauma bool noneSelected = langTickboxes.All(tb => !tb.Selected); bool allSelected = langTickboxes.All(tb => tb.Selected); - if (allSelected != allTickbox.Selected) { allTickbox.Selected = allSelected; } - if (allSelected) - { - languageDropdown.Text = TextManager.Get(allLanguagesKey); - } - else if (noneSelected) - { - languageDropdown.Text = TextManager.Get("None"); - } - - var languages = languageDropdown.SelectedDataMultiple.OfType(); - - ServerListFilters.Instance.SetAttribute(languageKey, string.Join(", ", languages)); - GameSettings.SaveCurrentConfig(); return true; } finally { inSelectedCall = false; - FilterServers(); } }; + languageDropdown.AfterSelected = (_, userData) => + { + bool noneSelected = langTickboxes.All(tb => !tb.Selected); + bool allSelected = langTickboxes.All(tb => tb.Selected); + if (allSelected) + { + languageDropdown.Text = TextManager.Get(allLanguagesKey); + } + else if (noneSelected) + { + languageDropdown.Text = TextManager.Get("None"); + } + + var languages = languageDropdown.SelectedDataMultiple.OfType(); + + ServerListFilters.Instance.SetAttribute(languageKey, string.Join(", ", languages)); + GameSettings.SaveCurrentConfig(); + FilterServers(); + return true; + }; } // Filter Tags @@ -998,6 +1003,8 @@ namespace Barotrauma private bool ShouldShowServer(ServerInfo serverInfo) { + if (serverInfo == null) { return false; } + #if !DEBUG //never show newer versions //(ignore revision number, it doesn't affect compatibility) diff --git a/Barotrauma/BarotraumaClient/ClientSource/Screens/SpriteEditorScreen.cs b/Barotrauma/BarotraumaClient/ClientSource/Screens/SpriteEditorScreen.cs index ae8b70f03..24fc2ae37 100644 --- a/Barotrauma/BarotraumaClient/ClientSource/Screens/SpriteEditorScreen.cs +++ b/Barotrauma/BarotraumaClient/ClientSource/Screens/SpriteEditorScreen.cs @@ -503,8 +503,18 @@ namespace Barotrauma { if (PlayerInput.ScrollWheelSpeed != 0) { - zoom = MathHelper.Clamp(zoom + PlayerInput.ScrollWheelSpeed * (float)deltaTime * 0.05f * zoom, MinZoom, MaxZoom); + float newZoom = MathHelper.Clamp(zoom + PlayerInput.ScrollWheelSpeed * (float)deltaTime * 0.05f * zoom, MinZoom, MaxZoom); + float zoomDeltaPrc = ((newZoom - zoom) / zoom); + zoom = newZoom; zoomBar.BarScroll = GetBarScrollValue(); + + // modify view area offset as well when zooming, to zoom into mouse cursor position + Point mouseToViewAreaScreenCenterDelta = (GetViewArea.Center - viewAreaOffset) - PlayerInput.MousePosition.ToPoint(); + Vector2 mouseDelta = mouseToViewAreaScreenCenterDelta.ToVector2(); + + Vector2 newViewAreaOffset = viewAreaOffset.ToVector2(); + newViewAreaOffset += (mouseDelta + newViewAreaOffset) * zoomDeltaPrc; + viewAreaOffset = newViewAreaOffset.ToPoint(); } widgets.Values.ForEach(w => w.Update((float)deltaTime)); if (PlayerInput.MidButtonHeld()) @@ -516,6 +526,36 @@ namespace Barotrauma } if (GUI.KeyboardDispatcher.Subscriber == null) { + if (PlayerInput.KeyDown(Keys.LeftControl) && PlayerInput.KeyHit(Keys.C)) + { + string text = ""; + if (selectedSprites.Count == 1) + { + var selectedSprite = selectedSprites.First(); + if (selectedSprite.SourceElement != null) + { + string sourceRectText = $"sourcerect=\"{XMLExtensions.RectToString(selectedSprite.SourceRect)}\""; + text += $"{sourceRectText}"; + } + } + else + { + foreach (var selectedSprite in selectedSprites) + { + if (selectedSprite.SourceElement == null) { continue; } + XElement xElement = new XElement(selectedSprite.SourceElement.Element); + xElement.SetAttributeValue("sourcerect", XMLExtensions.RectToString(selectedSprite.SourceRect)); + xElement.SetAttributeValue("origin", XMLExtensions.Vector2ToString(selectedSprite.RelativeOrigin)); + text += $"{xElement}"; + if (selectedSprites.Last() != selectedSprite) + { + text += Environment.NewLine; + } + } + } + + Clipboard.SetText(text); + } if (PlayerInput.KeyHit(Keys.Left)) { Nudge(Keys.Left); @@ -568,6 +608,28 @@ namespace Barotrauma { holdTimer = 0; } + + float moveSpeed = 600f * zoom; + float moveSpeedDeltaTime = (float)(moveSpeed * deltaTime); + Vector2 viewOffsetMove = Vector2.Zero; + if (PlayerInput.KeyDown(Keys.W)) + { + viewOffsetMove.Y += moveSpeedDeltaTime; + } + if (PlayerInput.KeyDown(Keys.S)) + { + viewOffsetMove.Y -= moveSpeedDeltaTime; + } + if (PlayerInput.KeyDown(Keys.A)) + { + viewOffsetMove.X += moveSpeedDeltaTime; + } + if (PlayerInput.KeyDown(Keys.D)) + { + viewOffsetMove.X -= moveSpeedDeltaTime; + } + + viewAreaOffset += viewOffsetMove.ToPoint(); } } @@ -963,8 +1025,14 @@ namespace Barotrauma //foreach (Sprite sprite in loadedSprites.OrderBy(s => GetSpriteName(s))) foreach (Sprite sprite in loadedSprites.OrderBy(s => s.SourceElement.GetAttributeContentPath("texture")?.Value ?? string.Empty)) { - new GUITextBlock(new RectTransform(new Vector2(1.0f, 0.05f), spriteList.Content.RectTransform) { MinSize = new Point(0, 20) }, - GetSpriteName(sprite) + " (" + sprite.SourceRect.X + ", " + sprite.SourceRect.Y + ", " + sprite.SourceRect.Width + ", " + sprite.SourceRect.Height + ")") + string elementLocalName = sprite.SourceElement.Element.Name.LocalName; + string text = $"{GetSpriteName(sprite)} ({sprite.SourceRect.X}, {sprite.SourceRect.Y}, {sprite.SourceRect.Width}, {sprite.SourceRect.Height}) [{elementLocalName}]"; + if (string.Equals(elementLocalName, "sprite", StringComparison.InvariantCultureIgnoreCase)) + { + text = $"{GetSpriteName(sprite)} ({sprite.SourceRect.X}, {sprite.SourceRect.Y}, {sprite.SourceRect.Width}, {sprite.SourceRect.Height})"; + } + + new GUITextBlock(new RectTransform(new Vector2(1.0f, 0.05f), spriteList.Content.RectTransform) { MinSize = new Point(0, 20) }, text: text) { UserData = sprite }; @@ -1005,11 +1073,11 @@ namespace Barotrauma string name = sprite.Name; if (string.IsNullOrWhiteSpace(name)) { - name = sourceElement.Parent.GetAttributeString("identifier", string.Empty); + name = sourceElement.Parent?.GetAttributeString("identifier", string.Empty); } if (string.IsNullOrEmpty(name)) { - name = sourceElement.Parent.GetAttributeString("name", string.Empty); + name = sourceElement.Parent?.GetAttributeString("name", string.Empty); } return string.IsNullOrEmpty(name) ? Path.GetFileNameWithoutExtension(sprite.FilePath.Value) : name; } diff --git a/Barotrauma/BarotraumaClient/ClientSource/Screens/SubEditorScreen.cs b/Barotrauma/BarotraumaClient/ClientSource/Screens/SubEditorScreen.cs index 008b62f46..3180a795c 100644 --- a/Barotrauma/BarotraumaClient/ClientSource/Screens/SubEditorScreen.cs +++ b/Barotrauma/BarotraumaClient/ClientSource/Screens/SubEditorScreen.cs @@ -11,6 +11,7 @@ using System.Collections.Immutable; using System.Linq; using System.Threading; using System.Xml.Linq; +using Barotrauma.Sounds; namespace Barotrauma { @@ -20,7 +21,7 @@ namespace Barotrauma public const int MaxWalls = 500; public const int MaxItems = 5000; public const int MaxLights = 600; - public const int MaxShadowCastingLights = 60; + public const int MaxShadowCastingLights = 100; private static Submarine MainSub { @@ -46,6 +47,7 @@ namespace Barotrauma NoBallastTag, NonLinkedGaps, NoHiddenContainers, + InsufficientFreeConnectionsWarning, StructureCount, WallCount, ItemCount, @@ -54,7 +56,8 @@ namespace Barotrauma WaterInHulls, LowOxygenOutputWarning, TooLargeForEndGame, - NotEnoughContainers + NotEnoughContainers, + NoSuitableBrainRooms } public static Vector2 MouseDragStart = Vector2.Zero; @@ -1071,9 +1074,16 @@ namespace Barotrauma backedUpSubInfo = new SubmarineInfo(MainSub); + GameSession gameSession = new GameSession(backedUpSubInfo, Option.None, CampaignDataPath.Empty, GameModePreset.TestMode, CampaignSettings.Empty, null); + + // if testing an outpost module, we will generate an entire outpost in place of the main submarine down the line + if (backedUpSubInfo.OutpostModuleInfo != null) + { + gameSession.ForceOutpostModule = new SubmarineInfo(MainSub); + } + GameMain.GameScreen.Select(); - - GameSession gameSession = new GameSession(backedUpSubInfo, "", GameModePreset.TestMode, CampaignSettings.Empty, null); + gameSession.StartRound(null, false); foreach ((string layerName, LayerData layerData) in Layers) @@ -1299,7 +1309,7 @@ namespace Barotrauma } GUITextBlock textBlock = new GUITextBlock(new RectTransform(new Vector2(1.0f, 0.0f), paddedFrame.RectTransform, Anchor.BottomCenter), - text: name, textAlignment: Alignment.Center, font: GUIStyle.SmallFont) + text: RichString.Rich(name), textAlignment: Alignment.Center, font: GUIStyle.SmallFont) { CanBeFocused = false }; @@ -1311,7 +1321,7 @@ namespace Barotrauma textBlock.Text = frame.ToolTip = ep.Identifier.Value; textBlock.TextColor = GUIStyle.Red; } - textBlock.Text = ToolBox.LimitString(textBlock.Text, textBlock.Font, textBlock.Rect.Width); + textBlock.Text = ToolBox.LimitString(textBlock.Text.SanitizedString, textBlock.Font, textBlock.Rect.Width); if (ep.Category == MapEntityCategory.ItemAssembly && ep.ContentPackage?.Files.Length == 1 @@ -1382,10 +1392,12 @@ namespace Barotrauma GUI.PreventPauseMenuToggle = false; if (!Directory.Exists(autoSavePath)) { - System.IO.DirectoryInfo e = Directory.CreateDirectory(autoSavePath); - e.Attributes = System.IO.FileAttributes.Directory | System.IO.FileAttributes.Hidden; - if (!e.Exists) + if (Directory.CreateDirectory(autoSavePath, catchUnauthorizedAccessExceptions: true) is { Exists: true } directoryInfo) { + directoryInfo.Attributes = System.IO.FileAttributes.Directory | System.IO.FileAttributes.Hidden; + } + else + { DebugConsole.ThrowError("Failed to create auto save directory!"); } } @@ -1395,7 +1407,7 @@ namespace Barotrauma try { AutoSaveInfo = new XDocument(new XElement("AutoSaves")); - IO.SafeXML.SaveSafe(AutoSaveInfo, autoSaveInfoPath); + AutoSaveInfo.SaveSafe(autoSaveInfoPath, throwExceptions: true); } catch (Exception e) { @@ -1456,8 +1468,8 @@ namespace Barotrauma MainSub.UpdateTransform(interpolate: false); cam.Position = MainSub.Position + MainSub.HiddenSubPosition; - GameMain.SoundManager.SetCategoryGainMultiplier("default", 0.0f); - GameMain.SoundManager.SetCategoryGainMultiplier("waterambience", 0.0f); + GameMain.SoundManager.SetCategoryGainMultiplier(SoundManager.SoundCategoryDefault, 0.0f); + GameMain.SoundManager.SetCategoryGainMultiplier(SoundManager.SoundCategoryWaterAmbience, 0.0f); string downloadFolder = Path.GetFullPath(SaveUtil.SubmarineDownloadFolder); linkedSubBox.ClearChildren(); @@ -1617,8 +1629,8 @@ namespace Barotrauma SetMode(Mode.Default); SoundPlayer.OverrideMusicType = Identifier.Empty; - GameMain.SoundManager.SetCategoryGainMultiplier("default", GameSettings.CurrentConfig.Audio.SoundVolume); - GameMain.SoundManager.SetCategoryGainMultiplier("waterambience", GameSettings.CurrentConfig.Audio.SoundVolume); + GameMain.SoundManager.SetCategoryGainMultiplier(SoundManager.SoundCategoryDefault, GameSettings.CurrentConfig.Audio.SoundVolume); + GameMain.SoundManager.SetCategoryGainMultiplier(SoundManager.SoundCategoryWaterAmbience, GameSettings.CurrentConfig.Audio.SoundVolume); if (CoroutineManager.IsCoroutineRunning("SubEditorAutoSave")) { @@ -1788,6 +1800,21 @@ namespace Barotrauma nameBox.Flash(); return false; } + + // when creating a new local package, prevent name clashes with existing packages + if (packageToSaveTo == null) + { + var subFiles = ContentPackageManager.EnabledPackages.All + .SelectMany(p => p.GetFiles()); + + var nameConflictFile = subFiles.FirstOrDefault(file => Path.GetFileNameWithoutExtension(file.Path.Value).Equals(nameBox.Text, StringComparison.InvariantCultureIgnoreCase)); + + if (nameConflictFile != null) + { + new GUIMessageBox(TextManager.Get("error"), TextManager.GetWithVariable("subeditor.duplicatefilenameerror", "[packagename]", nameConflictFile.ContentPackage.Name)); + return false; + } + } if (MainSub.Info.Type != SubmarineType.Player) { @@ -2060,7 +2087,7 @@ namespace Barotrauma saveFrame = new GUIFrame(new RectTransform(Vector2.One, GUI.Canvas, Anchor.Center), style: "GUIBackgroundBlocker"); - var innerFrame = new GUIFrame(new RectTransform(new Vector2(0.55f, 0.65f), saveFrame.RectTransform, Anchor.Center) { MinSize = new Point(750, 500) }); + var innerFrame = new GUIFrame(new RectTransform(new Vector2(0.6f, 0.7f), saveFrame.RectTransform, Anchor.Center) { MinSize = new Point(750, 500) }); var paddedSaveFrame = new GUILayoutGroup(new RectTransform(new Vector2(0.95f, 0.9f), innerFrame.RectTransform, Anchor.Center)) { Stretch = true, RelativeSpacing = 0.02f }; var columnArea = new GUILayoutGroup(new RectTransform(new Vector2(1.0f, 0.9f), paddedSaveFrame.RectTransform), isHorizontal: true) { RelativeSpacing = 0.02f, Stretch = true }; @@ -2162,17 +2189,15 @@ namespace Barotrauma layerVisibilityDropDown.SelectItem(layerName); } } - layerVisibilityDropDown.OnSelected += (button, obj) => + layerVisibilityDropDown.AfterSelected += (button, _) => { - string layerName = (string)obj; - bool isVisible = layerVisibilityDropDown.SelectedDataMultiple.Contains(obj); - if (isVisible) + MainSub.Info.LayersHiddenByDefault.Clear(); + foreach (var layer in Layers) { - MainSub.Info.LayersHiddenByDefault.Remove(layerName.ToIdentifier()); - } - else - { - MainSub.Info.LayersHiddenByDefault.Add(layerName.ToIdentifier()); + if (!layerVisibilityDropDown.SelectedDataMultiple.Contains(layer.Key)) + { + MainSub.Info.LayersHiddenByDefault.Add(layer.Key.ToIdentifier()); + } } UpdateLayerPanel(); layerVisibilityDropDown.Text = ToolBox.LimitString(layerVisibilityDropDown.Text.Value, layerVisibilityDropDown.Font, layerVisibilityDropDown.Rect.Width); @@ -2183,9 +2208,9 @@ namespace Barotrauma //--------------------------------------- - var subTypeDependentSettingFrame = new GUIFrame(new RectTransform((1.0f, 0.5f), leftColumn.RectTransform), style: "InnerFrame"); + var subTypeDependentSettingFrame = new GUIFrame(new RectTransform((1.0f, 0.6f), leftColumn.RectTransform), style: "InnerFrame"); - var outpostSettingsContainer = new GUILayoutGroup(new RectTransform(Vector2.One, subTypeDependentSettingFrame.RectTransform)) + var outpostModuleSettingsContainer = new GUILayoutGroup(new RectTransform(Vector2.One, subTypeDependentSettingFrame.RectTransform)) { CanBeFocused = true, Visible = false, @@ -2194,7 +2219,7 @@ namespace Barotrauma // module flags --------------------- - var outpostModuleGroup = new GUILayoutGroup(new RectTransform(new Vector2(.975f, 0.1f), outpostSettingsContainer.RectTransform), isHorizontal: true, childAnchor: Anchor.CenterLeft); + var outpostModuleGroup = new GUILayoutGroup(new RectTransform(new Vector2(.975f, 0.1f), outpostModuleSettingsContainer.RectTransform), isHorizontal: true, childAnchor: Anchor.CenterLeft); new GUITextBlock(new RectTransform(new Vector2(0.5f, 1f), outpostModuleGroup.RectTransform), TextManager.Get("outpostmoduletype"), textAlignment: Alignment.CenterLeft); HashSet availableFlags = new HashSet(); @@ -2209,7 +2234,13 @@ namespace Barotrauma availableFlags.Add(flag); } } - + if (MainSub?.Info?.OutpostModuleInfo is { } moduleInfo) + { + foreach (var moduleType in moduleInfo.ModuleFlags) + { + availableFlags.Add(moduleType); + } + } var moduleTypeDropDown = new GUIDropDown(new RectTransform(new Vector2(0.5f, 1f), outpostModuleGroup.RectTransform), text: LocalizedString.Join(", ", MainSub?.Info?.OutpostModuleInfo?.ModuleFlags.Select(s => TextManager.Capitalize(s.Value)) ?? ((LocalizedString)"None").ToEnumerable()), selectMultiple: true); foreach (Identifier flag in availableFlags.OrderBy(f => f.Value, StringComparer.InvariantCultureIgnoreCase)) @@ -2221,7 +2252,7 @@ namespace Barotrauma moduleTypeDropDown.SelectItem(flag); } } - moduleTypeDropDown.OnSelected += (_, __) => + moduleTypeDropDown.AfterSelected += (_, __) => { if (MainSub?.Info?.OutpostModuleInfo == null) { return false; } MainSub.Info.OutpostModuleInfo.SetFlags(moduleTypeDropDown.SelectedDataMultiple.Cast()); @@ -2232,9 +2263,34 @@ namespace Barotrauma }; outpostModuleGroup.RectTransform.MinSize = new Point(0, outpostModuleGroup.RectTransform.Children.Max(c => c.MinSize.Y)); + var addTypeGroup = new GUILayoutGroup(new RectTransform(new Vector2(.975f, 0.1f), outpostModuleSettingsContainer.RectTransform), isHorizontal: true, childAnchor: Anchor.CenterLeft); + new GUITextBlock(new RectTransform(new Vector2(0.5f, 1f), addTypeGroup.RectTransform), TextManager.Get("leveleditor.addmoduletype"), textAlignment: Alignment.CenterLeft); + var textBox = new GUITextBox(new RectTransform(new Vector2(0.4f, 1f), addTypeGroup.RectTransform)); + + new GUIButton(new RectTransform(new Vector2(0.1f, 0.9f), addTypeGroup.RectTransform), text: "+", style: "GUIButtonSmallFreeScale") + { + OnClicked = (btn, _) => + { + if (textBox.Text.IsNullOrEmpty()) + { + textBox.Flash(); + return false; + } + if (MainSub?.Info?.OutpostModuleInfo is { } moduleInfo) + { + moduleInfo.SetFlags(moduleInfo.ModuleFlags.Append(textBox.Text.ToIdentifier()).ToList()); + //refresh + saveFrame = null; + CreateSaveScreen(); + } + return true; + } + }; + addTypeGroup.RectTransform.MinSize = new Point(0, addTypeGroup.RectTransform.Children.Max(c => c.MinSize.Y)); + // module flags --------------------- - var allowAttachGroup = new GUILayoutGroup(new RectTransform(new Vector2(.975f, 0.1f), outpostSettingsContainer.RectTransform), isHorizontal: true, childAnchor: Anchor.CenterLeft); + var allowAttachGroup = new GUILayoutGroup(new RectTransform(new Vector2(.975f, 0.1f), outpostModuleSettingsContainer.RectTransform), isHorizontal: true, childAnchor: Anchor.CenterLeft); new GUITextBlock(new RectTransform(new Vector2(0.5f, 1f), allowAttachGroup.RectTransform), TextManager.Get("outpostmoduleallowattachto"), textAlignment: Alignment.CenterLeft); @@ -2257,7 +2313,7 @@ namespace Barotrauma allowAttachDropDown.SelectItem(flag); } } - allowAttachDropDown.OnSelected += (_, __) => + allowAttachDropDown.AfterSelected += (_, __) => { if (MainSub?.Info?.OutpostModuleInfo == null) { return false; } MainSub.Info.OutpostModuleInfo.SetAllowAttachTo(allowAttachDropDown.SelectedDataMultiple.Cast()); @@ -2270,7 +2326,7 @@ namespace Barotrauma // location types --------------------- - var locationTypeGroup = new GUILayoutGroup(new RectTransform(new Vector2(.975f, 0.1f), outpostSettingsContainer.RectTransform), isHorizontal: true, childAnchor: Anchor.CenterLeft); + var locationTypeGroup = new GUILayoutGroup(new RectTransform(new Vector2(.975f, 0.1f), outpostModuleSettingsContainer.RectTransform), isHorizontal: true, childAnchor: Anchor.CenterLeft); new GUITextBlock(new RectTransform(new Vector2(0.5f, 1f), locationTypeGroup.RectTransform), TextManager.Get("outpostmoduleallowedlocationtypes"), textAlignment: Alignment.CenterLeft); HashSet availableLocationTypes = new HashSet(); @@ -2290,7 +2346,7 @@ namespace Barotrauma } if (!MainSub.Info?.OutpostModuleInfo?.AllowedLocationTypes?.Any() ?? true) { locationTypeDropDown.SelectItem("any".ToIdentifier()); } - locationTypeDropDown.OnSelected += (_, __) => + locationTypeDropDown.AfterSelected += (_, __) => { MainSub?.Info?.OutpostModuleInfo?.SetAllowedLocationTypes(locationTypeDropDown.SelectedDataMultiple.Cast()); locationTypeDropDown.Text = ToolBox.LimitString(locationTypeDropDown.Text.Value, locationTypeDropDown.Font, locationTypeDropDown.Rect.Width); @@ -2300,7 +2356,7 @@ namespace Barotrauma // gap positions --------------------- - var gapPositionGroup = new GUILayoutGroup(new RectTransform(new Vector2(.975f, 0.1f), outpostSettingsContainer.RectTransform), isHorizontal: true, childAnchor: Anchor.CenterLeft); + var gapPositionGroup = new GUILayoutGroup(new RectTransform(new Vector2(.975f, 0.1f), outpostModuleSettingsContainer.RectTransform), isHorizontal: true, childAnchor: Anchor.CenterLeft); new GUITextBlock(new RectTransform(new Vector2(0.5f, 1f), gapPositionGroup.RectTransform), TextManager.Get("outpostmodulegappositions"), textAlignment: Alignment.CenterLeft); var gapPositionDropDown = new GUIDropDown(new RectTransform(new Vector2(0.5f, 1f), gapPositionGroup.RectTransform), text: "", selectMultiple: true); @@ -2323,7 +2379,7 @@ namespace Barotrauma } } - gapPositionDropDown.OnSelected += (_, __) => + gapPositionDropDown.AfterSelected += (_, __) => { if (MainSub.Info?.OutpostModuleInfo == null) { return false; } MainSub.Info.OutpostModuleInfo.GapPositions = OutpostModuleInfo.GapPosition.None; @@ -2345,7 +2401,7 @@ namespace Barotrauma }; gapPositionGroup.RectTransform.MinSize = new Point(0, gapPositionGroup.RectTransform.Children.Max(c => c.MinSize.Y)); - var canAttachToPrevGroup = new GUILayoutGroup(new RectTransform(new Vector2(.975f, 0.1f), outpostSettingsContainer.RectTransform), isHorizontal: true, childAnchor: Anchor.CenterLeft); + var canAttachToPrevGroup = new GUILayoutGroup(new RectTransform(new Vector2(.975f, 0.1f), outpostModuleSettingsContainer.RectTransform), isHorizontal: true, childAnchor: Anchor.CenterLeft); new GUITextBlock(new RectTransform(new Vector2(0.5f, 1f), canAttachToPrevGroup.RectTransform), TextManager.Get("canattachtoprevious"), textAlignment: Alignment.CenterLeft) { ToolTip = TextManager.Get("canattachtoprevious.tooltip") @@ -2365,7 +2421,7 @@ namespace Barotrauma } } - canAttachToPrevDropDown.OnSelected += (_, __) => + canAttachToPrevDropDown.AfterSelected += (_, __) => { if (Submarine.MainSub.Info?.OutpostModuleInfo == null) { return false; } Submarine.MainSub.Info.OutpostModuleInfo.CanAttachToPrevious = OutpostModuleInfo.GapPosition.None; @@ -2390,7 +2446,7 @@ namespace Barotrauma // ------------------- - var maxModuleCountGroup = new GUILayoutGroup(new RectTransform(new Vector2(1.0f, 0.5f), outpostSettingsContainer.RectTransform), isHorizontal: true) + var maxModuleCountGroup = new GUILayoutGroup(new RectTransform(new Vector2(1.0f, 0.05f), outpostModuleSettingsContainer.RectTransform), isHorizontal: true) { Stretch = true }; @@ -2410,8 +2466,9 @@ namespace Barotrauma MainSub.Info.OutpostModuleInfo.MaxCount = numberInput.IntValue; } }; + maxModuleCountGroup.RectTransform.MinSize = new Point(0, maxModuleCountGroup.RectTransform.Children.Max(c => c.MinSize.Y)); - var commonnessGroup = new GUILayoutGroup(new RectTransform(new Vector2(1.0f, 0.5f), outpostSettingsContainer.RectTransform), isHorizontal: true) + var commonnessGroup = new GUILayoutGroup(new RectTransform(new Vector2(1.0f, 0.05f), outpostModuleSettingsContainer.RectTransform), isHorizontal: true) { Stretch = true }; @@ -2427,7 +2484,9 @@ namespace Barotrauma MainSub.Info.OutpostModuleInfo.Commonness = numberInput.FloatValue; } }; - outpostSettingsContainer.RectTransform.MinSize = new Point(0, outpostSettingsContainer.RectTransform.Children.Sum(c => c.Children.Any() ? c.Children.Max(c2 => c2.MinSize.Y) : 0)); + commonnessGroup.RectTransform.MinSize = new Point(0, commonnessGroup.RectTransform.Children.Max(c => c.MinSize.Y)); + + outpostModuleSettingsContainer.RectTransform.MinSize = new Point(0, outpostModuleSettingsContainer.RectTransform.Children.Sum(c => c.Children.Any() ? c.Children.Max(c2 => c2.MinSize.Y) : 0)); //--------------------------------------- @@ -2476,6 +2535,133 @@ namespace Barotrauma //--------------------------------------- + var outpostSettingsContainer = new GUILayoutGroup(new RectTransform(Vector2.One, subTypeDependentSettingFrame.RectTransform)) + { + CanBeFocused = true, + Visible = false, + Stretch = true + }; + + var outpostTagsGroup = 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), outpostTagsGroup.RectTransform), + TextManager.Get("sp.item.tags.name"), textAlignment: Alignment.CenterLeft, wrap: true); + var outpostTagsBox = new GUITextBox(new RectTransform(new Vector2(0.4f, 1.0f), outpostTagsGroup.RectTransform)) + { + OnEnterPressed = (GUITextBox textBox, string text) => + { + MainSub.Info.OutpostTags = text.ToIdentifiers().ToImmutableHashSet(); + return true; + }, + OverflowClip = true, + Text = "default" + }; + outpostTagsBox.OnDeselected += (textbox, _) => + { + MainSub.Info.OutpostTags = outpostTagsBox.Text.ToIdentifiers().ToImmutableHashSet(); + }; + if (MainSub.Info.OutpostTags != null) + { + 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)) + { + CanBeFocused = true, + Visible = false, + Stretch = true + }; + + // ------------------- + + var enemySubmarineRewardGroup = new GUILayoutGroup(new RectTransform(new Vector2(1.0f, 0.25f), enemySubmarineSettingsContainer.RectTransform), isHorizontal: true) + { + Stretch = true + }; + new GUITextBlock(new RectTransform(new Vector2(0.6f, 1.0f), enemySubmarineRewardGroup.RectTransform), + TextManager.Get("enemysub.reward"), textAlignment: Alignment.CenterLeft, wrap: true); + numInput = new GUINumberInput(new RectTransform(new Vector2(0.4f, 1.0f), enemySubmarineRewardGroup.RectTransform), NumberType.Int, buttonVisibility: GUINumberInput.ButtonVisibility.ForceHidden) + { + IntValue = (int)(MainSub?.Info?.EnemySubmarineInfo?.Reward ?? 4000), + MinValueInt = 0, + MaxValueInt = 999999, + OnValueChanged = (numberInput) => + { + MainSub.Info.EnemySubmarineInfo.Reward = numberInput.IntValue; + } + }; + enemySubmarineRewardGroup.RectTransform.MaxSize = numInput.TextBox.RectTransform.MaxSize; + var enemySubmarineDifficultyGroup = new GUILayoutGroup(new RectTransform(new Vector2(1.0f, 0.25f), enemySubmarineSettingsContainer.RectTransform), isHorizontal: true) + { + Stretch = true + }; + new GUITextBlock(new RectTransform(new Vector2(0.6f, 1.0f), enemySubmarineDifficultyGroup.RectTransform), + TextManager.Get("preferreddifficulty"), textAlignment: Alignment.CenterLeft, wrap: true); + numInput = new GUINumberInput(new RectTransform(new Vector2(0.4f, 1.0f), enemySubmarineDifficultyGroup.RectTransform), NumberType.Int) + { + IntValue = (int)(MainSub?.Info?.EnemySubmarineInfo?.PreferredDifficulty ?? 50), + MinValueInt = 0, + MaxValueInt = 100, + OnValueChanged = (numberInput) => + { + MainSub.Info.EnemySubmarineInfo.PreferredDifficulty = numberInput.IntValue; + } + }; + enemySubmarineDifficultyGroup.RectTransform.MaxSize = numInput.TextBox.RectTransform.MaxSize; + var enemySubmarineTagsGroup = new GUILayoutGroup(new RectTransform(new Vector2(1.0f, 0.25f), enemySubmarineSettingsContainer.RectTransform), isHorizontal: true) + { + Stretch = true + }; + new GUITextBlock(new RectTransform(new Vector2(0.6f, 1.0f), enemySubmarineTagsGroup.RectTransform), + TextManager.Get("sp.item.tags.name"), textAlignment: Alignment.CenterLeft, wrap: true); + var tagsBox = new GUITextBox(new RectTransform(new Vector2(0.4f, 1.0f), enemySubmarineTagsGroup.RectTransform)) + { + OnEnterPressed = ChangeEnemySubTags, + OverflowClip = true, + Text = "default" + }; + tagsBox.OnDeselected += (textbox, _) => ChangeEnemySubTags(textbox, textbox.Text); + if (MainSub?.Info?.EnemySubmarineInfo?.MissionTags != null) + { + tagsBox.Text = string.Join(',', MainSub.Info.EnemySubmarineInfo.MissionTags); + } + + enemySubmarineTagsGroup.RectTransform.MaxSize = tagsBox.RectTransform.MaxSize; + enemySubmarineSettingsContainer.RectTransform.MinSize = new Point(0, enemySubmarineSettingsContainer.RectTransform.Children.Sum(c => c.Children.Any() ? c.Children.Max(c2 => c2.MinSize.Y) : 0)); + + //-------------------------------------------------------- + var beaconSettingsContainer = new GUILayoutGroup(new RectTransform(Vector2.One, extraSettingsContainer.RectTransform)) { CanBeFocused = true, @@ -2530,7 +2716,7 @@ namespace Barotrauma Stretch = true }; - var priceGroup = new GUILayoutGroup(new RectTransform(new Vector2(1.0f, 0.25f), subSettingsContainer.RectTransform), isHorizontal: true) + var priceGroup = new GUILayoutGroup(new RectTransform(new Vector2(1.0f, 0.05f), subSettingsContainer.RectTransform), isHorizontal: true) { Stretch = true }; @@ -2554,7 +2740,7 @@ namespace Barotrauma MainSub.Info.Price = Math.Max(MainSub.Info.Price, basePrice); } - var classGroup = new GUILayoutGroup(new RectTransform(new Vector2(1.0f, 0.25f), subSettingsContainer.RectTransform), isHorizontal: true, childAnchor: Anchor.CenterLeft) + var classGroup = new GUILayoutGroup(new RectTransform(new Vector2(1.0f, 0.05f), subSettingsContainer.RectTransform), isHorizontal: true, childAnchor: Anchor.CenterLeft) { Stretch = true }; @@ -2587,7 +2773,7 @@ namespace Barotrauma }; classDropDown.SelectItem(!MainSub.Info.HasTag(SubmarineTag.Shuttle) ? MainSub.Info.SubmarineClass : (object)SubmarineTag.Shuttle); - var tierGroup = new GUILayoutGroup(new RectTransform(new Vector2(1.0f, 0.25f), subSettingsContainer.RectTransform), isHorizontal: true) + var tierGroup = new GUILayoutGroup(new RectTransform(new Vector2(1.0f, 0.05f), subSettingsContainer.RectTransform), isHorizontal: true) { Stretch = true }; @@ -2612,14 +2798,14 @@ namespace Barotrauma MainSub.Info.Tier = Math.Clamp(MainSub.Info.Tier, 1, SubmarineInfo.HighestTier); } - var crewSizeArea = new GUILayoutGroup(new RectTransform(new Vector2(1.0f, 0.25f), subSettingsContainer.RectTransform), isHorizontal: true) + var crewSizeArea = new GUILayoutGroup(new RectTransform(new Vector2(1.0f, 0.05f), subSettingsContainer.RectTransform), isHorizontal: true) { Stretch = true, AbsoluteSpacing = 5 }; new GUITextBlock(new RectTransform(new Vector2(0.6f, 1.0f), crewSizeArea.RectTransform), - TextManager.Get("RecommendedCrewSize"), textAlignment: Alignment.CenterLeft, wrap: true, font: GUIStyle.SmallFont); + TextManager.Get("RecommendedCrewSize"), textAlignment: Alignment.CenterLeft, wrap: true); var crewSizeMin = new GUINumberInput(new RectTransform(new Vector2(0.17f, 1.0f), crewSizeArea.RectTransform), NumberType.Int, relativeButtonAreaWidth: 0.25f) { MinValueInt = 1, @@ -2646,14 +2832,14 @@ namespace Barotrauma MainSub.Info.RecommendedCrewSizeMax = crewSizeMax.IntValue; }; - var crewExpArea = new GUILayoutGroup(new RectTransform(new Vector2(1.0f, 0.25f), subSettingsContainer.RectTransform), isHorizontal: true) + var crewExpArea = new GUILayoutGroup(new RectTransform(new Vector2(1.0f, 0.05f), subSettingsContainer.RectTransform), isHorizontal: true) { Stretch = true, AbsoluteSpacing = 5 }; new GUITextBlock(new RectTransform(new Vector2(0.6f, 1.0f), crewExpArea.RectTransform), - TextManager.Get("RecommendedCrewExperience"), textAlignment: Alignment.CenterLeft, wrap: true, font: GUIStyle.SmallFont); + TextManager.Get("RecommendedCrewExperience"), textAlignment: Alignment.CenterLeft, wrap: true); var toggleExpLeft = new GUIButton(new RectTransform(new Vector2(0.05f, 1.0f), crewExpArea.RectTransform), style: "GUIButtonToggleLeft"); var experienceText = new GUITextBlock(new RectTransform(new Vector2(0.3f, 1.0f), crewExpArea.RectTransform), @@ -2682,13 +2868,13 @@ namespace Barotrauma return true; }; - var hideInMenusArea = new GUILayoutGroup(new RectTransform(new Vector2(1.0f, 0.25f), subSettingsContainer.RectTransform), isHorizontal: true, childAnchor: Anchor.CenterLeft) + var hideInMenusArea = new GUILayoutGroup(new RectTransform(new Vector2(1.0f, 0.05f), subSettingsContainer.RectTransform), isHorizontal: true, childAnchor: Anchor.CenterLeft) { Stretch = true, AbsoluteSpacing = 5 }; new GUITextBlock(new RectTransform(new Vector2(0.6f, 1.0f), hideInMenusArea.RectTransform), - TextManager.Get("HideInMenus"), textAlignment: Alignment.CenterLeft, wrap: true, font: GUIStyle.SmallFont); + TextManager.Get("HideInMenus"), textAlignment: Alignment.CenterLeft, wrap: true); new GUITickBox(new RectTransform((0.4f, 1.0f), hideInMenusArea.RectTransform), "") { @@ -2707,13 +2893,13 @@ namespace Barotrauma } }; - var outFittingArea = new GUILayoutGroup(new RectTransform(new Vector2(1.0f, 0.25f), subSettingsContainer.RectTransform), isHorizontal: true, childAnchor: Anchor.CenterLeft) + var outFittingArea = new GUILayoutGroup(new RectTransform(new Vector2(1.0f, 0.05f), subSettingsContainer.RectTransform), isHorizontal: true, childAnchor: Anchor.CenterLeft) { Stretch = true, AbsoluteSpacing = 5 }; new GUITextBlock(new RectTransform(new Vector2(0.6f, 1.0f), outFittingArea.RectTransform), - TextManager.Get("ManuallyOutfitted"), textAlignment: Alignment.CenterLeft, wrap: true, font: GUIStyle.SmallFont) + TextManager.Get("ManuallyOutfitted"), textAlignment: Alignment.CenterLeft, wrap: true) { ToolTip = TextManager.Get("manuallyoutfittedtooltip") }; @@ -2757,15 +2943,27 @@ namespace Barotrauma { MainSub.Info.WreckInfo ??= new WreckInfo(MainSub.Info); } + else if (type == SubmarineType.EnemySubmarine) + { + MainSub.Info.EnemySubmarineInfo ??= new EnemySubmarineInfo(MainSub.Info); + } previewImageButtonHolder.Children.ForEach(c => c.Enabled = MainSub.Info.AllowPreviewImage); - outpostSettingsContainer.Visible = type == SubmarineType.OutpostModule; + outpostModuleSettingsContainer.Visible = type == SubmarineType.OutpostModule; extraSettingsContainer.Visible = type == SubmarineType.BeaconStation || type == SubmarineType.Wreck; beaconSettingsContainer.Visible = type == SubmarineType.BeaconStation; + enemySubmarineSettingsContainer.Visible = type == SubmarineType.EnemySubmarine; subSettingsContainer.Visible = type == SubmarineType.Player; + outpostSettingsContainer.Visible = type == SubmarineType.Outpost; return true; }; subSettingsContainer.RectTransform.MinSize = new Point(0, subSettingsContainer.RectTransform.Children.Sum(c => c.Children.Any() ? c.Children.Max(c2 => c2.MinSize.Y) : 0)); + int minHeight = subSettingsContainer.Children.First().Children.Max(c => c.RectTransform.MinSize.Y); + foreach (var child in subSettingsContainer.Children) + { + child.RectTransform.MinSize = new Point(0, minHeight); + } + // right column --------------------------------------------------- new GUITextBlock(new RectTransform(new Vector2(1.0f, 0.0f), rightColumn.RectTransform), TextManager.Get("SubPreviewImage"), font: GUIStyle.SubHeadingFont); @@ -3036,11 +3234,12 @@ namespace Barotrauma paddedSaveFrame.Recalculate(); leftColumn.Recalculate(); - subSettingsContainer.RectTransform.MinSize = outpostSettingsContainer.RectTransform.MinSize = beaconSettingsContainer.RectTransform.MinSize = - new Point(0, Math.Max(subSettingsContainer.Rect.Height, outpostSettingsContainer.Rect.Height)); + subSettingsContainer.RectTransform.MinSize = outpostModuleSettingsContainer.RectTransform.MinSize = beaconSettingsContainer.RectTransform.MinSize = + new Point(0, Math.Max(subSettingsContainer.Rect.Height, outpostModuleSettingsContainer.Rect.Height)); subSettingsContainer.Recalculate(); - outpostSettingsContainer.Recalculate(); + outpostModuleSettingsContainer.Recalculate(); beaconSettingsContainer.Recalculate(); + enemySubmarineSettingsContainer.Recalculate(); descriptionBox.Text = MainSub == null ? "" : MainSub.Info.Description.Value; submarineDescriptionCharacterCount.Text = descriptionBox.Text.Length + " / " + submarineDescriptionLimit; @@ -3344,7 +3543,7 @@ namespace Barotrauma }; searchBox.OnSelected += (sender, userdata) => { searchTitle.Visible = false; }; - searchBox.OnDeselected += (sender, userdata) => { searchTitle.Visible = true; }; + searchBox.OnDeselected += (sender, userdata) => { searchTitle.Visible = sender.Text.IsNullOrEmpty(); }; searchBox.OnTextChanged += (textBox, text) => { FilterSubs(subList, text); return true; }; var sortedSubs = GetLoadableSubs() @@ -3542,7 +3741,7 @@ namespace Barotrauma /// private void LoadAutoSave(object userData) { - if (!(userData is XElement element)) { return; } + if (userData is not XElement element) { return; } #warning TODO: revise string filePath = element.GetAttributeStringUnrestricted("file", ""); @@ -3566,6 +3765,8 @@ namespace Barotrauma MainSub.Info.Name = loadedSub.Info.Name; subNameLabel.Text = ToolBox.LimitString(loadedSub.Info.Name, subNameLabel.Font, subNameLabel.Rect.Width); + ReconstructLayers(); + CreateDummyCharacter(); cam.Position = MainSub.Position + MainSub.HiddenSubPosition; @@ -3769,10 +3970,10 @@ namespace Barotrauma { if (subPackage != null) { - File.Delete(sub.FilePath); + File.Delete(sub.FilePath, catchUnauthorizedAccessExceptions: false); ModProject modProject = new ModProject(subPackage); modProject.RemoveFile(modProject.Files.First(f => ContentPath.FromRaw(subPackage, f.Path) == sub.FilePath)); - modProject.Save(subPackage.Path); + modProject.Save(subPackage.Path, catchUnauthorizedAccessExceptions: true); ReloadModifiedPackage(subPackage); if (MainSub?.Info != null && MainSub.Info.FilePath == sub.FilePath) { @@ -4458,7 +4659,7 @@ namespace Barotrauma private bool SelectLinkedSub(GUIComponent selected, object userData) { - if (!(selected.UserData is SubmarineInfo submarine)) return false; + if (userData is not SubmarineInfo submarine) { return false; } var prefab = new LinkedSubmarinePrefab(submarine); MapEntityPrefab.SelectPrefab(prefab); return true; @@ -4573,11 +4774,32 @@ namespace Barotrauma return false; } - if (MainSub != null) MainSub.Info.Name = text; + if (MainSub != null) { MainSub.Info.Name = text; } textBox.Deselect(); - textBox.Text = text; + textBox.Flash(GUIStyle.Green); + return true; + } + + private bool ChangeEnemySubTags(GUITextBox textBox, string text) + { + if (string.IsNullOrWhiteSpace(text)) + { + textBox.Flash(GUIStyle.Red); + return false; + } + + if (MainSub.Info.EnemySubmarineInfo is { } enemySubInfo) + { + enemySubInfo.MissionTags.Clear(); + string[] tags = text.Split(','); + foreach (string tag in tags) + { + enemySubInfo.MissionTags.Add(tag.ToIdentifier()); + } + } + textBox.Text = text; textBox.Flash(GUIStyle.Green); return true; @@ -5595,12 +5817,14 @@ namespace Barotrauma { foreach (LightComponent lightComponent in item.GetComponents()) { - lightComponent.Light.Color = - (item.body == null || item.body.Enabled || item.ParentInventory is ItemInventory { Container.HideItems: true }) && + 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; } } @@ -5741,7 +5965,7 @@ namespace Barotrauma newItem.Remove(); } } - else if (itemContainer != null && itemContainer.Inventory.CanBePut(itemPrefab)) + else if (itemContainer != null && itemContainer.Inventory.CanProbablyBePut(itemPrefab)) { bool placedItem = itemContainer.Inventory.TryPutItem(newItem, dummyCharacter); spawnedItem |= placedItem; diff --git a/Barotrauma/BarotraumaClient/ClientSource/Screens/TestScreen.cs b/Barotrauma/BarotraumaClient/ClientSource/Screens/TestScreen.cs index 115402131..b68beafc0 100644 --- a/Barotrauma/BarotraumaClient/ClientSource/Screens/TestScreen.cs +++ b/Barotrauma/BarotraumaClient/ClientSource/Screens/TestScreen.cs @@ -1,4 +1,4 @@ -#nullable enable +#nullable enable using System.Linq; using Barotrauma.Items.Components; using Microsoft.Xna.Framework; @@ -49,7 +49,7 @@ namespace Barotrauma } dummyCharacter = Character.Create(CharacterPrefab.HumanSpeciesName, Vector2.Zero, "", id: Entity.DummyID, hasAi: false); - dummyCharacter.Info.Job = new Job(JobPrefab.Prefabs.FirstOrDefault(static jp => jp.Identifier == "captain")); + dummyCharacter.Info.Job = new Job(JobPrefab.Prefabs.FirstOrDefault(static jp => jp.Identifier == "captain"), isPvP: false); dummyCharacter.Info.Name = "Galldren"; dummyCharacter.Inventory.CreateSlots(); dummyCharacter.Info.GiveExperience(999999); @@ -59,6 +59,9 @@ namespace Barotrauma Character.Controlled = dummyCharacter; GameMain.World.ProcessChanges(); + + dummyCharacter.Info.TalentRefundPoints = 2; + TabMenu = new TabMenu(); } public override void AddToGUIUpdateList() diff --git a/Barotrauma/BarotraumaClient/ClientSource/Serialization/SerializableEntityEditor.cs b/Barotrauma/BarotraumaClient/ClientSource/Serialization/SerializableEntityEditor.cs index fffd275a6..668b1bde8 100644 --- a/Barotrauma/BarotraumaClient/ClientSource/Serialization/SerializableEntityEditor.cs +++ b/Barotrauma/BarotraumaClient/ClientSource/Serialization/SerializableEntityEditor.cs @@ -748,7 +748,7 @@ namespace Barotrauma } } enumDropDown.MustSelectAtLeastOne = !hasNoneOption; - enumDropDown.OnSelected += (selected, val) => + enumDropDown.AfterSelected += (selected, val) => { if (SetPropertyValue(property, entity, string.Join(", ", enumDropDown.SelectedDataMultiple.Select(d => d.ToString())))) { @@ -788,8 +788,12 @@ namespace Barotrauma ToolTip = toolTip, Font = GUIStyle.SmallFont, Text = StripPrefabTags(value), - OverflowClip = true + OverflowClip = true, }; + if (editableAttribute != null && editableAttribute.MaxLength > 0) + { + propertyBox.MaxTextLength = editableAttribute.MaxLength; + } HashSet editedEntities = new HashSet(); propertyBox.OnTextChanged += (textBox, text) => @@ -805,8 +809,7 @@ namespace Barotrauma refresh += () => { if (propertyBox.Selected) { return; } - - propertyBox.Text = StripPrefabTags(property.GetValue(entity).ToString()); + propertyBox.Text = StripPrefabTags(property.GetValue(entity)?.ToString()); }; bool OnApply(GUITextBox textBox) diff --git a/Barotrauma/BarotraumaClient/ClientSource/Settings/SettingsMenu.cs b/Barotrauma/BarotraumaClient/ClientSource/Settings/SettingsMenu.cs index 0e5e54d1c..34ebe4aca 100644 --- a/Barotrauma/BarotraumaClient/ClientSource/Settings/SettingsMenu.cs +++ b/Barotrauma/BarotraumaClient/ClientSource/Settings/SettingsMenu.cs @@ -7,6 +7,7 @@ using System.Linq; using Barotrauma.Eos; using Barotrauma.Extensions; using Barotrauma.Networking; +using Barotrauma.Sounds; using Barotrauma.Steam; using Microsoft.Xna.Framework; using Microsoft.Xna.Framework.Graphics; @@ -106,6 +107,11 @@ namespace Barotrauma public void SelectTab(Tab tab) { + if (tab == Tab.AudioAndVC && CurrentDeviceMismatchesDisplayed()) + { + CreateAudioAndVCTab(refresh: true); + } + CurrentTab = tab; SwitchContent(tabContents[tab].Content); tabber.Children.ForEach(c => @@ -133,6 +139,11 @@ namespace Barotrauma private GUIFrame CreateNewContentFrame(Tab tab) { + if (tabContents.TryGetValue(tab, out (GUIButton Button, GUIFrame Content) tabContent)) + { + return tabContent.Content; + } + var content = new GUIFrame(new RectTransform(Vector2.One * 0.95f, contentFrame.RectTransform, Anchor.Center, Pivot.Center), style: null); AddButtonToTabber(tab, content); return content; @@ -179,7 +190,7 @@ namespace Barotrauma private static GUIDropDown Dropdown(GUILayoutGroup parent, Func textFunc, Func? tooltipFunc, IReadOnlyList values, T currentValue, Action setter) { - var dropdown = new GUIDropDown(NewItemRectT(parent)); + var dropdown = new GUIDropDown(NewItemRectT(parent), elementCount: values.Count); values.ForEach(v => dropdown.AddItem(text: textFunc(v), userData: v, toolTip: tooltipFunc?.Invoke(v) ?? null)); int childIndex = values.IndexOf(currentValue); dropdown.Select(childIndex); @@ -268,6 +279,11 @@ namespace Barotrauma DropdownEnum(left, (m) => TextManager.Get($"{m}"), null, unsavedConfig.Graphics.DisplayMode, v => unsavedConfig.Graphics.DisplayMode = v); Spacer(left); + var displayLabel = Label(left, TextManager.Get("TargetDisplay"), GUIStyle.SubHeadingFont); + displayLabel.ToolTip = TextManager.Get("TargetDisplay.Tooltip"); + Dropdown(left, m => TextManager.GetWithVariables(m == 0 ? "PrimaryDisplayFormat" : "SecondaryDisplayFormat", ("[num]", m.ToString()), ("[name]", Display.GetDisplayName(m))), null, Enumerable.Range(0, Display.GetNumberOfDisplays()).ToArray(), unsavedConfig.Graphics.Display, v => unsavedConfig.Graphics.Display = v); + Spacer(left); + Tickbox(left, TextManager.Get("EnableVSync"), TextManager.Get("EnableVSyncTooltip"), unsavedConfig.Graphics.VSync, v => unsavedConfig.Graphics.VSync = v); Tickbox(left, TextManager.Get("EnableTextureCompression"), TextManager.Get("EnableTextureCompressionTooltip"), unsavedConfig.Graphics.CompressTextures, v => unsavedConfig.Graphics.CompressTextures = v); Spacer(right); @@ -281,8 +297,8 @@ namespace Barotrauma Spacer(right); Label(right, TextManager.Get("VisibleLightLimit"), GUIStyle.SubHeadingFont); - Slider(right, (10, 210), 21, v => v > 200 ? TextManager.Get("unlimited").Value : Round(v).ToString(), unsavedConfig.Graphics.VisibleLightLimit, - v => unsavedConfig.Graphics.VisibleLightLimit = v > 200 ? int.MaxValue : Round(v), TextManager.Get("VisibleLightLimitTooltip")); + Slider(right, (10, 510), 21, v => v > 500 ? TextManager.Get("unlimited").Value : Round(v).ToString(), unsavedConfig.Graphics.VisibleLightLimit, + v => unsavedConfig.Graphics.VisibleLightLimit = v > 500 ? int.MaxValue : Round(v), TextManager.Get("VisibleLightLimitTooltip")); Spacer(right); Tickbox(right, TextManager.Get("RadialDistortion"), TextManager.Get("RadialDistortionTooltip"), unsavedConfig.Graphics.RadialDistortion, v => unsavedConfig.Graphics.RadialDistortion = v); @@ -346,17 +362,45 @@ namespace Barotrauma current = list[0]; } } - - private void CreateAudioAndVCTab() + + private static bool IsCurrentDevice(string savedDeviceName, int deviceType) + { + try + { + string currentDevice = Alc.GetString(IntPtr.Zero, deviceType); + if (string.IsNullOrEmpty(savedDeviceName) || string.IsNullOrEmpty(currentDevice)) + { + return false; + } + return currentDevice.Equals(savedDeviceName, StringComparison.OrdinalIgnoreCase); + } + catch (Exception ex) + { + Console.WriteLine($"Error checking output device name: {ex.Message}"); + return false; + } + } + + private static bool CurrentDeviceMismatchesDisplayed() + { + return !IsCurrentDevice(GameSettings.CurrentConfig.Audio.VoiceCaptureDevice, Alc.CaptureDefaultDeviceSpecifier) || + !IsCurrentDevice(GameSettings.CurrentConfig.Audio.AudioOutputDevice, Alc.DefaultDeviceSpecifier); + } + + public void CreateAudioAndVCTab(bool refresh = false) { if (GameMain.Client == null - && VoipCapture.Instance == null) + && (refresh || VoipCapture.Instance == null)) { string currDevice = unsavedConfig.Audio.VoiceCaptureDevice; GetAudioDevices(Alc.CaptureDeviceSpecifier, Alc.CaptureDefaultDeviceSpecifier, out var deviceList, ref currDevice); if (deviceList.Any()) { + if (VoipCapture.Instance is VoipCapture currentCaptureInstance) + { + currentCaptureInstance.Dispose(); + } VoipCapture.Create(unsavedConfig.Audio.VoiceCaptureDevice); } if (VoipCapture.Instance == null) @@ -366,7 +410,10 @@ namespace Barotrauma } GUIFrame content = CreateNewContentFrame(Tab.AudioAndVC); - + if (refresh) + { + content.ClearChildren(); + } var (audio, voiceChat) = CreateSidebars(content, split: true); static void audioDeviceElement( @@ -400,23 +447,49 @@ namespace Barotrauma string currentOutputDevice = unsavedConfig.Audio.AudioOutputDevice; audioDeviceElement(audio, v => unsavedConfig.Audio.AudioOutputDevice = v, Alc.OutputDevicesSpecifier, Alc.DefaultDeviceSpecifier, ref currentOutputDevice); + new GUIButton(new RectTransform(new Vector2(1.0f, 1.0f), audio.RectTransform), text: TextManager.Get("RefreshAudioDevices"), style: "GUIButtonSmall") + { + ToolTip = TextManager.Get("RefreshAudioDevicesToolTip"), + OnClicked = (btn, obj) => + { + CreateAudioAndVCTab(refresh: true); + return true; + } + }; Spacer(audio); Label(audio, TextManager.Get("SoundVolume"), GUIStyle.SubHeadingFont); - Slider(audio, (0, 1), 101, Percentage, unsavedConfig.Audio.SoundVolume, v => unsavedConfig.Audio.SoundVolume = v); + Slider(audio, (0, 1), 101, Percentage, unsavedConfig.Audio.SoundVolume, v => + { + unsavedConfig.Audio.SoundVolume = v; + GameMain.SoundManager.SetCategoryGainMultiplier(SoundManager.SoundCategoryDefault, v); + GameMain.SoundManager.SetCategoryGainMultiplier(SoundManager.SoundCategoryWaterAmbience, v); + }); Label(audio, TextManager.Get("MusicVolume"), GUIStyle.SubHeadingFont); - Slider(audio, (0, 1), 101, Percentage, unsavedConfig.Audio.MusicVolume, v => unsavedConfig.Audio.MusicVolume = v); + Slider(audio, (0, 1), 101, Percentage, unsavedConfig.Audio.MusicVolume, v => + { + unsavedConfig.Audio.MusicVolume = v; + GameMain.SoundManager.SetCategoryGainMultiplier(SoundManager.SoundCategoryMusic, v); + }); Label(audio, TextManager.Get("UiSoundVolume"), GUIStyle.SubHeadingFont); - Slider(audio, (0, 1), 101, Percentage, unsavedConfig.Audio.UiVolume, v => unsavedConfig.Audio.UiVolume = v); + Slider(audio, (0, 1), 101, Percentage, unsavedConfig.Audio.UiVolume, v => + { + unsavedConfig.Audio.UiVolume = v; + GameMain.SoundManager.SetCategoryGainMultiplier(SoundManager.SoundCategoryUi, v); + }); Tickbox(audio, TextManager.Get("MuteOnFocusLost"), TextManager.Get("MuteOnFocusLostTooltip"), unsavedConfig.Audio.MuteOnFocusLost, v => unsavedConfig.Audio.MuteOnFocusLost = v); Tickbox(audio, TextManager.Get("DynamicRangeCompression"), TextManager.Get("DynamicRangeCompressionTooltip"), unsavedConfig.Audio.DynamicRangeCompressionEnabled, v => unsavedConfig.Audio.DynamicRangeCompressionEnabled = v); Spacer(audio); Label(audio, TextManager.Get("VoiceChatVolume"), GUIStyle.SubHeadingFont); - Slider(audio, (0, 2), 201, Percentage, unsavedConfig.Audio.VoiceChatVolume, v => unsavedConfig.Audio.VoiceChatVolume = v); + Slider(audio, (0, 2), 201, Percentage, unsavedConfig.Audio.VoiceChatVolume, v => + { + unsavedConfig.Audio.VoiceChatVolume = v; + GameMain.SoundManager.SetCategoryGainMultiplier(SoundManager.SoundCategoryVoip, v); + }); Tickbox(audio, TextManager.Get("DirectionalVoiceChat"), TextManager.Get("DirectionalVoiceChatTooltip"), unsavedConfig.Audio.UseDirectionalVoiceChat, v => unsavedConfig.Audio.UseDirectionalVoiceChat = v); Tickbox(audio, TextManager.Get("VoipAttenuation"), TextManager.Get("VoipAttenuationTooltip"), unsavedConfig.Audio.VoipAttenuationEnabled, v => unsavedConfig.Audio.VoipAttenuationEnabled = v); @@ -425,6 +498,15 @@ namespace Barotrauma string currentInputDevice = unsavedConfig.Audio.VoiceCaptureDevice; audioDeviceElement(voiceChat, v => unsavedConfig.Audio.VoiceCaptureDevice = v, Alc.CaptureDeviceSpecifier, Alc.CaptureDefaultDeviceSpecifier, ref currentInputDevice); + new GUIButton(new RectTransform(new Vector2(1.0f, 1.0f), voiceChat.RectTransform), text: TextManager.Get("RefreshAudioDevices"), style: "GUIButtonSmall") + { + ToolTip = TextManager.Get("RefreshAudioDevicesToolTip"), + OnClicked = (btn, obj) => + { + CreateAudioAndVCTab(refresh: true); + return true; + } + }; Spacer(voiceChat); Label(voiceChat, TextManager.Get("VCInputMode"), GUIStyle.SubHeadingFont); @@ -729,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); @@ -832,6 +919,8 @@ namespace Barotrauma { OnClicked = (btn, obj) => { + // reset any modified audio settings to current config + GameMain.SoundManager?.ApplySettings(); Close(); return false; } diff --git a/Barotrauma/BarotraumaClient/ClientSource/Sounds/OggSound.cs b/Barotrauma/BarotraumaClient/ClientSource/Sounds/OggSound.cs index f5145ff10..03490b2c3 100644 --- a/Barotrauma/BarotraumaClient/ClientSource/Sounds/OggSound.cs +++ b/Barotrauma/BarotraumaClient/ClientSource/Sounds/OggSound.cs @@ -122,7 +122,7 @@ namespace Barotrauma.Sounds static void MuffleBuffer(float[] buffer, int sampleRate) { - var filter = new LowpassFilter(sampleRate, 1600); + var filter = new LowpassFilter(sampleRate, SoundPlayer.MuffleFilterFrequency); filter.Process(buffer); } diff --git a/Barotrauma/BarotraumaClient/ClientSource/Sounds/OpenAL/Alc.cs b/Barotrauma/BarotraumaClient/ClientSource/Sounds/OpenAL/Alc.cs index 96df43626..5647038aa 100644 --- a/Barotrauma/BarotraumaClient/ClientSource/Sounds/OpenAL/Alc.cs +++ b/Barotrauma/BarotraumaClient/ClientSource/Sounds/OpenAL/Alc.cs @@ -258,11 +258,11 @@ namespace OpenAL public static void GetInteger(IntPtr device, int param, out int data) { - int[] dataArr = new int[1]; - GCHandle handle = GCHandle.Alloc(dataArr,GCHandleType.Pinned); + data = 0; // (Optimization: let's pin an integer on the stack instead of an array on the heap, which previously allocated almost a GB of memory) + GCHandle handle = GCHandle.Alloc(data, GCHandleType.Pinned); GetIntegerv(device, param, 1, handle.AddrOfPinnedObject()); + data = Marshal.ReadInt32(handle.AddrOfPinnedObject()); handle.Free(); - data = dataArr[0]; } #endregion diff --git a/Barotrauma/BarotraumaClient/ClientSource/Sounds/Sound.cs b/Barotrauma/BarotraumaClient/ClientSource/Sounds/Sound.cs index 17f53bf9a..fa2f0080b 100644 --- a/Barotrauma/BarotraumaClient/ClientSource/Sounds/Sound.cs +++ b/Barotrauma/BarotraumaClient/ClientSource/Sounds/Sound.cs @@ -62,6 +62,8 @@ namespace Barotrauma.Sounds public float BaseNear; public float BaseFar; + public bool MuteBackgroundMusic; + public Sound(SoundManager owner, string filename, bool stream, bool streamsReliably, ContentXElement xElement = null, bool getFullPath = true) { Owner = owner; @@ -102,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) @@ -127,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 d90705fdb..df9d68998 100644 --- a/Barotrauma/BarotraumaClient/ClientSource/Sounds/SoundChannel.cs +++ b/Barotrauma/BarotraumaClient/ClientSource/Sounds/SoundChannel.cs @@ -13,7 +13,7 @@ namespace Barotrauma.Sounds private set; } - public SoundSourcePool(int sourceCount = SoundManager.SOURCE_COUNT) + public SoundSourcePool(int sourceCount = SoundManager.SourceCount) { int alError; @@ -398,8 +398,8 @@ namespace Barotrauma.Sounds } } - private string category; - public string Category + private Identifier category; + public Identifier Category { get { return category; } set @@ -434,6 +434,8 @@ namespace Barotrauma.Sounds private readonly uint[] unqueuedBuffers; private readonly float[] streamBufferAmplitudes; + public bool MuteBackgroundMusic; + public int StreamSeekPos { get { return streamSeekPos; } @@ -481,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 1c55f6070..1f2f37dea 100644 --- a/Barotrauma/BarotraumaClient/ClientSource/Sounds/SoundManager.cs +++ b/Barotrauma/BarotraumaClient/ClientSource/Sounds/SoundManager.cs @@ -11,7 +11,12 @@ namespace Barotrauma.Sounds { class SoundManager : IDisposable { - public const int SOURCE_COUNT = 32; + public const int SourceCount = 32; + 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 { @@ -196,7 +201,7 @@ namespace Barotrauma.Sounds } } - private readonly Dictionary categoryModifiers = new Dictionary(); + private readonly Dictionary categoryModifiers = new Dictionary(); public SoundManager() { @@ -204,7 +209,7 @@ namespace Barotrauma.Sounds updateChannelsThread = null; sourcePools = new SoundSourcePool[2]; - playingChannels[(int)SourcePoolIndex.Default] = new SoundChannel[SOURCE_COUNT]; + playingChannels[(int)SourcePoolIndex.Default] = new SoundChannel[SourceCount]; playingChannels[(int)SourcePoolIndex.Voice] = new SoundChannel[16]; string deviceName = GameSettings.CurrentConfig.Audio.AudioOutputDevice; @@ -334,7 +339,7 @@ namespace Barotrauma.Sounds return false; } - sourcePools[(int)SourcePoolIndex.Default] = new SoundSourcePool(SOURCE_COUNT); + sourcePools[(int)SourcePoolIndex.Default] = new SoundSourcePool(SourceCount); sourcePools[(int)SourcePoolIndex.Voice] = new SoundSourcePool(16); ReloadSounds(); @@ -353,11 +358,19 @@ namespace Barotrauma.Sounds throw new System.IO.FileNotFoundException("Sound file \"" + filename + "\" doesn't exist!"); } +#if DEBUG + System.Diagnostics.Stopwatch sw = new System.Diagnostics.Stopwatch(); + sw.Start(); +#endif Sound newSound = new OggSound(this, filename, stream, null); lock (loadedSounds) { loadedSounds.Add(newSound); } +#if DEBUG + sw.Stop(); + System.Diagnostics.Debug.WriteLine($"Loaded sound \"{filename}\" ({sw.ElapsedMilliseconds} ms)."); +#endif return newSound; } @@ -535,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)) @@ -566,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; } @@ -589,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)) @@ -614,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; } @@ -657,6 +666,7 @@ namespace Barotrauma.Sounds DebugConsole.ThrowError("Playback device has been disconnected. You can select another available device in the settings."); SetAudioOutputDevice(""); Disconnected = true; + TryRefreshDevice(); return; } } @@ -672,10 +682,10 @@ namespace Barotrauma.Sounds { voipAttenuatedGain = 1.0f; } - SetCategoryGainMultiplier("default", VoipAttenuatedGain, 1); - SetCategoryGainMultiplier("ui", VoipAttenuatedGain, 1); - SetCategoryGainMultiplier("waterambience", VoipAttenuatedGain, 1); - SetCategoryGainMultiplier("music", VoipAttenuatedGain, 1); + SetCategoryGainMultiplier(SoundCategoryDefault, VoipAttenuatedGain, 1); + SetCategoryGainMultiplier(SoundCategoryUi, VoipAttenuatedGain, 1); + SetCategoryGainMultiplier(SoundCategoryWaterAmbience, VoipAttenuatedGain, 1); + SetCategoryGainMultiplier(SoundCategoryMusic, VoipAttenuatedGain, 1); if (GameSettings.CurrentConfig.Audio.DynamicRangeCompressionEnabled) { @@ -721,11 +731,11 @@ namespace Barotrauma.Sounds public void ApplySettings() { - SetCategoryGainMultiplier("default", GameSettings.CurrentConfig.Audio.SoundVolume, 0); - SetCategoryGainMultiplier("ui", GameSettings.CurrentConfig.Audio.UiVolume, 0); - SetCategoryGainMultiplier("waterambience", GameSettings.CurrentConfig.Audio.SoundVolume, 0); - SetCategoryGainMultiplier("music", GameSettings.CurrentConfig.Audio.MusicVolume, 0); - SetCategoryGainMultiplier("voip", Math.Min(GameSettings.CurrentConfig.Audio.VoiceChatVolume, 1.0f), 0); + SetCategoryGainMultiplier(SoundCategoryDefault, GameSettings.CurrentConfig.Audio.SoundVolume, 0); + SetCategoryGainMultiplier(SoundCategoryUi, GameSettings.CurrentConfig.Audio.UiVolume, 0); + SetCategoryGainMultiplier(SoundCategoryWaterAmbience, GameSettings.CurrentConfig.Audio.SoundVolume, 0); + SetCategoryGainMultiplier(SoundCategoryMusic, GameSettings.CurrentConfig.Audio.MusicVolume, 0); + SetCategoryGainMultiplier(SoundCategoryVoip, Math.Min(GameSettings.CurrentConfig.Audio.VoiceChatVolume, 1.0f), 0); } /// @@ -872,5 +882,52 @@ namespace Barotrauma.Sounds throw new Exception("Failed to close ALC device!"); } } + + public static void TryRefreshDevice() + { + DebugConsole.NewMessage("Refreshing audio playback device"); + + List deviceList = Alc.GetStringList(IntPtr.Zero, Alc.OutputDevicesSpecifier).ToList(); + int alcError = Alc.GetError(IntPtr.Zero); + if (alcError != Alc.NoError) + { + DebugConsole.ThrowError("Failed to list available audio playback devices: " + alcError.ToString()); + return; + } + + if (deviceList.Any()) + { + string device; + + if (deviceList.Find(n => n.Equals(GameSettings.CurrentConfig.Audio.AudioOutputDevice, StringComparison.OrdinalIgnoreCase)) + is string availablePreviousDevice) + { + DebugConsole.NewMessage($" Previous device choice available: {availablePreviousDevice}"); + device = availablePreviousDevice; + } + else + { + device = Alc.GetString(IntPtr.Zero, Alc.DefaultDeviceSpecifier); + DebugConsole.NewMessage($" Reverting to default device: {device}"); + } + + if (string.IsNullOrEmpty(device)) + { + device = deviceList[0]; + DebugConsole.NewMessage($" No default device found, resorting to first available device: {device}"); + } + + // Save the new device choice and initialize it + var currentConfig = GameSettings.CurrentConfig; + currentConfig.Audio.AudioOutputDevice = device; + GameSettings.SetCurrentConfig(currentConfig); + GameMain.SoundManager.InitializeAlcDevice(device); + } + + if (GUI.SettingsMenuOpen) + { + SettingsMenu.Instance?.CreateAudioAndVCTab(true); + } + } } } diff --git a/Barotrauma/BarotraumaClient/ClientSource/Sounds/SoundPlayer.cs b/Barotrauma/BarotraumaClient/ClientSource/Sounds/SoundPlayer.cs index eaee24b70..d8c37f0ff 100644 --- a/Barotrauma/BarotraumaClient/ClientSource/Sounds/SoundPlayer.cs +++ b/Barotrauma/BarotraumaClient/ClientSource/Sounds/SoundPlayer.cs @@ -1,12 +1,11 @@ using Barotrauma.Extensions; +using Barotrauma.IO; using Barotrauma.Sounds; using Microsoft.Xna.Framework; using System; using System.Collections.Generic; -using Barotrauma.IO; using System.Linq; using System.Threading; -using System.Xml.Linq; namespace Barotrauma { @@ -16,6 +15,8 @@ namespace Barotrauma private const float MusicLerpSpeed = 1.0f; private const float UpdateMusicInterval = 5.0f; + public const float MuffleFilterFrequency = 600; + const int MaxMusicChannels = 6; private readonly static BackgroundMusic[] currentMusic = new BackgroundMusic[MaxMusicChannels]; @@ -28,9 +29,9 @@ namespace Barotrauma private static float updateMusicTimer; //ambience - private static Sound waterAmbienceIn => SoundPrefab.WaterAmbienceIn.ActivePrefab.Sound; - private static Sound waterAmbienceOut => SoundPrefab.WaterAmbienceOut.ActivePrefab.Sound; - private static Sound waterAmbienceMoving => SoundPrefab.WaterAmbienceMoving.ActivePrefab.Sound; + private static SoundPrefab waterAmbienceIn => SoundPrefab.WaterAmbienceIn.ActivePrefab; + private static SoundPrefab waterAmbienceOut => SoundPrefab.WaterAmbienceOut.ActivePrefab; + private static SoundPrefab waterAmbienceMoving => SoundPrefab.WaterAmbienceMoving.ActivePrefab; private static readonly HashSet waterAmbienceChannels = new HashSet(); private static float ambientSoundTimer; @@ -190,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 { @@ -213,9 +217,9 @@ namespace Barotrauma } } - updateWaterAmbience(waterAmbienceIn, ambienceVolume * (1.0f - movementSoundVolume) * insideSubFactor); - updateWaterAmbience(waterAmbienceMoving, ambienceVolume * movementSoundVolume * insideSubFactor); - updateWaterAmbience(waterAmbienceOut, 1.0f - insideSubFactor); + updateWaterAmbience(waterAmbienceIn.Sound, ambienceVolume * (1.0f - movementSoundVolume) * insideSubFactor * waterAmbienceIn.Volume); + updateWaterAmbience(waterAmbienceMoving.Sound, ambienceVolume * movementSoundVolume * insideSubFactor * waterAmbienceMoving.Volume); + updateWaterAmbience(waterAmbienceOut.Sound, (1.0f - insideSubFactor) * waterAmbienceOut.Volume); } private static void UpdateWaterFlowSounds(float deltaTime) @@ -306,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]); @@ -483,8 +488,20 @@ namespace Barotrauma if (sound == null) { return null; } return PlaySound(sound, position, volume ?? sound.BaseGain, range ?? sound.BaseFar, 1.0f, hullGuess); } + public static SoundChannel PlaySound(RoundSound sound, Vector2 position, float? volume = null, Hull hullGuess = null) + { + return PlaySound( + sound.Sound, + position, + volume ?? sound.Volume, + sound.Range, + ignoreMuffling: sound.IgnoreMuffling, + hullGuess: hullGuess, + freqMult: sound.GetRandomFrequencyMultiplier(), + muteBackgroundMusic: sound.MuteBackgroundMusic); + } - public static SoundChannel PlaySound(Sound sound, Vector2 position, float? volume = null, float? range = null, float? freqMult = null, Hull hullGuess = null, bool ignoreMuffling = false) + public static SoundChannel PlaySound(Sound sound, Vector2 position, float? volume = null, float? range = null, float? freqMult = null, Hull hullGuess = null, bool ignoreMuffling = false, bool muteBackgroundMusic = false) { if (sound == null) { @@ -500,7 +517,12 @@ namespace Barotrauma return null; } bool muffle = !ignoreMuffling && ShouldMuffleSound(Character.Controlled, position, far, hullGuess); - return sound.Play(volume ?? sound.BaseGain, far, freqMult ?? 1.0f, position, muffle: muffle); + var channel = sound.Play(volume ?? sound.BaseGain, far, freqMult ?? 1.0f, position, muffle: muffle); + if (channel != null) + { + channel.MuteBackgroundMusic = muteBackgroundMusic; + } + return channel; } public static void DisposeDisabledMusic() @@ -669,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; + } } int activeTrackCount = targetMusic.Count(m => m != null); @@ -705,7 +742,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; @@ -724,10 +761,14 @@ 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; + if (muteBackgroundMusic) + { + targetGain = 0.0f; + } if (targetMusic[i].DuckVolume) { targetGain *= (float)Math.Sqrt(1.0f / activeTrackCount); @@ -841,6 +882,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(); @@ -865,41 +951,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) @@ -956,7 +1007,7 @@ namespace Barotrauma PlayDamageSound(damageType, damage, bodyPosition, 800.0f); } - public static void PlayDamageSound(string damageType, float damage, Vector2 position, float range = 2000.0f, IEnumerable tags = null) + public static void PlayDamageSound(string damageType, float damage, Vector2 position, float range = 2000.0f, IEnumerable tags = null, float gain = 1.0f) { var suitableSounds = damageSounds.Where(s => s.DamageType == damageType && @@ -972,14 +1023,14 @@ namespace Barotrauma s.DamageRange == Vector2.Zero || (randomizedDamage >= s.DamageRange.X && randomizedDamage <= s.DamageRange.Y)); var damageSound = suitableSounds.GetRandomUnsynced(); - damageSound?.Sound?.Play(1.0f, range, position, muffle: !damageSound.IgnoreMuffling && ShouldMuffleSound(Character.Controlled, position, range, null)); + damageSound?.Sound?.Play(gain, range, position, muffle: !damageSound.IgnoreMuffling && ShouldMuffleSound(Character.Controlled, position, range, null)); } public static void PlayUISound(GUISoundType soundType) { 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 f6c8a2beb..87e52d927 100644 --- a/Barotrauma/BarotraumaClient/ClientSource/Sounds/SoundPrefab.cs +++ b/Barotrauma/BarotraumaClient/ClientSource/Sounds/SoundPrefab.cs @@ -183,6 +183,9 @@ namespace Barotrauma public readonly ContentPath SoundPath; public readonly ContentXElement Element; public readonly Identifier ElementName; + + public readonly float Volume; + public Sound Sound { get; private set; } public SoundPrefab(ContentXElement element, SoundsFile file, bool stream = false) : base(file, element) @@ -191,6 +194,8 @@ namespace Barotrauma Element = element; ElementName = element.NameAsIdentifier(); Sound = GameMain.SoundManager.LoadSound(element, stream: stream); + + Volume = element.GetAttributeFloat(nameof(Volume), 1.0f); } public bool IsPlaying() @@ -235,7 +240,6 @@ namespace Barotrauma public readonly Identifier Type; public readonly bool DuckVolume; - public readonly float Volume; public readonly Vector2 IntensityRange; public readonly bool MuteIntensityTracks; @@ -244,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) { @@ -255,9 +263,9 @@ namespace Barotrauma { ForceIntensityTrack = element.GetAttributeFloat(nameof(ForceIntensityTrack), 0.0f); } - Volume = element.GetAttributeFloat(nameof(Volume), 1.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/SpamServerFilter.cs b/Barotrauma/BarotraumaClient/ClientSource/SpamServerFilter.cs index c3a929ec9..c2f07cf96 100644 --- a/Barotrauma/BarotraumaClient/ClientSource/SpamServerFilter.cs +++ b/Barotrauma/BarotraumaClient/ClientSource/SpamServerFilter.cs @@ -48,6 +48,8 @@ namespace Barotrauma private static bool IsFiltered(ServerInfo info, SpamServerFilterType type, string value) { + if (info == null) { return true; } + string desc = info.ServerMessage, name = info.ServerName; diff --git a/Barotrauma/BarotraumaClient/ClientSource/Sprite/DecorativeSprite.cs b/Barotrauma/BarotraumaClient/ClientSource/Sprite/DecorativeSprite.cs index bbd74e417..54d26f5cb 100644 --- a/Barotrauma/BarotraumaClient/ClientSource/Sprite/DecorativeSprite.cs +++ b/Barotrauma/BarotraumaClient/ClientSource/Sprite/DecorativeSprite.cs @@ -12,6 +12,7 @@ namespace Barotrauma { public float RotationState; public float OffsetState; + public float ScaleState; public Vector2 RandomOffsetMultiplier = new Vector2(Rand.Range(-1.0f, 1.0f), Rand.Range(-1.0f, 1.0f)); public float RandomRotationFactor = Rand.Range(0.0f, 1.0f); public float RandomScaleFactor = Rand.Range(0.0f, 1.0f); @@ -27,7 +28,8 @@ namespace Barotrauma { None, Sine, - Noise + Noise, + Circle } [Serialize(0.0f, IsPropertySaveable.Yes), Editable] @@ -47,6 +49,15 @@ namespace Barotrauma [Serialize(0.0f, IsPropertySaveable.Yes), Editable] public float OffsetAnimSpeed { get; private set; } + [Serialize(AnimationType.None, IsPropertySaveable.No), Editable] + public AnimationType ScaleAnim { get; private set; } + + [Serialize("0,0", IsPropertySaveable.Yes), Editable] + public Vector2 ScaleAnimAmount { get; private set; } + + [Serialize(0.0f, IsPropertySaveable.Yes), Editable] + public float ScaleAnimSpeed { get; private set; } + private float rotationSpeedRadians; private float absRotationSpeedRadians; @@ -136,7 +147,7 @@ namespace Barotrauma foreach (var subElement in element.Elements()) { //choose which list the new conditional should be placed to - List conditionalList = null; + List conditionalList; switch (subElement.Name.ToString().ToLowerInvariant()) { case "conditional": @@ -162,15 +173,14 @@ namespace Barotrauma { case AnimationType.Sine: offsetState %= (MathHelper.TwoPi / OffsetAnimSpeed); - offset *= (float)Math.Sin(offsetState * OffsetAnimSpeed); + offset *= MathF.Sin(offsetState * OffsetAnimSpeed); + break; + case AnimationType.Circle: + offsetState %= (MathHelper.TwoPi / OffsetAnimSpeed); + offset *= new Vector2(MathF.Cos(offsetState * OffsetAnimSpeed), MathF.Sin(offsetState * OffsetAnimSpeed)); break; case AnimationType.Noise: - offsetState %= 1.0f / (OffsetAnimSpeed * 0.1f); - - float t = offsetState * 0.1f * OffsetAnimSpeed; - offset = new Vector2( - offset.X * (PerlinNoise.GetPerlin(t, t) - 0.5f), - offset.Y * (PerlinNoise.GetPerlin(t + 0.5f, t + 0.5f) - 0.5f)); + offset *= GetNoiseVector(ref offsetState, OffsetAnimSpeed); break; } } @@ -192,7 +202,7 @@ namespace Barotrauma case AnimationType.Sine: rotationState %= MathHelper.TwoPi / absRotationSpeedRadians; return - rotationRadians * (float)Math.Sin(rotationState * rotationSpeedRadians) + rotationRadians * MathF.Sin(rotationState * rotationSpeedRadians) + MathHelper.Lerp(randomRotationRadians.X, randomRotationRadians.Y, randomRotationFactor); case AnimationType.Noise: rotationState %= 1.0f / absRotationSpeedRadians; @@ -207,13 +217,40 @@ namespace Barotrauma } } - public float GetScale(float randomScaleModifier) + public Vector2 GetScale(ref float scaleState, float randomScaleModifier) { - if (RandomScale == Vector2.Zero) - { - return scale; - } - return MathHelper.Lerp(RandomScale.X, RandomScale.Y, randomScaleModifier); + Vector2 currentScale = Vector2.One * + (RandomScale == Vector2.Zero ? scale : MathHelper.Lerp(RandomScale.X, RandomScale.Y, randomScaleModifier)); + if (ScaleAnimSpeed > 0.0f) + { + switch (ScaleAnim) + { + case AnimationType.Sine: + scaleState %= (MathHelper.TwoPi / ScaleAnimSpeed); + currentScale *= Vector2.One + ScaleAnimAmount * MathF.Sin(scaleState * ScaleAnimSpeed); + break; + case AnimationType.Noise: + currentScale *= Vector2.One + ScaleAnimAmount * GetNoiseVector(ref scaleState, ScaleAnimSpeed); + break; + } + } + return currentScale; + } + + private static Vector2 GetNoiseVector(ref float state, float speed) + { + //multiply speed by a magic constant, because otherwise a speed of 1 would already be very fast (looping through the noise texture once per second) + //just makes the values more intuitive / closer to what constitutes as "fast" on the other types of animations + float modifiedSpeed = speed * 0.1f; + // wrap around the edge of the noise (t == 1) + state %= 1.0f / modifiedSpeed; + float t = state * modifiedSpeed; + Vector2 noiseValue = new Vector2( + PerlinNoise.GetPerlin(t, t), + //sample the y coordinate from a different position in the noise texture + PerlinNoise.GetPerlin(t + 0.5f, t + 0.5f)); + //move the value so it's in the range of -0.5 and 0.5, as opposed to 0-1. + return noiseValue - new Vector2(0.5f, 0.5f); } public static void UpdateSpriteStates(ImmutableDictionary> spriteGroups, Dictionary animStates, @@ -264,6 +301,7 @@ namespace Barotrauma if (!checkConditional(conditional)) { animate = false; break; } } if (!animate) { continue; } + spriteState.ScaleState += deltaTime; spriteState.OffsetState += deltaTime; spriteState.RotationState += deltaTime; } 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/Sprite/Sprite.cs b/Barotrauma/BarotraumaClient/ClientSource/Sprite/Sprite.cs index c253984c8..77cb3016d 100644 --- a/Barotrauma/BarotraumaClient/ClientSource/Sprite/Sprite.cs +++ b/Barotrauma/BarotraumaClient/ClientSource/Sprite/Sprite.cs @@ -1,4 +1,4 @@ -using Microsoft.Xna.Framework; +using Microsoft.Xna.Framework; using Microsoft.Xna.Framework.Graphics; using System; using Barotrauma.IO; @@ -10,7 +10,19 @@ namespace Barotrauma { public partial class Sprite { - public Identifier Identifier { get; private set; } + private Identifier identifier; + public Identifier Identifier + { + get + { + if (identifier.IsEmpty) + { + identifier = GetIdentifier(SourceElement); + } + return identifier; + } + } + public static IEnumerable LoadedSprites { get @@ -71,8 +83,6 @@ namespace Barotrauma } } - private string disposeStackTrace; - public bool Loaded { get { return texture != null && !cannotBeLoaded; } @@ -288,24 +298,27 @@ namespace Barotrauma /// Last version of the game that had broken handling of sprites that were scaled, flipped and offset /// public static readonly Version LastBrokenTiledSpriteGameVersion = new Version(major: 1, minor: 2, build: 7, revision: 0); + + public void DrawTiled(ISpriteBatch spriteBatch, Vector2 position, Vector2 targetSize, float rotation = 0f, Vector2? origin = null, Color? color = null, Vector2? startOffset = null, Vector2? textureScale = null, float? depth = null) + { + DrawTiled(spriteBatch, position, targetSize, effects, rotation, origin, color, startOffset, textureScale, depth); + } public void DrawTiled(ISpriteBatch spriteBatch, Vector2 position, Vector2 targetSize, + SpriteEffects spriteEffects, float rotation = 0f, Vector2? origin = null, Color? color = null, Vector2? startOffset = null, Vector2? textureScale = null, - float? depth = null, - SpriteEffects? spriteEffects = null) + float? depth = null) { if (Texture == null) { return; } - spriteEffects ??= effects; - - bool flipHorizontal = spriteEffects.Value.HasFlag(SpriteEffects.FlipHorizontally); - bool flipVertical = spriteEffects.Value.HasFlag(SpriteEffects.FlipVertically); + bool flipHorizontal = (spriteEffects & SpriteEffects.FlipHorizontally) == SpriteEffects.FlipHorizontally; // optimized from spriteEffects.HasFlag(SpriteEffects.FlipHorizontally) + bool flipVertical = (spriteEffects & SpriteEffects.FlipVertically) == SpriteEffects.FlipVertically; // optimized from spriteEffects.HasFlag(SpriteEffects.FlipVertically) float addedRotation = rotation + this.rotation; if (flipHorizontal != flipVertical) { addedRotation = -addedRotation; } @@ -344,7 +357,7 @@ namespace Barotrauma rotation: addedRotation, origin: Vector2.Zero, scale: scale, - effects: spriteEffects.Value, + effects: spriteEffects, layerDepth: depth ?? this.depth); } @@ -419,7 +432,6 @@ namespace Barotrauma partial void DisposeTexture() { - disposeStackTrace = Environment.StackTrace; if (texture != null) { //check if another sprite is using the same texture diff --git a/Barotrauma/BarotraumaClient/ClientSource/StatusEffects/StatusEffect.cs b/Barotrauma/BarotraumaClient/ClientSource/StatusEffects/StatusEffect.cs index e74ee34bf..8f022f2e4 100644 --- a/Barotrauma/BarotraumaClient/ClientSource/StatusEffects/StatusEffect.cs +++ b/Barotrauma/BarotraumaClient/ClientSource/StatusEffects/StatusEffect.cs @@ -78,14 +78,23 @@ namespace Barotrauma { bool entityAngleAssigned = false; Limb targetLimb = null; - if (entity is Item item && item.body != null) + if (entity is Item item) { - angle = item.body.Rotation + ((item.body.Dir > 0.0f) ? 0.0f : MathHelper.Pi); - particleRotation = -item.body.Rotation; - if (emitter.Prefab.Properties.CopyEntityDir && item.body.Dir < 0.0f) + if (item.body != null) { - particleRotation += MathHelper.Pi; - mirrorAngle = true; + angle = item.body.Rotation + ((item.body.Dir > 0.0f) ? 0.0f : MathHelper.Pi); + particleRotation = -item.body.Rotation; + if (emitter.Prefab.Properties.CopyEntityDir && item.body.Dir < 0.0f) + { + particleRotation += MathHelper.Pi; + mirrorAngle = true; + } + } + else + { + angle = -item.RotationRad; + if (item.FlippedX) { angle += MathHelper.Pi; } + particleRotation = item.RotationRad; } entityAngleAssigned = true; } @@ -135,6 +144,7 @@ namespace Barotrauma private void PlaySound(Entity entity, Hull hull, Vector2 worldPosition) { if (sounds.Count == 0) { return; } + if (entity is { Submarine.Loading: true }) { return; } if (soundChannel == null || !soundChannel.IsPlaying || forcePlaySounds) { @@ -185,13 +195,8 @@ namespace Barotrauma soundChannel.Position = new Vector3(worldPosition, 0.0f); } - if (soundChannel != null && soundChannel.Looping) - { - ActiveLoopingSounds.Add(this); - soundEmitter = entity; - loopStartTime = Timing.TotalTime; - } - + KeepLoopingSoundAlive(soundChannel); + void PlaySoundOrDelayIfNotLoaded(RoundSound selectedSound) { if (playSoundAfterLoadedCoroutine != null) { return; } @@ -222,14 +227,28 @@ namespace Barotrauma void PlaySound(RoundSound selectedSound) { - //if the sound loops, we must make sure the existing channel + //if the sound loops, we must make sure the existing channel has been stopped first before attempting to play a new one System.Diagnostics.Debug.Assert( soundChannel == null || !soundChannel.IsPlaying || soundChannel.FadingOutAndDisposing || !soundChannel.Looping, "A StatusEffect attempted to play a sound, but an looping sound is already playing. The looping sound should be stopped before playing a new one, or it will keep looping indefinitely."); soundChannel = SoundPlayer.PlaySound(selectedSound.Sound, worldPosition, selectedSound.Volume, selectedSound.Range, hullGuess: hull, ignoreMuffling: selectedSound.IgnoreMuffling, freqMult: selectedSound.GetRandomFrequencyMultiplier()); ignoreMuffling = selectedSound.IgnoreMuffling; - if (soundChannel != null) { soundChannel.Looping = loopSound; } + if (soundChannel != null) + { + soundChannel.Looping = loopSound; + KeepLoopingSoundAlive(soundChannel); + } + } + + void KeepLoopingSoundAlive(SoundChannel soundChannel) + { + if (soundChannel != null && soundChannel.Looping) + { + ActiveLoopingSounds.Add(this); + soundEmitter = entity; + loopStartTime = Timing.TotalTime; + } } } diff --git a/Barotrauma/BarotraumaClient/ClientSource/Steam/SteamManager.cs b/Barotrauma/BarotraumaClient/ClientSource/Steam/SteamManager.cs index c2b404b07..875369abc 100644 --- a/Barotrauma/BarotraumaClient/ClientSource/Steam/SteamManager.cs +++ b/Barotrauma/BarotraumaClient/ClientSource/Steam/SteamManager.cs @@ -41,7 +41,13 @@ namespace Barotrauma.Steam } } - Steamworks.SteamNetworkingUtils.OnDebugOutput += LogSteamworksNetworking; + Steamworks.SteamNetworkingUtils.OnDebugOutput += (Steamworks.NetDebugOutput nType, string pszMsg) => + { + if (GameSettings.CurrentConfig.VerboseLogging) + { + LogSteamworksNetworking(nType, pszMsg); + } + }; // Needed to detect invites for social overlay Steamworks.SteamFriends.ListenForFriendsMessages = true; diff --git a/Barotrauma/BarotraumaClient/ClientSource/Steam/Workshop.cs b/Barotrauma/BarotraumaClient/ClientSource/Steam/Workshop.cs index f2d9e207e..cc1ef9c79 100644 --- a/Barotrauma/BarotraumaClient/ClientSource/Steam/Workshop.cs +++ b/Barotrauma/BarotraumaClient/ClientSource/Steam/Workshop.cs @@ -173,7 +173,7 @@ namespace Barotrauma.Steam } DeletePublishStagingCopy(); - Directory.CreateDirectory(PublishStagingDir); + Directory.CreateDirectory(PublishStagingDir, catchUnauthorizedAccessExceptions: false); await CopyDirectory(contentPackage.Dir, contentPackage.Name, Path.GetDirectoryName(contentPackage.Path)!, PublishStagingDir, ShouldCorrectPaths.No); var stagingFileListPath = Path.Combine(PublishStagingDir, ContentPackage.FileListFileName); diff --git a/Barotrauma/BarotraumaClient/ClientSource/Steam/WorkshopMenu/Mutable/InstalledTab.cs b/Barotrauma/BarotraumaClient/ClientSource/Steam/WorkshopMenu/Mutable/InstalledTab.cs index 2feff3989..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, @@ -674,77 +698,125 @@ namespace Barotrauma.Steam var contextMenuHandler = new GUICustomComponent(new RectTransform(Vector2.Zero, modFrame.RectTransform), onUpdate: (f, component) => { - var parentList = modFrame.Parent?.Parent?.Parent as GUIListBox; //lovely jank :) - if (parentList is null) { return; } - if (GUI.MouseOn == modFrame && parentList.DraggedElement is null && PlayerInput.SecondaryMouseButtonClicked()) + if (modFrame.Parent?.Parent?.Parent is not GUIListBox parentList || GUI.MouseOn != modFrame || parentList.DraggedElement is not null || !PlayerInput.SecondaryMouseButtonClicked()) { return; } + if (!parentList.AllSelected.Contains(modFrame)) { - if (!parentList.AllSelected.Contains(modFrame)) { parentList.Select(parentList.Content.GetChildIndex(modFrame)); } - static void noop() { } - - List contextMenuOptions = new List(); - if (ContentPackageManager.WorkshopPackages.Contains(mod)) + parentList.Select(parentList.Content.GetChildIndex(modFrame)); + } + List contextMenuOptions = new List(); + ContentPackage[] selectedMods = parentList.AllSelected.Select(it => it.UserData).OfType().ToArray(); + Identifier swapLabel = ((parentList == enabledRegularModsList, selectedMods.Length > 1) switch + { + (false, true) => "EnableSelectedWorkshopMods", + (false, false) => "EnableWorkshopMod", + (true, true) => "DisableSelectedWorkshopMods", + (true, false) => "DisableWorkshopMod" + }).ToIdentifier(); + contextMenuOptions.Add(new(swapLabel, isEnabled: true, currentSwapFunc ?? NoOp)); + if (ContentPackageManager.WorkshopPackages.Contains(mod)) + { + if (selectedMods.Length == 1) { - contextMenuOptions.Add( - new ContextMenuOption("ViewWorkshopModDetails".ToIdentifier(), isEnabled: true, onSelected: () => PrepareToShowModInfo(mod))); + contextMenuOptions.Add(new("ViewWorkshopModDetails".ToIdentifier(), isEnabled: true, () => PrepareToShowModInfo(mod))); + contextMenuOptions.Add(new("CopyWorkshopToLocal".ToIdentifier(), isEnabled: true, () => CopyToLocal())); } - - var labelConditions - = (parentList == enabledRegularModsList, parentList.AllSelected.Count > 1); - Identifier swapLabel = (labelConditions switch + if (mod.MissingDependencies.Any()) { - (false, true) => "EnableSelectedWorkshopMods", - (false, false) => "EnableWorkshopMod", - (true, true) => "DisableSelectedWorkshopMods", - (true, false) => "DisableWorkshopMod" - }).ToIdentifier(); - - contextMenuOptions.Add(new ContextMenuOption(swapLabel, - isEnabled: true, onSelected: currentSwapFunc ?? noop)); - - var selectedMods = parentList.AllSelected.Select(it => it.UserData) - .OfType().ToArray(); - if (selectedMods.All(ContentPackageManager.LocalPackages.Contains) && selectedMods.Length > 1) - { - contextMenuOptions.Add(new ContextMenuOption("MergeSelectedMods".ToIdentifier(), isEnabled: true, - onSelected: () => ModMerger.AskMerge(selectedMods))); + contextMenuOptions.Add(new("workshop.dependencynotfound.showmissingdependencies".ToIdentifier(), isEnabled: true, + () => CreateDependencyErrorMessageBox(mod, mod.MissingDependencies))); } - - GUIButton? iconBtn(GUIComponent component) => component.GetChild()?.GetAllChildren().Last(); - if (selectedMods.All(ContentPackageManager.WorkshopPackages.Contains) - && parentList.AllSelected.All(c => iconBtn(c)?.Style?.Identifier == "WorkshopMenu.DownloadedIcon") - && selectedMods.Length > 0) + if (selectedMods.All(ContentPackageManager.WorkshopPackages.Contains)) { - contextMenuOptions.Add(new ContextMenuOption( - (selectedMods.Length > 1 ? "UnsubscribeFromAllSelected" : "WorkshopItemUnsubscribe").ToIdentifier(), - isEnabled: true, - onSelected: () => + if (parentList.AllSelected.All(c => c.GetChild()?.GetAllChildren().Last()?.Style?.Identifier == "WorkshopMenu.DownloadedIcon") && selectedMods.Length > 0 && SteamManager.IsInitialized) + { + contextMenuOptions.Add(new((selectedMods.Length > 1 ? "UnsubscribeFromAllSelected" : "WorkshopItemUnsubscribe").ToIdentifier(), true, () => { - var workshopIds = selectedMods - .Select(m => m.UgcId) - .NotNone() - .OfType() - .Select(id => id.Value); - TaskPool.AddIfNotFound($"UnsubFromSelected", Task.WhenAll(workshopIds.Select(SteamManager.Workshop.GetItem)), - t => + TaskPool.AddIfNotFound("UnsubFromSelected", Task.WhenAll(selectedMods.Select(m => m.UgcId).NotNone().OfType().Select(id => SteamManager.Workshop.GetItem(id.Value))), t => + { + if (!t.TryGetResult(out Option[]? itemOptions)) { return; } + itemOptions.ForEach(io => { - if (!t.TryGetResult(out Steamworks.Ugc.Item?[]? items)) { return; } - items.ForEach(it => - { - if (!(it is { } item)) { return; } - - item.Unsubscribe(); - SteamManager.Workshop.Uninstall(item); - PopulateInstalledModLists(); - }); + if (!io.TryUnwrap(out Steamworks.Ugc.Item item) || !item.IsSubscribed) { return; } + item.Unsubscribe(); + SteamManager.Workshop.Uninstall(item); + PopulateInstalledModLists(); }); + }); })); + } } - - GUIContextMenu.CreateContextMenu( - pos: PlayerInput.MousePosition, - header: ToolBox.LimitString(mod.Name, GUIStyle.SubHeadingFont, GUI.IntScale(300f)), - headerColor: null, - contextMenuOptions.ToArray()); + } + else if (ContentPackageManager.LocalPackages.Contains(mod)) + { + if (selectedMods.Length == 1) + { + contextMenuOptions.Add(new ContextMenuOption("RenamePackage".ToIdentifier(), isEnabled: true, AskToRenameLocal)); + } + if (selectedMods.All(ContentPackageManager.LocalPackages.Contains)) + { + if (selectedMods.Length > 1) + { + contextMenuOptions.Add(new("MergeSelectedMods".ToIdentifier(), isEnabled: true, () => ModMerger.AskMerge(selectedMods))); + } + contextMenuOptions.Add(new("Delete".ToIdentifier(), isEnabled: true, AskToDeleteLocal)); + } + } + GUIContextMenu.CreateContextMenu(PlayerInput.MousePosition, ToolBox.LimitString(mod.Name, GUIStyle.SubHeadingFont, GUI.IntScale(300)), null, contextMenuOptions.ToArray()); + static void NoOp() { } + void AskToRenameLocal() + { + GUIMessageBox msgBox = new(TextManager.Get("RenamePackage"), mod.Name, new LocalizedString[] { TextManager.Get("Confirm"), TextManager.Get("Cancel") }, minSize: new Point(0, GUI.IntScale(195))); + GUITextBox textBox = new(new(Vector2.One, msgBox.Content.RectTransform), mod.Name) + { + OnEnterPressed = (textBox, text) => + { + textBox.Text = text.Trim(); + return true; + } + }; + msgBox.Buttons[0].OnClicked += msgBox.Close; + msgBox.Buttons[1].OnClicked += (_, _) => + { + if (textBox.Text == mod.Name) + { + msgBox.Close(); + return true; + } + if (mod.TryRenameLocal(textBox.Text)) + { + PopulateInstalledModLists(); + msgBox.Close(); + return true; + } + else + { + textBox.Flash(); + return false; + } + }; + } + void CopyToLocal() + { + if (mod.TryCreateLocalFromWorkshop()) + { + PopulateInstalledModLists(); + } + } + void AskToDeleteLocal() + { + GUIMessageBox msgBox = new(TextManager.Get("DeleteMods"), TextManager.GetWithVariables("DeleteModsConfirm", ("[amount]", selectedMods.Length.ToString())), + new LocalizedString[] { TextManager.Get("Confirm"), TextManager.Get("Cancel")}, textAlignment: Alignment.TopCenter); + msgBox.Buttons[0].OnClicked += (_, _) => + { + foreach (ContentPackage mod in selectedMods) + { + mod.TryDeleteLocal(); + PopulateInstalledModLists(); + } + msgBox.Close(); + return true; + }; + msgBox.Buttons[1].OnClicked += msgBox.Close; } }); @@ -813,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/ModListPreset.cs b/Barotrauma/BarotraumaClient/ClientSource/Steam/WorkshopMenu/Mutable/ModListPreset.cs index 12b4b2876..a628471b8 100644 --- a/Barotrauma/BarotraumaClient/ClientSource/Steam/WorkshopMenu/Mutable/ModListPreset.cs +++ b/Barotrauma/BarotraumaClient/ClientSource/Steam/WorkshopMenu/Mutable/ModListPreset.cs @@ -127,7 +127,7 @@ namespace Barotrauma writePkgElem(CorePackage); RegularPackages.ForEach(writePkgElem); - if (!Directory.Exists(SavePath)) { Directory.CreateDirectory(SavePath); } + if (!Directory.Exists(SavePath)) { Directory.CreateDirectory(SavePath, catchUnauthorizedAccessExceptions: false); } newDoc.SaveSafe(Path.Combine(SavePath, ToolBox.RemoveInvalidFileNameChars($"{Name}.xml"))); } } 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/ClientSource/Utils/LocalizationCSVtoXML.cs b/Barotrauma/BarotraumaClient/ClientSource/Utils/LocalizationCSVtoXML.cs index 45111625f..f8459f3bd 100644 --- a/Barotrauma/BarotraumaClient/ClientSource/Utils/LocalizationCSVtoXML.cs +++ b/Barotrauma/BarotraumaClient/ClientSource/Utils/LocalizationCSVtoXML.cs @@ -45,7 +45,7 @@ namespace Barotrauma Dictionary> xmlContent; try { - xmlContent = ConvertInfoTextToXML(File.ReadAllLines(textFilePath, Encoding.UTF8)); + xmlContent = ConvertInfoTextToXML(File.ReadAllLines(textFilePath, Encoding.UTF8, catchUnauthorizedAccessExceptions: false)); } catch (Exception e) { @@ -156,7 +156,7 @@ namespace Barotrauma List xmlContent; try { - xmlContent = ConvertInfoTextToXML(File.ReadAllLines(infoTextFiles[j], Encoding.UTF8), language); + xmlContent = ConvertInfoTextToXML(File.ReadAllLines(infoTextFiles[j], Encoding.UTF8, catchUnauthorizedAccessExceptions: false), language); } catch (Exception e) { diff --git a/Barotrauma/BarotraumaClient/ClientSource/Utils/MathUtils.cs b/Barotrauma/BarotraumaClient/ClientSource/Utils/MathUtils.cs index d8b4d38d1..473a29f70 100644 --- a/Barotrauma/BarotraumaClient/ClientSource/Utils/MathUtils.cs +++ b/Barotrauma/BarotraumaClient/ClientSource/Utils/MathUtils.cs @@ -1,4 +1,5 @@ using Microsoft.Xna.Framework; +using Microsoft.Xna.Framework.Graphics; using System.Collections.Generic; namespace Barotrauma @@ -16,4 +17,22 @@ namespace Barotrauma return -CompareCCW.Compare(a.WorldPos, b.WorldPos, center); } } + + public class CompareVertexPositionColorCCW : IComparer + { + private Vector2 center; + + public CompareVertexPositionColorCCW(Vector2 center) + { + this.center = center; + } + public int Compare(VertexPositionColor a, VertexPositionColor b) + { + return -CompareCW.Compare(new Vector2(a.Position.X, a.Position.Y), new Vector2(b.Position.X, b.Position.Y), center); + } + public static int Compare(VertexPositionColor a, VertexPositionColor b, Vector2 center) + { + return -CompareCW.Compare(new Vector2(a.Position.X, a.Position.Y), new Vector2(b.Position.X, b.Position.Y), center); + } + } } diff --git a/Barotrauma/BarotraumaClient/ClientSource/Utils/WikiImage.cs b/Barotrauma/BarotraumaClient/ClientSource/Utils/WikiImage.cs index 7b13a797d..2958609f6 100644 --- a/Barotrauma/BarotraumaClient/ClientSource/Utils/WikiImage.cs +++ b/Barotrauma/BarotraumaClient/ClientSource/Utils/WikiImage.cs @@ -76,11 +76,14 @@ namespace Barotrauma float zoom = (float)texWidth / (float)boundingBox.Width; int texHeight = (int)(zoom * boundingBox.Height); - Camera cam = new Camera(); + Camera cam = new Camera() + { + AutoUpdateToScreenResolution = false, + MaxZoom = zoom, + MinZoom = zoom * 0.5f, + Zoom = zoom + }; cam.SetResolution(new Point(texWidth, texHeight)); - cam.MaxZoom = zoom; - cam.MinZoom = zoom * 0.5f; - cam.Zoom = zoom; cam.Position = boundingBox.Center.ToVector2(); cam.UpdateTransform(false); diff --git a/Barotrauma/BarotraumaClient/Content/Effects/damageshader.xnb b/Barotrauma/BarotraumaClient/Content/Effects/damageshader.xnb index 4b95d883feb67d25dc3adad5278be84be922ef21..929e4e43c252344132ee3261996e33ac086eaa64 100644 GIT binary patch delta 628 zcmZXQze}585XbM{-#RpJV}?Z3iV#9+hYljIQRr)w)IzO}aqAEYfl^B&h`1yngJ=-! z106C|)I~&s{sV4R+oe;bbm$Op6r^ME?e|{ZppXadecrqK-g9@Kz4(6IF1*gIn4I3W z>!r^Q(cFuC!Qros-4mu^GKq*?(h{U6$rvDw{^&$Ah1_(HUq1X4{J1^8-kAS6vwFc$ zO3uLj3|2(IgMSE{T6#bJ6uuZ~LGV}oHq!k};)VyYN66s6LKJJMnCYdMVsHW&?O+UD zBd5aRt2NGeLg1OFKBHr*-*6+YqwxkspL*21ugZ9z0`L!_KBzrTSQTQYZ!kR zKt0i(TQ{zY$<&}Vq-xqMv-+k@;A`t+lf54aFrdt9(H$3U=vSuX2^`F3*PxS=jr3X9 zRaL#oF*RU(STKoEWJzHo!g;)YG&;0=#i_^jmz6~FUnkeW=^@ZY=R$-t>?$IlI|I&4 zQpO5TH%$K_lp!ubAy8SpF*A4@KsHT3wTHZq;u5SINUa0pEilwAfpKb$CG3%VOUfv+ aTY~=;rtMY|oW$(5|e6mVOlsv6weoB=2#xn~5R){%@%DW+VWSYNv!fdE{ zQRZ5W@!#VNm)cgq2skdQWZeucn~#rs)nZ)5hJsCU?KhfeMysJM&8~AGFyH<6Z#@Ww zz7?-C8JpzGFTZxaHcpFkSC1F#_1=1G)=DzR#yMY5N|Jn}*1{(F**v&eaZIP~Vr)?- zX>l;tw2e=hk2=d(X#H3hGKV~>K;;7QKkI2T?@iNq-Lv-Zyy0}v_m3s}vX-S+eqyQZ S*-~Jpf)`pGe`(pw1dS)TdUW9c diff --git a/Barotrauma/BarotraumaClient/Content/Effects/damageshader_opengl.xnb b/Barotrauma/BarotraumaClient/Content/Effects/damageshader_opengl.xnb index 5cffd1d1a6f5697268c8d718c6bff009fdf87944..52ac9ea0f5e9de7fec834a343aa8f6ede00853d9 100644 GIT binary patch delta 296 zcmaDP_(*WVRM!h`Mmw1V3X0=P^D@)&i*k$O%Tkj~92i&_7#J7@7?>0om<<>(!IMlv7gEGV@XufOeD_ zD3l@OCiAgPnEaJVjL~2+53?ep;bcALN=Bo}E0|RnjVGUF)@3Z4{F_-1NNTav39>RU zGBU6N%_ufxV+2|)2ecEY%!g5XasX@QWMd{0Y+W{)o0)I&AvTf8y38_@Ke5Fyl^IM9 RU~dvYv4oL0om<E@Sd!4i-HiY0gq7$i~3P$iNCT zqu7w05oi_n#9PxR$FgQle$JQ*G-?{7(d4Uaia_nw%+iyE*khQI4JQ|{HyNPlV`Si9 ZWMBarotrauma FakeFish, Undertow Games Barotrauma - 1.5.9.1 + 1.8.4.0 Copyright © FakeFish 2018-2024 AnyCPU;x64 Barotrauma @@ -55,7 +55,7 @@ - + diff --git a/Barotrauma/BarotraumaClient/MacClient.csproj b/Barotrauma/BarotraumaClient/MacClient.csproj index da41bd347..21004ac62 100644 --- a/Barotrauma/BarotraumaClient/MacClient.csproj +++ b/Barotrauma/BarotraumaClient/MacClient.csproj @@ -6,7 +6,7 @@ Barotrauma FakeFish, Undertow Games Barotrauma - 1.5.9.1 + 1.8.4.0 Copyright © FakeFish 2018-2024 AnyCPU;x64 Barotrauma @@ -57,7 +57,7 @@ - + diff --git a/Barotrauma/BarotraumaClient/Shaders/damageshader.fx b/Barotrauma/BarotraumaClient/Shaders/damageshader.fx index 3ec964651..42fa721f9 100644 --- a/Barotrauma/BarotraumaClient/Shaders/damageshader.fx +++ b/Barotrauma/BarotraumaClient/Shaders/damageshader.fx @@ -1,4 +1,4 @@ - + Texture2D xTexture; sampler TextureSampler : register (s0) = sampler_state { Texture = ; }; @@ -7,8 +7,6 @@ sampler StencilSampler = sampler_state { Texture = ; }; float4 solidColor; -float4 inColor; - float aCutoff; float aMultiplier; @@ -33,7 +31,7 @@ float4 main(float4 position : POSITION0, float4 color : COLOR0, float2 texCoord float4 solidColorStencil(float4 position : POSITION0, float4 color : COLOR0, float2 texCoord : TEXCOORD0) : COLOR0 { - float4 c = xTexture.Sample(TextureSampler, texCoord) * inColor; + float4 c = xTexture.Sample(TextureSampler, texCoord) * color; float4 stencilColor = xStencil.Sample(StencilSampler, texCoord); float aDiff = stencilColor.a - aCutoff; diff --git a/Barotrauma/BarotraumaClient/Shaders/damageshader_opengl.fx b/Barotrauma/BarotraumaClient/Shaders/damageshader_opengl.fx index 69370113c..4601a6ed9 100644 --- a/Barotrauma/BarotraumaClient/Shaders/damageshader_opengl.fx +++ b/Barotrauma/BarotraumaClient/Shaders/damageshader_opengl.fx @@ -1,4 +1,4 @@ - + Texture xTexture; sampler TextureSampler : register (s0) = sampler_state { Texture = ; }; @@ -7,8 +7,6 @@ sampler StencilSampler = sampler_state { Texture = ; }; float4 solidColor; -float4 inColor; - float aCutoff; float aMultiplier; @@ -17,7 +15,7 @@ float cMultiplier; float4 main(float4 position : POSITION0, float4 color : COLOR0, float2 texCoord : TEXCOORD0) : COLOR0 { - float4 c = tex2D(TextureSampler, texCoord) * inColor; + float4 c = tex2D(TextureSampler, texCoord) * color; float4 stencilColor = tex2D(StencilSampler, texCoord); float aDiff = stencilColor.a - aCutoff; @@ -33,7 +31,7 @@ float4 main(float4 position : POSITION0, float4 color : COLOR0, float2 texCoord float4 solidColorStencil(float4 position : POSITION0, float4 color : COLOR0, float2 texCoord : TEXCOORD0) : COLOR0 { - float4 c = tex2D(TextureSampler, texCoord) * inColor; + float4 c = tex2D(TextureSampler, texCoord) * color; float4 stencilColor = tex2D(StencilSampler, texCoord); float aDiff = stencilColor.a - aCutoff; diff --git a/Barotrauma/BarotraumaClient/WindowsClient.csproj b/Barotrauma/BarotraumaClient/WindowsClient.csproj index f28e3cf55..eef991d7c 100644 --- a/Barotrauma/BarotraumaClient/WindowsClient.csproj +++ b/Barotrauma/BarotraumaClient/WindowsClient.csproj @@ -6,7 +6,7 @@ Barotrauma FakeFish, Undertow Games Barotrauma - 1.5.9.1 + 1.8.4.0 Copyright © FakeFish 2018-2024 AnyCPU;x64 Barotrauma @@ -63,7 +63,7 @@ - + diff --git a/Barotrauma/BarotraumaServer/LinuxServer.csproj b/Barotrauma/BarotraumaServer/LinuxServer.csproj index 4e9e9584b..047e5204b 100644 --- a/Barotrauma/BarotraumaServer/LinuxServer.csproj +++ b/Barotrauma/BarotraumaServer/LinuxServer.csproj @@ -6,7 +6,7 @@ Barotrauma FakeFish, Undertow Games Barotrauma Dedicated Server - 1.5.9.1 + 1.8.4.0 Copyright © FakeFish 2018-2023 AnyCPU;x64 DedicatedServer @@ -55,7 +55,7 @@ - + diff --git a/Barotrauma/BarotraumaServer/MacServer.csproj b/Barotrauma/BarotraumaServer/MacServer.csproj index 3a47a3f9d..190e00550 100644 --- a/Barotrauma/BarotraumaServer/MacServer.csproj +++ b/Barotrauma/BarotraumaServer/MacServer.csproj @@ -6,7 +6,7 @@ Barotrauma FakeFish, Undertow Games Barotrauma Dedicated Server - 1.5.9.1 + 1.8.4.0 Copyright © FakeFish 2018-2023 AnyCPU;x64 DedicatedServer @@ -59,7 +59,7 @@ - + diff --git a/Barotrauma/BarotraumaServer/ServerSource/Characters/Character.cs b/Barotrauma/BarotraumaServer/ServerSource/Characters/Character.cs index 144b8fbdc..fc53217c5 100644 --- a/Barotrauma/BarotraumaServer/ServerSource/Characters/Character.cs +++ b/Barotrauma/BarotraumaServer/ServerSource/Characters/Character.cs @@ -1,6 +1,7 @@ using Barotrauma.Networking; using System.Linq; using System.Xml.Linq; +using Barotrauma.Items.Components; namespace Barotrauma { @@ -40,7 +41,7 @@ namespace Barotrauma { matchingData.ApplyPermadeath(); - if (GameMain.Server is { ServerSettings.IronmanMode: true }) + if (GameMain.Server?.ServerSettings is { IronmanModeActive: true }) { mpCampaign.SaveSingleCharacter(matchingData); } @@ -85,5 +86,16 @@ namespace Barotrauma { GameServer.Log($"{GameServer.CharacterLogName(this)} has gained the talent '{talentPrefab.DisplayName}'", ServerLog.MessageType.Talent); } + + private void SyncInGameEditables(Item item) + { + foreach (ItemComponent itemComponent in item.Components) + { + foreach (var serializableProperty in SerializableProperty.GetProperties(itemComponent)) + { + GameMain.Server.CreateEntityEvent(item, new Item.ChangePropertyEventData(serializableProperty, itemComponent)); + } + } + } } } diff --git a/Barotrauma/BarotraumaServer/ServerSource/Characters/CharacterInfo.cs b/Barotrauma/BarotraumaServer/ServerSource/Characters/CharacterInfo.cs index 6b35b7d99..21ca78783 100644 --- a/Barotrauma/BarotraumaServer/ServerSource/Characters/CharacterInfo.cs +++ b/Barotrauma/BarotraumaServer/ServerSource/Characters/CharacterInfo.cs @@ -21,16 +21,16 @@ namespace Barotrauma CauseOfDeath = null; } - partial void OnSkillChanged(Identifier skillIdentifier, float prevLevel, float newLevel) + partial void OnSkillChanged(Identifier skillIdentifier, float prevLevel, float newLevel, bool forceNotification) { if (Character == null || Character.Removed) { return; } if (!prevSentSkill.ContainsKey(skillIdentifier)) { prevSentSkill[skillIdentifier] = prevLevel; } - if (Math.Abs(prevSentSkill[skillIdentifier] - newLevel) > 0.01f) + if (Math.Abs(prevSentSkill[skillIdentifier] - newLevel) > 0.1f || forceNotification) { - GameMain.NetworkMember.CreateEntityEvent(Character, new Character.UpdateSkillsEventData()); + GameMain.NetworkMember.CreateEntityEvent(Character, new Character.UpdateSkillsEventData(skillIdentifier, forceNotification)); prevSentSkill[skillIdentifier] = newLevel; } } @@ -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) { @@ -98,6 +100,8 @@ namespace Barotrauma msg.WriteInt32(ExperiencePoints); msg.WriteRangedInteger(AdditionalTalentPoints, 0, MaxAdditionalTalentPoints); msg.WriteBoolean(PermanentlyDead); + msg.WriteInt32(TalentRefundPoints); + msg.WriteInt32(TalentResetCount); } } } diff --git a/Barotrauma/BarotraumaServer/ServerSource/Characters/CharacterNetworking.cs b/Barotrauma/BarotraumaServer/ServerSource/Characters/CharacterNetworking.cs index 9d5bce1a6..b8c7ffbaa 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) { @@ -298,17 +299,12 @@ namespace Barotrauma Kill(causeOfDeath.type, causeOfDeath.affliction); } break; + case EventType.ConfirmTalentRefund: + if (!CanManageTalents(c)) { return; } + Info?.RefundTalents(); + break; case EventType.UpdateTalents: - if (c.Character != this) - { - if (!IsBot || !c.HasPermission(ClientPermissions.ManageBotTalents)) - { -#if DEBUG - DebugConsole.Log("Received a character update message from a client who's not controlling the character"); -#endif - return; - } - } + if (!CanManageTalents(c)) { return; } // get the full list of talents from the player, only give the ones // that are not already given (or otherwise not viable) @@ -332,6 +328,22 @@ namespace Barotrauma } break; } + + bool CanManageTalents(Client client) + { + if (client.Character != this) + { + if (client.TeamID != TeamID || !IsBot || !client.HasPermission(ClientPermissions.ManageBotTalents) || client.Spectating) + { +#if DEBUG + DebugConsole.Log("A client tried to manage talents of a character they don't control or have permission to manage"); +#endif + return false; + } + } + + return true; + } } public void ServerWritePosition(ReadWriteMessage tempBuffer, Client c) @@ -377,8 +389,15 @@ namespace Barotrauma tempBuffer.WriteBoolean(shoot); tempBuffer.WriteBoolean(use); - tempBuffer.WriteBoolean(AnimController is HumanoidAnimController { Crouching: true }); - + if (AnimController is HumanoidAnimController humanAnim) + { + tempBuffer.WriteBoolean(humanAnim.Crouching); + } + else if (AnimController is FishAnimController fishAnim) + { + tempBuffer.WriteBoolean(fishAnim.Reverse); + } + tempBuffer.WriteBoolean(attack); Vector2 relativeCursorPos = cursorPosition - AimRefPosition; @@ -409,21 +428,28 @@ namespace Barotrauma tempBuffer.WriteSingle(SimPosition.Y); float MaxVel = NetConfig.MaxPhysicsBodyVelocity; AnimController.Collider.LinearVelocity = new Vector2( - MathHelper.Clamp(AnimController.Collider.LinearVelocity.X, -MaxVel, MaxVel), - MathHelper.Clamp(AnimController.Collider.LinearVelocity.Y, -MaxVel, MaxVel)); + NetConfig.Quantize(AnimController.Collider.LinearVelocity.X, -MaxVel, MaxVel, 12), + NetConfig.Quantize(AnimController.Collider.LinearVelocity.Y, -MaxVel, MaxVel, 12)); tempBuffer.WriteRangedSingle(AnimController.Collider.LinearVelocity.X, -MaxVel, MaxVel, 12); tempBuffer.WriteRangedSingle(AnimController.Collider.LinearVelocity.Y, -MaxVel, MaxVel, 12); - bool fixedRotation = AnimController.Collider.FarseerBody.FixedRotation || !AnimController.Collider.PhysEnabled; + AnimController.TargetMovement = new Vector2( + NetConfig.Quantize(AnimController.TargetMovement.X, -Ragdoll.MAX_SPEED, Ragdoll.MAX_SPEED, 12), + NetConfig.Quantize(AnimController.TargetMovement.Y, -Ragdoll.MAX_SPEED, Ragdoll.MAX_SPEED, 12)); + tempBuffer.WriteRangedSingle(AnimController.TargetMovement.X, -Ragdoll.MAX_SPEED, Ragdoll.MAX_SPEED, 12); + tempBuffer.WriteRangedSingle(AnimController.TargetMovement.Y, -Ragdoll.MAX_SPEED, Ragdoll.MAX_SPEED, 12); + + bool fixedRotation = AnimController.Collider.FarseerBody.FixedRotation; tempBuffer.WriteBoolean(fixedRotation); if (!fixedRotation) { tempBuffer.WriteSingle(AnimController.Collider.Rotation); - float MaxAngularVel = NetConfig.MaxPhysicsBodyAngularVelocity; - AnimController.Collider.AngularVelocity = 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); } + + tempBuffer.WriteBoolean(AnimController.IgnorePlatforms); + bool writeStatus = healthUpdateTimer <= 0.0f; tempBuffer.WriteBoolean(writeStatus); if (writeStatus) @@ -463,21 +489,19 @@ namespace Barotrauma break; case CharacterStatusEventData statusEventData: WriteStatus(msg, statusEventData.ForceAfflictionData); + msg.WriteBoolean(GodMode); break; - case UpdateSkillsEventData _: - if (Info?.Job == null) + case UpdateSkillsEventData updateSkillsData: + if (Info?.Job is { } job) { - msg.WriteByte((byte)0); + msg.WriteIdentifier(updateSkillsData.SkillIdentifier); + msg.WriteBoolean(updateSkillsData.ForceNotification); + //don't use Character.GetSkillLevel here, because it applies all the temporary boosts from items and afflictions on the skill level + msg.WriteSingle(job.GetSkillLevel(updateSkillsData.SkillIdentifier)); } else { - var skills = Info.Job.GetSkills(); - msg.WriteByte((byte)skills.Count()); - foreach (Skill skill in skills) - { - msg.WriteIdentifier(skill.Identifier); - msg.WriteSingle(skill.Level); - } + msg.WriteIdentifier(Identifier.Empty); } break; case IAttackEventData attackEventData: @@ -501,7 +525,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: @@ -555,6 +586,7 @@ namespace Barotrauma break; case UpdateExperienceEventData _: msg.WriteInt32(Info.ExperiencePoints); + msg.WriteInt32(info.AdditionalTalentPoints); break; case UpdateTalentsEventData _: msg.WriteUInt16((ushort)characterTalents.Count); @@ -565,9 +597,16 @@ namespace Barotrauma } break; case UpdateMoneyEventData _: - msg.WriteInt32(GameMain.GameSession.Campaign.GetWallet(c).Balance); + msg.WriteInt32(Wallet?.Balance ?? 0); + break; + case UpdateRefundPointsEventData when Info is { } i: + msg.WriteInt32(i.TalentRefundPoints); + break; + case ConfirmRefundEventData: + // No data break; case UpdatePermanentStatsEventData updatePermanentStatsEventData: + StatTypes statType = updatePermanentStatsEventData.StatType; if (Info == null) { @@ -644,6 +683,7 @@ namespace Barotrauma { CharacterHealth.ServerWrite(msg); } + if (AnimController?.LimbJoints == null) { //0 limbs severed @@ -699,7 +739,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/Characters/Limb.cs b/Barotrauma/BarotraumaServer/ServerSource/Characters/Limb.cs new file mode 100644 index 000000000..b4e16fe32 --- /dev/null +++ b/Barotrauma/BarotraumaServer/ServerSource/Characters/Limb.cs @@ -0,0 +1,70 @@ +using Barotrauma.Networking; +using System.Collections.Generic; +using System.Linq; + +namespace Barotrauma +{ + partial class Limb : ISerializableEntity, ISpatialEntity + { + /// + /// An invisible "ghost body" used for doing lag compensation server side by allowing clients' shots to hit bodies at the positions where + /// they "used to be" back when the client fired a weapon. + /// + public PhysicsBody LagCompensatedBody { get; private set; } + + /// + /// A queue of past positions of the limb. + /// + public Queue MemState { get; } = new Queue(); + + partial void InitProjSpecific(ContentXElement element) + { + LagCompensatedBody = new PhysicsBody(Params, findNewContacts: false) + { + BodyType = FarseerPhysics.BodyType.Static, + CollisionCategories = Physics.CollisionLagCompensationBody, + CollidesWith = Physics.CollisionNone, + UserData = this + }; + } + + partial void UpdateProjSpecific(float deltaTime) + { + if (GameMain.Server == null) { return; } + + MemState.Enqueue(new PosInfo(body.SimPosition, body.Rotation, body.LinearVelocity, body.AngularVelocity, (float)Timing.TotalTime)); + + //clear old states + while ( + MemState.Any() && + MemState.Peek().Timestamp < Timing.TotalTime - GameMain.Server.ServerSettings.MaxLagCompensationSeconds) + { + MemState.Dequeue(); + } + } + + public static void SetLagCompensatedBodyPositions(Client client) + { + if (GameMain.Server == null) { return; } + //convert from milliseconds to seconds, assume latency is symmetrical (time from client to server is half of the roundtrip time / ping) + float latency = client.Ping / 1000.0f / 2; + float time = (float)Timing.TotalTime - MathUtils.Min(latency, GameMain.Server.ServerSettings.MaxLagCompensationSeconds); + + foreach (var character in Character.CharacterList) + { + foreach (var limb in character.AnimController.Limbs) + { + if (limb.body.Enabled == false || limb.IgnoreCollisions) { continue; } + var matchingState = limb.MemState.FirstOrDefault(l => l.Timestamp <= time); + if (matchingState == null) { continue; } + limb.LagCompensatedBody.SetTransformIgnoreContacts(matchingState.Position, matchingState.Rotation ?? 0.0f); + } + } + } + + partial void RemoveProjSpecific() + { + LagCompensatedBody.Remove(); + } + } +} diff --git a/Barotrauma/BarotraumaServer/ServerSource/DebugConsole.cs b/Barotrauma/BarotraumaServer/ServerSource/DebugConsole.cs index 0285452fa..2c966e762 100644 --- a/Barotrauma/BarotraumaServer/ServerSource/DebugConsole.cs +++ b/Barotrauma/BarotraumaServer/ServerSource/DebugConsole.cs @@ -377,11 +377,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 { @@ -390,13 +390,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 { @@ -498,14 +498,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) => { @@ -716,9 +712,7 @@ namespace Barotrauma ShowQuestionPrompt("Console command permissions to revoke from \"" + client.Name + "\"? You may enter multiple commands separated with a space.", (commandsStr) => { - Identifier[] splitCommands = commandsStr.Split(' ') - .Select(s => s.Trim()) - .ToIdentifiers().ToArray(); + Identifier[] splitCommands = commandsStr.ToIdentifiers(separator: " ").ToArray(); List revokedCommands = new List(); bool revokeAll = splitCommands.Length > 0 && splitCommands[0] == "all"; if (revokeAll) @@ -1351,14 +1345,14 @@ namespace Barotrauma commands.Add(new Command("mission", "mission [name]: Select the mission type for the next round.", (string[] args) => { - GameMain.NetLobbyScreen.MissionTypeName = string.Join(" ", args); - NewMessage("Set mission to " + GameMain.NetLobbyScreen.MissionTypeName, Color.Cyan); + GameMain.NetLobbyScreen.MissionTypes = args.ToIdentifiers(); + NewMessage("Set mission to " + string.Join(",", GameMain.NetLobbyScreen.MissionTypes), Color.Cyan); }, () => { return new string[][] { - Enum.GetNames(typeof(MissionType)) + MissionPrefab.GetAllMultiplayerSelectableMissionTypes().Select(id => id.Value).ToArray() }; })); @@ -1404,10 +1398,7 @@ namespace Barotrauma AssignOnExecute("respawnnow", (string[] args) => { if (GameMain.Server?.RespawnManager == null) { return; } - if (GameMain.Server.RespawnManager.CurrentState != RespawnManager.State.Transporting) - { - GameMain.Server.RespawnManager.ForceRespawn(); - } + GameMain.Server.RespawnManager.ForceRespawn(); }); commands.Add(new Command("startgame|startround|start", "start/startgame/startround: Start a new round.", (string[] args) => @@ -1416,7 +1407,7 @@ namespace Barotrauma if (GameMain.GameSession?.GameMode is MultiPlayerCampaign mpCampaign && GameMain.NetLobbyScreen.SelectedMode == GameModePreset.MultiPlayerCampaign) { - MultiPlayerCampaign.LoadCampaign(GameMain.GameSession.SavePath, client: null); + MultiPlayerCampaign.LoadCampaign(GameMain.GameSession.DataPath, client: null); } else { @@ -1425,7 +1416,9 @@ namespace Barotrauma MultiPlayerCampaign.StartCampaignSetup(); return; } - if (!GameMain.Server.TryStartGame()) { NewMessage("Failed to start a new round", Color.Yellow); } + + var result = GameMain.Server.TryStartGame(); + if (result != GameServer.TryStartGameResult.Success) { NewMessage($"Failed to start a new round: {TextManager.Get($"TryStartGameError.{result}")}", Color.Yellow); } } })); @@ -1614,18 +1607,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) => @@ -1714,7 +1695,34 @@ namespace Barotrauma SpawnItem(args, cursorWorldPos, client.Character, out string errorMsg); if (!string.IsNullOrWhiteSpace(errorMsg)) { - GameMain.Server.SendConsoleMessage(errorMsg, client); + GameMain.Server.SendConsoleMessage(errorMsg, client, Color.Red); + } + } + ); + + AssignOnClientRequestExecute( + "give", + (Client client, Vector2 cursorWorldPos, string[] args) => + { + if (client.Character == null) + { + GameMain.Server.SendConsoleMessage("No character is selected!", client, Color.Red); + return; + } + + if (args.Length == 0) + { + GameMain.Server.SendConsoleMessage("Please give the name or identifier of the item to spawn.", client, Color.Red); + return; + } + + var modifiedArgs = new List(args); + modifiedArgs.Insert(1, "inventory"); + + SpawnItem(modifiedArgs.ToArray(), cursorWorldPos, client.Character, out string errorMsg); + if (!string.IsNullOrWhiteSpace(errorMsg)) + { + GameMain.Server.SendConsoleMessage(errorMsg, client, Color.Red); } } ); @@ -1770,11 +1778,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); } ); @@ -1832,14 +1846,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); + } } ); @@ -1897,24 +1913,25 @@ namespace Barotrauma } } ); + + AssignOnClientRequestExecute( + "healme", + (Client client, Vector2 cursorWorldPos, string[] args) => + { + bool healAll = args.Length > 0 && args[0].Equals("all", StringComparison.OrdinalIgnoreCase); + if (client.Character != null) + { + HealCharacter(client.Character, healAll, client); + } + } + ); AssignOnClientRequestExecute( "heal", (Client client, Vector2 cursorWorldPos, 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) - { - healedCharacter.SetAllDamage(0.0f, 0.0f, 0.0f); - healedCharacter.Oxygen = 100.0f; - healedCharacter.Bloodloss = 0.0f; - healedCharacter.SetStun(0.0f, true); - if (healAll) - { - healedCharacter.CharacterHealth.RemoveAllAfflictions(); - } - } + HandleCommandForCrewOrSingleCharacter(args, (Character targetCharacter) => HealCharacter(targetCharacter, healAll, client), client); } ); @@ -1934,7 +1951,7 @@ namespace Barotrauma // If killed in ironman mode, the character has been wiped from the save mid-round, so its // original data needs to be restored to the save file (without making a backup of the dead character) - if (GameMain.Server.ServerSettings.IronmanMode && GameMain.GameSession?.Campaign is MultiPlayerCampaign mpCampaign) + if (GameMain.Server.ServerSettings is { IronmanModeActive: true } && GameMain.GameSession?.Campaign is MultiPlayerCampaign mpCampaign) { if (mpCampaign.RestoreSingleCharacterFromBackup(c) is CharacterCampaignData characterToRestore) { @@ -2546,6 +2563,7 @@ namespace Barotrauma { foreach (Skill skill in character.Info.Job.GetSkills()) { + GameMain.NetworkMember.CreateEntityEvent(character, new Character.UpdateSkillsEventData(skill.Identifier, forceNotification: true)); character.Info.SetSkillLevel(skill.Identifier, level); } GameMain.Server.SendConsoleMessage($"Set all {character.Name}'s skills to {level}", senderClient); @@ -2553,10 +2571,10 @@ namespace Barotrauma else { character.Info.SetSkillLevel(skillIdentifier, level); + GameMain.NetworkMember.CreateEntityEvent(character, new Character.UpdateSkillsEventData(skillIdentifier, forceNotification: true)); GameMain.Server.SendConsoleMessage($"Set {character.Name}'s {skillIdentifier} level to {level}", senderClient); } - GameMain.NetworkMember.CreateEntityEvent(character, new Character.UpdateSkillsEventData()); } else { @@ -2699,6 +2717,11 @@ namespace Barotrauma { GameMain.Server.CreateEntityEvent(wall); } + foreach (Hull hull in Hull.HullList) + { + if (hull.IdFreed) { continue; } + hull.CreateStatusEvent(); + } })); commands.Add(new Command("stallfiletransfers", "stallfiletransfers [seconds]: A debug command that makes all file transfers take at least the specified duration.", (string[] args) => { diff --git a/Barotrauma/BarotraumaServer/ServerSource/Events/EventActions/ConversationAction.cs b/Barotrauma/BarotraumaServer/ServerSource/Events/EventActions/ConversationAction.cs index 39fa32cab..159c83310 100644 --- a/Barotrauma/BarotraumaServer/ServerSource/Events/EventActions/ConversationAction.cs +++ b/Barotrauma/BarotraumaServer/ServerSource/Events/EventActions/ConversationAction.cs @@ -1,4 +1,5 @@ -using Barotrauma.Networking; +using Barotrauma.Extensions; +using Barotrauma.Networking; using System; using System.Collections.Generic; using System.Linq; @@ -16,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 @@ -51,29 +58,59 @@ 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(); } private bool IsBlockedByAnotherConversation(IEnumerable targets, float duration) { - foreach (Entity e in targets) + if (targets == null || targets.None()) { - if (e is not Character character || !character.IsRemotePlayer) { continue; } - Client targetClient = GameMain.Server.ConnectedClients.Find(c => c.Character == character); - if (targetClient != null) + //if the action doesn't target anyone in specific, it's shown to every client + foreach (var client in GameMain.Server.ConnectedClients) { - if (lastActiveAction.ContainsKey(targetClient) && - lastActiveAction[targetClient].ParentEvent != ParentEvent && - Timing.TotalTime < lastActiveAction[targetClient].lastActiveTime + duration) - { - return true; - } + if (IsBlockedByAnotherConversation(client, duration)) { return true; } } } + else + { + foreach (Entity e in targets) + { + if (e is not Character character || !character.IsRemotePlayer) { continue; } + Client targetClient = GameMain.Server.ConnectedClients.Find(c => c.Character == character); + if (targetClient != null && IsBlockedByAnotherConversation(targetClient, duration)) { return true; } + } + } + return false; + } + + private bool IsBlockedByAnotherConversation(Client targetClient, float duration) + { + if (lastActiveAction.ContainsKey(targetClient) && + !lastActiveAction[targetClient].ParentEvent.IsFinished && + lastActiveAction[targetClient].ParentEvent != ParentEvent && + Timing.TotalTime < lastActiveAction[targetClient].lastActiveTime + duration) + { + return true; + } return false; } @@ -91,6 +128,7 @@ namespace Barotrauma { targetClients.Add(targetClient); lastActiveAction[targetClient] = this; + lastActiveTime = Timing.TotalTime; ServerWrite(speaker, targetClient, interrupt); } } @@ -99,12 +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); } } @@ -112,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/CombatMission.cs b/Barotrauma/BarotraumaServer/ServerSource/Events/Missions/CombatMission.cs index f7656922a..736c98542 100644 --- a/Barotrauma/BarotraumaServer/ServerSource/Events/Missions/CombatMission.cs +++ b/Barotrauma/BarotraumaServer/ServerSource/Events/Missions/CombatMission.cs @@ -1,20 +1,50 @@ -using System.Collections.Generic; +#nullable enable +using Barotrauma.Extensions; +using Barotrauma.Networking; +using Microsoft.Xna.Framework; +using System.Collections.Generic; using System.Linq; namespace Barotrauma { partial class CombatMission { + class KillCount + { + public readonly Character Victim; + public readonly Client? VictimClient; + public readonly Character? Killer; + public readonly Client? KillerClient; + public KillCount(Character victim, Character? killer) + { + Victim = victim; + VictimClient = GameMain.Server.ConnectedClients.FirstOrDefault(c => victim.IsClientOwner(c)); + Killer = killer; + if (killer != null) + { + KillerClient = GameMain.Server.ConnectedClients.FirstOrDefault(c => killer.IsClientOwner(c)); + } + } + } + const float RoundEndDuration = 5.0f; private readonly bool[] teamDead = new bool[2]; + /// + /// Lists of characters currently alive in the teams + /// private List[] crews; - private bool initialized = false; + /// + /// List of all kills (of the characters in either team) during the round + /// + private readonly List kills = new List(); private float roundEndTimer; + private float timeInTargetSubmarineTimer; + public override LocalizedString Description { get @@ -28,51 +58,16 @@ namespace Barotrauma protected override void UpdateMissionSpecific(float deltaTime) { - if (!initialized) - { - crews[0].Clear(); - crews[1].Clear(); - foreach (Character character in Character.CharacterList) - { - if (character.TeamID == CharacterTeamType.Team1) - { - crews[0].Add(character); - } - else if (character.TeamID == CharacterTeamType.Team2) - { - crews[1].Add(character); - } - } - - initialized = true; - } - - if (crews[0].Count == 0 || crews[1].Count == 0) - { - //if there are no characters in either crew, end the round - teamDead[0] = teamDead[1] = true; - state = 1; - } - else - { - teamDead[0] = crews[0].All(c => c.IsDead || c.IsIncapacitated); - teamDead[1] = crews[1].All(c => c.IsDead || c.IsIncapacitated); - if (teamDead[0] && teamDead[1]) { state = 1; } - } + CheckTeamCharacters(); if (state == 0) { + CheckWinCondition(deltaTime); for (int i = 0; i < teamDead.Length; i++) { if (!teamDead[i] && teamDead[1 - i]) { - //make sure nobody in the other team can be revived because that would be pretty weird - crews[1 - i].ForEach(c => { if (!c.IsDead) c.Kill(CauseOfDeathType.Unknown, null); }); - - GameMain.GameSession.WinningTeam = i == 0 ? CharacterTeamType.Team1 : CharacterTeamType.Team2; - - //state 1 = team 1 won, 2 = team 2 won - State = i + 1; + SetWinningTeam(i); break; } } @@ -85,7 +80,7 @@ namespace Barotrauma if (teamDead[0] && teamDead[1]) { GameMain.GameSession.WinningTeam = CharacterTeamType.None; - if (GameMain.Server != null) { GameMain.Server.EndGame(); } + GameMain.Server?.EndGame(); } else if (GameMain.GameSession.WinningTeam != CharacterTeamType.None) { @@ -93,5 +88,180 @@ namespace Barotrauma } } } + + private void CheckTeamCharacters() + { + for (int i = 0; i < crews.Length; i++) + { + foreach (var character in crews[i]) + { + if (character.IsDead) + { + AddKill(character); + } + } + } + + crews[0].Clear(); + crews[1].Clear(); + foreach (Character character in Character.CharacterList) + { + if (character.IsDead) { continue; } + if (character.TeamID == CharacterTeamType.Team1) + { + crews[0].Add(character); + } + else if (character.TeamID == CharacterTeamType.Team2) + { + crews[1].Add(character); + } + if (character.IsBot && character.AIController is HumanAIController humanAi) + { + if (!humanAi.ObjectiveManager.HasOrder(o => o.TargetCharactersInOtherSubs) && + OrderPrefab.Prefabs.TryGet(Tags.AssaultEnemyOrder, out OrderPrefab? assaultOrder)) + { + character.SetOrder(assaultOrder.CreateInstance( + OrderPrefab.OrderTargetType.Entity, orderGiver: null).WithManualPriority(CharacterInfo.HighestManualOrderPriority), + isNewOrder: true, speak: false); + } + } + } + } + + private void CheckWinCondition(float deltaTime) + { + switch (winCondition) + { + case WinCondition.LastManStanding: + if (crews[0].Count == 0 || crews[1].Count == 0) + { + //if there are no characters in either crew, end the round + teamDead[0] = teamDead[1] = true; + state = 1; + } + else + { + teamDead[0] = crews[0].All(c => c.IsDead || c.IsIncapacitated); + teamDead[1] = crews[1].All(c => c.IsDead || c.IsIncapacitated); + if (teamDead[0] && teamDead[1]) { state = 1; } + } + break; + case WinCondition.KillCount: + //no need to do anything, kills are counted in AddKill + break; + case WinCondition.ControlSubmarine: + CheckTargetSubmarineControl(deltaTime); + break; + } + CheckScore(); + } + + private void CheckScore() + { + for (int i = 0; i < crews.Length; i++) + { + if (Scores[i] >= WinScore) + { + SetWinningTeam(i); + break; + } + } + } + + private void CheckTargetSubmarineControl(float deltaTime) + { + if (targetSubmarine == null) { return; } + + //score updates at 1 second intervals, so the score represents the time in seconds + timeInTargetSubmarineTimer += deltaTime; + if (timeInTargetSubmarineTimer < 1.0f) + { + return; + } + timeInTargetSubmarineTimer = 0.0f; + + bool crew1InSubmarine = crews[0].Any(c => c.Submarine == targetSubmarine); + bool crew2InSubmarine = crews[1].Any(c => c.Submarine == targetSubmarine); + + for (int i = 0; i < crews.Length; i++) + { + if (crews[i].Any(c => c.Submarine == targetSubmarine) && + crews[1 - i].None(c => c.Submarine == targetSubmarine)) + { + Scores[i]++; + GameMain.Server?.UpdateMissionState(this); + } + } + } + + public void AddToScore(CharacterTeamType team, int amount) + { + if (!HasWinScore) { return; } + int index; + switch (team) + { + case CharacterTeamType.Team1: + index = 0; + break; + case CharacterTeamType.Team2: + index = 1; + break; + default: + DebugConsole.AddSafeError($"Attempted to increase the score of an invalid team ({team})."); + return; + } + Scores[index] = MathHelper.Clamp(Scores[index] + amount, 0, WinScore); + GameMain.Server?.UpdateMissionState(this); + } + + private void AddKill(Character character) + { + kills.Add(new KillCount(character, character.CauseOfDeath?.Killer)); + if (winCondition == WinCondition.KillCount) + { + Scores[character.TeamID == CharacterTeamType.Team1 ? 1 : 0] += PointsPerKill; + } + GameMain.Server?.UpdateMissionState(this); + } + + private void SetWinningTeam(int teamIndex) + { + //state 1 = team 1 won, 2 = team 2 won + State = teamIndex + 1; + GameMain.GameSession.WinningTeam = teamIndex == 0 ? CharacterTeamType.Team1 : CharacterTeamType.Team2; + } + + public override void ServerWrite(IWriteMessage msg) + { + base.ServerWrite(msg); + msg.WriteUInt16((ushort)Scores[0]); + msg.WriteUInt16((ushort)Scores[1]); + + IEnumerable uniqueClients = kills + .Select(k => k.VictimClient) + .Union(kills.Select(k => k.KillerClient)) + .NotNull(); + msg.WriteVariableUInt32((uint)uniqueClients.Count()); + foreach (Client client in uniqueClients) + { + msg.WriteByte(client.SessionId); + msg.WriteVariableUInt32((uint)kills.Count(k => k.VictimClient == client)); + msg.WriteVariableUInt32((uint)kills.Count(k => k.KillerClient == client)); + } + + IEnumerable uniqueBots = kills + .Select(k => k.Killer) + .Union(kills.Select(k => k.Victim)) + .NotNull() + .Where(c => c.Info != null && c.IsBot) + .Select(c => c.Info); + msg.WriteVariableUInt32((uint)uniqueBots.Count()); + foreach (CharacterInfo botInfo in uniqueBots) + { + msg.WriteUInt16(botInfo.ID); + msg.WriteVariableUInt32((uint)kills.Count(k => k.Victim?.Info == botInfo)); + msg.WriteVariableUInt32((uint)kills.Count(k => k.Killer?.Info == botInfo)); + } + } } } 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/Events/Missions/ScanMission.cs b/Barotrauma/BarotraumaServer/ServerSource/Events/Missions/ScanMission.cs index ec766fd9a..43efff6f2 100644 --- a/Barotrauma/BarotraumaServer/ServerSource/Events/Missions/ScanMission.cs +++ b/Barotrauma/BarotraumaServer/ServerSource/Events/Missions/ScanMission.cs @@ -1,3 +1,4 @@ +using System.Collections.Generic; using Barotrauma.Networking; namespace Barotrauma @@ -12,9 +13,9 @@ namespace Barotrauma { item.WriteSpawnData(msg, item.ID, - parentInventoryIDs.ContainsKey(item) ? parentInventoryIDs[item] : Entity.NullEntityID, - parentItemContainerIndices.ContainsKey(item) ? parentItemContainerIndices[item] : (byte)0, - inventorySlotIndices.ContainsKey(item) ? inventorySlotIndices[item] : -1); + parentInventoryIDs.GetValueOrDefault(item, Entity.NullEntityID), + parentItemContainerIndices.GetValueOrDefault(item, (byte)0), + inventorySlotIndices.GetValueOrDefault(item, -1)); } ServerWriteScanTargetStatus(msg); } @@ -30,7 +31,7 @@ namespace Barotrauma msg.WriteByte((byte)scanTargets.Count); foreach (var kvp in scanTargets) { - msg.WriteUInt16(kvp.Key != null ? kvp.Key.ID : Entity.NullEntityID); + msg.WriteUInt16(kvp.Key?.ID ?? Entity.NullEntityID); msg.WriteBoolean(kvp.Value); } } diff --git a/Barotrauma/BarotraumaServer/ServerSource/GameMain.cs b/Barotrauma/BarotraumaServer/ServerSource/GameMain.cs index c62784177..ecada43df 100644 --- a/Barotrauma/BarotraumaServer/ServerSource/GameMain.cs +++ b/Barotrauma/BarotraumaServer/ServerSource/GameMain.cs @@ -160,7 +160,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); @@ -241,7 +243,7 @@ namespace Barotrauma maxPlayers, ownerKey, ownerEndpoint); - Server.StartServer(); + Server.StartServer(registerToServerList: true); for (int i = 0; i < CommandLineArgs.Length; i++) { @@ -275,6 +277,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 f9404cc87..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()) { @@ -143,6 +150,7 @@ namespace Barotrauma { Reset(); CharacterInfo.PermanentlyDead = true; + GameMain.GameSession?.IncrementPermadeath(AccountId); DebugConsole.NewMessage($"Permadeath applied on {Name}'s CharacterCampaignData.CharacterInfo."); } @@ -179,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 f153d82d1..2a4e4aa6c 100644 --- a/Barotrauma/BarotraumaServer/ServerSource/GameSession/GameModes/MultiPlayerCampaign.cs +++ b/Barotrauma/BarotraumaServer/ServerSource/GameSession/GameModes/MultiPlayerCampaign.cs @@ -121,21 +121,21 @@ namespace Barotrauma { if (string.IsNullOrWhiteSpace(savePath)) { return; } - GameMain.GameSession = new GameSession(new SubmarineInfo(subPath), savePath, GameModePreset.MultiPlayerCampaign, startingSettings, seed); + GameMain.GameSession = new GameSession(new SubmarineInfo(subPath), Option.None, CampaignDataPath.CreateRegular(savePath), GameModePreset.MultiPlayerCampaign, startingSettings, seed); GameMain.NetLobbyScreen.ToggleCampaignMode(true); - SaveUtil.SaveGame(GameMain.GameSession.SavePath); + SaveUtil.SaveGame(GameMain.GameSession.DataPath); DebugConsole.NewMessage("Campaign started!", Color.Cyan); DebugConsole.NewMessage("Current location: " + GameMain.GameSession.Map.CurrentLocation.DisplayName, Color.Cyan); ((MultiPlayerCampaign)GameMain.GameSession.GameMode).LoadInitialLevel(); } - public static void LoadCampaign(string selectedSave, Client client) + public static void LoadCampaign(CampaignDataPath path, Client client) { GameMain.NetLobbyScreen.ToggleCampaignMode(true); try { - SaveUtil.LoadGame(selectedSave); + SaveUtil.LoadGame(path); if (GameMain.GameSession.GameMode is MultiPlayerCampaign mpCampaign) { mpCampaign.LastSaveID++; @@ -148,7 +148,7 @@ namespace Barotrauma } catch (Exception e) { - string errorMsg = $"Error while loading the save {selectedSave}"; + string errorMsg = $"Error while loading the save {path.LoadPath}"; if (client != null) { GameMain.Server?.SendDirectChatMessage($"{errorMsg}: {e.Message}\n{e.StackTrace}", client, ChatMessageType.Error); @@ -209,7 +209,7 @@ namespace Barotrauma { try { - LoadCampaign(saveFiles[saveIndex].FilePath, client: null); + LoadCampaign(CampaignDataPath.CreateRegular(saveFiles[saveIndex].FilePath), client: null); } catch (Exception ex) { @@ -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 @@ -289,7 +295,8 @@ namespace Barotrauma data.Refresh(character, refreshHealthData: character.CauseOfDeath?.Type != CauseOfDeathType.Disconnected); characterData.Add(data); } - else + //check the cause of death in the CharacterInfo too (the character instance may have despawned, so we can't just rely on that) + else if (data.CharacterInfo.CauseOfDeath is not { Type: CauseOfDeathType.Disconnected }) { //character dead or removed -> reduce skills, remove items, health data, etc data.CharacterInfo.ApplyDeathEffects(); @@ -389,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 @@ -406,13 +413,13 @@ namespace Barotrauma LeaveUnconnectedSubs(leavingSub); NextLevel = newLevel; GameMain.GameSession.SubmarineInfo = new SubmarineInfo(GameMain.GameSession.Submarine); - SaveUtil.SaveGame(GameMain.GameSession.SavePath); + SaveUtil.SaveGame(GameMain.GameSession.DataPath); } else { PendingSubmarineSwitch = null; GameMain.Server.EndGame(TransitionType.None, wasSaved: false); - LoadCampaign(GameMain.GameSession.SavePath, client: null); + LoadCampaign(GameMain.GameSession.DataPath, client: null); LastSaveID++; IncrementAllLastUpdateIds(); yield return CoroutineStatus.Success; @@ -638,6 +645,7 @@ namespace Barotrauma msg.WriteBoolean(IsFirstRound); msg.WriteByte(CampaignID); + msg.WriteByte(RoundID); msg.WriteUInt16(lastSaveID); msg.WriteString(map.Seed); @@ -1208,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(); @@ -1229,9 +1241,9 @@ namespace Barotrauma if (renameCharacter) { renamedIdentifier = msg.ReadUInt16(); - newName = msg.ReadString(); + 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; } @@ -1249,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); @@ -1267,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) { @@ -1318,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); @@ -1326,9 +1339,11 @@ 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; pendingHireInfos.Add(match); - if (pendingHireInfos.Count + CrewManager.GetCharacterInfos().Count() >= CrewManager.MaxCrewSize) + if (pendingHireInfos.Count(ci => ci.BotStatus == BotStatus.PendingHireToActiveService) + CrewManager.GetCharacterInfos().Count() >= CrewManager.MaxCrewSize) { break; } @@ -1396,14 +1411,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); @@ -1466,7 +1488,16 @@ namespace Barotrauma return wallet.Balance + Bank.Balance; } - public override void Save(XElement element) + /// + /// Serializes the campaign and character data to XML. + /// + /// Game session element to save the campaign data to. + /// + /// Whether the save is being done during loading to ensure the campaign ID matches the one in the save file. + /// Used to work around some quirks with the backup save system. + /// See: + /// + public override void Save(XElement element, bool isSavingOnLoading) { element.Add(new XAttribute("campaignid", CampaignID)); XElement modeElement = new XElement("MultiPlayerCampaign", @@ -1516,8 +1547,16 @@ namespace Barotrauma element.Add(modeElement); - //save character data to a separate file - string characterDataPath = GetCharacterDataSavePath(); + // save character data to a separate file + + // When loading a campaign in multiplayer, we save the campaign to ensure the campaign ID that gets assigned + // matches the one in the save file, this is a problem with the backup save system since this causes the + // character data to save too, and we don't want to overwrite the main save file's character data. + // So we instead save over the load path in this case, which in backup saves is the backup file + // which we don't mind getting overriden since the data should be the same + string characterDataPath = isSavingOnLoading + ? GetCharacterDataPathForLoading() + : GetCharacterDataPathForSaving(); XDocument characterDataDoc = new XDocument(new XElement("CharacterData")); foreach (CharacterCampaignData cd in characterData) { @@ -1525,6 +1564,7 @@ namespace Barotrauma } try { + SaveUtil.DeleteIfExists(characterDataPath); characterDataDoc.SaveSafe(characterDataPath); } catch (Exception e) @@ -1544,7 +1584,7 @@ namespace Barotrauma /// eg. when using this method to save a character itself restored from the backup. public void SaveSingleCharacter(CharacterCampaignData newData, bool skipBackup = false) { - string characterDataPath = GetCharacterDataSavePath(); + string characterDataPath = GetCharacterDataPathForSaving(); if (!File.Exists(characterDataPath)) { DebugConsole.ThrowError($"Failed to load the character data for the campaign. Could not find the file \"{characterDataPath}\"."); diff --git a/Barotrauma/BarotraumaServer/ServerSource/Items/Components/Holdable/Holdable.cs b/Barotrauma/BarotraumaServer/ServerSource/Items/Components/Holdable/Holdable.cs index 0e1e1a839..3f5692b46 100644 --- a/Barotrauma/BarotraumaServer/ServerSource/Items/Components/Holdable/Holdable.cs +++ b/Barotrauma/BarotraumaServer/ServerSource/Items/Components/Holdable/Holdable.cs @@ -1,5 +1,6 @@ using Barotrauma.Networking; using Microsoft.Xna.Framework; +using System; namespace Barotrauma.Items.Components { @@ -13,10 +14,18 @@ namespace Barotrauma.Items.Components msg.WriteBoolean(writeAttachData); if (!writeAttachData) { return; } + UInt16 attacherId = Entity.NullEntityID; + if (TryExtractEventData(extraData, out AttachEventData attachEventData) && + attachEventData.Attacher != null) + { + attacherId = attachEventData.Attacher.ID; + } + msg.WriteBoolean(Attached); msg.WriteSingle(body.SimPosition.X); msg.WriteSingle(body.SimPosition.Y); msg.WriteUInt16(item.Submarine?.ID ?? Entity.NullEntityID); + msg.WriteUInt16(attacherId); } public void ServerEventRead(IReadMessage msg, Client c) @@ -34,7 +43,7 @@ namespace Barotrauma.Items.Components AttachToWall(); OnUsed.Invoke(new ItemUseInfo(item, c.Character)); - item.CreateServerEvent(this); + item.CreateServerEvent(this, new AttachEventData(simPosition, c.Character)); c.Character.Inventory?.CreateNetworkEvent(); GameServer.Log(GameServer.CharacterLogName(c.Character) + " attached " + item.Name + " to a wall", ServerLog.MessageType.ItemInteraction); diff --git a/Barotrauma/BarotraumaServer/ServerSource/Items/Components/ItemLabel.cs b/Barotrauma/BarotraumaServer/ServerSource/Items/Components/ItemLabel.cs index 81610eb59..ea29665f7 100644 --- a/Barotrauma/BarotraumaServer/ServerSource/Items/Components/ItemLabel.cs +++ b/Barotrauma/BarotraumaServer/ServerSource/Items/Components/ItemLabel.cs @@ -11,7 +11,7 @@ namespace Barotrauma.Items.Components private string lastSentText; private float sendStateTimer; - [Serialize("", IsPropertySaveable.Yes, description: "The text to display on the label.", alwaysUseInstanceValues: true), Editable(100)] + [Serialize("", IsPropertySaveable.Yes, description: "The text to display on the label.", alwaysUseInstanceValues: true), Editable(MaxLength = 100)] public string Text { get; diff --git a/Barotrauma/BarotraumaServer/ServerSource/Items/Components/Machines/Steering.cs b/Barotrauma/BarotraumaServer/ServerSource/Items/Components/Machines/Steering.cs index f618c0a72..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 { @@ -74,7 +78,7 @@ namespace Barotrauma.Items.Components if (!AutoPilot) { steeringInput = newSteeringInput; - steeringAdjustSpeed = MathHelper.Lerp(0.2f, 1.0f, c.Character.GetSkillLevel("helm") / 100.0f); + steeringAdjustSpeed = MathHelper.Lerp(0.2f, 1.0f, c.Character.GetSkillLevel(Tags.HelmSkill) / 100.0f); } else { diff --git a/Barotrauma/BarotraumaServer/ServerSource/Items/Components/Signal/CircuitBox.cs b/Barotrauma/BarotraumaServer/ServerSource/Items/Components/Signal/CircuitBox.cs index f3dbfeb14..9cc01bcbc 100644 --- a/Barotrauma/BarotraumaServer/ServerSource/Items/Components/Signal/CircuitBox.cs +++ b/Barotrauma/BarotraumaServer/ServerSource/Items/Components/Signal/CircuitBox.cs @@ -1,4 +1,4 @@ -#nullable enable +#nullable enable using Barotrauma.Networking; using Microsoft.Xna.Framework; @@ -310,7 +310,13 @@ namespace Barotrauma.Items.Components return wire.BackingWire.TryUnwrap(out var backingWire) ? backingWire.Name : "a wire"; } - bool CanAccessAndUnlocked(Client client) => item.CanClientAccess(client) && !Locked; + bool CanAccessAndUnlocked(Client client) => + !IsLocked() && + item.CanClientAccess(client) && + ClientHasRequiredItems(client); + + bool ClientHasRequiredItems(Client client) => + client.Character is { } chara && HasRequiredItems(chara, addMessage: false); } /// diff --git a/Barotrauma/BarotraumaServer/ServerSource/Items/Components/Signal/CustomInterface.cs b/Barotrauma/BarotraumaServer/ServerSource/Items/Components/Signal/CustomInterface.cs index b103e8e7f..acea2c8d2 100644 --- a/Barotrauma/BarotraumaServer/ServerSource/Items/Components/Signal/CustomInterface.cs +++ b/Barotrauma/BarotraumaServer/ServerSource/Items/Components/Signal/CustomInterface.cs @@ -11,13 +11,17 @@ namespace Barotrauma.Items.Components string[] elementValues = new string[customInterfaceElementList.Count]; for (int i = 0; i < customInterfaceElementList.Count; i++) { - if (customInterfaceElementList[i].HasPropertyName) + var element = customInterfaceElementList[i]; + switch (element.InputType) { - elementValues[i] = msg.ReadString(); - } - else - { - elementStates[i] = msg.ReadBoolean(); + case CustomInterfaceElement.InputTypeOption.Number: + case CustomInterfaceElement.InputTypeOption.Text: + elementValues[i] = msg.ReadString(); + break; + case CustomInterfaceElement.InputTypeOption.Button: + case CustomInterfaceElement.InputTypeOption.TickBox: + elementStates[i] = msg.ReadBoolean(); + break; } } @@ -26,15 +30,10 @@ namespace Barotrauma.Items.Components { for (int i = 0; i < customInterfaceElementList.Count; i++) { - var element = customInterfaceElementList[i]; - if (element.HasPropertyName) + var element = customInterfaceElementList[i]; + switch (element.InputType) { - if (!element.IsNumberInput) - { - TextChanged(element, elementValues[i]); - } - else - { + case CustomInterfaceElement.InputTypeOption.Number: switch (element.NumberType) { case NumberType.Int when int.TryParse(elementValues[i], out int value): @@ -44,16 +43,20 @@ namespace Barotrauma.Items.Components ValueChanged(element, value); break; } - } - } - else if (element.ContinuousSignal) - { - TickBoxToggled(element, elementStates[i]); - } - else if (elementStates[i]) - { - clickedButton = element; - ButtonClicked(element); + break; + case CustomInterfaceElement.InputTypeOption.Text: + TextChanged(element, elementValues[i]); + break; + case CustomInterfaceElement.InputTypeOption.TickBox: + TickBoxToggled(element, elementStates[i]); + break; + case CustomInterfaceElement.InputTypeOption.Button: + if (elementStates[i]) + { + clickedButton = element; + ButtonClicked(element); + } + break; } } } @@ -70,17 +73,19 @@ namespace Barotrauma.Items.Components for (int i = 0; i < customInterfaceElementList.Count; i++) { var element = customInterfaceElementList[i]; - if (element.HasPropertyName) + + switch (element.InputType) { - msg.WriteString(element.Signal); - } - else if(element.ContinuousSignal) - { - msg.WriteBoolean(element.State); - } - else - { - msg.WriteBoolean(extraData is Item.ComponentStateEventData { ComponentData: EventData eventData } && eventData.BtnElement == customInterfaceElementList[i]); + case CustomInterfaceElement.InputTypeOption.Number: + case CustomInterfaceElement.InputTypeOption.Text: + msg.WriteString(element.Signal); + break; + case CustomInterfaceElement.InputTypeOption.TickBox: + msg.WriteBoolean(element.State); + break; + case CustomInterfaceElement.InputTypeOption.Button: + msg.WriteBoolean(extraData is Item.ComponentStateEventData { ComponentData: EventData eventData } && eventData.BtnElement == customInterfaceElementList[i]); + break; } } } 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/Items/Item.cs b/Barotrauma/BarotraumaServer/ServerSource/Items/Item.cs index 62c2477a1..4713be50f 100644 --- a/Barotrauma/BarotraumaServer/ServerSource/Items/Item.cs +++ b/Barotrauma/BarotraumaServer/ServerSource/Items/Item.cs @@ -175,6 +175,10 @@ namespace Barotrauma } } break; + case SwapItemEventData swapItemEventData: + msg.WriteUInt16(swapItemEventData.NewId); + msg.WriteUInt32(swapItemEventData.NewItem.UintIdentifier); + break; default: throw error($"Unsupported event type {itemEventData.GetType().Name}"); } @@ -317,7 +321,7 @@ namespace Barotrauma msg.WriteBoolean(tagsChanged); if (tagsChanged) { - IEnumerable splitTags = Tags.Split(',').ToIdentifiers(); + IEnumerable splitTags = Tags.ToIdentifiers(); msg.WriteString(string.Join(',', splitTags.Where(t => !base.Prefab.Tags.Contains(t)))); msg.WriteString(string.Join(',', base.Prefab.Tags.Where(t => !splitTags.Contains(t)))); } @@ -420,7 +424,14 @@ namespace Barotrauma if (!components.Contains(ic)) { return; } var eventData = new ComponentStateEventData(ic, extraData); - if (!ic.ValidateEventData(eventData)) { throw new Exception($"Component event creation for the item \"{Prefab.Identifier}\" failed: {typeof(T).Name}.{nameof(ItemComponent.ValidateEventData)} returned false."); } + if (!ic.ValidateEventData(eventData)) + { + string errorMsg = + $"Server-side component event creation for the item \"{Prefab.Identifier}\" failed: {typeof(T).Name}.{nameof(ItemComponent.ValidateEventData)} returned false. " + + $"Data: {extraData?.GetType().ToString() ?? "null"}"; + GameAnalyticsManager.AddErrorEventOnce($"Item.CreateServerEvent:ValidateEventData:{Prefab.Identifier}", GameAnalyticsManager.ErrorSeverity.Error, errorMsg); + throw new Exception(errorMsg); + } GameMain.Server.CreateEntityEvent(this, eventData); } @@ -431,10 +442,12 @@ namespace Barotrauma foreach (ItemComponent ic in components) { - if (!(ic is IServerSerializable)) { continue; } - var eventData = new ComponentStateEventData(ic, ic.ServerGetEventData()); - if (!ic.ValidateEventData(eventData)) { continue; } - GameMain.Server.CreateEntityEvent(this, eventData); + if (ic is not IServerSerializable) { continue; } + var eventData = ic.ServerGetEventData(); + if (eventData == null) { continue; } + var componentData = new ComponentStateEventData(ic, eventData); + if (!ic.ValidateEventData(componentData)) { continue; } + GameMain.Server.CreateEntityEvent(this, componentData); } } #endif diff --git a/Barotrauma/BarotraumaServer/ServerSource/Map/Hull.cs b/Barotrauma/BarotraumaServer/ServerSource/Map/Hull.cs index 814152a26..463ff3427 100644 --- a/Barotrauma/BarotraumaServer/ServerSource/Map/Hull.cs +++ b/Barotrauma/BarotraumaServer/ServerSource/Map/Hull.cs @@ -76,6 +76,12 @@ namespace Barotrauma } } + + public void CreateStatusEvent() + { + GameMain.NetworkMember?.CreateEntityEvent(this, new StatusEventData()); + } + public void ServerEventWrite(IWriteMessage msg, Client c, NetEntityEvent.IData extraData = null) { if (!(extraData is IEventData eventData)) { throw new Exception($"Malformed hull event: expected {nameof(Hull)}.{nameof(IEventData)}"); } diff --git a/Barotrauma/BarotraumaServer/ServerSource/Networking/BanList.cs b/Barotrauma/BarotraumaServer/ServerSource/Networking/BanList.cs index 73e844d25..300853f29 100644 --- a/Barotrauma/BarotraumaServer/ServerSource/Networking/BanList.cs +++ b/Barotrauma/BarotraumaServer/ServerSource/Networking/BanList.cs @@ -49,7 +49,7 @@ namespace Barotrauma.Networking string[] lines; try { - lines = File.ReadAllLines(LegacySavePath); + lines = File.ReadAllLines(LegacySavePath, catchUnauthorizedAccessExceptions: false); } catch (Exception e) { diff --git a/Barotrauma/BarotraumaServer/ServerSource/Networking/ChatMessage.cs b/Barotrauma/BarotraumaServer/ServerSource/Networking/ChatMessage.cs index f95371efe..5a804f9bc 100644 --- a/Barotrauma/BarotraumaServer/ServerSource/Networking/ChatMessage.cs +++ b/Barotrauma/BarotraumaServer/ServerSource/Networking/ChatMessage.cs @@ -125,7 +125,7 @@ namespace Barotrauma.Networking } else { - GameMain.Server.SendChatMessage(txt, senderClient: c, chatMode: chatMode); + GameMain.Server.SendChatMessage(txt, senderClient: c, chatMode: chatMode, type: type == ChatMessageType.Team ? type : null); } } diff --git a/Barotrauma/BarotraumaServer/ServerSource/Networking/Client.cs b/Barotrauma/BarotraumaServer/ServerSource/Networking/Client.cs index e7f4cd5d6..145571884 100644 --- a/Barotrauma/BarotraumaServer/ServerSource/Networking/Client.cs +++ b/Barotrauma/BarotraumaServer/ServerSource/Networking/Client.cs @@ -22,6 +22,8 @@ namespace Barotrauma.Networking public UInt16 LastRecvLobbyUpdate = NetIdUtils.GetIdOlderThan(GameMain.NetLobbyScreen.LastUpdateID); + public bool InitialLobbyUpdateSent; + public UInt16 LastSentChatMsgID = 0; //last msg this client said public UInt16 LastRecvChatMsgID = 0; //last msg this client knows about @@ -82,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; } } @@ -92,6 +98,7 @@ namespace Barotrauma.Networking public NetworkConnection Connection { get; set; } public bool SpectateOnly; + public bool AFK; public bool? WaitForNextRoundRespawn; public int KarmaKickCount; @@ -102,13 +109,19 @@ namespace Barotrauma.Networking { get { - if (GameMain.Server == null || !GameMain.Server.ServerSettings.KarmaEnabled) { return 100.0f; } + if (GameMain.Server == null || !GameMain.Server.ServerSettings.KarmaEnabled || GameMain.GameSession?.GameMode is PvPMode) + { + return 100.0f; + } if (HasPermission(ClientPermissions.KarmaImmunity)) { return 100.0f; } return karma; } set { - if (GameMain.Server == null || !GameMain.Server.ServerSettings.KarmaEnabled) { return; } + if (GameMain.Server == null || !GameMain.Server.ServerSettings.KarmaEnabled || GameMain.GameSession?.GameMode is PvPMode) + { + return; + } karma = Math.Min(Math.Max(value, 0.0f), 100.0f); if (!MathUtils.NearlyEqual(karma, syncedKarma, 10.0f)) { @@ -160,8 +173,8 @@ namespace Barotrauma.Networking LastSentChatMsgID = 0; LastRecvChatMsgID = ChatMessage.LastID; - LastRecvLobbyUpdate = 0; - + LastRecvLobbyUpdate = NetIdUtils.GetIdOlderThan(GameMain.NetLobbyScreen.LastUpdateID); + InitialLobbyUpdateSent = false; LastRecvEntityEventID = 0; UnreceivedEntityEventCount = 0; @@ -327,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/FileTransfer/FileSender.cs b/Barotrauma/BarotraumaServer/ServerSource/Networking/FileTransfer/FileSender.cs index 9b8ab528d..d82f95fc3 100644 --- a/Barotrauma/BarotraumaServer/ServerSource/Networking/FileTransfer/FileSender.cs +++ b/Barotrauma/BarotraumaServer/ServerSource/Networking/FileTransfer/FileSender.cs @@ -94,7 +94,7 @@ namespace Barotrauma.Networking { try { - Data = File.ReadAllBytes(filePath); + Data = File.ReadAllBytes(filePath, catchUnauthorizedAccessExceptions: false); } catch (System.IO.IOException e) { @@ -400,7 +400,7 @@ namespace Barotrauma.Networking if (GameMain.GameSession != null && !ActiveTransfers.Any(t => t.Connection == inc.Sender && t.FileType == FileTransferType.CampaignSave)) { - StartTransfer(inc.Sender, FileTransferType.CampaignSave, GameMain.GameSession.SavePath); + StartTransfer(inc.Sender, FileTransferType.CampaignSave, GameMain.GameSession.DataPath.LoadPath); if (GameMain.GameSession?.GameMode is MultiPlayerCampaign campaign) { client.LastCampaignSaveSendTime = (campaign.LastSaveID, (float)Lidgren.Network.NetTime.Now); diff --git a/Barotrauma/BarotraumaServer/ServerSource/Networking/GameServer.cs b/Barotrauma/BarotraumaServer/ServerSource/Networking/GameServer.cs index d221ecedf..8e304e710 100644 --- a/Barotrauma/BarotraumaServer/ServerSource/Networking/GameServer.cs +++ b/Barotrauma/BarotraumaServer/ServerSource/Networking/GameServer.cs @@ -11,6 +11,7 @@ using System.Diagnostics; using System.Linq; using System.Threading; using System.Xml.Linq; +using Barotrauma.PerkBehaviors; namespace Barotrauma.Networking { @@ -35,8 +36,10 @@ namespace Barotrauma.Networking private readonly List connectedClients = new List(); - //for keeping track of disconnected clients in case the reconnect shortly after - private readonly List disconnectedClients = new List(); + /// + /// For keeping track of disconnected clients in case they reconnect shortly after. + /// + private readonly List clientsAttemptingToReconnectSoon = new List(); //keeps track of players who've previously been playing on the server //so kick votes persist during the session and the server can let the clients know what name this client used previously @@ -63,6 +66,11 @@ namespace Barotrauma.Networking public float EndRoundTimeRemaining => EndRoundTimer > 0 ? EndRoundDelay - EndRoundTimer : 0; + private const int PvpAutoBalanceCountdown = 10; + private static float pvpAutoBalanceCountdownRemaining = -1; + private int Team1Count => GetPlayingClients().Count(static c => c.TeamID == CharacterTeamType.Team1); + private int Team2Count => GetPlayingClients().Count(static c => c.TeamID == CharacterTeamType.Team2); + /// /// Chat messages that get sent to the owner of the server when the owner is determined /// @@ -126,6 +134,38 @@ namespace Barotrauma.Networking private readonly Option ownerKey; private readonly Option ownerEndpoint; + public void ClearRecentlyDisconnectedClients() + { + lock (clientsAttemptingToReconnectSoon) + { + clientsAttemptingToReconnectSoon.Clear(); + } + } + + public bool FindAndRemoveRecentlyDisconnectedConnection(NetworkConnection conn) + { + lock (clientsAttemptingToReconnectSoon) + { + Client found = null; + foreach (var client in clientsAttemptingToReconnectSoon) + { + if (conn.AddressMatches(client.Connection)) + { + found = client; + break; + } + } + + if (found is not null) + { + clientsAttemptingToReconnectSoon.Remove(found); + return true; + } + } + + return false; + } + public GameServer( string name, int port, @@ -158,7 +198,7 @@ namespace Barotrauma.Networking entityEventManager = new ServerEntityEventManager(this); } - public void StartServer() + public void StartServer(bool registerToServerList) { Log("Starting the server...", ServerLog.MessageType.ServerMessage); @@ -178,8 +218,18 @@ namespace Barotrauma.Networking { Log("Using Lidgren networking. Manual port forwarding may be required. If players cannot connect to the server, you may want to use the in-game hosting menu (which uses Steamworks and EOS networking and does not require port forwarding).", ServerLog.MessageType.ServerMessage); serverPeer = new LidgrenServerPeer(ownerKey, ServerSettings, callbacks); - registeredToSteamMaster = SteamManager.CreateServer(this, ServerSettings.IsPublic); - Eos.EosSessionManager.UpdateOwnedSession(Option.None, ServerSettings); + if (registerToServerList) + { + 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); + } } FileSender = new FileSender(serverPeer, MsgConstants.MTU); @@ -374,20 +424,32 @@ 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) { character.KillDisconnectedTimer += deltaTime; 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; @@ -464,7 +526,8 @@ namespace Barotrauma.Networking EndRoundDelay = 5.0f; EndRoundTimer += deltaTime; } - else if (isCrewDown && (RespawnManager == null || !RespawnManager.CanRespawnAgain)) + else if (isCrewDown && + (RespawnManager == null || (!RespawnManager.CanRespawnAgain(CharacterTeamType.Team1) && !RespawnManager.CanRespawnAgain(CharacterTeamType.Team2)))) { #if !DEBUG if (EndRoundTimer <= 0.0f) @@ -565,13 +628,14 @@ 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; } } - if (readyToStartAutomatically) + if (readyToStartAutomatically && !isRoundStartWarningActive) { if (!wasReadyToStartAutomatically) { GameMain.NetLobbyScreen.LastUpdateID++; } TryStartGame(); @@ -579,18 +643,14 @@ namespace Barotrauma.Networking wasReadyToStartAutomatically = readyToStartAutomatically; } - for (int i = disconnectedClients.Count - 1; i >= 0; i--) + lock (clientsAttemptingToReconnectSoon) { - disconnectedClients[i].DeleteDisconnectedTimer -= deltaTime; - if (disconnectedClients[i].DeleteDisconnectedTimer > 0.0f) continue; - - if (GameStarted && disconnectedClients[i].Character != null) + foreach (var client in clientsAttemptingToReconnectSoon) { - disconnectedClients[i].Character.Kill(CauseOfDeathType.Disconnected, null); - disconnectedClients[i].Character = null; + client.DeleteDisconnectedTimer -= deltaTime; } - disconnectedClients.RemoveAt(i); + clientsAttemptingToReconnectSoon.RemoveAll(static c => c.DeleteDisconnectedTimer < 0f); } foreach (Client c in connectedClients) @@ -600,12 +660,44 @@ 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; } } } + if (pvpAutoBalanceCountdownRemaining > 0) + { + if (GameStarted || initiatedStartGame || Screen.Selected != GameMain.NetLobbyScreen || + ServerSettings.PvpTeamSelectionMode == PvpTeamSelectionMode.PlayerPreference || ServerSettings.PvpAutoBalanceThreshold == 0) + { + StopAutoBalanceCountdown(); + } + else + { + float prevTimeRemaining = pvpAutoBalanceCountdownRemaining; + pvpAutoBalanceCountdownRemaining -= deltaTime; + if (pvpAutoBalanceCountdownRemaining <= 0) + { + pvpAutoBalanceCountdownRemaining = -1; + RefreshPvpTeamAssignments(autoBalanceNow: true); + } + else + { + // Send a chat message about the countdown every 5 seconds the countdown is running, but not when + // it (=its integer part, which gets printed out) is still at the starting value, or zero + int currentTimeRemainingInteger = (int)Math.Ceiling(pvpAutoBalanceCountdownRemaining); + if (Math.Ceiling(prevTimeRemaining) > currentTimeRemainingInteger && currentTimeRemainingInteger % 5 == 0) + { + SendChatMessage( + TextManager.GetWithVariable("AutoBalance.CountdownRemaining", "[number]", currentTimeRemainingInteger.ToString()).Value, + ChatMessageType.Server); + } + } + } + } + if (connectedClients.Any(c => c.KickAFKTimer >= ServerSettings.KickAFKTime)) { IEnumerable kickAFK = connectedClients.FindAll(c => @@ -752,6 +844,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 @@ -761,6 +854,17 @@ namespace Barotrauma.Networking } } break; + case ClientPacketHeader.RESPONSE_CANCEL_STARTGAME: + if (isRoundStartWarningActive) + { + foreach (Client c in connectedClients) + { + IWriteMessage msg = new WriteOnlyMessage().WithHeader(ServerPacketHeader.CANCEL_STARTGAME); + serverPeer.Send(msg, c.Connection, DeliveryMethod.Reliable); + } + AbortStartGameIfWarningActive(); + } + break; case ClientPacketHeader.REQUEST_STARTGAMEFINALIZE: if (connectedClient == null) { @@ -822,7 +926,11 @@ namespace Barotrauma.Networking } else { - string saveName = inc.ReadString(); + string savePath = inc.ReadString(); + bool isBackup = inc.ReadBoolean(); + inc.ReadPadBits(); + uint backupIndex = isBackup ? inc.ReadUInt32() : uint.MinValue; + if (GameStarted) { SendDirectChatMessage(TextManager.Get("CampaignStartFailedRoundRunning").Value, connectedClient, ChatMessageType.MessageBox); @@ -832,7 +940,18 @@ namespace Barotrauma.Networking { using (dosProtection.Pause(connectedClient)) { - MultiPlayerCampaign.LoadCampaign(saveName, connectedClient); + CampaignDataPath dataPath; + if (isBackup) + { + string backupPath = SaveUtil.GetBackupPath(savePath, backupIndex); + dataPath = new CampaignDataPath(loadPath: backupPath, savePath: savePath); + } + else + { + dataPath = CampaignDataPath.CreateRegular(savePath); + } + + MultiPlayerCampaign.LoadCampaign(dataPath, connectedClient); } } } @@ -855,9 +974,16 @@ namespace Barotrauma.Networking case ClientPacketHeader.SERVER_SETTINGS: ServerSettings.ServerRead(inc, connectedClient); break; + case ClientPacketHeader.SERVER_SETTINGS_PERKS: + ServerSettings.ReadPerks(inc, connectedClient); + break; case ClientPacketHeader.SERVER_COMMAND: ClientReadServerCommand(inc); break; + case ClientPacketHeader.ENDROUND_SELF: + connectedClient.InGame = false; + connectedClient.ResetSync(); + break; case ClientPacketHeader.CREW: ReadCrewMessage(inc, connectedClient); break; @@ -885,6 +1011,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) { @@ -897,12 +1026,27 @@ namespace Barotrauma.Networking case ClientPacketHeader.UPDATE_CHARACTERINFO: UpdateCharacterInfo(inc, connectedClient); break; + case ClientPacketHeader.REQUEST_BACKUP_INDICES: + SendBackupIndices(inc, connectedClient); + break; case ClientPacketHeader.ERROR: HandleClientError(inc, connectedClient); break; } } + private void SendBackupIndices(IReadMessage inc, Client connectedClient) + { + string savePath = inc.ReadString(); + + var indexData = SaveUtil.GetIndexData(savePath); + + IWriteMessage msg = new WriteOnlyMessage().WithHeader(ServerPacketHeader.SEND_BACKUP_INDICES); + msg.WriteString(savePath); + msg.WriteNetSerializableStruct(indexData.ToNetCollection()); + serverPeer?.Send(msg, connectedClient.Connection, DeliveryMethod.Reliable); + } + private void HandleClientError(IReadMessage inc, Client c) { string errorStr = "Unhandled error report"; @@ -1013,9 +1157,9 @@ namespace Barotrauma.Networking { errorLines.Add("Submarine: " + GameMain.GameSession.Submarine.Info.Name); } - if (GameMain.NetworkMember?.RespawnManager?.RespawnShuttle != null) + if (GameMain.NetworkMember?.RespawnManager is { } respawnManager) { - errorLines.Add("Respawn shuttle: " + GameMain.NetworkMember.RespawnManager.RespawnShuttle.Info.Name); + errorLines.Add("Respawn shuttles: " + string.Join(", ", respawnManager.RespawnShuttles.Select(s => s.Info.Name))); } if (Level.Loaded != null) { @@ -1106,6 +1250,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); @@ -1171,11 +1316,26 @@ namespace Barotrauma.Networking //check if midround syncing is needed due to missed unique events if (!midroundSyncingDone) { entityEventManager.InitClientMidRoundSync(c); } MissionAction.NotifyMissionsUnlockedThisRound(c); - if (GameMain.GameSession.Campaign is MultiPlayerCampaign mpCampaign) + UnlockPathAction.NotifyPathsUnlockedThisRound(c); + + if (GameMain.GameSession.GameMode is PvPMode) { - mpCampaign.SendCrewState(); + if (c.TeamID == CharacterTeamType.None) + { + AssignClientToPvpTeamMidgame(c); + } + } + else + { + if (GameMain.GameSession.Campaign is MultiPlayerCampaign mpCampaign) + { + mpCampaign.SendCrewState(); + } + //everyone's in team 1 in non-pvp game modes + c.TeamID = CharacterTeamType.Team1; } c.InGame = true; + c.AFK = false; } } @@ -1386,7 +1546,7 @@ namespace Barotrauma.Networking UInt16 botId = inc.ReadUInt16(); if (GameMain.GameSession?.GameMode is not MultiPlayerCampaign campaign) { return; } - if (ServerSettings.IronmanMode) + if (ServerSettings.IronmanModeActive) { DebugConsole.ThrowError($"Client {sender.Name} has requested to take over a bot in Ironman mode!"); return; @@ -1418,17 +1578,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) { @@ -1444,8 +1610,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"); @@ -1458,13 +1638,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) { @@ -1576,7 +1802,7 @@ namespace Barotrauma.Networking mpCampaign.SavePlayers(); mpCampaign.HandleSaveAndQuit(); GameMain.GameSession.SubmarineInfo = new SubmarineInfo(GameMain.GameSession.Submarine); - SaveUtil.SaveGame(GameMain.GameSession.SavePath); + SaveUtil.SaveGame(GameMain.GameSession.DataPath); } else { @@ -1609,7 +1835,7 @@ namespace Barotrauma.Networking { using (dosProtection.Pause(sender)) { - MultiPlayerCampaign.LoadCampaign(GameMain.GameSession.SavePath, sender); + MultiPlayerCampaign.LoadCampaign(GameMain.GameSession.DataPath, sender); } } } @@ -1618,7 +1844,11 @@ namespace Barotrauma.Networking using (dosProtection.Pause(sender)) { Log("Client \"" + ClientLogName(sender) + "\" started the round.", ServerLog.MessageType.ServerMessage); - TryStartGame(); + var result = TryStartGame(); + if (result != TryStartGameResult.Success) + { + SendDirectChatMessage(TextManager.Get($"TryStartGameError.{result}").Value, sender, ChatMessageType.Error); + } } } else if (mpCampaign != null && (CampaignMode.AllowedToManageCampaign(sender, ClientPermissions.ManageCampaign) || CampaignMode.AllowedToManageCampaign(sender, ClientPermissions.ManageMap))) @@ -1660,24 +1890,27 @@ namespace Barotrauma.Networking } break; case ClientPermissions.SelectSub: - bool isShuttle = inc.ReadBoolean(); - inc.ReadPadBits(); + SelectedSubType subType = (SelectedSubType)inc.ReadByte(); string subHash = inc.ReadString(); var subList = GameMain.NetLobbyScreen.GetSubList(); - var sub = GameMain.NetLobbyScreen.GetSubList().FirstOrDefault(s => s.MD5Hash.StringRepresentation == subHash); + var sub = subList.FirstOrDefault(s => s.MD5Hash.StringRepresentation == subHash); if (sub == null) { DebugConsole.NewMessage($"Client \"{ClientLogName(sender)}\" attempted to select a sub, could not find a sub with the MD5 hash \"{subHash}\".", Color.Red); } else { - if (isShuttle) + switch (subType) { - GameMain.NetLobbyScreen.SelectedShuttle = sub; - } - else - { - GameMain.NetLobbyScreen.SelectedSub = sub; + case SelectedSubType.Shuttle: + GameMain.NetLobbyScreen.SelectedShuttle = sub; + break; + case SelectedSubType.Sub: + GameMain.NetLobbyScreen.SelectedSub = sub; + break; + case SelectedSubType.EnemySub: + GameMain.NetLobbyScreen.SelectedEnemySub = sub; + break; } } break; @@ -1777,7 +2010,7 @@ namespace Barotrauma.Networking if (!FileSender.ActiveTransfers.Any(t => t.Connection == c.Connection && t.FileType == FileTransferType.CampaignSave)) { - FileSender.StartTransfer(c.Connection, FileTransferType.CampaignSave, GameMain.GameSession.SavePath); + FileSender.StartTransfer(c.Connection, FileTransferType.CampaignSave, GameMain.GameSession.DataPath.SavePath); c.LastCampaignSaveSendTime = (campaign.LastSaveID, (float)NetTime.Now); } } @@ -1809,6 +2042,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); @@ -2033,6 +2267,9 @@ namespace Barotrauma.Networking segmentTable.StartNewSegment(ServerNetSegment.ClientList); outmsg.WriteUInt16(LastClientListUpdateID); + outmsg.WriteByte((byte)Team1Count); + outmsg.WriteByte((byte)Team2Count); + outmsg.WriteByte((byte)connectedClients.Count); foreach (Client client in connectedClients) { @@ -2046,6 +2283,7 @@ namespace Barotrauma.Networking ? client.Character.Info.Job.Prefab.Identifier : client.PreferredJob, PreferredTeam = client.PreferredTeam, + TeamID = client.TeamID, CharacterId = client.Character == null || !GameStarted ? (ushort)0 : client.Character.ID, Karma = c.HasPermission(ClientPermissions.ServerLog) ? client.Karma : 100.0f, Muted = client.Muted, @@ -2093,19 +2331,32 @@ namespace Barotrauma.Networking outmsg.WriteUInt16((UInt16)settingsBuf.LengthBytes); outmsg.WriteBytes(settingsBuf.Buffer, 0, settingsBuf.LengthBytes); - outmsg.WriteBoolean(c.LastRecvLobbyUpdate < 1); - if (c.LastRecvLobbyUpdate < 1) + outmsg.WriteBoolean(!c.InitialLobbyUpdateSent); + if (!c.InitialLobbyUpdateSent) { isInitialUpdate = true; initialUpdateBytes = outmsg.LengthBytes; ClientWriteInitial(c, outmsg); + c.InitialLobbyUpdateSent = true; initialUpdateBytes = outmsg.LengthBytes - initialUpdateBytes; } outmsg.WriteString(GameMain.NetLobbyScreen.SelectedSub.Name); outmsg.WriteString(GameMain.NetLobbyScreen.SelectedSub.MD5Hash.ToString()); + + if (GameMain.NetLobbyScreen.SelectedEnemySub is { } enemySub) + { + outmsg.WriteBoolean(true); + outmsg.WriteString(enemySub.Name); + outmsg.WriteString(enemySub.MD5Hash.ToString()); + } + else + { + outmsg.WriteBoolean(false); + } + outmsg.WriteBoolean(IsUsingRespawnShuttle()); var selectedShuttle = GameStarted && RespawnManager != null && RespawnManager.UsingShuttle ? - RespawnManager.RespawnShuttle.Info : + RespawnManager.RespawnShuttles.First().Info : GameMain.NetLobbyScreen.SelectedShuttle; outmsg.WriteString(selectedShuttle.Name); outmsg.WriteString(selectedShuttle.MD5Hash.ToString()); @@ -2116,11 +2367,16 @@ 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); - outmsg.WriteRangedInteger((int)GameMain.NetLobbyScreen.MissionType, 0, (int)MissionType.All); + outmsg.WriteVariableUInt32((uint)GameMain.NetLobbyScreen.MissionTypes.Count()); + foreach (var missionType in GameMain.NetLobbyScreen.MissionTypes) + { + outmsg.WriteIdentifier(missionType); + } outmsg.WriteByte((byte)GameMain.NetLobbyScreen.SelectedModeIndex); outmsg.WriteString(GameMain.NetLobbyScreen.LevelSeed); @@ -2243,15 +2499,25 @@ namespace Barotrauma.Networking } } - public bool TryStartGame() + public enum TryStartGameResult { - if (initiatedStartGame || GameStarted) { return false; } + Success, + GameAlreadyStarted, + PerksExceedAllowance, + SubmarineNotFound, + GameModeNotSelected, + CannotStartMultiplayerCampaign, + } - GameModePreset selectedMode = + public TryStartGameResult TryStartGame() + { + if (initiatedStartGame || GameStarted) { return TryStartGameResult.GameAlreadyStarted; } + + GameModePreset selectedMode = Voting.HighestVoted(VoteType.Mode, connectedClients) ?? GameMain.NetLobbyScreen.SelectedMode; if (selectedMode == null) { - return false; + return TryStartGameResult.GameModeNotSelected; } if (selectedMode == GameModePreset.MultiPlayerCampaign && GameMain.GameSession?.GameMode is not MultiPlayerCampaign) { @@ -2260,37 +2526,189 @@ namespace Barotrauma.Networking { GameMain.NetLobbyScreen.SelectedModeIdentifier = GameModePreset.MultiPlayerCampaign.Identifier; } - return false; + return TryStartGameResult.CannotStartMultiplayerCampaign; + } + + bool applyPerks = GameSession.ShouldApplyDisembarkPoints(selectedMode); + if (applyPerks) + { + if (!GameSession.ValidatedDisembarkPoints(selectedMode, GameMain.NetLobbyScreen.MissionTypes)) + { + return TryStartGameResult.PerksExceedAllowance; + } } Log("Starting a new round...", ServerLog.MessageType.ServerMessage); SubmarineInfo selectedShuttle = GameMain.NetLobbyScreen.SelectedShuttle; SubmarineInfo selectedSub; + Option selectedEnemySub = Option.None; + if (ServerSettings.AllowSubVoting) { - selectedSub = Voting.HighestVoted(VoteType.Sub, connectedClients); - if (selectedSub == null) { selectedSub = GameMain.NetLobbyScreen.SelectedSub; } + if (selectedMode == GameModePreset.PvP) + { + var team1Voters = connectedClients.Where(static c => c.PreferredTeam == CharacterTeamType.Team1); + var team2Voters = connectedClients.Where(static c => c.PreferredTeam == CharacterTeamType.Team2); + + SubmarineInfo team1Sub = Voting.HighestVoted(VoteType.Sub, team1Voters, out int team1VoteCount); + SubmarineInfo team2Sub = Voting.HighestVoted(VoteType.Sub, team2Voters, out int team2VoteCount); + + // check if anyone on coalition voted for a sub + if (team1VoteCount > 0) + { + // use the most voted one + selectedSub = team1Sub; + } + else + { + selectedSub = team2VoteCount > 0 + ? team2Sub // only separatists voted for a sub, use theirs + : GameMain.NetLobbyScreen.SelectedSub; // nobody voted for a sub so use the default one + } + + // check if separatists voted for a sub + if (team2VoteCount > 0 && team2Sub != null) + { + selectedEnemySub = Option.Some(team2Sub); + } + // no reason to fall back to coalition sub, + // since not selecting an enemy submarine automatically selects the coalition sub + // deeper in the code + } + else + { + selectedSub = Voting.HighestVoted(VoteType.Sub, connectedClients) ?? GameMain.NetLobbyScreen.SelectedSub; + } } else { selectedSub = GameMain.NetLobbyScreen.SelectedSub; + SubmarineInfo enemySub = GameMain.NetLobbyScreen.SelectedEnemySub ?? GameMain.NetLobbyScreen.SelectedSub; + + // Option throws an exception if the value is null, prevent that + if (enemySub != null) + { + selectedEnemySub = Option.Some(enemySub); + } } if (selectedSub == null || selectedShuttle == null) { - return false; + return TryStartGameResult.SubmarineNotFound; + } + + if (applyPerks && CheckIfAnyPerksAreIncompatible(selectedSub, selectedEnemySub.Fallback(selectedSub), selectedMode, out var incompatiblePerks)) + { + CoroutineManager.StartCoroutine(WarnAndDelayStartGame(incompatiblePerks, selectedSub, selectedEnemySub, selectedShuttle, selectedMode), nameof(WarnAndDelayStartGame)); + return TryStartGameResult.Success; } initiatedStartGame = true; - startGameCoroutine = CoroutineManager.StartCoroutine(InitiateStartGame(selectedSub, selectedShuttle, selectedMode), "InitiateStartGame"); + startGameCoroutine = CoroutineManager.StartCoroutine(InitiateStartGame(selectedSub, selectedEnemySub, selectedShuttle, selectedMode), "InitiateStartGame"); - return true; + return TryStartGameResult.Success; } - - private IEnumerable InitiateStartGame(SubmarineInfo selectedSub, SubmarineInfo selectedShuttle, GameModePreset selectedMode) + private bool CheckIfAnyPerksAreIncompatible(SubmarineInfo team1Sub, SubmarineInfo team2Sub, GameModePreset preset, out PerkCollection incompatiblePerks) { + var incompatibleTeam1Perks = ImmutableArray.CreateBuilder(); + var incompatibleTeam2Perks = ImmutableArray.CreateBuilder(); + bool hasIncompatiblePerks = false; + PerkCollection perks = GameSession.GetPerks(); + + bool ignorePerksThatCanNotApplyWithoutSubmarine = GameSession.ShouldIgnorePerksThatCanNotApplyWithoutSubmarine(preset, GameMain.NetLobbyScreen.MissionTypes); + + foreach (DisembarkPerkPrefab perk in perks.Team1Perks) + { + if (ignorePerksThatCanNotApplyWithoutSubmarine && perk.PerkBehaviors.Any(static p => !p.CanApplyWithoutSubmarine())) { continue; } + bool anyCanNotApply = perk.PerkBehaviors.Any(p => !p.CanApply(team1Sub)); + + if (anyCanNotApply) + { + incompatibleTeam1Perks.Add(perk); + hasIncompatiblePerks = true; + } + } + + if (preset == GameModePreset.PvP) + { + foreach (DisembarkPerkPrefab perk in perks.Team2Perks) + { + if (ignorePerksThatCanNotApplyWithoutSubmarine && perk.PerkBehaviors.Any(static p => !p.CanApplyWithoutSubmarine())) { continue; } + + bool anyCanNotApply = perk.PerkBehaviors.Any(p => !p.CanApply(team2Sub)); + + if (anyCanNotApply) + { + incompatibleTeam2Perks.Add(perk); + hasIncompatiblePerks = true; + } + } + } + + incompatiblePerks = new PerkCollection(incompatibleTeam1Perks.ToImmutable(), incompatibleTeam2Perks.ToImmutable()); + return hasIncompatiblePerks; + } + + private bool isRoundStartWarningActive; + + private void AbortStartGameIfWarningActive() + { + isRoundStartWarningActive = false; + //reset autorestart countdown to give the clients time to reselect perks + if (ServerSettings.AutoRestart) + { + ServerSettings.AutoRestartTimer = Math.Max(ServerSettings.AutoRestartInterval, 5.0f); + } + //reset start round votes so we don't immediately attempt to restart + foreach (var client in connectedClients) + { + client.SetVote(VoteType.StartRound, false); + } + + int clientsReady = connectedClients.Count(c => c.GetVote(VoteType.StartRound)); + + GameMain.NetLobbyScreen.LastUpdateID++; + + CoroutineManager.StopCoroutines(nameof(WarnAndDelayStartGame)); + } + + private IEnumerable WarnAndDelayStartGame(PerkCollection incompatiblePerks, SubmarineInfo selectedSub, Option selectedEnemySub, SubmarineInfo selectedShuttle, GameModePreset selectedMode) + { + isRoundStartWarningActive = true; + const float warningDuration = 15.0f; + + SerializableDateTime waitUntilTime = SerializableDateTime.UtcNow + TimeSpan.FromSeconds(warningDuration); + if (connectedClients.Any()) + { + IWriteMessage msg = new WriteOnlyMessage().WithHeader(ServerPacketHeader.WARN_STARTGAME); + INetSerializableStruct warnData = new RoundStartWarningData( + RoundStartsAnywaysTimeInSeconds: warningDuration, + Team1Sub: selectedSub.Name, + Team1IncompatiblePerks: ToolBox.PrefabCollectionToUintIdentifierArray(incompatiblePerks.Team1Perks), + Team2Sub: selectedEnemySub.Fallback(selectedSub).Name, + Team2IncompatiblePerks: ToolBox.PrefabCollectionToUintIdentifierArray(incompatiblePerks.Team2Perks)); + msg.WriteNetSerializableStruct(warnData); + + foreach (Client c in connectedClients) + { + serverPeer.Send(msg, c.Connection, DeliveryMethod.Reliable); + } + } + + while (waitUntilTime > SerializableDateTime.UtcNow) + { + yield return CoroutineStatus.Running; + } + + CoroutineManager.StartCoroutine(InitiateStartGame(selectedSub, selectedEnemySub, selectedShuttle, selectedMode), "InitiateStartGame"); + yield return CoroutineStatus.Success; + } + + private IEnumerable InitiateStartGame(SubmarineInfo selectedSub, Option selectedEnemySub, SubmarineInfo selectedShuttle, GameModePreset selectedMode) + { + isRoundStartWarningActive = false; initiatedStartGame = true; if (connectedClients.Any()) @@ -2301,6 +2719,17 @@ namespace Barotrauma.Networking msg.WriteString(selectedSub.Name); msg.WriteString(selectedSub.MD5Hash.StringRepresentation); + if (selectedEnemySub.TryUnwrap(out var enemySub)) + { + msg.WriteBoolean(true); + msg.WriteString(enemySub.Name); + msg.WriteString(enemySub.MD5Hash.StringRepresentation); + } + else + { + msg.WriteBoolean(false); + } + msg.WriteBoolean(IsUsingRespawnShuttle()); msg.WriteString(selectedShuttle.Name); msg.WriteString(selectedShuttle.MD5Hash.StringRepresentation); @@ -2322,7 +2751,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; @@ -2339,19 +2768,27 @@ namespace Barotrauma.Networking } } - startGameCoroutine = GameMain.Instance.ShowLoading(StartGame(selectedSub, selectedShuttle, selectedMode, CampaignSettings.Empty), false); + startGameCoroutine = GameMain.Instance.ShowLoading(StartGame(selectedSub, selectedShuttle, selectedEnemySub, selectedMode, CampaignSettings.Empty), false); yield return CoroutineStatus.Success; } - private IEnumerable StartGame(SubmarineInfo selectedSub, SubmarineInfo selectedShuttle, GameModePreset selectedMode, CampaignSettings settings) + private IEnumerable StartGame(SubmarineInfo selectedSub, SubmarineInfo selectedShuttle, Option selectedEnemySub, GameModePreset selectedMode, CampaignSettings settings) { + PerkCollection perkCollection = PerkCollection.Empty; + + if (GameSession.ShouldApplyDisembarkPoints(selectedMode)) + { + perkCollection = GameSession.GetPerks(); + } + entityEventManager.Clear(); roundStartSeed = DateTime.Now.Millisecond; Rand.SetSyncedSeed(roundStartSeed); int teamCount = 1; + bool isPvP = selectedMode == GameModePreset.PvP; MultiPlayerCampaign campaign = selectedMode == GameMain.GameSession?.GameMode.Preset ? GameMain.GameSession?.GameMode as MultiPlayerCampaign : null; @@ -2374,31 +2811,38 @@ namespace Barotrauma.Networking if (campaign == null || GameMain.GameSession == null) { traitorManager = new TraitorManager(this); - GameMain.GameSession = new GameSession(selectedSub, "", selectedMode, settings, GameMain.NetLobbyScreen.LevelSeed, missionType: GameMain.NetLobbyScreen.MissionType); + GameMain.GameSession = new GameSession(selectedSub, selectedEnemySub, CampaignDataPath.Empty, selectedMode, settings, GameMain.NetLobbyScreen.LevelSeed, missionTypes: GameMain.NetLobbyScreen.MissionTypes); } else { initialSuppliesSpawned = GameMain.GameSession.SubmarineInfo is { InitialSuppliesSpawned: true }; } - List playingClients = new List(connectedClients); - if (ServerSettings.AllowSpectating) - { - playingClients.RemoveAll(c => c.SpectateOnly); - } - //always allow the server owner to spectate even if it's disallowed in server settings - playingClients.RemoveAll(c => c.Connection == OwnerConnection && c.SpectateOnly); - if (GameMain.GameSession.GameMode is PvPMode pvpMode) { - pvpMode.AssignTeamIDs(playingClients); teamCount = 2; + + // In Player Preference mode, team assignments are handled only at this point, and in Player Choice mode, + // everyone should already have chosen a team, ie. players can no longer make choices now and we should + // finalize all the team assignments without further delay. + RefreshPvpTeamAssignments(assignUnassignedNow: true, autoBalanceNow: true); } else { connectedClients.ForEach(c => c.TeamID = CharacterTeamType.Team1); } + bool missionAllowRespawn = GameMain.GameSession.GameMode is not MissionMode missionMode || missionMode.Missions.All(m => m.AllowRespawning); + foreach (var mission in GameMain.GameSession.GameMode.Missions) + { + if (mission.Prefab.ForceRespawnMode.HasValue) + { + ServerSettings.RespawnMode = mission.Prefab.ForceRespawnMode.Value; + } + } + + + List playingClients = GetPlayingClients(); if (campaign != null) { if (campaign.Map == null) @@ -2416,7 +2860,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; @@ -2428,7 +2872,7 @@ namespace Barotrauma.Networking else { SendStartMessage(roundStartSeed, GameMain.NetLobbyScreen.LevelSeed, GameMain.GameSession, connectedClients, false); - GameMain.GameSession.StartRound(GameMain.NetLobbyScreen.LevelSeed, ServerSettings.SelectedLevelDifficulty); + GameMain.GameSession.StartRound(GameMain.NetLobbyScreen.LevelSeed, ServerSettings.SelectedLevelDifficulty, forceBiome: ServerSettings.Biome); Log("Game mode: " + selectedMode.Name.Value, ServerLog.MessageType.ServerMessage); Log("Submarine: " + selectedSub.Name, ServerLog.MessageType.ServerMessage); Log("Level seed: " + GameMain.NetLobbyScreen.LevelSeed, ServerLog.MessageType.ServerMessage); @@ -2447,9 +2891,7 @@ namespace Barotrauma.Networking yield return CoroutineStatus.Failure; } - bool missionAllowRespawn = GameMain.GameSession.GameMode is not MissionMode missionMode || !missionMode.Missions.Any(m => !m.AllowRespawn); bool isOutpost = campaign != null && campaign.NextLevel?.Type == LevelData.LevelType.Outpost; - if (ServerSettings.RespawnMode != RespawnMode.BetweenRounds && missionAllowRespawn) { RespawnManager = new RespawnManager(this, ServerSettings.UseRespawnShuttle && !isOutpost ? selectedShuttle : null); @@ -2459,36 +2901,47 @@ 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 } - Level.Loaded?.SpawnNPCs(); + if (GameMain.GameSession.Missions.None(m => !m.Prefab.AllowOutpostNPCs)) + { + Level.Loaded?.SpawnNPCs(); + } Level.Loaded?.SpawnCorpses(); Level.Loaded?.PrepareBeaconStation(); AutoItemPlacer.SpawnItems(campaign?.Settings.StartItemSet); - CrewManager crewManager = campaign?.CrewManager; + CrewManager crewManager = GameMain.GameSession.CrewManager; bool hadBots = true; + List team1Characters = new(), + team2Characters = new(); + //assign jobs and spawnpoints separately for each team for (int n = 0; n < teamCount; n++) { var teamID = n == 0 ? CharacterTeamType.Team1 : CharacterTeamType.Team2; - Submarine.MainSubs[n].TeamID = teamID; - foreach (Item item in Item.ItemList) + Submarine teamSub = Submarine.MainSubs[n]; + if (teamSub != null) { - if (item.Submarine == null) { continue; } - if (item.Submarine != Submarine.MainSubs[n] && !Submarine.MainSubs[n].DockedTo.Contains(item.Submarine)) { continue; } - foreach (WifiComponent wifiComponent in item.GetComponents()) + teamSub.TeamID = teamID; + foreach (Item item in Item.ItemList) { - wifiComponent.TeamID = Submarine.MainSubs[n].TeamID; + if (item.Submarine == null) { continue; } + if (item.Submarine != teamSub && !teamSub.DockedTo.Contains(item.Submarine)) { continue; } + foreach (WifiComponent wifiComponent in item.GetComponents()) + { + wifiComponent.TeamID = teamSub.TeamID; + } + } + foreach (Submarine sub in teamSub.DockedTo) + { + if (sub.Info.Type != SubmarineType.Player) { continue; } + sub.TeamID = teamID; } - } - foreach (Submarine sub in Submarine.MainSubs[n].DockedTo) - { - if (sub.Info.Type != SubmarineType.Player) { continue; } - sub.TeamID = teamID; } //find the clients in this team @@ -2509,29 +2962,25 @@ 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) { client.CharacterInfo = new CharacterInfo(CharacterPrefab.HumanSpeciesName, client.Name); } characterInfos.Add(client.CharacterInfo); - if (client.CharacterInfo.Job == null || client.CharacterInfo.Job.Prefab != client.AssignedJob.Prefab) + if (client.CharacterInfo.Job == null || + client.CharacterInfo.Job.Prefab != client.AssignedJob.Prefab || + //always recreate the job to reset the skills in non-campaign modes + campaign == null) { - client.CharacterInfo.Job = new Job(client.AssignedJob.Prefab, Rand.RandSync.Unsynced, client.AssignedJob.Variant); + client.CharacterInfo.Job = new Job(client.AssignedJob.Prefab, isPvP, Rand.RandSync.Unsynced, client.AssignedJob.Variant); } } List bots = new List(); - // do not load new bots if we already have them - if (crewManager == null || !crewManager.HasBots) + if (!crewManager.HasBots || campaign == null) { int botsToSpawn = ServerSettings.BotSpawnMode == BotSpawnMode.Fill ? ServerSettings.BotCount - characterInfos.Count : ServerSettings.BotCount; for (int i = 0; i < botsToSpawn; i++) @@ -2544,49 +2993,38 @@ namespace Barotrauma.Networking bots.Add(botInfo); } - AssignBotJobs(bots, teamID); - if (campaign != null) + AssignBotJobs(bots, teamID, isPvP); + foreach (CharacterInfo bot in bots) { - foreach (CharacterInfo bot in bots) - { - crewManager?.AddCharacterInfo(bot); - } + crewManager.AddCharacterInfo(bot); } - if (crewManager != null) - { - crewManager.HasBots = true; - hadBots = false; - } + crewManager.HasBots = true; + hadBots = false; } - List spawnWaypoints = null; - List mainSubWaypoints = WayPoint.SelectCrewSpawnPoints(characterInfos, Submarine.MainSubs[n]).ToList(); + WayPoint[] spawnWaypoints = null; + WayPoint[] mainSubWaypoints = teamSub != null ? WayPoint.SelectCrewSpawnPoints(characterInfos, Submarine.MainSubs[n]) : null; if (Level.Loaded != null && Level.Loaded.ShouldSpawnCrewInsideOutpost()) { - spawnWaypoints = WayPoint.WayPointList.FindAll(wp => - wp.SpawnType == SpawnType.Human && - wp.Submarine == Level.Loaded.StartOutpost && - wp.CurrentHull?.OutpostModuleTags != null && - wp.CurrentHull.OutpostModuleTags.Contains("airlock".ToIdentifier())); - - 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 (spawnWaypoints == null || !spawnWaypoints.Any()) + if (teamSub != null) { - spawnWaypoints = mainSubWaypoints; + if (spawnWaypoints == null || !spawnWaypoints.Any()) + { + spawnWaypoints = mainSubWaypoints; + } + Debug.Assert(spawnWaypoints.Length == mainSubWaypoints.Length); } - Debug.Assert(spawnWaypoints.Count == mainSubWaypoints.Count); - + + // 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), + //give the job items based on that spawnpoint + WayPoint jobItemSpawnPoint = mainSubWaypoints != null ? mainSubWaypoints[i] : spawnWaypoints[i]; + Character spawnedCharacter = Character.Create(teamClients[i].CharacterInfo, spawnWaypoints[i].WorldPosition, teamClients[i].CharacterInfo.Name, isRemotePlayer: true, hasAi: false); spawnedCharacter.AnimController.Frozen = true; spawnedCharacter.TeamID = teamID; @@ -2594,7 +3032,7 @@ namespace Barotrauma.Networking var characterData = campaign?.GetClientCharacterData(teamClients[i]); if (characterData == null) { - spawnedCharacter.GiveJobItems(mainSubWaypoints[i]); + spawnedCharacter.GiveJobItems(GameMain.GameSession.GameMode is PvPMode, jobItemSpawnPoint); if (campaign != null) { characterData = campaign.SetClientCharacterData(teamClients[i]); @@ -2606,7 +3044,7 @@ namespace Barotrauma.Networking if (!characterData.HasItemData && !characterData.CharacterInfo.StartItemsGiven) { //clients who've chosen to spawn with the respawn penalty can have CharacterData without inventory data - spawnedCharacter.GiveJobItems(mainSubWaypoints[i]); + spawnedCharacter.GiveJobItems(GameMain.GameSession.GameMode is PvPMode, jobItemSpawnPoint); } else { @@ -2615,7 +3053,7 @@ namespace Barotrauma.Networking characterData.ApplyHealthData(spawnedCharacter); characterData.ApplyOrderData(spawnedCharacter); characterData.ApplyWalletData(spawnedCharacter); - spawnedCharacter.GiveIdCardTags(mainSubWaypoints[i]); + spawnedCharacter.GiveIdCardTags(jobItemSpawnPoint); spawnedCharacter.LoadTalents(); characterData.HasSpawned = true; } @@ -2631,22 +3069,39 @@ 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]; Character spawnedCharacter = Character.Create(characterInfos[i], spawnWaypoints[i].WorldPosition, characterInfos[i].Name, isRemotePlayer: false, hasAi: true); spawnedCharacter.TeamID = teamID; - spawnedCharacter.GiveJobItems(mainSubWaypoints[i]); - spawnedCharacter.GiveIdCardTags(mainSubWaypoints[i]); + spawnedCharacter.GiveJobItems(GameMain.GameSession.GameMode is PvPMode, jobItemSpawnPoint); + spawnedCharacter.GiveIdCardTags(jobItemSpawnPoint); spawnedCharacter.Info.InventoryData = new XElement("inventory"); spawnedCharacter.Info.StartItemsGiven = true; spawnedCharacter.SaveInventory(); spawnedCharacter.LoadTalents(); + AddCharacterToList(teamID, spawnedCharacter); + } + + void AddCharacterToList(CharacterTeamType team, Character character) + { + switch (team) + { + case CharacterTeamType.Team1: + team1Characters.Add(character); + break; + case CharacterTeamType.Team2: + team2Characters.Add(character); + break; + } } } - if (crewManager != null && crewManager.HasBots) + if (campaign != null && crewManager.HasBots) { if (hadBots) { @@ -2656,7 +3111,7 @@ namespace Barotrauma.Networking else { //created new bots -> save them - SaveUtil.SaveGame(GameMain.GameSession.SavePath); + SaveUtil.SaveGame(GameMain.GameSession.DataPath); } } @@ -2684,10 +3139,12 @@ namespace Barotrauma.Networking GameAnalyticsManager.AddDesignEvent("Traitors:" + (TraitorManager == null ? "Disabled" : "Enabled")); + perkCollection.ApplyAll(team1Characters, team2Characters); + yield return CoroutineStatus.Running; Voting.ResetVotes(GameMain.Server.ConnectedClients, resetKickVotes: false); - + GameMain.GameScreen.Select(); Log("Round started.", ServerLog.MessageType.ServerMessage); @@ -2718,7 +3175,7 @@ namespace Barotrauma.Networking msg.WriteByte((byte)ServerPacketHeader.STARTGAME); msg.WriteInt32(seed); msg.WriteIdentifier(gameSession.GameMode.Preset.Identifier); - bool missionAllowRespawn = GameMain.GameSession.GameMode is not MissionMode missionMode || !missionMode.Missions.Any(m => !m.AllowRespawn); + bool missionAllowRespawn = GameMain.GameSession.GameMode is not MissionMode missionMode || !missionMode.Missions.Any(m => !m.AllowRespawning); msg.WriteBoolean(ServerSettings.RespawnMode != RespawnMode.BetweenRounds && missionAllowRespawn); msg.WriteBoolean(ServerSettings.AllowDisguises); msg.WriteBoolean(ServerSettings.AllowRewiring); @@ -2728,6 +3185,7 @@ namespace Barotrauma.Networking msg.WriteBoolean(ServerSettings.LockAllDefaultWires); msg.WriteBoolean(ServerSettings.AllowLinkingWifiToChat); msg.WriteInt32(ServerSettings.MaximumMoneyTransferRequest); + msg.WriteByte((byte)ServerSettings.RespawnMode); msg.WriteBoolean(IsUsingRespawnShuttle()); msg.WriteByte((byte)ServerSettings.LosMode); msg.WriteByte((byte)ServerSettings.ShowEnemyHealthBars); @@ -2739,12 +3197,25 @@ namespace Barotrauma.Networking { msg.WriteString(levelSeed); msg.WriteSingle(ServerSettings.SelectedLevelDifficulty); + msg.WriteIdentifier(ServerSettings.Biome == "Random".ToIdentifier() ? Identifier.Empty : ServerSettings.Biome); msg.WriteString(gameSession.SubmarineInfo.Name); msg.WriteString(gameSession.SubmarineInfo.MD5Hash.StringRepresentation); var selectedShuttle = GameStarted && RespawnManager != null && RespawnManager.UsingShuttle ? - RespawnManager.RespawnShuttle.Info : GameMain.NetLobbyScreen.SelectedShuttle; + RespawnManager.RespawnShuttles.First().Info : GameMain.NetLobbyScreen.SelectedShuttle; msg.WriteString(selectedShuttle.Name); msg.WriteString(selectedShuttle.MD5Hash.StringRepresentation); + + if (gameSession.EnemySubmarineInfo is { } enemySub) + { + msg.WriteBoolean(true); + msg.WriteString(enemySub.Name); + msg.WriteString(enemySub.MD5Hash.StringRepresentation); + } + else + { + msg.WriteBoolean(false); + } + msg.WriteByte((byte)GameMain.GameSession.GameMode.Missions.Count()); foreach (Mission mission in GameMain.GameSession.GameMode.Missions) { @@ -2756,6 +3227,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); @@ -2814,6 +3286,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) @@ -2830,6 +3303,8 @@ namespace Barotrauma.Networking } msg.WriteBoolean(GameMain.GameSession.CrewManager != null); GameMain.GameSession.CrewManager?.ServerWriteActiveOrders(msg); + + msg.WriteBoolean(GameSession.ShouldApplyDisembarkPoints(GameMain.GameSession.GameMode?.Preset)); } public void EndGame(CampaignMode.TransitionType transitionType = CampaignMode.TransitionType.None, bool wasSaved = false, IEnumerable missions = null) @@ -2871,9 +3346,7 @@ namespace Barotrauma.Networking entityEventManager.Clear(); foreach (Client c in connectedClients) { - c.EntityEventLastSent.Clear(); - c.PendingPositionUpdates.Clear(); - c.PositionUpdateLastSent.Clear(); + c.ResetSync(); } if (GameStarted) @@ -2953,6 +3426,24 @@ namespace Barotrauma.Networking if (c == null || string.IsNullOrEmpty(newName) || !NetIdUtils.IdMoreRecent(nameId, c.NameId)) { return false; } + if (!newJob.IsEmpty) + { + if (!JobPrefab.Prefabs.TryGet(newJob, out JobPrefab newJobPrefab) || newJobPrefab.HiddenJob) + { + newJob = Identifier.Empty; + } + } + + if (newName == c.Name && newJob == c.PreferredJob && newTeam == c.PreferredTeam) { return false; } + + c.NameId = nameId; + c.PreferredJob = newJob; + if (newTeam != c.PreferredTeam) + { + c.PreferredTeam = newTeam; + RefreshPvpTeamAssignments(); + } + var timeSinceNameChange = DateTime.Now - c.LastNameChangeTime; if (timeSinceNameChange < Client.NameChangeCoolDown && newName != c.Name) { @@ -2962,31 +3453,21 @@ namespace Barotrauma.Networking var coolDownRemaining = Client.NameChangeCoolDown - timeSinceNameChange; SendDirectChatMessage($"ServerMessage.NameChangeFailedCooldownActive~[seconds]={(int)coolDownRemaining.TotalSeconds}", c); LastClientListUpdateID++; + //increment the ID to make sure the current server-side name is treated as the "latest", + //and the client correctly reverts back to the old name + c.NameId++; } - c.NameId = nameId; c.RejectedName = newName; return false; } - if (!newJob.IsEmpty) - { - if (!JobPrefab.Prefabs.TryGet(newJob, out JobPrefab newJobPrefab) || newJobPrefab.HiddenJob) - { - newJob = Identifier.Empty; - } - } - c.NameId = nameId; - if (newName == c.Name && newJob == c.PreferredJob && newTeam == c.PreferredTeam) { return false; } - c.PreferredJob = newJob; - c.PreferredTeam = newTeam; - - 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; @@ -3005,10 +3486,8 @@ namespace Barotrauma.Networking } } - public bool IsNameValid(Client c, string newName) + public bool IsNameValid(Client c, string newName, bool clientRenamingSelf = false) { - newName = Client.SanitizeName(newName); - if (c.Connection != OwnerConnection) { if (!Client.IsValidName(newName, ServerSettings)) @@ -3029,18 +3508,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; @@ -3190,6 +3674,16 @@ namespace Barotrauma.Networking previousPlayer = new PreviousPlayer(client); previousPlayers.Add(previousPlayer); } + + if (peerDisconnectPacket.ShouldAttemptReconnect) + { + lock (clientsAttemptingToReconnectSoon) + { + client.DeleteDisconnectedTimer = ServerSettings.KillDisconnectedTime; + clientsAttemptingToReconnectSoon.Add(client); + } + } + previousPlayer.Name = client.Name; previousPlayer.Karma = client.Karma; previousPlayer.KarmaKickCount = client.KarmaKickCount; @@ -3204,6 +3698,12 @@ namespace Barotrauma.Networking serverPeer.Disconnect(client.Connection, peerDisconnectPacket); KarmaManager.OnClientDisconnected(client); + + // A player disconnecting might impact PvP team assignments if still in the lobby + if (!GameStarted) + { + RefreshPvpTeamAssignments(); + } UpdateVoteStatus(); @@ -3337,13 +3837,13 @@ namespace Barotrauma.Networking } else //msg sent by an AI character { - senderName = senderCharacter.Name; + senderName = senderCharacter.DisplayName; } } else //msg sent by a client { senderCharacter = senderClient.Character; - senderName = senderCharacter == null ? senderClient.Name : senderCharacter.Name; + senderName = senderCharacter == null ? senderClient.Name : senderCharacter.DisplayName; if (type == ChatMessageType.Private) { if (senderCharacter != null && !senderCharacter.IsDead || targetClient.Character != null && !targetClient.Character.IsDead) @@ -3376,8 +3876,8 @@ namespace Barotrauma.Networking } else //msg sent by a client { - //game not started -> clients can only send normal and private chatmessages - if (type != ChatMessageType.Private) type = ChatMessageType.Default; + //game not started -> clients can only send normal, private, and team chatmessages + if (type != ChatMessageType.Private && type != ChatMessageType.Team) type = ChatMessageType.Default; senderName = senderClient.Name; } } @@ -3442,6 +3942,10 @@ namespace Barotrauma.Networking //private msg sent to someone else than this client -> don't send if (client != targetClient && client != senderClient) { continue; } break; + case ChatMessageType.Team: + // No need to relay team messages at all to clients in opposing teams (or without a team) + if (client.TeamID == CharacterTeamType.None || client.TeamID != senderClient.TeamID) { continue; } + break; } var chatMsg = ChatMessage.Create( @@ -3562,10 +4066,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); @@ -3802,13 +4307,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; @@ -3819,7 +4328,7 @@ namespace Barotrauma.Networking else { newName = Client.SanitizeName(newName); - if (!IsNameValid(sender, newName)) + if (!IsNameValid(sender, newName, clientRenamingSelf: true)) { newName = sender.Name; } @@ -3830,11 +4339,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); @@ -4040,12 +4554,12 @@ namespace Barotrauma.Networking } } - public void AssignBotJobs(List bots, CharacterTeamType teamID) + public void AssignBotJobs(List bots, CharacterTeamType teamID, bool isPvP) { //shuffle first so the parts where we go through the prefabs //and find ones there's too few of don't always pick the same job List shuffledPrefabs = JobPrefab.Prefabs.Where(static jp => !jp.HiddenJob).ToList(); - shuffledPrefabs.Shuffle(); + shuffledPrefabs.Shuffle(Rand.RandSync.Unsynced); Dictionary assignedPlayerCount = new Dictionary(); foreach (JobPrefab jp in shuffledPrefabs) @@ -4114,7 +4628,7 @@ namespace Barotrauma.Networking void AssignJob(CharacterInfo bot, JobPrefab job) { int variant = Rand.Range(0, job.Variants); - bot.Job = new Job(job, Rand.RandSync.Unsynced, variant); + bot.Job = new Job(job, isPvP, Rand.RandSync.Unsynced, variant); assignedPlayerCount[bot.Job.Prefab]++; unassignedBots.Remove(bot); } @@ -4207,6 +4721,201 @@ namespace Barotrauma.Networking SteamManager.CloseServer(); } } + + private void UpdateClientLobbies() + { + // Triggers a call to WriteClientList(), which causes clients to call GameClient.ReadClientList() + LastClientListUpdateID++; + } + + private List GetPlayingClients() + { + List playingClients = new List(connectedClients.Where(c => !c.AFK || !ServerSettings.AllowAFK)); + if (ServerSettings.AllowSpectating) + { + playingClients.RemoveAll(static c => c.SpectateOnly); + } + // Always allow the server owner to spectate even if it's disallowed in server settings + playingClients.RemoveAll(c => c.Connection == OwnerConnection && c.SpectateOnly); + return playingClients; + } + + /// + /// Assigns currently playing clients into PvP teams according to current server settings. + /// + /// Should players without team preference be randomized into teams or given time to choose? + /// Should auto-balance be applied immediately? Otherwise, only the auto-balance countdown is started (in case of imbalance). + public void RefreshPvpTeamAssignments(bool assignUnassignedNow = false, bool autoBalanceNow = false) + { + List team1 = new List(); + List team2 = new List(); + List playingClients = GetPlayingClients(); + + // First assign clients with a team preference/choice into the teams they want (applies in both team selection modes) + List unassignedClients = new List(playingClients); + for (int i = 0; i < unassignedClients.Count; i++) + { + if (unassignedClients[i].PreferredTeam == CharacterTeamType.Team1 || + unassignedClients[i].PreferredTeam == CharacterTeamType.Team2) + { + assignTeam(unassignedClients[i], unassignedClients[i].PreferredTeam); + i--; + } + } + + // Should unassigned players be forced into teams now? (eg. at round start when the time to make choices is over) + if (assignUnassignedNow) + { + if (unassignedClients.Any()) + { + SendChatMessage(TextManager.Get("PvP.WithoutTeamWillBeRandomlyAssigned").Value, ChatMessageType.Server); + } + + // Assign to the team that has the least players + while (unassignedClients.Any()) + { + var randomClient = unassignedClients.GetRandom(Rand.RandSync.Unsynced); + assignTeam(randomClient, team1.Count < team2.Count ? CharacterTeamType.Team1 : CharacterTeamType.Team2); + } + } + + if (ServerSettings.PvpAutoBalanceThreshold > 0) + { + // Deal with team size balance as necessary + int sizeDifference = Math.Abs(team1.Count - team2.Count); + if (sizeDifference > ServerSettings.PvpAutoBalanceThreshold) + { + if (autoBalanceNow) + { + SendChatMessage(TextManager.Get("AutoBalance.Activating").Value, ChatMessageType.Server); + + // Assign a random player from the bigger team into the smaller team until the teams are no longer too imbalanced + while (Math.Abs(team1.Count - team2.Count) > ServerSettings.PvpAutoBalanceThreshold) + { + // Note: team size difference never 0 at this point + var biggerTeam = GetPlayingClients().Where( + c => team1.Count > team2.Count ? + c.TeamID == CharacterTeamType.Team1 : + c.TeamID == CharacterTeamType.Team2) + .ToList(); + switchTeam(biggerTeam.GetRandom(Rand.RandSync.Unsynced), team1.Count < team2.Count ? CharacterTeamType.Team1 : CharacterTeamType.Team2); + } + } + else if (ServerSettings.PvpTeamSelectionMode != PvpTeamSelectionMode.PlayerPreference) + { + // Start a countdown (if not already running) to auto-balancing, so players have a chance to manually rebalance the team before that + if (pvpAutoBalanceCountdownRemaining == -1) + { + SendChatMessage(TextManager.GetWithVariables( + "AutoBalance.CountdownStarted", + ("[teamname]", TextManager.Get(team1.Count > team2.Count ? "teampreference.team1" : "teampreference.team2")), + ("[numberplayers]", (sizeDifference - ServerSettings.PvpAutoBalanceThreshold).ToString()), + ("[numberseconds]", PvpAutoBalanceCountdown.ToString()) + ).Value, ChatMessageType.Server); + pvpAutoBalanceCountdownRemaining = PvpAutoBalanceCountdown; + } + } + } + else + { + // Stop countdown if there was one + StopAutoBalanceCountdown(); + } + } + else + { + // Stop countdown if there was one (eg. if the settings were changed during countdown) + StopAutoBalanceCountdown(); + } + + // Finally, push the assignments to the clients + UpdateClientLobbies(); + + void assignTeam(Client client, CharacterTeamType newTeam) + { + client.TeamID = newTeam; + unassignedClients.Remove(client); + if (newTeam == CharacterTeamType.Team1) + { + team1.Add(client); + } + else if (newTeam == CharacterTeamType.Team2) + { + team2.Add(client); + } + } + + void switchTeam(Client client, CharacterTeamType newTeam) + { + string teamNameVariable = ""; + if (newTeam == CharacterTeamType.Team1) + { + team2.Remove(client); + team1.Add(client); + teamNameVariable = "teampreference.team1"; + } + else if (newTeam == CharacterTeamType.Team2) + { + team1.Remove(client); + team2.Add(client); + teamNameVariable = "teampreference.team2"; + } + SendChatMessage(TextManager.GetWithVariables( + "AutoBalance.PlayerMoved", + ("[clientname]", client.Name), + ("[teamname]", TextManager.Get(teamNameVariable)) + ).Value, ChatMessageType.Server); + client.TeamID = newTeam; + client.PreferredTeam = newTeam; + } + } + + /// + /// Assign a team for single clients who join the server when a round is already running. + /// + public void AssignClientToPvpTeamMidgame(Client client) + { + if (client.PreferredTeam == CharacterTeamType.None) + { + // If teams are currently even, assign the preference-less new player into a random team + if (Team1Count == Team2Count) + { + client.TeamID = Rand.Value() > 0.5f ? CharacterTeamType.Team1 : CharacterTeamType.Team2; + } + else // Otherwise, just assign them to the smaller team + { + client.TeamID = Team1Count < Team2Count ? CharacterTeamType.Team1 : CharacterTeamType.Team2; + } + } + else if (ServerSettings.PvpAutoBalanceThreshold > 0) // Check if the player can be put into their preferred team + { + int newTeam1Count = Team1Count + (client.PreferredTeam == CharacterTeamType.Team1 ? 1 : 0); + int newTeam2Count = Team2Count + (client.PreferredTeam == CharacterTeamType.Team2 ? 1 : 0); + + // Threshold won't be crossed by assigning the player to their preferred team, so do it + if (Math.Abs(newTeam1Count - newTeam2Count) <= ServerSettings.PvpAutoBalanceThreshold) + { + client.TeamID = client.PreferredTeam; + } + else // Preferred team would go against balance threshold, assing the player to the smaller team + { + client.TeamID = Team1Count < Team2Count ? CharacterTeamType.Team1 : CharacterTeamType.Team2; + } + } + else // Nothing stopping us from assigning the player into their preferred team + { + client.TeamID = client.PreferredTeam; + } + } + + private void StopAutoBalanceCountdown() + { + if (pvpAutoBalanceCountdownRemaining != -1) + { + SendChatMessage(TextManager.Get("AutoBalance.CountdownCancelled").Value, ChatMessageType.Server); + } + pvpAutoBalanceCountdownRemaining = -1; + } } class PreviousPlayer diff --git a/Barotrauma/BarotraumaServer/ServerSource/Networking/KarmaManager.cs b/Barotrauma/BarotraumaServer/ServerSource/Networking/KarmaManager.cs index 8c0fceec9..6bab9e4ec 100644 --- a/Barotrauma/BarotraumaServer/ServerSource/Networking/KarmaManager.cs +++ b/Barotrauma/BarotraumaServer/ServerSource/Networking/KarmaManager.cs @@ -151,11 +151,11 @@ namespace Barotrauma //increase the strength of the herpes affliction in steps instead of linearly //otherwise clients could determine their exact karma value from the strength float herpesStrength = 0.0f; - if (client.Karma < 20) + if (client.Karma < HerpesThreshold * 0.5f) herpesStrength = 100.0f; - else if (client.Karma < 30) + else if (client.Karma < HerpesThreshold * 0.75f) herpesStrength = 60.0f; - else if (client.Karma < 40.0f) + else if (client.Karma < HerpesThreshold) herpesStrength = 30.0f; var existingAffliction = client.Character.CharacterHealth.GetAffliction(AfflictionPrefab.SpaceHerpesType); diff --git a/Barotrauma/BarotraumaServer/ServerSource/Networking/NetEntityEvent/ServerEntityEventManager.cs b/Barotrauma/BarotraumaServer/ServerSource/Networking/NetEntityEvent/ServerEntityEventManager.cs index fc721f6f8..2cd089631 100644 --- a/Barotrauma/BarotraumaServer/ServerSource/Networking/NetEntityEvent/ServerEntityEventManager.cs +++ b/Barotrauma/BarotraumaServer/ServerSource/Networking/NetEntityEvent/ServerEntityEventManager.cs @@ -229,7 +229,9 @@ namespace Barotrauma.Networking GameMain.GameSession.RoundDuration > NetConfig.RoundStartSyncDuration) { lastWarningTime = Timing.TotalTime; - GameServer.Log("WARNING: ServerEntityEventManager is lagging behind! Last sent id: " + lastSentToAnyone.ToString() + ", latest create id: " + ID.ToString(), ServerLog.MessageType.ServerMessage); + string warningMsg = $"WARNING: ServerEntityEventManager is lagging behind! Last sent id: {lastSentToAnyone}, latest create id: {ID}"; + warningMsg += "\n" + GetHighEventCountsWarning(events, maxEventsToList: 3); + GameServer.Log(warningMsg, ServerLog.MessageType.ServerMessage); events.ForEach(e => e.ResetCreateTime()); //TODO: reset clients if this happens, maybe do it if a majority are behind rather than all of them? } @@ -323,30 +325,20 @@ namespace Barotrauma.Networking } //too many events for one packet - //(normal right after a round has just started, don't show a warning if it's been less than 10 seconds) - if (eventsToSync.Count > 200 && GameMain.GameSession != null && GameMain.GameSession.RoundDuration > 10.0) + //(normal right after a round has just started, don't show a warning if it's been less than 30 seconds) + if (eventsToSync.Count > 200 && GameMain.GameSession != null && GameMain.GameSession.RoundDuration > 30.0) { if (eventsToSync.Count > 200 && !client.NeedsMidRoundSync && Timing.TotalTime > lastEventCountHighWarning + 2.0) { Color color = eventsToSync.Count > 500 ? Color.Red : Color.Orange; if (eventsToSync.Count < 300) { color = Color.Yellow; } string warningMsg = "WARNING: event count very high: " + eventsToSync.Count; - - var sortedEvents = eventsToSync.GroupBy(e => e.Entity.ToString()) - .Select(e => new { Value = e.Key, Count = e.Count() }) - .OrderByDescending(e => e.Count); - - int count = 1; - foreach (var sortedEvent in sortedEvents) - { - warningMsg += "\n" + count + ". " + (sortedEvent.Value?.ToString() ?? "null") + " x" + sortedEvent.Count; - count++; - if (count > 3) { break; } - } + warningMsg += "\n" + GetHighEventCountsWarning(eventsToSync, maxEventsToList: 3); if (GameSettings.CurrentConfig.VerboseLogging) { GameServer.Log(warningMsg, ServerLog.MessageType.Error); } + server.SendConsoleMessage(warningMsg, client, color); DebugConsole.NewMessage(warningMsg, color); lastEventCountHighWarning = Timing.TotalTime; } @@ -373,6 +365,31 @@ namespace Barotrauma.Networking } } + private string GetHighEventCountsWarning(IEnumerable events, int maxEventsToList) + { + string warningMsg = string.Empty; + + var sortedEvents = events.GroupBy(e => e.Entity.ToString()) + .Select(e => new { Value = e.First(), Count = e.Count() }) + .OrderByDescending(e => e.Count); + + int count = 1; + foreach (var sortedEvent in sortedEvents) + { + Entity targetEntity = sortedEvent.Value.Entity; + if (!warningMsg.IsNullOrEmpty()) { warningMsg += "\n"; } + warningMsg += count + ". " + (targetEntity?.ToString() ?? "null") + " x" + sortedEvent.Count; + if (targetEntity != null && targetEntity.ContentPackage != ContentPackageManager.VanillaCorePackage) + { + warningMsg += $" (content package: {targetEntity.ContentPackage.Name})"; + } + count++; + if (count > maxEventsToList) { break; } + } + + return warningMsg; + } + /// /// Returns a list of events that should be sent to the client from the eventList /// diff --git a/Barotrauma/BarotraumaServer/ServerSource/Networking/Primitives/Peers/Server/LidgrenServerPeer.cs b/Barotrauma/BarotraumaServer/ServerSource/Networking/Primitives/Peers/Server/LidgrenServerPeer.cs index 05cbbc7db..6a40cdd8c 100644 --- a/Barotrauma/BarotraumaServer/ServerSource/Networking/Primitives/Peers/Server/LidgrenServerPeer.cs +++ b/Barotrauma/BarotraumaServer/ServerSource/Networking/Primitives/Peers/Server/LidgrenServerPeer.cs @@ -269,10 +269,15 @@ namespace Barotrauma.Networking { if (netServer == null) { return; } - switch (inc.SenderConnection.Status) + NetConnectionStatus status = inc.ReadHeader(); + switch (status) { case NetConnectionStatus.Disconnected: LidgrenConnection? conn = connectedClients.Select(c => c.Connection).FirstOrDefault(c => c.NetConnection == inc.SenderConnection); + + string disconnectMsg = inc.ReadString(); + var peerDisconnectPacket = + PeerDisconnectPacket.FromLidgrenStringRepresentation(disconnectMsg).Fallback(PeerDisconnectPacket.WithReason(DisconnectReason.Unknown)); if (conn != null) { if (conn == OwnerConnection) @@ -283,7 +288,7 @@ namespace Barotrauma.Networking } else { - Disconnect(conn, PeerDisconnectPacket.WithReason(DisconnectReason.Disconnected)); + Disconnect(conn, peerDisconnectPacket); } } else @@ -291,7 +296,7 @@ namespace Barotrauma.Networking PendingClient? pendingClient = pendingClients.Find(c => c.Connection is LidgrenConnection l && l.NetConnection == inc.SenderConnection); if (pendingClient != null) { - RemovePendingClient(pendingClient, PeerDisconnectPacket.WithReason(DisconnectReason.Disconnected)); + RemovePendingClient(pendingClient, peerDisconnectPacket); } } @@ -332,7 +337,9 @@ namespace Barotrauma.Networking if (status == Steamworks.AuthResponse.OK) { pendingClient.Connection.SetAccountInfo(new AccountInfo(new SteamId(steamId), new SteamId(ownerId))); - pendingClient.InitializationStep = serverSettings.HasPassword ? ConnectionInitialization.Password : ConnectionInitialization.ContentPackageOrder; + pendingClient.InitializationStep = ShouldAskForPassword(serverSettings, pendingClient.Connection) + ? ConnectionInitialization.Password + : ConnectionInitialization.ContentPackageOrder; pendingClient.UpdateTime = Timing.TotalTime; } else @@ -440,7 +447,7 @@ namespace Barotrauma.Networking { if (pendingClient.AccountInfo.AccountId != packet.AccountId) { - RemovePendingClient(pendingClient, PeerDisconnectPacket.WithReason(DisconnectReason.AuthenticationFailed)); + rejectClient(); } return; } @@ -450,7 +457,9 @@ namespace Barotrauma.Networking pendingClient.Connection.SetAccountInfo(accountInfo); pendingClient.Name = packet.Name; pendingClient.OwnerKey = packet.OwnerKey; - pendingClient.InitializationStep = serverSettings.HasPassword ? ConnectionInitialization.Password : ConnectionInitialization.ContentPackageOrder; + pendingClient.InitializationStep = ShouldAskForPassword(serverSettings, pendingClient.Connection) + ? ConnectionInitialization.Password + : ConnectionInitialization.ContentPackageOrder; } void rejectClient() @@ -470,7 +479,7 @@ namespace Barotrauma.Networking if (authenticators is null || !packet.AuthTicket.TryUnwrap(out var authTicket) || !authenticators.TryGetValue(authTicket.Kind, out var authenticator)) - { + { #if DEBUG DebugConsole.NewMessage("Debug server accepts unauthenticated connections", Microsoft.Xna.Framework.Color.Yellow); acceptClient(new AccountInfo(new UnauthenticatedAccountId(packet.Name))); @@ -494,10 +503,16 @@ namespace Barotrauma.Networking pendingClient.AuthSessionStarted = true; TaskPool.Add($"{nameof(LidgrenServerPeer)}.ProcessAuth", authenticator.VerifyTicket(authTicket), t => { - if (!t.TryGetResult(out AccountInfo accountInfo) - || accountInfo.IsNone) + if (!t.TryGetResult(out AccountInfo accountInfo) || accountInfo.IsNone) { - rejectClient(); + if (GameMain.Server.ServerSettings.RequireAuthentication) + { + rejectClient(); + } + else + { + acceptClient(new AccountInfo(new UnauthenticatedAccountId(packet.Name))); + } return; } diff --git a/Barotrauma/BarotraumaServer/ServerSource/Networking/Primitives/Peers/Server/P2PServerPeer.cs b/Barotrauma/BarotraumaServer/ServerSource/Networking/Primitives/Peers/Server/P2PServerPeer.cs index 4781c5e0a..3b317e7a7 100644 --- a/Barotrauma/BarotraumaServer/ServerSource/Networking/Primitives/Peers/Server/P2PServerPeer.cs +++ b/Barotrauma/BarotraumaServer/ServerSource/Networking/Primitives/Peers/Server/P2PServerPeer.cs @@ -344,7 +344,9 @@ namespace Barotrauma.Networking { // Do nothing with the auth ticket because that should be handled by the owner peer, // just assume that authentication succeeded - pendingClient.InitializationStep = serverSettings.HasPassword ? ConnectionInitialization.Password : ConnectionInitialization.ContentPackageOrder; + pendingClient.InitializationStep = ShouldAskForPassword(serverSettings, pendingClient.Connection) + ? ConnectionInitialization.Password + : ConnectionInitialization.ContentPackageOrder; pendingClient.Name = packet.Name; pendingClient.AuthSessionStarted = true; } diff --git a/Barotrauma/BarotraumaServer/ServerSource/Networking/Primitives/Peers/Server/ServerPeer.cs b/Barotrauma/BarotraumaServer/ServerSource/Networking/Primitives/Peers/Server/ServerPeer.cs index c9329cf3e..6a19c93ad 100644 --- a/Barotrauma/BarotraumaServer/ServerSource/Networking/Primitives/Peers/Server/ServerPeer.cs +++ b/Barotrauma/BarotraumaServer/ServerSource/Networking/Primitives/Peers/Server/ServerPeer.cs @@ -359,5 +359,18 @@ namespace Barotrauma.Networking } protected static void LogMalformedMessage() => DebugConsole.ThrowError("Received malformed message from remote peer."); + + protected bool ShouldAskForPassword(ServerSettings serverSettings, NetworkConnection connection) + { + if (!serverSettings.HasPassword) { return false; } + + if (GameMain.Server is { } server && server.FindAndRemoveRecentlyDisconnectedConnection(connection)) + { + // do not ask passwords from clients that have recently disconnected + return false; + } + + return true; + } } } \ No newline at end of file diff --git a/Barotrauma/BarotraumaServer/ServerSource/Networking/RespawnManager.cs b/Barotrauma/BarotraumaServer/ServerSource/Networking/RespawnManager.cs index f0b0e7a80..e1e2a6c78 100644 --- a/Barotrauma/BarotraumaServer/ServerSource/Networking/RespawnManager.cs +++ b/Barotrauma/BarotraumaServer/ServerSource/Networking/RespawnManager.cs @@ -1,4 +1,5 @@ -using Barotrauma.Items.Components; +using Barotrauma.Extensions; +using Barotrauma.Items.Components; using Microsoft.Xna.Framework; using System; using System.Collections.Generic; @@ -8,16 +9,11 @@ namespace Barotrauma.Networking { partial class RespawnManager : Entity, IServerSerializable { - private DateTime despawnTime; - private float shuttleEmptyTimer; - private int pendingRespawnCount, requiredRespawnCount; - private int prevPendingRespawnCount, prevRequiredRespawnCount; + public bool IsShuttleInsideLevel => RespawnShuttles.Any(s => s.WorldPosition.Y < Level.Loaded.Size.Y); - public bool IsShuttleInsideLevel => RespawnShuttle != null && RespawnShuttle.WorldPosition.Y < Level.Loaded.Size.Y; - - private IEnumerable GetClientsToRespawn() + private IEnumerable GetClientsToRespawn(CharacterTeamType teamId) { MultiPlayerCampaign campaign = GameMain.GameSession.GameMode as MultiPlayerCampaign; foreach (Client c in networkMember.ConnectedClients) @@ -25,6 +21,10 @@ namespace Barotrauma.Networking if (!c.InGame) { continue; } if (c.SpectateOnly && (GameMain.Server.ServerSettings.AllowSpectating || GameMain.Server.OwnerConnection == c.Connection)) { continue; } if (c.Character != null && !c.Character.IsDead) { continue; } + if (c.TeamID != CharacterTeamType.None && c.TeamID != teamId) + { + continue; + } var matchingData = campaign?.GetClientCharacterData(c); @@ -35,8 +35,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; @@ -44,7 +45,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; } @@ -63,7 +64,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 => @@ -80,36 +81,52 @@ namespace Barotrauma.Networking return false; } - private static List GetBotsToRespawn() + 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 + var botInfos = GameMain.GameSession.CrewManager.GetCharacterInfos() + .Where(botInfo => botInfo.TeamID == teamId) + //filter out players in case a player has been given control of a bot using console commands + .Where(botInfo => GameMain.Server.ConnectedClients.None(c => c.CharacterInfo == botInfo)) + .ToList(); + if (GameMain.Server.ServerSettings.BotSpawnMode == BotSpawnMode.Normal) { - return Character.CharacterList - .FindAll(c => c.TeamID == CharacterTeamType.Team1 && c.AIController != null && c.Info != null && c.IsDead) - .Select(c => c.Info) - .ToList(); + return botInfos.Where(ci => ci.Character == null || ci.Character.IsDead).ToList(); } int currPlayerCount = GameMain.Server.ConnectedClients.Count(c => c.InGame && (!c.SpectateOnly || (!GameMain.Server.ServerSettings.AllowSpectating && GameMain.Server.OwnerConnection != c.Connection))); - var existingBots = Character.CharacterList - .FindAll(c => c.TeamID == CharacterTeamType.Team1 && c.AIController != null && c.Info != null); - + var existingBots = Character.CharacterList.FindAll(c => c.IsBot && !c.IsDead && c.TeamID == teamId); int requiredBots = GameMain.Server.ServerSettings.BotCount - currPlayerCount; requiredBots -= existingBots.Count(b => !b.IsDead); List botsToRespawn = new List(); for (int i = 0; i < requiredBots; i++) { - CharacterInfo botToRespawn = existingBots.Find(b => b.IsDead)?.Info; + CharacterInfo botToRespawn = botInfos.FirstOrDefault(b => b.Character == null || b.Character.IsDead); if (botToRespawn == null) { - botToRespawn = new CharacterInfo(CharacterPrefab.HumanSpeciesName); + botToRespawn = new CharacterInfo(CharacterPrefab.HumanSpeciesName) + { + TeamID = teamId + }; } else { + botInfos.Remove(botToRespawn); existingBots.Remove(botToRespawn.Character); } botsToRespawn.Add(botToRespawn); @@ -117,15 +134,34 @@ namespace Barotrauma.Networking return botsToRespawn; } - private bool ShouldStartRespawnCountdown() + private string GetRespawnShuttleText(CharacterTeamType team) { - int characterToRespawnCount = GetClientsToRespawn().Count(); + if (teamSpecificStates.Count == 1) + { + return "respawn shuttle"; + } + return team == CharacterTeamType.Team1 ? "respawn shuttle (team 1)" : "respawn shuttle (team 2)"; + } + private string GetTeamNameText(CharacterTeamType team) + { + if (teamSpecificStates.Count == 1) + { + return "everyone"; + } + return team == CharacterTeamType.Team1 ? "team 1" : "team 2"; + } + + + private bool ShouldStartRespawnCountdown(TeamSpecificState teamSpecificState) + { + int characterToRespawnCount = GetClientsToRespawn(teamSpecificState.TeamID).Count(); return ShouldStartRespawnCountdown(characterToRespawnCount); } 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) @@ -133,108 +169,92 @@ namespace Barotrauma.Networking return characterToRespawnCount >= GetMinCharactersToRespawn(); } - partial void UpdateWaiting(float _) + partial void UpdateWaiting(TeamSpecificState teamSpecificState) { - if (RespawnShuttle != null) + //no respawns in the first minute of the round - otherwise it can be that bots + //are respawned to "fill" the spots of players who are taking a long time to load in + if (GameMain.GameSession is { RoundDuration: < 60 }) { - RespawnShuttle.Velocity = Vector2.Zero; + return; } - pendingRespawnCount = GetClientsToRespawn().Count(); - requiredRespawnCount = GetMinCharactersToRespawn(); - if (pendingRespawnCount != prevPendingRespawnCount || - requiredRespawnCount != prevRequiredRespawnCount) + var teamId = teamSpecificState.TeamID; + var respawnShuttle = GetShuttle(teamId); + if (respawnShuttle != null) { - prevPendingRespawnCount = pendingRespawnCount; - prevRequiredRespawnCount = requiredRespawnCount; + respawnShuttle.Velocity = Vector2.Zero; + } + + teamSpecificState.PendingRespawnCount = GetClientsToRespawn(teamId).Count(); + if (GameMain.GameSession?.Campaign == null) + { + teamSpecificState.PendingRespawnCount += GetBotsToRespawn(teamId).Count; + } + teamSpecificState.RequiredRespawnCount = GetMinCharactersToRespawn(); + if (teamSpecificState.PendingRespawnCount != teamSpecificState.PrevPendingRespawnCount || + teamSpecificState.RequiredRespawnCount != teamSpecificState.PrevRequiredRespawnCount) + { + teamSpecificState.PrevPendingRespawnCount = teamSpecificState.PendingRespawnCount; + teamSpecificState.PrevRequiredRespawnCount = teamSpecificState.RequiredRespawnCount; GameMain.Server.CreateEntityEvent(this); } - if (RespawnCountdownStarted) + if (teamSpecificState.RespawnCountdownStarted) { - if (pendingRespawnCount == 0) + if (teamSpecificState.PendingRespawnCount == 0) { - RespawnCountdownStarted = false; + teamSpecificState.RespawnCountdownStarted = false; GameMain.Server.CreateEntityEvent(this); } } else { - bool shouldStartCountdown = ShouldStartRespawnCountdown(pendingRespawnCount); + bool shouldStartCountdown = ShouldStartRespawnCountdown(teamSpecificState.PendingRespawnCount); if (shouldStartCountdown) { - RespawnCountdownStarted = true; - if (RespawnTime < DateTime.Now) + teamSpecificState.RespawnCountdownStarted = true; + if (teamSpecificState.RespawnTime < DateTime.Now) { - RespawnTime = DateTime.Now + new TimeSpan(0, 0, 0, 0, (int)(GameMain.Server.ServerSettings.RespawnInterval * 1000.0f)); + teamSpecificState.RespawnTime = DateTime.Now + new TimeSpan(0, 0, 0, 0, (int)(GameMain.Server.ServerSettings.RespawnInterval * 1000.0f)); } GameMain.Server.CreateEntityEvent(this); - } + } } - if (RespawnCountdownStarted && DateTime.Now > RespawnTime) + if (teamSpecificState.RespawnCountdownStarted && DateTime.Now > teamSpecificState.RespawnTime) { - DispatchShuttle(); - RespawnCountdownStarted = false; + DispatchShuttle(teamSpecificState); + teamSpecificState.RespawnCountdownStarted = false; } } - private void DispatchShuttle() + private void DispatchShuttle(TeamSpecificState teamSpecificState) { - if (RespawnShuttle != null) + if (RespawnShuttles.Any()) { - CurrentState = State.Transporting; + ResetShuttle(teamSpecificState); + teamSpecificState.CurrentState = State.Transporting; GameMain.Server.CreateEntityEvent(this); - - ResetShuttle(); - - if (shuttleSteering != null) - { - shuttleSteering.TargetVelocity = Vector2.Zero; - } - - Vector2 spawnPos = FindSpawnPos(); - RespawnCharacters(spawnPos, out bool anyCharacterSpawnedInShuttle); - if (anyCharacterSpawnedInShuttle) - { - GameServer.Log("Dispatching the respawn shuttle.", ServerLog.MessageType.Spawning); - CoroutineManager.StopCoroutines("forcepos"); - if (spawnPos.Y > Level.Loaded.Size.Y) - { - CoroutineManager.StartCoroutine(ForceShuttleToPos(Level.Loaded.StartPosition - Vector2.UnitY * Level.ShaftHeight, 100.0f), "forcepos"); - } - else - { - RespawnShuttle.SetPosition(spawnPos); - RespawnShuttle.Velocity = Vector2.Zero; - RespawnShuttle.NeutralizeBallast(); - RespawnShuttle.EnableMaintainPosition(); - } - } - else - { - GameServer.Log("Respawning everyone in main sub.", ServerLog.MessageType.Spawning); - } + SetShuttleBodyType(teamSpecificState.TeamID, FarseerPhysics.BodyType.Dynamic); } else { - CurrentState = State.Waiting; - GameServer.Log("Respawning everyone in main sub.", ServerLog.MessageType.Spawning); + teamSpecificState.CurrentState = State.Waiting; + GameServer.Log($"Respawning {GetTeamNameText(teamSpecificState.TeamID)} in the main sub.", ServerLog.MessageType.Spawning); GameMain.Server.CreateEntityEvent(this); - - RespawnCharacters(shuttlePos: null, out _); } + RespawnCharacters(teamSpecificState); } - partial void UpdateReturningProjSpecific(float deltaTime) + partial void UpdateReturningProjSpecific(TeamSpecificState teamSpecificState, float deltaTime) { //speed up despawning if there's no-one inside the shuttle - if (despawnTime > DateTime.Now + new TimeSpan(0, 0, seconds: 30) && CheckShuttleEmpty(deltaTime)) + if (teamSpecificState.DespawnTime > DateTime.Now + new TimeSpan(0, 0, seconds: 30) && CheckShuttleEmpty(deltaTime)) { - despawnTime = DateTime.Now + new TimeSpan(0, 0, seconds: 30); + teamSpecificState.DespawnTime = DateTime.Now + new TimeSpan(0, 0, seconds: 30); } - foreach (Door door in shuttleDoors) + foreach (Door door in shuttleDoors[teamSpecificState.TeamID]) { if (door.IsOpen) { @@ -242,87 +262,71 @@ namespace Barotrauma.Networking } } - var shuttleGaps = Gap.GapList.FindAll(g => g.Submarine == RespawnShuttle && g.ConnectedWall != null); + var shuttleGaps = Gap.GapList.FindAll(g => RespawnShuttles.Contains(g.Submarine) && g.ConnectedWall != null); shuttleGaps.ForEach(g => Spawner.AddEntityToRemoveQueue(g)); - var dockingPorts = Item.ItemList.FindAll(i => i.Submarine == RespawnShuttle && i.GetComponent() != null); + var dockingPorts = Item.ItemList.FindAll(i => RespawnShuttles.Contains(i.Submarine) && i.GetComponent() != null); dockingPorts.ForEach(d => d.GetComponent().Undock()); - //shuttle has returned if the path has been traversed or the shuttle is close enough to the exit - if (!CoroutineManager.IsCoroutineRunning("forcepos")) + if (!IsShuttleInsideLevel || DateTime.Now > teamSpecificState.DespawnTime) { - if ((shuttleSteering?.SteeringPath != null && shuttleSteering.SteeringPath.Finished) - || (RespawnShuttle.WorldPosition.Y + RespawnShuttle.Borders.Y > Level.Loaded.StartPosition.Y - Level.ShaftHeight && - Math.Abs(Level.Loaded.StartPosition.X - RespawnShuttle.WorldPosition.X) < 1000.0f)) - { - CoroutineManager.StopCoroutines("forcepos"); - CoroutineManager.StartCoroutine( - ForceShuttleToPos(new Vector2(Level.Loaded.StartPosition.X, Level.Loaded.Size.Y + 1000.0f), 100.0f), "forcepos"); + ResetShuttle(teamSpecificState); - } - } - - if (!IsShuttleInsideLevel || DateTime.Now > despawnTime) - { - CoroutineManager.StopCoroutines("forcepos"); - - ResetShuttle(); - - CurrentState = State.Waiting; - GameServer.Log("The respawn shuttle has left.", ServerLog.MessageType.Spawning); + teamSpecificState.CurrentState = State.Waiting; + GameServer.Log($"The {GetRespawnShuttleText(teamSpecificState.TeamID)} has left.", ServerLog.MessageType.Spawning); GameMain.Server.CreateEntityEvent(this); - RespawnCountdownStarted = false; - ReturnCountdownStarted = false; + teamSpecificState.RespawnCountdownStarted = false; + teamSpecificState.ReturnCountdownStarted = false; } } - partial void UpdateTransportingProjSpecific(float deltaTime) + partial void UpdateTransportingProjSpecific(TeamSpecificState teamSpecificState, float deltaTime) { - if (!ReturnCountdownStarted) + if (!teamSpecificState.ReturnCountdownStarted) { //if there are no living chracters inside, transporting can be stopped immediately if (CheckShuttleEmpty(deltaTime)) { - ReturnTime = DateTime.Now; - ReturnCountdownStarted = true; + teamSpecificState.ReturnTime = DateTime.Now; + teamSpecificState.ReturnCountdownStarted = true; } - else if (!ShouldStartRespawnCountdown()) + else if (!ShouldStartRespawnCountdown(teamSpecificState)) { //don't start counting down until someone else needs to respawn - ReturnTime = DateTime.Now + new TimeSpan(0, 0, 0, 0, milliseconds: (int)(maxTransportTime * 1000)); - despawnTime = ReturnTime + new TimeSpan(0, 0, seconds: 30); + teamSpecificState.ReturnTime = DateTime.Now + new TimeSpan(0, 0, 0, 0, milliseconds: (int)(maxTransportTime * 1000)); + teamSpecificState.DespawnTime = teamSpecificState.ReturnTime + new TimeSpan(0, 0, seconds: 30); return; } else { - ReturnCountdownStarted = true; + teamSpecificState.ReturnCountdownStarted = true; GameMain.Server.CreateEntityEvent(this); } } else if (CheckShuttleEmpty(deltaTime)) { - ReturnTime = DateTime.Now; + teamSpecificState.ReturnTime = DateTime.Now; } - if (DateTime.Now > ReturnTime) + if (DateTime.Now > teamSpecificState.ReturnTime) { if (IsShuttleInsideLevel) { - GameServer.Log("The respawn shuttle is leaving.", ServerLog.MessageType.ServerMessage); + GameServer.Log($"The {GetRespawnShuttleText(teamSpecificState.TeamID)} is leaving.", ServerLog.MessageType.ServerMessage); } - CurrentState = State.Returning; + teamSpecificState.CurrentState = State.Returning; GameMain.Server.CreateEntityEvent(this); - RespawnCountdownStarted = false; + teamSpecificState.RespawnCountdownStarted = false; maxTransportTime = GameMain.Server.ServerSettings.MaxTransportTime; } } private bool CheckShuttleEmpty(float deltaTime) { - if (!Character.CharacterList.Any(c => c.Submarine == RespawnShuttle && !c.IsDead)) + if (RespawnShuttles.All(respawnShuttle => Character.CharacterList.None(c => c.Submarine == respawnShuttle && !c.IsDead))) { shuttleEmptyTimer += deltaTime; } @@ -333,15 +337,18 @@ namespace Barotrauma.Networking return shuttleEmptyTimer > 1.0f; } - private void RespawnCharacters(Vector2? shuttlePos, out bool anyCharacterSpawnedInShuttle) + private void RespawnCharacters(TeamSpecificState teamSpecificState) { - respawnedCharacters.Clear(); - - var respawnSub = RespawnShuttle ?? Submarine.MainSub; + var teamID = teamSpecificState.TeamID; + int teamIndex = teamID == CharacterTeamType.Team1 ? 0 : 1; + bool anyCharacterSpawnedInShuttle = false; + teamSpecificState.RespawnedCharacters.Clear(); MultiPlayerCampaign campaign = GameMain.GameSession.GameMode as MultiPlayerCampaign; + bool isPvPMode = GameMain.GameSession.GameMode is PvPMode; + int teamCount = isPvPMode ? 2 : 1; - var clients = GetClientsToRespawn().ToList(); + var clients = GetClientsToRespawn(teamID).ToList(); foreach (Client c in clients) { // Get rid of the existing character @@ -355,18 +362,31 @@ namespace Barotrauma.Networking c.CharacterInfo = matchingData.CharacterInfo; } - //all characters are in Team 1 in game modes/missions with only one team. - //if at some point we add a game mode with multiple teams where respawning is possible, this needs to be reworked - c.TeamID = CharacterTeamType.Team1; c.CharacterInfo ??= new CharacterInfo(CharacterPrefab.HumanSpeciesName, c.Name); + + //force everyone to team 1 if there's just one team + if (teamCount == 1) + { + c.TeamID = teamID; + } + else if (isPvPMode && c.TeamID == CharacterTeamType.None) + { + GameMain.Server.AssignClientToPvpTeamMidgame(c); + } + c.CharacterInfo.TeamID = c.TeamID; } List characterInfos = clients.Select(c => c.CharacterInfo).ToList(); //bots don't respawn in the campaign + var botsToSpawn = GetBotsToRespawn(teamID); if (campaign == null) { - var botsToSpawn = GetBotsToRespawn(); characterInfos.AddRange(botsToSpawn); + foreach (var bot in botsToSpawn) + { + // Get rid of the existing bots' corpses + if (bot.Character is Character character) { character.DespawnNow(); } + } } GameMain.Server.AssignJobs(clients); @@ -374,38 +394,56 @@ namespace Barotrauma.Networking { if (campaign?.GetClientCharacterData(c) == null || c.CharacterInfo.Job == null) { - c.CharacterInfo.Job = new Job(c.AssignedJob.Prefab, Rand.RandSync.Unsynced, c.AssignedJob.Variant); + c.CharacterInfo.Job = new Job(c.AssignedJob.Prefab, isPvPMode, Rand.RandSync.Unsynced, c.AssignedJob.Variant); } } - //the spawnpoints where the characters will spawn - var shuttleSpawnPoints = 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) - var mainSubSpawnPoints = WayPoint.SelectCrewSpawnPoints(characterInfos, Submarine.MainSub); + System.Diagnostics.Debug.Assert(characterInfos.All(c => c.TeamID == teamID), + "List of characters to respawn contained characters from the wrong team."); + + Submarine mainSub = Submarine.MainSubs[teamIndex]; + Submarine respawnSub = null; + + Submarine respawnShuttle = GetShuttle(teamID); + Vector2? shuttlePos = null; + if (respawnShuttle != null) + { + respawnSub = respawnShuttle; + shuttlePos = FindSpawnPos(respawnShuttle, mainSub); + } + + respawnSub ??= mainSub ?? Level.Loaded.StartOutpost; ItemPrefab divingSuitPrefab = null; if ((shuttlePos != null && Level.Loaded.GetRealWorldDepth(shuttlePos.Value.Y) > Level.DefaultRealWorldCrushDepth) || - Level.Loaded.GetRealWorldDepth(Submarine.MainSub.WorldPosition.Y) > Level.DefaultRealWorldCrushDepth) + (mainSub != null && Level.Loaded.GetRealWorldDepth(mainSub.WorldPosition.Y) > Level.DefaultRealWorldCrushDepth)) { divingSuitPrefab = ItemPrefab.Prefabs.FirstOrDefault(it => it.Tags.Any(t => t == "respawnsuitdeep")); } - divingSuitPrefab ??= + divingSuitPrefab ??= ItemPrefab.Prefabs.FirstOrDefault(it => it.Tags.Any(t => t == "respawnsuit")) ?? ItemPrefab.Find(null, "divingsuit".ToIdentifier()); ItemPrefab oxyPrefab = ItemPrefab.Find(null, "oxygentank".ToIdentifier()); ItemPrefab scooterPrefab = ItemPrefab.Find(null, "underwaterscooter".ToIdentifier()); ItemPrefab batteryPrefab = ItemPrefab.Find(null, "batterycell".ToIdentifier()); - var cargoSp = WayPoint.WayPointList.Find(wp => wp.Submarine == respawnSub && wp.SpawnType == SpawnType.Cargo); + //the spawnpoints where the characters will spawn + var selectedSpawnPoints = + isPvPMode && Level.Loaded != null && Level.Loaded.ShouldSpawnCrewInsideOutpost() ? + WayPoint.SelectOutpostSpawnPoints(characterInfos, teamID) : + WayPoint.SelectCrewSpawnPoints(characterInfos, respawnSub); - anyCharacterSpawnedInShuttle = false; + //the spawnpoints where they would spawn if they were spawned inside the main sub + //(in order to give them appropriate ID card tags) + var mainSubSpawnPoints = mainSub != null ? WayPoint.SelectCrewSpawnPoints(characterInfos, mainSub) : null; + var cargoSp = WayPoint.WayPointList.Find(wp => wp.Submarine == respawnSub && wp.SpawnType == SpawnType.Cargo); for (int i = 0; i < characterInfos.Count; i++) { - bool bot = i >= clients.Count; + var characterInfo = characterInfos[i]; - characterInfos[i].ClearCurrentOrders(); + bool bot = botsToSpawn.Contains(characterInfo); + characterInfo.ClearCurrentOrders(); CharacterCampaignData characterCampaignData = null; bool forceSpawnInMainSub = false; @@ -413,9 +451,9 @@ namespace Barotrauma.Networking { //the client has opted to change the name of their new character //when the character spawns, set the client's name to match - if (clients[i].PendingName == characterInfos[i].Name) + 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; } @@ -428,32 +466,31 @@ namespace Barotrauma.Networking } else { - ReduceCharacterSkillsOnDeath(characterInfos[i]); - characterInfos[i].RemoveSavedStatValuesOnDeath(); - characterInfos[i].CauseOfDeath = null; + ReduceCharacterSkillsOnDeath(characterInfo); + characterInfo.RemoveSavedStatValuesOnDeath(); + characterInfo.CauseOfDeath = null; } } } - if (!forceSpawnInMainSub) + if (!forceSpawnInMainSub && respawnShuttle != null) { - anyCharacterSpawnedInShuttle = true; + anyCharacterSpawnedInShuttle = true; } - var character = Character.Create(characterInfos[i], (forceSpawnInMainSub ? mainSubSpawnPoints[i] : shuttleSpawnPoints[i]).WorldPosition, characterInfos[i].Name, isRemotePlayer: !bot, hasAi: bot); + var character = Character.Create(characterInfo, (forceSpawnInMainSub ? mainSubSpawnPoints[i] : selectedSpawnPoints[i]).WorldPosition, characterInfo.Name, isRemotePlayer: !bot, hasAi: bot); characterCampaignData?.ApplyWalletData(character); - character.TeamID = CharacterTeamType.Team1; character.LoadTalents(); - if (characterInfos[i].LastRewardDistribution.TryUnwrap(out int salary)) + if (characterInfo.LastRewardDistribution.TryUnwrap(out int salary)) { character.Wallet.SetRewardDistribution(salary); } - respawnedCharacters.Add(character); + teamSpecificState.RespawnedCharacters.Add(character); if (bot) { - GameServer.Log(string.Format("Respawning bot {0} as {1}", character.Info.Name, characterInfos[i].Job.Name), ServerLog.MessageType.Spawning); + GameServer.Log(string.Format("Respawning bot {0} as {1}.", character.Info.Name, characterInfo.Job.Name), ServerLog.MessageType.Spawning); } else { @@ -475,11 +512,18 @@ namespace Barotrauma.Networking clients[i].Character = character; character.SetOwnerClient(clients[i]); GameServer.Log( - $"Respawning {GameServer.ClientLogName(clients[i])} ({clients[i].Connection.Endpoint}) as {characterInfos[i].Job.Name}", ServerLog.MessageType.Spawning); + $"Respawning {GameServer.ClientLogName(clients[i])} ({clients[i].Connection.Endpoint}) as {characterInfo.Job.Name}.", ServerLog.MessageType.Spawning); } - if (RespawnShuttle != null && anyCharacterSpawnedInShuttle) + if (respawnShuttle != null && anyCharacterSpawnedInShuttle) { + GameServer.Log($"Dispatching the {GetRespawnShuttleText(teamID)}.", ServerLog.MessageType.Spawning); + respawnShuttle.SetPosition(shuttlePos.Value); + respawnShuttle.Velocity = Vector2.Zero; + respawnShuttle.NeutralizeBallast(); + respawnShuttle.EnableMaintainPosition(); + shuttleSteering[teamID].ForEach(s => s.TargetVelocity = Vector2.Zero); + List newRespawnItems = new List(); Vector2 pos = cargoSp?.Position ?? character.Position; if (divingSuitPrefab != null) @@ -513,25 +557,29 @@ namespace Barotrauma.Networking } } } - if (respawnContainer != null) - { - AutoItemPlacer.RegenerateLoot(RespawnShuttle, respawnContainer); - } - //try to put the items in containers in the shuttle foreach (var respawnItem in newRespawnItems) { System.Diagnostics.Debug.Assert(!respawnItem.Removed); - foreach (Item shuttleItem in RespawnShuttle.GetItems(alsoFromConnectedSubs: false)) + //already in a container (a battery we just placed in a scooter?) -> don't move to a cabinet + if (respawnItem.Container == null) { - if (shuttleItem.NonInteractable || shuttleItem.NonPlayerTeamInteractable) { continue; } - var container = shuttleItem.GetComponent(); - if (container != null && container.Inventory.TryPutItem(respawnItem, user: null)) + foreach (Item shuttleItem in respawnShuttle.GetItems(alsoFromConnectedSubs: false)) { - break; + if (shuttleItem.NonInteractable || shuttleItem.NonPlayerTeamInteractable) { continue; } + var container = shuttleItem.GetComponent(); + if (container != null && container.Inventory.TryPutItem(respawnItem, user: null)) + { + break; + } } } - respawnItems.Add(respawnItem); + teamSpecificState.RespawnItems.Add(respawnItem); + } + + foreach (var respawnContainer in respawnContainers[teamID]) + { + teamSpecificState.RespawnItems.AddRange(AutoItemPlacer.RegenerateLoot(respawnShuttle, respawnContainer)); } } @@ -541,10 +589,11 @@ namespace Barotrauma.Networking { ReduceCharacterSkillsOnDeath(characterInfos[i], applyExtraSkillLoss: true); } + WayPoint jobItemSpawnPoint = mainSubSpawnPoints != null ? mainSubSpawnPoints[i] : selectedSpawnPoints[i]; if (characterData == null || characterData.HasSpawned) { //give the character the items they would've gotten if they had spawned in the main sub - character.GiveJobItems(mainSubSpawnPoints[i]); + character.GiveJobItems(isPvPMode, jobItemSpawnPoint); if (campaign != null) { characterData = campaign.SetClientCharacterData(clients[i]); @@ -559,16 +608,17 @@ namespace Barotrauma.Networking } else { - character.GiveJobItems(mainSubSpawnPoints[i]); + character.GiveJobItems(isPvPMode, jobItemSpawnPoint); } characterData.ApplyHealthData(character); - character.GiveIdCardTags(mainSubSpawnPoints[i]); + character.GiveIdCardTags(jobItemSpawnPoint); characterData.HasSpawned = true; } //add the ID card tags they should've gotten when spawning in the shuttle - character.GiveIdCardTags(shuttleSpawnPoints[i], createNetworkEvent: true); + character.GiveIdCardTags(selectedSpawnPoints[i], createNetworkEvent: true); } + } /// @@ -605,24 +655,30 @@ namespace Barotrauma.Networking public void ServerEventWrite(IWriteMessage msg, Client c, NetEntityEvent.IData extraData = null) { - msg.WriteRangedInteger((int)CurrentState, 0, Enum.GetNames(typeof(State)).Length); - - switch (CurrentState) + msg.WriteByte((byte)c.TeamID); + foreach (var teamSpecificState in teamSpecificStates.Values) { - case State.Transporting: - msg.WriteBoolean(ReturnCountdownStarted); - msg.WriteSingle(GameMain.Server.ServerSettings.MaxTransportTime); - msg.WriteSingle((float)(ReturnTime - DateTime.Now).TotalSeconds); - break; - case State.Waiting: - msg.WriteUInt16((ushort)pendingRespawnCount); - msg.WriteUInt16((ushort)requiredRespawnCount); - msg.WriteBoolean(IsRespawnDecisionPendingForClient(c)); - msg.WriteBoolean(RespawnCountdownStarted); - msg.WriteSingle((float)(RespawnTime - DateTime.Now).TotalSeconds); - break; - case State.Returning: - break; + msg.WriteByte((byte)teamSpecificState.TeamID); + msg.WriteRangedInteger((int)teamSpecificState.CurrentState, 0, Enum.GetNames(typeof(State)).Length); + + switch (teamSpecificState.CurrentState) + { + case State.Transporting: + msg.WriteBoolean(teamSpecificState.ReturnCountdownStarted); + msg.WriteSingle(GameMain.Server.ServerSettings.MaxTransportTime); + msg.WriteSingle((float)(teamSpecificState.ReturnTime - DateTime.Now).TotalSeconds); + break; + case State.Waiting: + 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; + case State.Returning: + break; + } } msg.WritePadBits(); diff --git a/Barotrauma/BarotraumaServer/ServerSource/Networking/ServerSettings.cs b/Barotrauma/BarotraumaServer/ServerSource/Networking/ServerSettings.cs index b474f5548..8e07d5c89 100644 --- a/Barotrauma/BarotraumaServer/ServerSource/Networking/ServerSettings.cs +++ b/Barotrauma/BarotraumaServer/ServerSource/Networking/ServerSettings.cs @@ -2,9 +2,9 @@ using Barotrauma.IO; using System; using System.Collections.Generic; +using System.Collections.Immutable; using System.Linq; using System.Xml.Linq; -using Barotrauma.Steam; namespace Barotrauma.Networking { @@ -74,7 +74,7 @@ namespace Barotrauma.Networking { var property = netProperties[key]; property.SyncValue(); - if (NetIdUtils.IdMoreRecent(property.LastUpdateID, c.LastRecvLobbyUpdate)) + if (NetIdUtils.IdMoreRecent(property.LastUpdateID, c.LastRecvLobbyUpdate) || !c.InitialLobbyUpdateSent) { outMsg.WriteUInt32(key); netProperties[key].Write(outMsg); @@ -106,6 +106,7 @@ namespace Barotrauma.Networking if (requiredFlags.HasFlag(NetFlags.Properties)) { WriteExtraCargo(outMsg); + WritePerks(outMsg); } if (requiredFlags.HasFlag(NetFlags.HiddenSubs)) @@ -129,6 +130,40 @@ namespace Barotrauma.Networking } } + public void ReadPerks(IReadMessage incMsg, Client c) + { + if (!HasPermissionToChangePerks(c)) return; + + bool changed = ReadPerks(incMsg); + if (!changed) { return; } + + UpdateFlag(NetFlags.Properties); + SaveSettings(); + GameMain.NetLobbyScreen.LastUpdateID++; + + static bool HasPermissionToChangePerks(Client client) + { + if (client.HasPermission(Networking.ClientPermissions.ManageSettings)) { return true; } + + bool isPvP = GameMain.NetLobbyScreen?.SelectedMode == GameModePreset.PvP; + bool hasSelectedTeam = client.PreferredTeam is CharacterTeamType.Team1 or CharacterTeamType.Team2; + var otherClients = GameMain.NetworkMember?.ConnectedClients?.Where(c => c != client).ToImmutableArray() ?? ImmutableArray.Empty; + + if (isPvP) + { + if (!hasSelectedTeam) { return false; } + + return !otherClients + .Where(c => c.PreferredTeam == client.PreferredTeam) + .Any(static c => c.HasPermission(Networking.ClientPermissions.ManageSettings)); + } + else + { + return !otherClients.Any(static c => c.HasPermission(Networking.ClientPermissions.ManageSettings)); + } + } + } + public void ServerRead(IReadMessage incMsg, Client c) { if (!c.HasPermission(Networking.ClientPermissions.ManageSettings)) return; @@ -136,7 +171,7 @@ namespace Barotrauma.Networking NetFlags flags = (NetFlags)incMsg.ReadByte(); bool changed = false; - + if (flags.HasFlag(NetFlags.Properties)) { bool propertiesChanged = ReadExtraCargo(incMsg); @@ -176,6 +211,7 @@ namespace Barotrauma.Networking if (propertiesChanged) { UpdateFlag(NetFlags.Properties); + GameMain.Server.RefreshPvpTeamAssignments(); // the changed settings might be relevant to team logic, so refresh } changed |= propertiesChanged; } @@ -189,9 +225,18 @@ namespace Barotrauma.Networking if (flags.HasFlag(NetFlags.Misc)) { - int orBits = incMsg.ReadRangedInteger(0, (int)Barotrauma.MissionType.All) & (int)Barotrauma.MissionType.All; - int andBits = incMsg.ReadRangedInteger(0, (int)Barotrauma.MissionType.All) & (int)Barotrauma.MissionType.All; - GameMain.NetLobbyScreen.MissionType = (MissionType)(((int)GameMain.NetLobbyScreen.MissionType | orBits) & andBits); + List missionTypes = new List(GameMain.NetLobbyScreen.MissionTypes); + Identifier addedMissionType = incMsg.ReadIdentifier(); + Identifier removedMissionType = incMsg.ReadIdentifier(); + if (!addedMissionType.IsEmpty) + { + missionTypes.Add(addedMissionType); + } + if (!removedMissionType.IsEmpty) + { + missionTypes.Remove(removedMissionType); + } + GameMain.NetLobbyScreen.MissionTypes = missionTypes; //the byte indicates the direction we're changing the value, subtract one to get negative values from a byte TraitorDangerLevel = TraitorDangerLevel + incMsg.ReadByte() - 1; @@ -223,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) @@ -235,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)); @@ -280,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; @@ -361,28 +408,16 @@ namespace Barotrauma.Networking if (min > -1 && max > -1) { AllowedClientNameChars.Add(new Range(min, max)); } } - AllowedRandomMissionTypes = new List(); - string[] allowedMissionTypeNames = doc.Root.GetAttributeStringArray( - "AllowedRandomMissionTypes", Enum.GetValues(typeof(MissionType)).Cast().Select(m => m.ToString()).ToArray()); - foreach (string missionTypeName in allowedMissionTypeNames) - { - if (Enum.TryParse(missionTypeName, out MissionType missionType)) - { - if (missionType == Barotrauma.MissionType.None) { continue; } - if (MissionPrefab.HiddenMissionClasses.Contains(missionType)) { continue; } - AllowedRandomMissionTypes.Add(missionType); - } - } - - ServerName = doc.Root.GetAttributeString("name", ""); - if (ServerName.Length > NetConfig.ServerNameMaxLength) { ServerName = ServerName.Substring(0, NetConfig.ServerNameMaxLength); } - ServerMessageText = doc.Root.GetAttributeString("ServerMessage", ""); + AllowedRandomMissionTypes = doc.Root.GetAttributeIdentifierArray( + "AllowedRandomMissionTypes", MissionPrefab.GetAllMultiplayerSelectableMissionTypes().ToArray()).ToList(); GameMain.NetLobbyScreen.SelectedModeIdentifier = GameModeIdentifier; - //handle Random as the mission type, which is no longer a valid setting - //MissionType.All offers equivalent functionality - if (MissionType == "Random") { MissionType = "All"; } - GameMain.NetLobbyScreen.MissionTypeName = MissionType; + if (AllowedRandomMissionTypes.Contains(Tags.MissionTypeAll)) + { + AllowedRandomMissionTypes = MissionPrefab.GetAllMultiplayerSelectableMissionTypes().ToList(); + } + AllowedRandomMissionTypes = AllowedRandomMissionTypes.Distinct().ToList(); + ValidateMissionTypes(); GameMain.NetLobbyScreen.SetBotSpawnMode(BotSpawnMode); GameMain.NetLobbyScreen.SetBotCount(BotCount); @@ -404,6 +439,20 @@ namespace Barotrauma.Networking CampaignSettings = new CampaignSettings(element); } } + + HashSet selectedCoalitionPerks = SelectedCoalitionPerks.ToHashSet(); + HashSet selectedSeparatistsPerks = SelectedSeparatistsPerks.ToHashSet(); + foreach (DisembarkPerkPrefab prefab in DisembarkPerkPrefab.Prefabs) + { + if (prefab.Cost == 0) + { + selectedSeparatistsPerks.Add(prefab.Identifier); + selectedCoalitionPerks.Add(prefab.Identifier); + } + } + + SelectedCoalitionPerks = selectedCoalitionPerks.ToArray(); + SelectedSeparatistsPerks = selectedSeparatistsPerks.ToArray(); } public string SelectNonHiddenSubmarine(string current = null) diff --git a/Barotrauma/BarotraumaServer/ServerSource/Networking/Voting.cs b/Barotrauma/BarotraumaServer/ServerSource/Networking/Voting.cs index 81c472caf..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)) @@ -374,13 +384,32 @@ namespace Barotrauma msg.WriteBoolean(GameMain.Server.ServerSettings.AllowSubVoting); if (GameMain.Server.ServerSettings.AllowSubVoting) { - IReadOnlyDictionary voteList = GetVoteCounts(VoteType.Sub, GameMain.Server.ConnectedClients); + bool isMultiSub = GameMain.NetLobbyScreen.SelectedMode == GameModePreset.PvP; + msg.WriteBoolean(isMultiSub); + + var subVoters = isMultiSub ? + GameMain.Server.ConnectedClients.Where(static c => c.PreferredTeam is CharacterTeamType.Team1) : + GameMain.Server.ConnectedClients; + + IReadOnlyDictionary voteList = GetVoteCounts(VoteType.Sub, subVoters); msg.WriteByte((byte)voteList.Count); foreach (KeyValuePair vote in voteList) { msg.WriteByte((byte)vote.Value); msg.WriteString(vote.Key.Name); } + + if (isMultiSub) + { + var separatistsVotes = GetVoteCounts(VoteType.Sub, GameMain.Server.ConnectedClients.Where(static c => c.PreferredTeam is CharacterTeamType.Team2)); + msg.WriteByte((byte)separatistsVotes.Count); + + foreach (var (info, amount) in separatistsVotes) + { + msg.WriteByte((byte)amount); + msg.WriteString(info.Name); + } + } } msg.WriteBoolean(GameMain.Server.ServerSettings.AllowModeVoting); if (GameMain.Server.ServerSettings.AllowModeVoting) @@ -396,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/Screens/NetLobbyScreen.cs b/Barotrauma/BarotraumaServer/ServerSource/Screens/NetLobbyScreen.cs index 554930461..4c08108ad 100644 --- a/Barotrauma/BarotraumaServer/ServerSource/Screens/NetLobbyScreen.cs +++ b/Barotrauma/BarotraumaServer/ServerSource/Screens/NetLobbyScreen.cs @@ -1,7 +1,9 @@ -using Barotrauma.Networking; +using Barotrauma.Extensions; +using Barotrauma.Networking; using Microsoft.Xna.Framework; using System; using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; using System.Linq; namespace Barotrauma @@ -9,6 +11,7 @@ namespace Barotrauma partial class NetLobbyScreen : Screen { private SubmarineInfo selectedSub; + private SubmarineInfo selectedEnemySub; private SubmarineInfo selectedShuttle; public bool RadiationEnabled = true; @@ -26,6 +29,18 @@ namespace Barotrauma } } } + + [MaybeNull, AllowNull] + public SubmarineInfo SelectedEnemySub + { + get => selectedEnemySub; + set + { + selectedEnemySub = value; + lastUpdateID++; + } + } + public SubmarineInfo SelectedShuttle { get { return selectedShuttle; } @@ -81,31 +96,19 @@ namespace Barotrauma get { return GameModes[SelectedModeIndex]; } } - private MissionType missionType; - public MissionType MissionType + public IEnumerable MissionTypes { - get { return missionType; } + get { return GameMain.NetworkMember.ServerSettings.AllowedRandomMissionTypes; } set { lastUpdateID++; - missionType = value; if (GameMain.NetworkMember?.ServerSettings != null) { - GameMain.NetworkMember.ServerSettings.MissionType = missionType.ToString(); + GameMain.NetworkMember.ServerSettings.MissionTypes = string.Join(",", value.Select(t => t.ToIdentifier())); } } } - public string MissionTypeName - { - get { return missionType.ToString(); } - set - { - Enum.TryParse(value, out MissionType type); - MissionType = type; - } - } - public NetLobbyScreen() { LevelSeed = ToolBox.RandomSeed(8); diff --git a/Barotrauma/BarotraumaServer/ServerSource/Steam/SteamManager.cs b/Barotrauma/BarotraumaServer/ServerSource/Steam/SteamManager.cs index 0288536df..ba697c824 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 @@ -63,9 +65,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/ServerSource/Traitors/TraitorManager.cs b/Barotrauma/BarotraumaServer/ServerSource/Traitors/TraitorManager.cs index c9c691212..ab1e682dd 100644 --- a/Barotrauma/BarotraumaServer/ServerSource/Traitors/TraitorManager.cs +++ b/Barotrauma/BarotraumaServer/ServerSource/Traitors/TraitorManager.cs @@ -1,4 +1,4 @@ -#nullable enable +#nullable enable using Barotrauma.Extensions; using Barotrauma.Networking; @@ -273,8 +273,11 @@ namespace Barotrauma for (int i = 0; i < amountToChoose; i++) { var traitor = viableTraitors.GetRandomUnsynced(); - viableTraitors.Remove(traitor); - traitors.Add(traitor); + if (traitor != null) + { + viableTraitors.Remove(traitor); + traitors.Add(traitor); + } } return traitors; } @@ -388,7 +391,7 @@ namespace Barotrauma { if (level?.LevelData is { Type: LevelData.LevelType.LocationConnection }) { - if (Submarine.MainSub.WorldPosition.X > level.Size.X / 2) + if (Submarine.MainSub != null && Submarine.MainSub.WorldPosition.X > level.Size.X / 2) { //try starting ASAP if the submarine is already half-way through the level //(brief delay regardless, because otherwise we might retry every frame if finding a suitable event fails below) diff --git a/Barotrauma/BarotraumaServer/WindowsServer.csproj b/Barotrauma/BarotraumaServer/WindowsServer.csproj index 537fd1635..92ee7b701 100644 --- a/Barotrauma/BarotraumaServer/WindowsServer.csproj +++ b/Barotrauma/BarotraumaServer/WindowsServer.csproj @@ -6,7 +6,7 @@ Barotrauma FakeFish, Undertow Games Barotrauma Dedicated Server - 1.5.9.1 + 1.8.4.0 Copyright © FakeFish 2018-2023 AnyCPU;x64 DedicatedServer @@ -61,7 +61,7 @@ - + diff --git a/Barotrauma/BarotraumaShared/Data/campaignsettings.xml b/Barotrauma/BarotraumaShared/Data/campaignsettings.xml index 9ab82603d..f6424d1a7 100644 --- a/Barotrauma/BarotraumaShared/Data/campaignsettings.xml +++ b/Barotrauma/BarotraumaShared/Data/campaignsettings.xml @@ -1,4 +1,4 @@ - + @@ -20,6 +20,7 @@ OxygenMultiplier="1.2" FuelMultiplier="1.2" MissionRewardMultiplier="1.0" + ExperienceRewardMultiplier="1.0" ShopPriceMultiplier="0.9" ShipyardPriceMultiplier="0.9" RepairFailMultiplier="1.0" @@ -38,6 +39,7 @@ OxygenMultiplier="1.0" FuelMultiplier="1.0" MissionRewardMultiplier="1.0" + ExperienceRewardMultiplier="1.0" ShopPriceMultiplier="1.0" ShipyardPriceMultiplier="1.0" RepairFailMultiplier="1.0" @@ -56,6 +58,7 @@ OxygenMultiplier="0.7" FuelMultiplier="0.9" MissionRewardMultiplier="1.0" + ExperienceRewardMultiplier="1.0" ShopPriceMultiplier="1.5" ShipyardPriceMultiplier="1.5" RepairFailMultiplier="2.0" @@ -74,6 +77,7 @@ OxygenMultiplier="0.4" FuelMultiplier="0.8" MissionRewardMultiplier="0.8" + ExperienceRewardMultiplier="0.8" ShopPriceMultiplier="2.0" ShipyardPriceMultiplier="2.0" RepairFailMultiplier="5.0" 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 038059a5d..19f31cec3 100644 --- a/Barotrauma/BarotraumaShared/Data/permissionpresets.xml +++ b/Barotrauma/BarotraumaShared/Data/permissionpresets.xml @@ -1,4 +1,6 @@  + + + diff --git a/Barotrauma/BarotraumaShared/Data/permissionpresets_player.xml b/Barotrauma/BarotraumaShared/Data/permissionpresets_player.xml new file mode 100644 index 000000000..f5f4afb64 --- /dev/null +++ b/Barotrauma/BarotraumaShared/Data/permissionpresets_player.xml @@ -0,0 +1,36 @@ + + + + + \ No newline at end of file diff --git a/Barotrauma/BarotraumaShared/LocalMods/[DebugOnlyTest]Character override and variant tests/Characters/Crawler/Animations/CrawlerRun.xml b/Barotrauma/BarotraumaShared/LocalMods/[DebugOnlyTest]Character override and variant tests/Characters/Crawler/Animations/CrawlerRun.xml new file mode 100644 index 000000000..41448aef4 --- /dev/null +++ b/Barotrauma/BarotraumaShared/LocalMods/[DebugOnlyTest]Character override and variant tests/Characters/Crawler/Animations/CrawlerRun.xml @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/Barotrauma/BarotraumaShared/LocalMods/[DebugOnlyTest]Character override and variant tests/Characters/Crawler/Animations/CrawlerSwimFast.xml b/Barotrauma/BarotraumaShared/LocalMods/[DebugOnlyTest]Character override and variant tests/Characters/Crawler/Animations/CrawlerSwimFast.xml new file mode 100644 index 000000000..5a5abac55 --- /dev/null +++ b/Barotrauma/BarotraumaShared/LocalMods/[DebugOnlyTest]Character override and variant tests/Characters/Crawler/Animations/CrawlerSwimFast.xml @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/Barotrauma/BarotraumaShared/LocalMods/[DebugOnlyTest]Character override and variant tests/Characters/Crawler/Animations/CrawlerSwimSlow.xml b/Barotrauma/BarotraumaShared/LocalMods/[DebugOnlyTest]Character override and variant tests/Characters/Crawler/Animations/CrawlerSwimSlow.xml new file mode 100644 index 000000000..0e8efab83 --- /dev/null +++ b/Barotrauma/BarotraumaShared/LocalMods/[DebugOnlyTest]Character override and variant tests/Characters/Crawler/Animations/CrawlerSwimSlow.xml @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/Barotrauma/BarotraumaShared/LocalMods/[DebugOnlyTest]Character override and variant tests/Characters/Crawler/Animations/CrawlerWalk.xml b/Barotrauma/BarotraumaShared/LocalMods/[DebugOnlyTest]Character override and variant tests/Characters/Crawler/Animations/CrawlerWalk.xml new file mode 100644 index 000000000..39297cf28 --- /dev/null +++ b/Barotrauma/BarotraumaShared/LocalMods/[DebugOnlyTest]Character override and variant tests/Characters/Crawler/Animations/CrawlerWalk.xml @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/Barotrauma/BarotraumaShared/LocalMods/[DebugOnlyTest]Character override and variant tests/Characters/Crawler/Crawler.xml b/Barotrauma/BarotraumaShared/LocalMods/[DebugOnlyTest]Character override and variant tests/Characters/Crawler/Crawler.xml new file mode 100644 index 000000000..f90685787 --- /dev/null +++ b/Barotrauma/BarotraumaShared/LocalMods/[DebugOnlyTest]Character override and variant tests/Characters/Crawler/Crawler.xml @@ -0,0 +1,77 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/Barotrauma/BarotraumaShared/LocalMods/[DebugOnlyTest]Character override and variant tests/Characters/Crawler/Ragdolls/CrawlerDefaultRagdoll.xml b/Barotrauma/BarotraumaShared/LocalMods/[DebugOnlyTest]Character override and variant tests/Characters/Crawler/Ragdolls/CrawlerDefaultRagdoll.xml new file mode 100644 index 000000000..d3a6deb73 --- /dev/null +++ b/Barotrauma/BarotraumaShared/LocalMods/[DebugOnlyTest]Character override and variant tests/Characters/Crawler/Ragdolls/CrawlerDefaultRagdoll.xml @@ -0,0 +1,127 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/Barotrauma/BarotraumaShared/LocalMods/[DebugOnlyTest]Character override and variant tests/Characters/Crawler/crawler.png b/Barotrauma/BarotraumaShared/LocalMods/[DebugOnlyTest]Character override and variant tests/Characters/Crawler/crawler.png new file mode 100644 index 0000000000000000000000000000000000000000..06feffae2aa2c5bdfcbc9b4c986b55822073a342 GIT binary patch literal 220731 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 z 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� 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

n!`rJ{Fo=g~zbeAQj9VF}~f;K`)(tM>t0 z1-c%hlr_&J9;Rb#BH&j`bcxEIWai@7w+rjc2!LJWTR_QSoX91(_Moyv#g5I^`g0F$B#YDh+<`oR6KG2bCfi4}LEM#DFg?^Nw z_;{c}#q(OE?QEiym2$wUkmu!6b3HqKj{L?A@cllUnC3s!B$#&RRDZUSw=%ejtop+= z@@cyNFQKZS%?rpn8F3g8(+vGU6h)Y9g%i)9Bd7iHsKN&V2^7+RuX2YpzHjY)>QXJ_ z!3h_aIytqBylak~J*1^ntoukxnKT%?6o_GLsX6$tC=wm@BI?>?okZ2?d+rR}4e~gC z*B$QKFi^XJcnYwojTFE?;%CR{+extW8LI9bqJB=7QMjsQ)kNPt6Wcy+g>sgrkCO0ZqGeAp=}lsG~9n zX=wNtbA}qpxFkT z3vK=PzMv87fiVOUly4;WTbwNh_v6+Vo<0y1W}D67 zM=^D1G>PCOL;(YTCkq=Ucx;#$>^(~lV|Dk!4C!{u);JY{=Xw&ug&0MD130lr@RlQz1Jlz{#XhZ z-h*ZrX2B*2phG+Q^w7_tdPdBM3d1(2D;HgqYHSRd4tAQ@$WHwNCghTeH|P;5b8jT&M=m(c8)2$Mt7{^j9QPAjLtg`6F_H+K#s zW&&m@BF#kH8Df5WUo<5lz0QmkCqGaX7I804juaCbpYf5rsczj79b`j|?}(_6dyb2` z@YIpa6CWB{D((GGh~;S&jTjcgx_+p3U87VO#@!Z9@5E0mu?ffaR$4Cjdr`O5vfo1o~yC#A6pu|vm2fPGW?on*xXpRMnU4&{JP)yp6 zGP>%YyLr2PC8FgVP>?tHfY1;z`*o`{fR_+Np=44#&Fd+b7o}Ub<|#lp*!Y{Ak#i8J zi)f|_l(xdNPbU`i4G)2Ga;>`?azZ>~P*}7v7Q4xVWK9GV$k>`(7pD*+zhzf%iGDK=3-=VydJb!R_wc$qsbihBe!Rx`* z1cu@iAjmsZ`~lt@{ZA@7lb$ICVs2%cG8zPW{)^-wW%96n19!98f2#EeKAd9~sFo2t z0o4&&B$DK8KqluNfjb-}DIPZ-^wLYz4<^N_O3_Wq%!}PIWs+Y;y$!0aB(jnC8a$?x z(2FCL0p8?93MgX3-Q)-UOb+dw2Nw5hg6(bu_aTyF>4orrWO=NW3iYofpkn|-3Uj}b zBfR~3+P&=-`SC7=J*Buod;A)H-jG0vec$$SxaKu-H;zg^!^IF__tb}fX$Pwp=4N1f(QV7LJ zSId&p3w^8iJ9_CY;X-g+irUFAQ|jEfWp8yKwnlHP^EP-}XbDks$xd8j`{`BA?LCW0 zV$zPQVHvgk%-FV2Aw~nI>sS;_1_}G8e>nfZb*0}^@d67>uxwDKS5O>MM+i53Xkan( zQH%lG9;PM9*13!oe#0f;CWm}3fjWq!*>HVB7tBZ#k}y+ugbj0q(L6>>Mv53nYUU8p z3_A_c_#F-t3;OQDAh>D55j1B1b9h9&3m6<-uBu6gohcl|Vu*9J8-&^7|I;EsrO$A0 zRW}a8*oZJNSw<`r=yrvVT#l*Q^2IRp^w5-MMpNSTubiW0rXkd699a&g&^#W8l;I*c z@4wsvP|Ro`R{62xw9C;9R|SW2C@hwE_&<^>G9m=&yD121Y;i9VsxqTiC|N#=0<8u( zXyZmRcjcNQ2<&K^v!wp84e3Mij>b(00G3rc*dkYuof&AvDj$DK{Fh`kMNcu~%u8L2jtqmu@{N)B#bVI|IB^=ifO zGQ*+1+|MVAv#SDP>6vBEys%3~kjTko>%Gn+ko{*m;C4`OW#p*Gy;9jd?k;lmc*oGj z{d=DTvh1ege5DEDaLJlbQ~U0A2Ud~b1)1c6+XPr-L;cD;D}?HP+qa%-eTRE3V4MX)2?R1jYEl;% zDqMMblM-J)^u;rn{^ez)!I`3eby?AenU&x6SfT`p!D+TYA|Q7+slba?s$d{G%;uwr zTn16$1=wvFBn%;O6^h{A6LdHS=k`a?Y#@r?>(JKy*Sj_F2X3QCU&QJz<#a+1S!JsH z^mW9AMarjZ#7$-J`- zx~Y*8*UybWVFD!3rXQaVY{!-D8kq1k#+$!1$A~29+exI^p|dkFLZfc*Z3JyG?yx8I zb+}6G47;{-DTRE75%K-N%?Txh!_SdEXOLSgC)}T*mL<2X;f=%mzUc&g)u1vx6U*0> zLIj|}Oo~kkdV2yMATI!L7mR%X>=k1);e;?=9F5~Hl}Qw0#>c)RTBbrua*)t+V6T|q z_RKWVJ#bM56P@d6BsLI!=RAZSQ+>EiIm@jEtQKbb4^b*7tX3qYV&Xl9wDeAb?(Vh#rnQHz^1!|3JQF%q2&H%HXce+3>PD z(W+!Sw&Nc^CHjN|RYHJwGkWl777`e_?4JVs8wH3X4QU&)Elt76 zz%!m2w_ zK7IDBu&|#{S*6JeUs~s+m>ut*0-SRy(O#*V0R{Kb4s-N+^e;Yf^dc=I1MU z)DE$#k?kT-#K@?3!4WfrEs7vgQsoIJ#%Or-zZe!imXDwa_1>@%jphuxf061Qaqy!^ z09bk75qZ=zm*f#q-d5KegQy+cuaEuq*IPucOW;shN8&8)!d)1Js&cxM0-N^-5`U_ccEDb zyw?giIaS06*uIB^|AKgVSRt}=7>;Q1PD|=H#`ir4Y=poeSKZ}SX}kKzUkRQ(6#%B9~O5};*4 z>4+sMA?$kUg22E^MggfXo7zMMW#|V01S{w>?jtAS74{BAC@a!`AhQ#6D6}(;ire4> zjyQ4izoa1s7D{ZaI0bX?-|W$6C*J8n8gzDgyaK*a)%)|tfI0=?B9TO2=q+&y#*Zpu zpEQ1C0^n3l>azzsQD|;asy6)zD?9y@z+KFrSNh`PgXZ9XXtW-{87aBP-k)jr-9+Ji zhz8|-72<<*0~+R8l7!A!-gln}=ErJ}l29g+p7-MPFRWc5E&Idu%?V&!o;xxgr(OFL zV1G7ZK^)ckgl$$n(v9mn1@ww?qYZU^C)p-*LPmo~3TWiY6Gx`K%`xC~0q!*xMf!#h z;1736g*F6EAxcS^Rn~Pjn72dC$0tpmNlby2359xh=-u^GuKQ4XnmZy%#Tc`|lPdG_vNAy!aZ!^<4;NDaks<3Jx0(?P(+X9>Qm^U>g#ru*co zJpW;>ooWE0`8tpR9J!D#|O5p(XNNr0%UIc(O|6ICf)r z7^3%ZitHUHa6K*l8D502#2pLs)d}=iY8Q@>A9*pUPvlnmrols@y;-0pqDwj_~KaLVd_E_34 zrwb>XK~H(F49Y|Ak<66NRj2x*1h0TMeZuK_*t(Y5OYb z9LT&{#W1!8Caagm2?dY`2sCWQc5%EAa2B%PIAxzdNB|@UaWK@VG%>LwLKBlU3>dV8 z2l64AYp=p5&p*S;d3may+{m-P3FQ zc!b9TE-2$bK8xnRz#|FQqx;CTNpL6D6-;#3+$y(=-H!{#`PEV5|DoI5l~XJO8aJY! z{qPGKWU>k6B;dK zo$oU?{-sUHfGdwMx#E>!TTirS!k;2WtN`&p_+So};VY`wFH4X-_bp}FT z#Jm5iGkUo(xrSvznDo`H6qCb6r;tF;Fc_t?=H64mH7~BF^k(;v(#Dd~FX_cTHNfFh z!OV9gsWUmvY^R$8ls)7;`6tdFJ!0_JJu&bLh-;uCMQ&cmlI15O{cj=x(>3VTff;Q> z3FSyugT~6F)qYt(Hs}00JR6wJE`B2Mut) z!~!i7`F*26$5vAgYRI)=ZDJO;c`V#P|K~rcD(Xi6XDw?Etgo4H9M_kn@z64$z2thS zwG$9B+7gAl1|mY~E0nnmw(bBlK-q8Rnt(>%z@u;m(GM?5S3pP;I_F9)F|?11kd4YC z!|as|6v!us)sB2nyne3?nMYhjX=QY85N36wq8tNG9Ab0Hz(Iw2BFKvZbowrAH3H(klhSvOBnUq$p6kkloN*}TcAL>vw z0|ZDDZbivglNec+V#=!!HlZ*c<=@8BOhSQtwXB$v{Eq)*l7_^P$I$}h5es+^03fqr zU9~w6j|1J6x#I-8tEfx$S*_B^Mz;iZC^XCFYuyj^>-rBh;JAXX-LPb`r`@<^D@(5% zN{1(rGs~{k7;epHmN)lNzx!p97aZo+c4xNKQo^qu{?^XE_Wmo($q;G{{VH;81MvDH z2;NH%FZuejr2Wjo@Wifo6atDC`o-efYh~`#wMLqm3#%;*StN1p8 zbhqUyGHNzM_%;hwj}VXTBboqkmjTH{`K-t-2pVOuodq@&;&{(|EniS+z6V9pc+=E9 z|3;faC=C=u;2=rqg5e_Y!^I6x2AO^sCQzc6TX z@aA|QxejWTa*ol$8|`|AjIKAzxHD1UgDgrP;p90mGzIY7@Af0uq_@*Le*%H>c~`$2 zkRm0#t7Nx=KrC0wHX={<8PVoPiFvZ;f1)-2d2=7Iv5YI%RF3+H}hT`^W3u$ESiW#S6Zf zS91*WP{ObbDIT8I{`4~UR=rf9m$owEnkei}S~=|eF`+`GKd=-!be}AgD%DY-RE{0t{HT)E;7Cp;;tPZ-N;S8F8fERRZnx%i3 zk>-=+6h*LmEi<^{yC=XR#NJ;IrOrUcdsQV=V`pfh~z6hwxg!IZh=CX^UTJdS; z0E2<5Uu{OCl_W+%yUOhX-&P+`O~E83VlQ~DT2ZL|#L(@_Q(|W{6Sd#t4u@!8WVv+h zVpasn*~8)57u5-~*y&6qc*=Oxj;m28XC`WXqPy-OCDpB~e^=&+yLo3^(nQ{U_9Wr7 zR13G~y{<>h_4+zqo?G2s+1b0;ne>Q8{)psbft6d99_73S>+4ku{+(XwUWzO@-0Wtw z#P=kht)C9VT^fI$?FS5hlQi>ftr}hu6UCEXUNh%fj&GjJDE_fMGnpptUU_nBIo{zF z`c=D3teDKzb2lPh;-BqKc2)E%3_s(Om6zlQc|%}d@u<80UuYxu8M{2U2F2Y!-O*zi zd6P$i9L4mNxeNPO&xsnQs;_>uB9UHtr+en2pM*v6pAUh@xF5Zr$bKux+Bc*{@ZOB& zuq)AJ_hAkJx6X%mvp!ak>Aae2F08s|e-b|NlQ}&Fjn$9e;A61t0LGhx*YhY8yS4Y= z#aYv#<6h(TgSdrWin7#Cqq)D$aM|(f$+jm+Owx)fpBR zl8%!me`CqRv!}xKQcYL&=-Y>lF+FMqwI}V~g;VCgaq}53B>G$*6e^kSr~fs)tU*T7 zOQbKQQ&=GoaobyLBE5q^r1*$NbM=dl9DX|kQg3N0OnY6Kl~`8^t_rSjMLl7>({aD| zTvmWx%{PXPCm23E=+@EwZo^}jK54v`u|Jult08p%SSdU^x}#O!pDSx*zHV7wYIBj=D4)NjoBCz~z)ntdnG?J^F}kz_X_WS$B7#&5ybE!lEQ|KW7h zs=%$%Pd`_PPg(i0I>Gb)9yQQ!&DlH9nUX52V}qI5TD*DP)dtK0OybfDSO3mizFeul ze=B{r>Vu_H;PmspC`b21gZsM8@EC7bLk5Nqb|`o27JPNEUYv3#*pE}tHomQm+~Zro}i_suprNcoKb^JBs1C6?QY+{uaQ#;eUy>r4w#Y4D|kO$Oq^a3{E{Eln&DDmF**XTPYGh;dAM;YSWBoyvvr1i4tH+gA@Aet*1t*~l74B-L}XXHU~a?tyi+ z{)ZPI9|aQQ_UbKH_l=3MUj5zEnCh+J9Z~JIuh01|7#OcESu;`zOZ^JT<5Dcd$9b0p zr{ZD!ZtqKgBVw=Y>T7@8_3B^VQPuJnJrK7ljcw^X!4r9PWX&`#8uvA%Y!lNOt=@Dq z2)l|)qdCQ8w99w~|D4TaKd{qbu&vm*MAd<5e_K~+XZI)Rp7OC?!Gw^}^{&7Om(t5Z zNxvO+4P2F1T*=^zz$;<03V9MOU~mk6RWE(LUiJ8of;Ugty8D`m7i2NCH%jJ8D+xF6 zx$(j4XOcP7#N+I9IhQSSF)vd!cdk^iNaZ8babv5o-=80$w_aN9I(*Z{P-5JuTe(nW z{KTYi+NaC1Vl0@+Yemwfyo*c17i1XeSqaX)I}>Un_sF5<85Q-2Hajishhu;LJzvWqDG>ZuuCA>gf$#D3kXnG+7O)UBQ zA4(5>_0Qdy;;-tyfs=EUsNhv^wn9rrE#0@E)3G#C9h z)-SmBk$sn)>Ey3bw47eWyzBk^*g~FGhs$QG}o16yHaxJd4* zESH(f&GOPWOqf^Py9!hGtGa62tS!1V%%PtYj{x=AXCv;^TZmLu0P_% z5OO^Exwnn^yV713ruCG8x2<|TJh}U)oOMSvOlt3)7pQzDZFII^@Ti=aZg=^|MtHl@9v1RSO%fyW52!iw0@ z-*5R}Tzy66rhCS;LiP9Q!hLdU*-o96>Exh);02?NshXM(@|Gcx@=) z3bBU$z-m^{#$xuvFEuX5JGGx2lx?Dk&?N2)|9x}!j{jD_CH)(b>|7y}o5uyncrX)c zKMP|Lvqd87y=zDJ_0xaQyx+30)(ZaC&P}y768LekOX{%2pPMowPlDGjq{3?tc7MZv zM$y^Ki%J=k}>LjgIHf%g*PU4FL?;HF3(=c+li}jpXMJAgb zn{QcqRi1uk741j8@~`zpTY|UfS+C{A{1@D3C<|@gv`^`~%WGY9m4iz{Ie4Es_IzUm z$ss1flhhWiyptC@)+9ODS=L9osGqI7p0gaW*|vJJRix1CdF59$^MIc-vAp(glbUCS z;o4VkdQ!FYUE04m9m^j4B7mTPPnF={!hZz&w|2fXs3Y=z&bdJ{Si-x}=F~?5zjj8m z)E-A`pol`(WqHi%4DLsB;V)eg9s`M%%*;+c{`@%G^MRomvK5M)!}j^u8#w%6*_3)* zZn$6}LmO~2$?AA^0xxQDzcKEO7N_ED(9So!;Zf(IrXB0q&5(U(S-Y)IY`TZlXI$c|$a<}}cP;$6;m;kfWB;1T zmaLqX60Y8T^iV{{@N>K?(@9q&iU5%_>HNPk3k|X|4E_e9OAD$AOF{w%dh8#ENBqI= zpm}N^IaQ}W(dXKl)vh6_q++V6CQM1qz}PV`5-R^`x=_B4B}tp{atbZ2UFXCJrH*~= zXE;53qdTs){T{+$urUn-dj0L*iwvBjil%syd(>0fjgnF|U-UL@?uS6*_fCI90&GML zJzu>=^${ok&Gl!*3HAva{AV}jgRcMb@E_o=ls;BIWcxYnfzX+;Kput@7aD#`ksGyK zqO#84q~YaMR{J_S+viu~eN!VvMm)F*gE9*3WUd>%vzyH0 zPGyNkkJbXqBuOZ^c1XFl@C^AOT(H8$Ejbe=ty=AF`gbJW;|6Exw)G0^`?rh~CyMq2 zThd9ZzvfTgQLwzX`{^{{26wvejYAfqbrnW*gP&}&e{ip-Cyt?{Za{7=cz0IX^k0c>OkGQBby5_YLdiR-C)=#eTn2TO{ud(U-w9WcjDtUG9 zX7HtALpdt5;H-eif`xo}T(;Cn-;crh%gH7;NkvvBi(|*UidiTWcfR|aui&dtw43|> z{kOoIg7~7sYT~N~C6k%5+Lf$wqK+3XzI|nLg*r$NOL+ZdarD`~vchY)R~2u*GCt{a z)@r$Fs(^cYt7uM+_7h?9@UEMVlv}YHDGqzIZboSLy4xPh zmXy&98~M4To@2-+aK^bwOSFwa)WzT};SqHssNMq2Ka#AaU)8XDq|tE* zaf_qeiaobn(WX=I4B#X=U98cgigu+0{OV3~pehNzPT;C<39-wIlLm)VOQaad?Uhc0bp_vBQk*;41*K!?7sDAwpT}4hyhHzy zvj4VM_sk3}#6PATp@2Kwgpu+#Il5MZ+-}#|=x~jm{BQ`q;SIvvsf~hvu2(86dA-|n zPOt?kD_%TQR8Nss^ZIp^tk!nnHm}2u0CT~fP*Sffp@I-u(X8;=c?;vH)2*5tA4vRc zq`u#iKG75(WV6~#)+(v@v<#{VH>_n|Mhw1)Nou#&z~=w zMLM^Qu`iNlky%SzP`g>!;Hyi0dOd`bShW2Em09F^a?g(HG7!tb8!VImyLeTM8G8e7 zb?f+ff^^-A=WgPcpN-aZM{55`ICKB^Cw*=oN;as<^iy@A+`W8$>+y?Uk1QB;4yz0H z#o_;^`VjZ9^VbjT=O-B0Ea~#*|m66$Zzlc8hIx)saQR-1fQ~XB#*N?)_E|<#5xlIJS zl=Lirn8#*a7C*vSKyd;bnPbE(O$#W9da>uvi9#>aCH*d#=5I7eh@GdJ$H$IT4Lg$@Lme~I;&vH8x29XM$;6}QLU_o@S3X0 zKq+z1^?kqpa;T)tfz$sq6vvA{hi_~^6w9~TIo~9l?q|uUHkxh@Vmpq%qd`J%s=oj1 z5=o8SjA-uSc*&$$aOBeY2cIKe{+RoenJy8NCGSv`<=6V`m^2TxVl*ekv`x;68h$0n zT&Q{C-0VY47&qV>E2~ABl=%7!{`^lno4`!_deN0F|DIpZp?HSMt~Np)$=+sO4I!y#NujfG%U;V$$63EP5_Uhm`V z&e|#8;i=-G|3=Z1=dtAk@1rpKG}V5xh&#pEQ#CJRUi2Du?PC?Jgc%BF2#p25nGs+A z-Af_c@w|FZS2lLgNllvL3QrGeqoEvYp zt2czjR#HBtP$)TZWU^&Co3TM<@`P&4xob(M18yhl4+QTy4Dsh5em3!^h-*uvZ1Dna ze(GmtjH%^f-FTgf$!VHt^5!@`HG<1)|GqQiev9ge+EOyU{MiVPOFv#nm-sib_6)9= zsFPyg5cGtfB`3)rW~x{eU!jI?GlsUT=STM+8E$tBb)2WJO4C|7?{+`43HBC#0#}l+ zwA;QS)KR*-96{nQne&X$llVa$yFKgqv9+SsUxm}T#s`y-+#f82BR$`2Q;|yd)5@ru zV2kYWIu3phA*GGJO^~d{WQr)7Y*liI{<|vnpiuUFmYTZ|bKRXLBK|gp*sl+5p(XE@ zpJNHWWM=S=;PjkJxdQ;Ap1|NTzJz1NzHqFgv71%3) zf~xPM2K9$;Tm?ES30>bk;f^liS{)yK=0tUO9Xc)Dr?d*%zrVz(DbAn54zui$?L%(XoKJ@tI+6Ew&y z4}t?iy3U2r=Tljk$sW6T+LroKyU=O1y`0AdLuG9KGKX}j z%ug-kDJ*9Ar4(`-xrG)b^SM^68hzdmX8aLiQuXTQxWs*CxH_k;ZHf&kgJu@YamV); z1@~392|qtSVrfzPf6Mc=O-sqN24y}t|;{loE7l$VEtXz6ju6WV( z{W+UYto+Z{Dico2`)gA*hqhWKZy(~xB*6Cl_n*^vTc)Wzp#N;av}IwbbO>r&R|^rF z-5?R_PtW4%ZzmRoDO}>V5iVpT`uFOK?btlEg&`K&pSR3V)}uRW@%QobvxIvANza=t zk8=K_kTA&zevM;Nc-<6~uNVH9w?`}2ZXiK0kW_1Q$M2n1Z6)jP`il!M|Btly4#fI@ zzsGNTW`w9zLX>0_*`pyXD=RXx3MENKvKx|Bltf07EtySwC5nndLJQg1+xI+rf8L+p z=l|ayz49vE_w#;?>pIst=el^kpM7?C@e|jjwt`!a4{gv0eQ?!!QhLfBtm9L|IIEfC zs@hU@{PQm#XpOF?#K_S9z9*(3Q+t~=kUHEYFSp^jUhLknRlRYU%$f3n`y8daxWlsZ zUnKjdRj_Ala%8pNJZR={<=L0u9X44{?atnve4N8m>Zn#yuGuZ&&=4oHP5nw#x`Nfg z#s?~gJS*47EL{}*aN{{gn@V)V=skAIu=}nf$DG?U-`THAFCWm81ObyiaN*#Is)82= zrqqbvZ9cwP*{zQ(s3-sn)))52A8-ucGtPnXo3@eT*I0$!CV5Bgn9k;uIfZTKMY%ZL zGD;5nJ_-GCr^!h?bK&iww_NvR$&tt^~16oVPWDHlr zdIUgtPKy__E^4y;*;Q4QczNRqD`g>1zj4;&VX3dBZ@DW{I_V5=9Qy9LC!Vg$!g6(v z;&P+)F3@w)A7$nWKwT&wLmfHpC-z1B<)l_2-3SY%CvL>qK-w^wXQ7w-*?H~=NmV)K zd`fm~OZ#!^Db=__~5LT)MAiP=aEnVCy89YDzo*#A7ZDhUA76;vw zJ?yiV$=`hEm>7ilUrD z^<}DV7wM7ToVFvq-6~T!f_GN*KQ@^3%;W^esX{(AAv0=oD0jMFxt*lXKa z%v^O^?&oY#37_h#wGAH1g8Hod9x1_V*9Ksbk01V2eyfl~Bufva9+neS6YH&6{7w2D zSN7{hT954&KJmfRimC11ag2}tSRHqG z-pt41G^0%8e3nHOPT|X69jT)gL$v?KIyyDPykDrN%<6j(3h4B6&a}16>+^+I%8i_^ zyFOl(^Qx1?K%)q+56Z`bk;9lYNP1d|-g$Aux#|JE+_pcbxYK}V^aSr5TA3(Hs*^KW zolj*wvMaBBLe_BM(sE1D3jH3%bD0AXPWRd~ZsRuZn7@)jmzTJ>i>jRWvDu4Z zYs;>u&AL_o2F+EYZpn7DUIz_y!|16?H`e;^puPDcRq^R);mGH%*i4O7X2!I)Urv@K zT(=TmV_3Cip-E|C{h_SW=*8-|w-xG6T(~Ad{CR^5E_1`K&ljf^SQEdAGu>!O9V8CkB(h}1LqKne%3{Q zuP!=$UajNJ3=ie}qE)5Gl8WK}io*%M!`?LZy!DRvE0vB7E&4gG_jgWqoM_mXn)xI2 z21@|e8%*#+tFNm$xiag020S-y8zoCF^1Zfl-+_3$Ete~*4#{#P&zsw+U&!ixl-UqK z>ix2CIh`U(s!7Bh%!iaM>a;nY&bZEalgxaQ#EA8_^)}5jX&nqs7 zN0iJX6z^P}(R*cKW~kNPo`*J*;aZv3!a14eWmI8XTc%zvk2a)cX5OMXrRsfT!&iOw zF2v#=?~;z)cHB)rI+(%cY-s!Vdh@J)?y`&;Zz8WB=EIu#HCUSSzD^79cRnR}T|nJQR?BYJ@CyIJGtQBV-5E}jQ@)RSLu3@`8kd9f z#NQ)_L$^u)v+V%#?QBJ}glet`4T(M~19!(1HZF@jhDt&0IX&MVy%E>uwB-WRxz5s? zBQ{z;PT9w#1-0J}&6HrktAv)m-lsY;nH%0iwqvUI$ZS>J=+72O_L6zGV(L;3id5ql z2KsC7`qKC`aZAaW! z)-R4qor4=jeK*|Ah@~7S@(OCq@NYJAF`M@{eo2*3fA=-jtL(K(;uHVX4KJ4akNxH9 zIkWHde#M9_r{1p_{B`FVy>N`p)sIup{HA8ErUgX3oj=O+N3oS^w9MqF-Tb)E;7q2) zI4QBTzi3_dHSO8w*SGZk_Ey6iZ^N|TB_xWix5!ykJN;($M)}9d-Ii+VwGoXG^WGdo z8T9)-J}P!@>wi<`#2wEkG4tRp+x*N8CVN6An4XPE^D}PP;GkQh5Ut-juYb*M(pOYoFRc~FDEmsSDibF$WDNE(R$txRezyVv@G0l z8K>5Wbr^)nx9AF7k7z!=wLo~PbS9OM0jm=JsD6CvRL?_1h8K*~XU`R+9|3<${Zag} z10t@bo5$^Rv$fg#&HYciW*l9OElWu>%{#&-(fj1A;OK4Xbzi8!*%vszH z3zoV1)_$#}$FUv{<|~^wyvg8TekbDnx$M~!3oo1f$HBO~mAB*J;4sL#{c2~Vk_#+k z&&BQ}`_Wg78VIU5o^J^=^e{kJ12LNCA8n+59bx@R z(8u7(*HEH-LBbh&9hDd2{>?*gX!}4{Pwu6^P0YG(f{tD(e)LSE!5N_uZGwmMs|A4_ z4n-e-Avbqc%CfxGf%kR-a2kNN@QITe?D|FNdiNsJ6W?y9h8xW+%oPfJJI(d(w@ zhVjQcUi7PezDh8icbyP+^zd@0PxhhEKLna5ehd9x5a;r9+HgpL+M`UvUvlZoMDdXo zANT4_lv$d9$;JJ137~zzzJkZl1Z8`CbAg?DR7&`5MU)P!%MP7ZH%>D*Wueu7P<~LV z?#!dR)0Bm| zO9np7E9UWRCx4xHzodwJAe?o*^)>bimnV6B!HaiM+)K9W%;Me8u065ErWgB$nKetri;uJ6!60fK zFyR8=Kb`F_T0fWXmktZ(VZ2<402n2>#n5JhgqzW&)rSqLH!jm}U6fnVTeae{C0Wan zy?-CLSl-=Lz8Q+OZ!A`CXLU<8nNsX&QnKRowxm)gYy~Yv$+FYaNsLl{*Cf&_#C2}+ zbKe^#o4f7nAdYZY3HaGbHhzfNk$Mn`0IKVqnGo$84>IgFg|qJ}TQ*?6pn6A4BU}4+ zI{QL==g-vGowKC{6pzai3YB&UVev}`1GgW`%DjHfwdL&Tnz&?6S<#_zHt)_DrZ<<$ z78stU2fHcXS<AXLLkD#i^#K z8;V~wzie3LoU1SLcx#VPNYC)ZrAA^K_@pmhv)Y6RrGzh>R{T9Ss`^=&N3ESIFsz7S zKpKQGc;o8n5|F`!)-Pjo_Lt12Vr(6pC-ve?vwghoJMO^SR7qJI^MaK2hHE?$#N&f2>`{F)D|uD`iMp zD>GTdB8XtK&Zcet_m&4QeZ3~9U1UB#S|AjcaOQ=#9Hqy;aru(zxk`qk)5*E+Aa@$y z#a)TyQP^s7TS({%f6zslQ$p?QB&FX4-!IsbU2i$N8gg_jzf4+p-M)7(xyakN#~M~Ab=pedo_ux`Lp$FEu< zL6%3BW;42SGxl42I;AXpHYvtQOIe>*();}uQxEH#0iVehj1X{LiINrlcPvKwEKP_Qcz@O}Tv*gCxIm)Mqb~SASp6NX1NzL;Ej6DWBsC+}q zJc?FoAH~K>9sAVkPzQxU;NZD~DFTK%uIH`~97yRnBw}J+c7gAf->9X^;&#+-graz? zv|$;fNz99_bKV!NXVX&lGf`j`_&CwmCh#txmv`Nko*V3O@LbUE#U9WFr1j43T+?aOr!xcyq9+zotSmY&4 z(OsGlRW*L*F5@@hMCFB!N~JElJr!3hcO=;_1@!>2x-4zqgtyQ|86Zk9?i#Vw%GZ>5K^s zuhkxBmG* z`@3!3W6I@(|b!7lb&-kJI{G1-d7l&|6%+)R)Yl} zN$c*JHRj2@EbaW-=cU}^&)#+1)>LpU#L2wt%md*bpG%@EEV4lYAV|13E)zwf}q!Yv-F*Gswks3jyb-3jX zt?!z;j3?>Vh}v?sI98~2M9MMU&g_u1cD{JvJO%pvQ==GAc?y$u8U0YV@~je;r-++v zDy+!)_xGk{wLRq!I4`_QK7xC~cMpHER%?rbcYv%PR0>Zuy?6ku4iKiq3MF$8R;aTA9%n0IO=i+I}OOsQQ zUF{z6+h!G|^LFGaLgQPpsjzFV+`Ji=Dt6;{K+`~{-=a=yYjs?*=!fUDt@=g2Ek;Qa z%DUA_+5N2)Y3hT42dAT3uOu2}c_$d2`OBo6{cU}BD6JyLcg!!=t!j;qRqoEl8K*Jww*pqj_Vvq4 zPgi%TT2iH?c8~XcuXvx~Ul*~X3YF=@!Sg~$Cr{75I`YvKZjdNbIxhd8j~42wJ9klZ z|M>B2$k?kotv4&6!(;MCZ9?TPsIBVVtyJ$FHKsCVW=_r+GcD9vWUiw&G|bkfogHL- z{=GRYT#jYwaUbtG&de`94y~sETXhz!vl^~mcRKQfgOW|`hEQ6}dWHSfIFi)VaRTqX z1^0V0Yj?!`p+jW{iQcRiWmvls6M+P?M#&2Q_H`^Pz{W}V*(`VdxO%erSgc4p)q`KU zS@N)H&`N{iIPcbBCv$4-=rbv5u}_u42i*#5NAd*VveNS|*z(ZGi>a3nb>ve zP6zU3mlM~qO_~oiU58(U>vqBT&^$x-EK7Pz>GlVo8DjT-nR2|*|2(2I&*=& zNG*+d*ecN*(n^t5u5qF!g=<<&s;_*5Wug-$0P}GCin}>iNB-xSJDU=PZrPY!-V?9! z(W1TbJ74;`tt*&xDRiT)ciQ1&}{w0QFW-RuZl)cm{`w1VO7zq zdp;Z3d^a>%ZDqhmEV2L9QSxjiXbaACx z)Qz^rkJ{hn20z21m}?y=7eBg1lo+5EeUAdm_XTl+y&#Uf8Vf4pB zolg^7qIne9$dYM5{`#yKSw*T!v!6KjDl`4qaFc7X@-xTM#>(R1jJwY47DANCqXJTkK4UlQ_ZDyHVzZdhB{EmsXefm(d}3=T3W zIzZ51-AWBzX&0wyjzy{b;VpJ7;NAv-N%-Ae$GUTkH}}2Wd*w#Vi~P5}+Uqg9 zLk>?YB$_NA^0O^znb3&OKNi4}c7As5{+6oUlsy#l<6kzJ->kZqcmNeGYK5J$_w*hF zBKCbS{u9O-bN+kSQZ1$Y>UI-38=cmD;{id-`Wuhky`XnZWjS1Tc{+Ew#Jni6A@}i1 z>P*T$+3UI!@vWs{^V0e?rt=YDV|}{Ybg9=DF=WQl0F+v}&54Kac&&S`1Hx2cC=N(0DF5TO)g<9(!Jh|jq1-- zs-08uy+P;awe^}Gz;+Fc2%c5Wqkf|Os-IDQqiw$E(%wfNqob#e{@fhSC#K5#QS$NT zn~L>S8u9Pre*|q4>7~y~y`I^7fcu)uVhEFC%1pbMZeLNm{?xSMzfGaJ-W?VaxQA>t z+^QdBbfq)m_fZb!CBf4Qk5uGV#~dj_$Vt&X@U+tQkKM0mC?7pQ^(eME?{`XUjb6Xz z&uwR}G1X?vJA`EAT%a~dP46{&hmxD~Gq&ix&qf(`!^27IYO-O3JfBm1WOANXs?=Gi zcZQbIGGG~BcX|qy&(lQTG=;{DWb3(6{l^h=EV~V&1d86yFi;%r9+tjX-m@(;O`+^r z#(a|4xW1pE!(l^n%eGtBh;vlZRbXxJAPuVh+$QZX?v_J3|5!}u$iK(ou)E2ptFl;6 z3vt3@CuE5&J8mppeRT8aP{B)YmF=1~#WA&wk}{PqKGMIc6I9E}EM67Dlpq}Xt7*h} zYn7H+t4X@=#G)!H=sfTp)8_n<<*S-{j~&`E&8f`erz1vq#Xdh#a&Cl$DGT92U+YF(GuIF+>VtP0^?eZ8>M|7Qt5dZS+KunF z(Z1(?R}5to;iXumDDqQ9wTzYfCJiaaKBNr}4*G zDD(YF*3YZ@Q&$*ww?)N*_Q4{R-fvbFg&~LBov2{WY)NmaYcPR7f)ajry=!uQ!tS4& z!LX|qT^1irfwleL9V50k66>PYRQpDfOLySJPP^NztG=~BH4>f-vhP5SZQ-YZ&4bp? zRVI}E=Dfvtk)ZY4UeVx@T?|%4s3#iut@NZ3ro$d-vHtz+{P5Sr7s28;15h)({!F*& zE33aLe!Jk@xNtQamAUVkYYtrZ>Juhe45gy+?xZGT>W$flTKt=3(50L1J_W{JdwxW$ zxx_2K9s&KM;!t$@1iAR2fy)bCa1*<*Kz9GuTe~T;)G{U3+~-dn%Dh3}`a@&YvydAs zdrln^gSmWGBvr(WI%B{%UguHo)y=YX0x!j=Y1bfur0AL&F7B$o+bB9^>?kAcMgd=h zbPEW||8moC_GN0y!w)Zi5DU*Sc3r;>6!!heB1Ydci_-i?p3nQW-uGGFBs&~zP^wmx zTT1nyZpK+*He%yk-Q9T_dc&n$BZ);1o*NvG^K$ob#=|>)+KYR(xw|ZT;W)zLs+#k( z$7w}UQV!m$jZ3DpI~K6PfiXVCFzLCNY#jwQq7V0IS&AKZ5#RK^GAJojs|MaY?pU!l zbasu~;=T2eyhgu?;xp+h{_GE8b;`T=zH$=Km(x>sxA>PJ%Gaa1Z}-xCyuhW(NZdyS zo~`}XJmObzdWtMoW=02Ku08E`KbBNvEpE>_k*$dZu- z!B?jvS1In&PhX|WvZYvg-y^rm-D`XQNGr)6n{LRsa2{8MH~_)>w{@P%ixt&B=`O5e zod?#&z)j)m_k2UYV$rA?!8R$S$UR2yh7MlO=}>l8Gc`qIBjy_T<`ZT-48})Cvji1a z5*7?x5|z)+1upn$8~s=g5Lr&3q#Q{f7iJurp*;V}s!$fb()2K&CdzDBghI_}YN17s zXB5urGICmTcmAFo6%o^HL7MnPCSKm(BYcHtMR$!OeOp$tZirCK%kznQ0gIUqi`blW zyN^$%Su4*(@#LB~GhUP|bP|LBikwFdNtO3@%6}-xH<~S_FQO|9?(IK0lP6+HEA7U} zwYD>2(+QC%DsNo`%0Hd67J&-Aw@BP_z4%FDc>bR4ed{xF;v;grTM|Y0#ppc{?05`{ zqhABH<`4h31F_aBUh@-4zY{N=FEFoPx@}PUSjOq&!RHd49&9OnqRfwCPln$fJrpLK zx?8>IeuOsbJ?djlJRG6tx#QG@+t;zX>W$AWeE0Nf(x)I`Zr?yzTIgABZ_2#!ex*Bg ze4VkAkD*~|tnvG*cD0R>T-3Cb`p=$gm5!->`F7M+es6lFa)#TOGhI5Hy_XFw7VcM% zPH}BW^VJ|f6x7nh@j%?F_byctPCA{TXO+f4yvnib9Jh;qSm_jrZ})R#h96bB{moYG z+p6on-B)2a%eR6>pK|ogeo;#w^2hEdv z&rEHc637%1&`4dBqp!^CIqCK!yglPAh3<*WBXF+|0nxaZl^^wMJzR4Jm-vvx^|N&8 z#Z`_0N5;x?-(}y05VZ4gjmy3hMVE$rpC-((Qd;CcLUwJ^`J%NDm3eb!G;1MZNbBxW zPUhH>ruc|VnnLT!X*+&d(WBI77Ava1p~Zyye%Vs?yvKm&4M&e7a$+u9Cywl6iJ2SA zkyPS=mGf#%%Hr&UcTNYq_9Q>uQuraIO{}C`&RFO2mpj)=OL-kUuiK~a3cr10i9|)T zEmWLDFzPGVh$}A=Szxyl9-#3X#2!?iW5Z+;(kV=mbg&$s;N;vbRnH9DG9upQV&dv5N_@~>pNn*n?-KTE#;w02~St|F>Y!q;5 zG2c&a7Ezjm!xLxHxy~&v<1{$v+;TXXcQ4k4Xd|R7pFcq5zY@TWT)0n~y+%bphb(T^ zNWik|BKNZop4HvrWr=3LuSR9-%E82KX|R3|?8e_>I;VXA&#d zz3F>uql79X)}9tM<0mE;9UT1TCdh|le!%n8b+51y7X*9KNOGY1T8ajKIlLI|cx>J= zr6IYF`ETb#hxnDXw{3&3J(gJoD^TQ_z3%Ef^!d+p*qP~RJ`6cVYK>I~=p=GYk0`uq zx~<_Aeg281-S(DxKIRo@cu|6{0n|!A_seBa&AxR$T=*pB=1Dj2{)TKNO4yf0noWxp zwIvk)jPd%VCDxHb1Fb7Jd*!=k4f!ZjhY80+6_c4?3}668c9Y9$b#qeaqe#*wAIE6e zXL6kMPppT-_iCmiZb2jZ?Vh01NRRN1S^|Iih-kVUJ#i~LLT~|9B_bbvooUL7y2K&t7WAVvjeXf+nc6rd_O}?Nj)qU$9!}) zf_MJ(*DUJS!B+~W28Ya1B3NPRcj#_+kU;ZR^hG^FxwEc2)=r22-7P2~yugc5GcbkG;#O`ufFlDHfmWsRUHl6MSy}=gM3hJ7+mJPPiDG-X>j!|?Y zw;0vN-aq|tUHu#lMbaR-6YbfIQNvauvPg6~$x2dVtIZ$r0+@Fk1NPJUVdHLq(p%poou`?9) z6Xdz^_U4wk@5Ms{CzS%P`B}SPqo@3Pr6V+yjwwk`yyE|UA0F}lyw86hyyGLq-~GSe zR`{5TGK@d?&(o0Cz#ov0hxft@lRx;kSUXlx?C<^eoycdGz#shku76L2KOjr`pAYo^ z>y60c{O@a%CwTSWmm>?l;QJU(&#mOl-??_3;S&%~%Kv%Z(mn=1z)URw6I}vM0Jl!% ziw?^Ge8XTI2dknhNdst!8v9Din)=$WFzDcaS8Kyiu-eOe`;>&m(hoR?dP?rzu^R3( zdO{fxTTd3Yy|)) zK1=;LKj~1woBdDU0z4N$tm05gNb}Jr{ZP^yzTQn=d-=? zXr>^nEi@P+z<*6Jh0=MA)H$Ug`{Xa!$%UJ>hY|Ae3D%%{V5cmJh5zTA^{9SaAUv2z zj18M!_GxT0DV*V8UnUOcMn<;s##?^~sz+f3$56$wSTyz;Ao5iX9XxHmWx1G7Gj z*f7pNAgX-~#W|4+W(>0|h>{B5hh2#a>gk^s(JR}K7|WMV(fxm?Y4+a0u-aI&-w*I# zT)XzA3OQ=?fv1O^Dha(R5J1omP4C+X$dNJ9&IXG#sf@BExL~sqg6QI~3lnKSZ7io@ zUrM7eR4J=qPo-qKu5f)x*fVi*3v9G{FyA^HdE%|*oXgc^A!?T86SP8nBMoh01hTj$ zYN}U8=NYD(74%1fKd-k8P5#_$t4Fc}MuQlD=6+xz@-e4HHq*yxE# zA<+ux^xt)oqd|(PzKZu%v3I>7J$W#Cblf(HWH#TQ#J{+*ylF+`q=0OlKZI~Bz74vw z-6QBw_wKL0cCsThNQBBCR@&R<3E=?f1Xsd@O4cChJvFQZF;{VLK!89m;RQcIO;Mb= zM8JH-u5X?LW}ATU1D`bo;}#Ksabfs+&5|V@qy7KJ;W3>uNJCO6`}M)&{cH5{D1H9- z*+1EQ?C8vG(r!tbPMg;YS*PJ=>Pc68CcG7Kh<$_gXgBHg-!(HdTn8lAN&0V>M_2wj z&(5sO$|=haQ{kv{cy+frXkx{|L+Sl%$YC|#-_w^le=A)8smTUhJbc%LVaL(Baf79T zTn!vO>|f;WKnZ0I?CP{df8A_u1o5V!szF*!M$4ct6^sYyauEbY>`%cS%Bs{HFa60$ zf(70SD<&}y>$M_L3hc-yB;obHX@Z8*T}z`%K#G=gDC2}$wf>rQ8(9G&cgdJCn0pFD z=KQ_D_fm0~C{DG0KK$eo9R>@*Oz!a`w;J^8vFGQr2QybL?iG5l-HG@acPAXD>N~t1 zr7m1tfgp6ys!+%7H@lC6R=1FdcIYQPG)a6Eh_30OREEnq)#UTvQHc&hr~cFO``117 zWdv6}WDI7GjQH6ncdEv!XHwCuWNtOA6IBob;h=#|`GE$Z;_Y|Kj>wQ?k?r{npRVnI zY(Yi^q>{tSUslX}<@8I0ak_sLxcwRR|E5YR|J=Ku*|5+BJHH36`?Bb>qb)SEy=x5N7CTm2sJ^aiXPm*(yR&C*ur22j3BI;=Jn%n}e_m zk&ik5HR4ij*?ys?-HaJ@C9dLTIKGZk92pq-aBK5s_Nw7LXx1Iv050*G6X|n0gNR_< z!NI*1YRWD11U5KfiAizcfxNGiVF}I3}sB`)!yVlN7Bm@ zrp>B}S_wg)3`KbMg{egPM+vERt;^!?Dl7~mSVLW{M0A8C@OOIDq7JbRK5_r7PvNjf zWb~M1Of*4Lg|T}zq+_^KFdZP&;19dL~jlU z#-&dDd^j^k@PYW$H(}3IXd%i76@^CzLJg5YiR@|YTzd7hl^izQu22e$QVzKdR1~xu zoDQr|O*}pZ51V%sO^G&|V=BSMkw!T9s?gZhoBV5HC4I;L=1NNW7k6OBw)!idka5se z04Fzm*w_2$~-(Tn5K}AD?w_D52dJVsi;q>h9XFzgl@+AXUSPshJ7_A1{BQl;8?>Iia2m} zKiy`$2o4=WK@ysvNP7eb3Vc!x^>k_y3-+QF9o8;FR+r8FJOLa2cKA_mp}fUk5Ob5P zDC>X7LJ--(yU7!y*uL+IeNfjo=8}ekv-*!9C8Q>R5RkVwb<~TwYIAWO)u= zG5+wfA&dlpAA8B#G}~_={Z6m9t%D1MZ)M7bbOxcUz6Ay=QOAw3MPqi7*s0~0vV@*{ zO>7}GHqrpe(BdL|SQfAz0yrsFdl5y!^N<86WbFP3dM)0U$O16Xq0ew%LzJQYrF>?z zUi(uB!1E|r^O;gE=RFH-{|Z32l-(HN)0EhuTkRZ4h26ES@p|#g$@*t6-aUQsjx^b= z>w57~q~(R$V;upGc)oD#DT3{I6v~Kb0v4LJkV}X2a=~D6kiJ0_#gQ9KpPG z=4;vI4X}$nO0O5QymHhZXcS~JVPW7F`aT^bNBn*G^a5CNTvie$4jMG4Fa@H2Qfhhd zCpi%KfE+O0CI$`FB8f1DeSU}+6EAmOo?XLc>AMc+0R$P05`sAG1TUZw3T%Xp??X;} zwUZ5U6)hqMBoRV-!LJ6*@^+bIdO`Wu!Rp)FiM&y2h)DW9*ZkihxhqM^UiTNIbp9{t zFC{IB3f)+LLZs2d!p2e=NF5~>T70b`e2ol<7ppFG1)LPtj9Dk^;*Ev`lQ_ zW1ksve_+@LSqZp?Xg~uLQUJ&huLRK+xLzl_{KTf$HwpH^6M!0S)}TE+m3xyxp?%D6 zWz_HQm$Id`Wh)Jr<-O+|(3f_2j3bH``X@0(=slnYXd`?yGA4|8Q{pfKnr78>II|<@}8yoe=-p0EvSd?PH>!#t}foeI`NIPR@YCz25p;=@(!g z8mtAex(LdQF>_D(o0U^wyew2Uxpw8=b(e*zI0BV`Tm)3qI02yh1*spWsX5i|1H#yY zipfi-_0a7+ncCp83C51ISBePeA0%@m*Vr!eSWaO*)S_2}1Dq^zQNS22_^Qwp_o zUzMJjnIbUrANdtJi4MCd?Si^Lbirz5>Wf{a9G9a z{j6&R-`@?H2~hHJC9dxQgcKZ1$cKk7BuH=9Ry{@B-7~z^AFhg`AF{9r`stC5@n1bh z(2mZLluzMw*^G6TLd;I#DL?%DwTh)`!doA)Wt&feB#e-NjSoA%a`xOVI>@b_l)ZQ=TZq27;2fvZAhl|j#sxSwRX-4naTLg}Zd zv2T36f5@?8aelCxGWPF~BKVVNhZ}MQ$hPu&_90evl#Rf^6*!Ja@0`@I3ur)DFC4k> z$Vq2M?UPYWV3PNDcf*qkYw)kP@fRuRkP_uzUdPw`#JTD&TKZ2C0}~E~A!_Kto=uO# zh^+SsVoj0)lX)X9Od@7Wl@WU+=~N#mc}<#1f4x0t=DYBqQQY;XqRa5w*NdM+al`iZ?tp2E5FEoynx2Au0&Q7lH)HVI*-LtCRQJPRZYA8Icy{KUr83=8&j) zIMj9gsJR{4&>n!}y>@X~a`d~LpEnB>QXvRcY|BV7*d|0v(uH^)p_PElSbDKr35i>K z)mtb)0W3D!838O(0JnSKR zHqHDSeDn9rWf$D6Q1k8x-gEplW9{!qVEiBQ#q)cu-s|%}x02qRXMQt^66CsJl7dUe ztA%&N=3tF}KKJUsgbKAaS%EcM>29mdiODU zBxOK7G6ai~gu<9C(C=qW-^ZctO$eYB-d^loI6Y-VzqP4q<=mj}QyLFP1^dzJvaZMf zqlEFZ+s$A5GRggO=!{wgpnXh8WYfY-|DoRqMyU5;q$344HmqfmJ7k^G4!Q4F$o(uB zwd_h@2cZItY8(D4>3txIP}3kE*Jc7>uw~>ND0D`cweKiPcs|Vv11F&)NDn~waV3lA zF5CnNlf!?`&y&m9XH3k(cd#aow@bb?_1jlVNmsanhEo!3KtFI^@B23n0K)-4<;#Kn z6ajOnC_VpWgVeH0Y~pGY4yplaLnoe))bNkM;?^z=~bmbSCn+RdFY39ykN7;Eb{sGgb!z zsHy4(n1X%!>e8brH-9QE&9SAIB(V@)216#ECpI9ORx6xYaa0AL@eWs3@$$G%{9qLc z3h0tiu0>C?)i$jK88Wp y7SN*{X%sY3(q!bX!f!g$`>7^?;lS7BR>y#k;o)j2v% z#lMd1?NepPhaam9dqhAsI+uZm_k$BfO><(m6tzDw$X(7~1p)p#5+8x=q@SvGIE(NG zdZ+c>a`LDwn`EV9XKqSV8W%NbPX+%ZhXSQ6SXZEj68YA4^j{Mf6a#b?JB)dlAhO6r zZf*O|RC`Hq3m8~PNEM`amzIXD+5z~XiJE&kG|m z5D~!**1D%Je1bgf3)$dz6H#C=J^bkx$FIf}^x5M$lGOLqt7VX@^~F(ArX)Y2pp+uF z8GnDwZM3AB9gP)c^JrQ-Xwo0&E^y{N3N=unefZDxJzYtWJos4fw+lAW`^ ziC!HKoAItz7(~)y!-tO}%m(?zNQrjxumUwi3Sn@9k-wh8ZAePn;#wg^>BUdMErsWH zGC%_R%b!Md=xnSr?RJ>7&mKefa3CHD%y=^L2J-%e@&DdbIJ8Mf!4Q;S3=A#DSD44euje0gbH+G)o?( zWGvw22!bVy0O*UkR+!+%4r!HFe-B1~B>p{d3m-QZ0h;^?d`f~lA`L0iVW{AwTi5&N zHb@9~1hDseBPrd>-v&uXI~Nz&3S58}hbaC*s_lg+Q^_R8wbEvnlQx3GBPAf%JkUSH zjZ1piaQko??s8L*1MJu_Hq?(Xw}q>g4t3C_-^}hhE7OiGWZW zMU|q*4->OA7~ma_#0pP(*T75w%Hto|39UjQjS7^|tDuO0uQWhz+G8dZ{qBFqwE|;f?74Ws4L<1XyjlK2b4-QeO0H%Sv3tgFiJ~_aE z0bCf-MTzX<+x#R5*-xUWyfsb|Hp=}V8Awk7d@mCw?C(pbxd<>ICE@B_4?(~jd6hk5 zyPgos0I~c1Y}|!ePD(n}6RVhZ8g3CD`qzxw0eJ>BhJd<9EZo>YZhXm<6k|K~KyHCV{@ zMjciMw~s8)&|F*t|$dID4McODGQJ(<7g4?~BWoET6T8aj@ ziA*10PDoV(PVcXz5q&%~cO^jh$dGWw*DjW&kNb4;tF@`Q7JU%y@y+9YP$HHj|NRGG zkr}ZA-9F@GI~RNx-z~Ik4J(Feya&8!z9(YOS?eIT8ygq zsYW>4PMNTS7PyUq0LeEAmjbrD4{T$qn?TKh$gTiq)5bqBm1h*QU#1Nz9XN%PIZ_pv zW^ppGY1@5D{ub;vx$tX8ZTwef%?B#7z^_JfFE)X6me)EVnGllj!#$6r^m=CE4l){bk-$h%Y>7XvMaUF;EfMODdsnOsw9m_0+@huU3M*h;4=xq8<&M zpP}AxHA)A8o5bS~J~Cq`fKBsLr{Jw4WCSZQdxG7^P4+&pi7PGwj5Y?<0HQ>O0j$y# z2wgh%+XAy(CcO}0_`}+xF1!WsCFcj#D%R#FJVIkYJ>OZ-0TLt)AkYmsSLsUXKURVvf>GGgLwx#S+u40%ufQxK1Jt% z>5(3Z5+BGnQOxo$Fdf?WN$}Ou6J&Ip61zDRw5=`rYvwccjXu-fc_5vLIZ1&o{TKSW zn-ZC6A(~!&FLI_3Q3T~NnW4lzgK0TLWMGNLI^ z;lSDJu9$`}V;g60ilq%ndIC`z)ZhgvH5bQKguno=hS~u~l+2;JAXz+e!KS-rGXg6u zqEKo8^WUztA7wTv+LMw*tHdM*iOM=#!gA#LE$qTel4SrR5qYgVF?Yfw;yFzzsU0rZ zJu7@i-*nLi1EN5gLvRnl(fF_xK?kA_(Y+}C!NKS%BExO^1IWZYQo}JNgMOo9Cp3>N zMrJ*hZ}7{LTTg6NJbVx^?e`FV!$3-%lJrC=z<*)0KudAGq#rw(>`MA+bori5)P#M( znP4NHf%#9Li0`}AE-@$3H}!?#J5P>P912 z)YW{z>*tC-yS|-eCn_HzHkRT@!$WLCKTTVN?W?3qPmg&;fyqq{A6b7!6Y@52!`-)u zJbW5gvx`ZHfr)Cl*g4y0@EfJAj6Y`+{9y1Stkb%b{`ZDf$eo!^bay@lpj>! z;0en~$lhCySr>2)fC4wKt^j8NRnh~(p(Te2aGD#}9xlUwzq4}ipP#$pPkQ9kDCB5W z7*Ki|>}f}MXzfq@{>DD1RC3^ArHyc3{;&i=0pPO!4F~?XJl;pBAxp%r7Q~|~m8bYa zF-VWnvso+RoY5>+c<1W6^0)bI6IGNpnoy}SZ)MIGGkOO-WmJ)jI`oZv6e z+3Mg?k<+$p{RO`R+GdH*<3fLw1_|(F6#61+7f>b#C0jvyBFZBFS0XKgpotl&9XJEm zLG@73DPdaDdbBgSf1ofV;1DTWCxdD)|C%BV5-2q=^lJ@$4`_A5sSxG?)lemL3UeEp zJ4?X)S>1_j%b=rwAlwe1=Rx(yPW2G8p0SAj)jv2wGk}$!)(e`_5P*c9UpIrU}`rb?XN( z;O?Is3w-q<5|GsP?mG>{V}3L9v9vWaW@N0G;A(=|~{W)BVakNu6X# zz+*jwK)Q!phgHs)$_i>+1Xsv%mRDbpql8_jqPo|u8icZeD9|Kw(^E=&zBkPsr#IuE zC1nX|6y@4>{F|8=$H}uI88%NySNQrpRJ?u7VWpzZJBa{O>Mr7>t zOkH?GI>$4F>^Egyds61bSzF#z#ho?1}}7@cysE2M!g2-5};*tcLOsJS&Jo z-_0EMA;No?Z3t`6*V7lT-lvK(1b2#2>rmntNPvr~gPgh`;YGW3a6{l%L1L`l9$zR# z<&P%rXZeIhpOrWYDiOI!V&uik};GFocj}if|3k3uL#J@+!(@&CS3;1kJ(GsTbgqw5% zT^A-FNT=Y=ptI7MOqUaB(ztHF;xDslxXz#^y5|jT>m;|3wCiqYKb2!oPru9XKe#Vl zcUyn1=rN?qCJlf6|?!H$G4eNJ>giJU~_!2dDGzzBv| zu{I8KrUEf`_*CCEHsSsh!9<3?sPF+##AXID?hsr^`M^%(Lp@~%Q1{GP4Smm6!1PBp zl5+=gE?K7?p$p89fea_^DMTf*OemKhfF2@*5NX`GrejpoJ1xCVoPwf5qoACL-dBxV zLKVQ2Se=I5Dp44sC_#C%nxonfYe$SmAQ+l2>C;iQP-D6>>0qc`I7ma8@sxu7fbD}6 zu#4+f{7NUy!laR9DEEW%K9d|;YOa>QH<9>^#F6bTWLOL>HtcNxLkc0GyQ*VSYFTW2TOwswa}J>B^0hIpv>P%DO=K!9PuTY`2743AN@agvonIk|{s4kUzDpnMoL@Uk%q-%@Nuo z8Cgi!c9H4(Mux11f>g!+z~+L4$^Eb$pms9ko|DHzn-n$-qr?b#(r3Wqz<3Rf$0L@o z(Aq}9KnqlN7~`#C(q@WiD%0qxZTa|0jtGEm35gcX!aP(~;E1+N zpYl6wUf|X-Sog@|MIvIN#3mX)A!1M`;!2~)`-!-PE<{e}n+MQoDLj$aln5>eCEpKd zO48#6!#}u)=Lx0WV{=X2oMLME!!fAI5$pjo2ssC3ig3uN!N*V+p|%rBf*8hrqKE+W zW*J>78zX+=0AUfh$+#b!SjQFbXs2@p^2>%EydWeJg;S@d=Poi3P7Mr)l(&01lGJb5$jB@$Y%`VZdEpzQ{DCK zL_&5TrU11$pF_yApTQBX9>2ppND|B|tGsl7QG0p4KG!SBTO|R^E7cJGRN2RvIuc7< zr{#@N+K@rCi-72aq$f2o^s$X<#YesF$#`bK_Q*>Aizo9DkHgw@8cg-hXDqlscYDK0H3H_&BjX^LlvT zz*VX#`g_X@LvNl9m+po=P6C$>&awO-+N{@z0)(Ic;_Nm@u|zG1qL-bxF4GIhKqkVN zNNZhi>$r{cMdnh3enp9EN(@*;%h4udMGJp#agB=o8D|9}0=iii9uN;$;?owpz;8Ac z)Td)(2FG-NROlS`gV7Y-KHxP)e5bMPw!2H<@H=~E?VpC*ABkCzIJGivv?^0T{F>Og zUUTnT(E7cwzyA)Su2btNgc>84vSM&S1tJKD{p2=i+V&sR7@*~`V19pr!Cu>M1M^_9_oC5u@W zHLi*=_Wbx&uJ(~>QHYXML^I-xHhQH$)4zw^4j0WKlkF@kB@za^5wnlHoHN_mR@H~P z*m}l5Mh-X;qz~%-(Kg~sp*nQAK&5H#oq}o3GPwIMhH$Zqxe%qVOcQ*xvo1ZpxWO z@#j%1Q!Sy}s$Ga8dpo($ksC;!&F3V{i*F+X)vPM~n#$ z0qjfWU?*UeCH_&wTHo2DF*bv5p*$n3AqX*KH3%zm6jK{;9C@@+r+SH?e0&P{df4o{aj;-Z2j`k(Jn}YL@{stgp5c$LP&+txA)ZYt?&IqLMGyJ zMk8Wsho^#;T1mv~7v(pe|h9_3rHPmL@Y=@l?0 zgJlSOBb_?ILlhG#YKq@CGKwNNhstn$Pq&Bwhi*kVetLL`u}BZbXFsibdsWeIS17S| z8)}T;`==|y#<;%dJ`)kKDP>Z6{tgu8H>bXi+wy>w`F*DO zva;PnD%{;I>%XBcy<<5jv1#0SWA@d{CAsYV?Pg*s<{L)}C$`u%2}Hwf`nU4D5Ue_Zb4?+d}ooEo;EU@{hqu`2To%?|7{D|9|{-8JQVbX_!Ssw(OON z?Cg?RMp8z@zG#SyBoVT+JGNw#oiZXTdnTD>bNwFA-k?@V7aUuCR={RQbjFE`p)O|@Bsl4rJMef^DxqW1?FcHbxh;r& z9c;PHtc$Yv3}7HGA)WTTcK>w=cpYSR6t}k z_@=CLh~mGmwem8lNDU1lm>s%SWkAP{Y&1kjfvzEf6~ZF{{ntQt{eJI6zEl zEJc}fn89LgsD=Sdar!D=hI&Jil~1dl7Xlv{tdw#z4;a zMHq#6wv^g0;~?1MNOb|A{sEG7XpqBhF3_?h6XGEksnYm3yEA{ zrbF23|K#m?0o#QjFFE}yaH>-B1MT99cL-dk0H+G7=x6~Xw#_yWSWw~9pe#xW|9ANP zz#bD2{e{e?XrkZ-I}!C8+gfm|F@j)0GlK^-pp%47AFNNJ0MI;yA{~HI<)ni@>e~Jm zn9%zE-2|`7NhF&_IpXm5Z-bLV`%w&JDiIE4f1r!a9wKILqlgBCX`l!LeaBHyY+X9+ z&&dG-BRkUQTG%&rgV>PcZhi&cpNa0@_7x;SU)MVgeH6vW*XwI=yWNFmTy~YsFCj6B z9BIx43V|>Cl^ept)(Bsq*Jb4|gIP5=#Nh=tFem!)7;?DY@sB_4sB#a#BBB;!qZn=! zdG1{+KMb!h(5I}JWqa=?^bC(d;3fQKx(Ifn4)Yh1{E2WNQh{-~C|KHL2yxhuGpes= zPhW5WLy4D=AI&{DcBIb_L|4z~sB~75?06@ydGg5Dal~Nr= zz5-B6X;{(i67sOxFe0U=;%(P_A9BDULgn|l%I?gE>s8V)uRlX~y|ONYwf6GrflUP9 z7h)QJn!aPoD9LhJfqx||OzjH=gIFRN+m0@MD&*&K2@5(EQIgMa#@Yol5wn%5*wb3x z`#BY${o(@lg5badH_`y0aIC+lch#=ipEKS_h%s^hCou+s4MFbp*bpE||w@RI22olv^=#Ar5c1Jk!LALc-x8v>$muA#|A zh20F$UJ#K0w0Xq&W!$>iRY=}0B?UqB>o4CR@z**i?7SGN>I{4 zJ%iq9FONwWN8~XP0IeIJRa~km(G4fchvmgQ+AqBJdqD!#g<3-F55+m5Ti zTP>Hq#W0V4xMjiN!+SRYIEZWF0FOgmse4%__kV&fv~O{jS6-pj;YBcCR&lGs6$nd# zun4$yYgDW+hHud$KN2)1fCDzb*+2_@Aio6(!MmVXz1@{F*miG!lz6;16mGqmd>mf( z1S8skfO=lEnhC|fq2!73Z$&^VB9M#{T=9b7Z;<~>KsA<^8&X94N4jJ8-J|sp`g9}Y z&}X;+m3ZJ98#pq)OhM#u-iMbi`5{^3=;gKX&66bmPoL-TN}1k5KdO&c;n(`#1OyY< zd4@YXr3#K|=z4LP_U+vY+e_q53s(Rot0qxUMkddRfaQK}g@&&SX zO}!dCSkwBY`251@*G$3>yB!O#jOP+E{?Cc(aUTuK@JWCh0N7%fV2bKS0!oo^)?>@{ zpxW9t*1;ab?2gRU&jA(d2m;Ka1%wE}=MV+6ng(MfiV_RvpSAT*NPFY+{_WY@t^VRy z8gDocuHfU~Fg%qLcMk!ighOMQoU#`buRXOq4j_mp&$5*b3=&<|2(a z+Z1Ks58xw{ZmUDk0Fd>OaIvAchB(Byj^x;a!-=E_)C}BK>e7lh_!Fr5DsSc8&f$dX zybQRKUo)GKMM2L_bL)vlvtDk6&!ke$hIHUO#NiXD%Orub2c-b&_&^!lpajqyYFejC zVBiGmiJS^VKTrB@yj7p!Z5QYm|dpvbGO>*FB&CirEsY4qp+Z0_I{_N z2X&2`2a$)cjH914!2V=FyM+?ukOipjk#EjeCCct0Q+$J=;Au)O6jB7mfAls|+5RJ9 zfR{eYNGAi$TMdc)1`eBHhXiNki$ zQ!wSBcG#%bt_i+P!%Zahgq97C*fR`mHH1zMnu}9fq$9Fm4dlX~kgC>sT)?;swa1Xg zS7Eo8R8I&^kdub(_8yb=VE-m?*4JF^2^`Qx*xL-!paj1Y|9u6{QX!$|IQM8J!)4fK z<#kzp4=J%xw^WI&qK>eBZVXn8UXTxvQGrF1{(tR>dxLQyBorj`M`smJ-_6voUzYvH zO)0}v@ZHFpz1kTT(H6eS@YOV_hDV3SO1l#aKryEWje^e?ei>jn{Cf&gN_UeGwbl#tYY#fc|1}2 zwRQPzgc$HFR?oo^gPzvOF{gN){fjDuuRvc*HPdElUQvK90oGM5t@!!X4gce;Stuqj zEtkvtCw%bKZ&vNxQS8vX&)6dPpG!YX_Y6!>!`C7hXs9D(wFCIVn`- zN*N@i7>0u6kQxWNHXb6lpW;p>&Ln}zGz_Tr?nPiWOT_Cs|Ncz9*97F6_d5f+FY z@hKA$iJP%ORu6E?E)Wl~UpwS`Or-u1AvNJ!xr4>4~_b%=9cTZ^B66>Jzjtne!Fe z1q4Bgl(GUT_UadP$pke4jL1p=m^dq?&q7LII}a0(%L7DQw>L)2`oJT*b%IJ5yeS5^ z>Fo*@^!xf5Pm5ANaUZ#scL{oGlv)oWTmS=S>fjE3RuKGZ86GM)F_6I$UTD{lVxxEf zqar}iWiV6F{uvm>yPu?;&G!xNs{WNJ#q2N$1-H=i?qoc91U4)2v9T>aQUDc$nqQfP zmgTt`X|!`Hq;{t^Z1WC+bs(Fb89rZs#QBS9!1yC}<{Ywh0%|(l#alPMe~}q2pZadS znQnX;h_vRzmO$e8Z*A>sMB4%kYWd4Pa3=#V2whgNKeYqbStVXggO!r^H?FI~g^e~N z{%SIgr{tM0)Xm6(BLI^qG|1Im+fb!Y^fPpGKeN|&0k!2&YV!4}i69-Xl2fNUpJ>V{ z69pSuSZSxnOhA$#v*=E3H^6w<8^)28xrfE`{4n4V5lQjYef0J6(`#*Yq^0Di=(x0d z9;}HoZT#J*=(Y-cm3RX1`0TiZXFp|rJOh=>yv&a?9C(GJfM!!2zYEeNVE-cRpFe4M zyfig;pg%+?!tk0S6pFKawEk>+VC6o5z>tG%MRIWNC;~REIQg#(dhRAzm3DrK+;|7( z>8-1qV4@=-`9&k~!wpS&D2hIDXC}o&I%i|;s?cbjd|l|Kz>hB$Pwj9R-S|y(>L^>E_@JO#xdUAPH(WkaiTBB~QIp zN@$qCM9G^f53AyKA{Mv1+deN|V&0VN<2nxxKqKvR&Oh!y9gyGrDQM*ATV0ppzgi^} z8*7_L)u{Xb0f@k5Y!5b@m9Nu&KbFS`TFn0r$4r^JV$UhOZijJ}i zertEUnn2^E!X*X8%5*ahh7tM2e)D4;JnTH2+-=Bu{s@cXhWfQu{X!8D`pK)oVi7Q2FK6VOG5-q0`M+dPa^)&LX(tYaGwKf!b=qVjxv!|y^UiMR z(KHe^{&9}oy6&qBIEQ19C{#Ewhu9PTCAc|6epvGy@?J`qTb3TB2483nG*nf93n2~) z=~C;0hlu`rIa9Mi<)0t7FZ|$w;E^-sr!Uv7DidKkUqJN2!}_4xr(<|d6;M*@x-Bs} z?5?IY0RAB6B1ZoMK{-E#JNfPghw5s@F%AjjEkh{sTWgko5|p#=Q9rd3hPL&2APbf* zs)DdE(wHFkr!qn!1;82hqKGPAo$Qc*GD46;g4j;P(I+@EL#X!enA|!aUt=KSUe=Wm zf7U0-v`<2`kYNr)N*m=^%GY_5GMb0w3|a+>Ij}~pSriH+#HNEK`(nmn@>3wET&vc5 zsAg>hB@C?goKyb4BF-@ly^dyTNgd!BZ$TDQ5YiY)wgQrksEzUA6NjB4QV-Hkla8Dy zl=u{uw9K?T3_=J{9qT*052{%aakDTj<{4kU>bBbZQDJXlY7jEvb!W z3OdRKaxECqV1nbQ!1ZjVkGyei`AI~iPxKTnsyB0)CfJieaT2cB`}q%m2X8B(R4!1w zP;dWO@T@{D(kp}ipk2O^)mNt!l&T;pXMkCR_58cmSF`GHzCq?_v-b`3^T-u}jN40v z;DCNZ8w;@OZ!BjY7{4Rh0qri?Q`OMK;kJBkO0DZ+t;>b5GtgWX{#VE-18l4GR@+XY z#CH%a+*AUy=IQr$!KHNMfe(1QkP*%?dD307j_?xuSH93}%}I?`1=efBmE1$+!=wW@ z|BoMx_dx7n_f5N~kzcen0YTJ-(5#F3rGpPZk-8LKCZ||DwBkXDL$E-UN6-f;t z8w8xdZwy!S<@F+--${(ji3!jE*)keC)rz6HLE~TorMh3)Nx*GQZzwK| zXczr-vOZnrp;qISaUu8yZIqTGMS%G05pw*AEKUIb{BFm8;o8`#lXb}R1vK4(G&Wva zOZbe#GHzxs8N&_+5{rl9>4&8uh2yegoS~^|G6=2<7f*#i<|5&hN;t@hQyc8w`EQga zwNi`eOZ)o+Ba0S*{@Y1?42Q}t%Ie5L&mY}`#&7Tl<~Ov!{NLH*YtP|r11(8NeBO3^ zZp>SU)sHrcd>{vCb3ywV5y$YAkDQ3en2@e=Zz2GR$v_mQ|LVh%+b6id4cy5OE64ROR-AG2R%Rtry=fQnXSF0%cGafM7w{!KDyW2fzJfnuSxv+Q~7e?U%2 zZZyI!fS78AK#xjLi-t8UMN0e zZ~#)i7wzSs!p~WK$yBqu3`CmhnMy!Q;peH*TqD8*G||ZQ#zy(-UwglOAO?{CA0|=s z31D&nxL-p~MEx+u5mT+ZM33`@vi(E2etxX?PUt{dWB^(YAzum1`Im(U;aFS4^w502 zlIFbT^?)HtBp$&PEP!V}4o=Fsdw6%03FX+ch@U@b7vW$w} zpBN9-0k{5m{}&=8g#*mLh@vs$q78thNcn{T^fdV88HU1l#Rs;b1xpm)*%CI8FN4bm z;Mqs;8mc@O9A0|4D$73)qd!Cm@uyaT(mMO=$LyX579(9*(=)S8a&E?>TI<|R8mATI z`_1lM;nQjn4&QaX2=N{_K0QkEn_TQOAXbWe;r>pW|D<0hXq0=7fmVj@ZYhxb$TkNz z-J7E+vX7AZ6gV4D-@_6#0V57pnn0?4r%J<^KeTk--za3Q%Y<4r^)l;fW`NQ#$X;i) zj&7iIfpv^S5($+znOc&f`vh%Gq!*0AHHa9LvHNK2_TD%d^6xG}khGi@yahG|pQ?j~ zs6@l;@*d{*SOGmxcef>&dLszft|!_*K??*U7n<8af>99Kv|2D(onR8hnST$hPd%UY z)6Z}>%8Waz?o<3!0m*x3+10MOO`T>w;Df;ofQU_SWZF6FpS+T;_o8~^yM@BsrP86f zEr)fhne?Qf4khSz0B!{}H;PeE9dH5_j@?BXc%NRseg$MjD9aMqg=BD|hCYWV8a!*G zn5JVhA&Won!_QZjV)Tqw)bsuNc%)#lGc+Yl5FCmSp}pCNI~o(Mtqy?`h%azY5{g^} zqpR!Q(DmSMzugDZ#N39^OUm3~K z3kk5@qH$U|5F;34#Ux9lBtLYS5r0q$Mgn-1HibZy1J2Jv2Cc@^8xlsENr5rL?4ykU zg;Go!pI55LiImCY2$z@H7y}I+7{@;E%5qC7Ik@{#@4i}mU*u$Mbc;{>5s;ZivS(RC zhD=H6POXh{NF0ZnG<6?Hrp}zc8%HKa0AEPam!@eYOLDX(iH4$4c<}}iC+l-?#h_JD zYY+0iBbXarS*qQ}{-}>Xe+bNfzqx68O5j<%^x)wIuGX@wXsWpn>rSH6Ce0+pNrN(r z&2aM2vK+BN(81aQiSUHf8?sbfp*B>|oNs^H`WmBsNE8a5H{jR<0Xnbij{R-Rzmou3 zwq+y%^kXr9fimqfSrqtO%18%fU52kb2R*cC9|%uS><#@@H~JSv_Ad9{JYECgIg5D( zSl=mE(IQx55?7!VR=uohz<%{acUWA+Rq3vd=EsuL{pY`Cn^Jw#kLq|04xxs6&?I*^ z>T$9=l3#Q-PY?CWY4azcgQi#mkGD+PWIa0z=^B92`Y;Px?6W?vPm0-hb2>bbKNq0o zi=Fi{*zuq`O{}g>`lxKaGo`G4pv?AHU>;|E5-2m94>^ivFu%5I2E5?qJCp{pGsM7P zMlSxl$SD4uV0*GaAS#0o5?*e{;4qX8o_^ps*^ta!YWZ$RcWqo3aD}5Cz^eudeDz$L z0yRZ9Z1!|ZgSe6N!D|HMTVk&B43hWXP5H^FBBMw0+0hKf)+L>P8MX_@5%K$4SZ_NPmOTyJW(HcDh zNg_OnJ9aJ}6ADg=wzrI9G}xNq_!s_pNAQEGPt^fjWZFd*OjS(b#lo(EG$IXBe^93_63KU` zvju9IXs(u(k~{NPmSqsDU-ch??U$O!*sxSsw^VrPUS|^I!|3xfR8+y0i^A=ovCR!N zaZM9&Dr&Ei9*l(}@Mg^Z{w^TFP2+Zmz*_wxhC&w{BQ5-PPrP1!sJey&T{p6C!*|Z! zqBtU@AhEiM9!=3?WdTbxj4JpPh0tf(&=ykmYjobfDy^qKmzOs0x%{vZ+#AaGhk%83z)7BZKX#>Kd!F6+%VYufgB}OdBJ$ zlf%(Yy6}qza}DiDo5U6*u*d}pRR~g{0}guYaL_iMR7+YjUzw|HE?_JJqgqCDRw0a_suUYB|J;L2Z6$PGxL z053SgiSiH>A8cKDw+)#gW?t!_f`1gK6i!hA`!mB8;{I5HShyP* zA#Em;@EA`?1Xj}bSMd1GLToebuoA7`MiOg&F5A~$3*h2WKt?{(7mKVUU}X8E2cRtX zT%`{CPO-88&(Js)s!h*jc%5LqG6}RnKuIIRFboz@lr*cd+GMiF+E-X<9pufovy!MnbttA#kkfZ2#olk zSj9)iJ5mR~3&dW8YMDwD$026yuX+rM{(`tCfO#Q15OhugcQaA|#%((eF$Gq0mKwWi{U>NnYJ9P`voRM=6@*m@q`d}(J2JU|%Dy5(LL=uomTz%pYtoF>New7q8tN zgPISPM)201!SJk83sflZ`AM17axa>R-yO)c5#&%6~EZNJwn>68sP7iqw!mFElcYDE|QG2xK%sBB}s2 zX7&p1_SC~xx?$sj5Qzl(S)_J`4||F=!nX_hrXw2hpuYk43`*&!;xoTFy*>H`!bTuj z-*v(q+%xC}1YQJsyqMLKn_fZA*kVM{W~=6#`etxzK`44K=~wap%URG2--gdgAvh|tn8?Ve(tQoEA?ViO|EvB#aj&i^M)U;A z9Ev;y8A?;aJJ{v}s@3h6dtPtRO+%(QhzIEgat`Sxm_LH!oC>mY05opg^oQ#Kfjpq| zUWE!rdA#m>?2a5#_n;!rsiNrKJPvv~W%!xEGd%H1v}$4YF_F9E+&3$Ne3XD-LsQ+l zDCKAyXt1#j60iU$@#w%J;|eG>CO3|vW+l+>HSj0!bf>N6tOsBL9GnNlZ0+9?y(k`# z`k;;KeE1|>VsGN}7u=pbTt_rK$j`wyf_y0A$B_|Or4%OBNqHns6_rpaqgA7of+IQ(CS_S8@5ym4RsGM_`{peqdfsL#AHQVw2`DqEa^GQFW^;0uR*xwFcrL0ut@-A zO75wPk^20G2Rf>dq!+VJ01r0WiQAI@8yW5=K&b-9#dlhVjdjc$t8)e~R>JS_I0i|L zNNw{F=6txtezJEPK7Ads7)f6b)6)NJ!x#|IssbJn<{;^U+P8{g#L(P>kW;vwf(g6+ zgy(<|sX#7ju#>~JoZNruE^s`8v%3%h6XZ$suZM_2*-J6xBo42*{|jIM-_Ja5Dd=Bk z`nF4u{BJn`2v`F0{Oj4jvGe@A=zg#HZN5m~4v6)@LI_JYwam{0wo>j%FP;ZfqqDeK zq|{Q#ziFi|3&50XlrAT|ob<%S1T362K05C1PZ z1|&w6rmu2-TIV&JHDxi8Gb1^6F4JK9rNEZz75$V9k7X~ z_KPk4O5san*FfebS90KaW#JI4s-W zh+7!Mtx#8dnHi04Cwgr4-{C{CJCUEphUbcBGwRI%o ze$nl%QFIgwcLJLRn1ZHq=XtO9f>`JO%*l0ZZ1mi}*{`@$r=GEd^9+6wrn#r;8Hxvc#HU{xD4tsH19TJ!j? zL_<|jz3XTKNcOKuO608`g^gtJDZCQ>CAU$gnaO6pEm=hjUTyOJ}1rsg>DYtHAG zo*;QG3R{3~_XbIrps*3~r|hGpf*W9Q<;4QV6sx7kYc9W@YxPyeOK{?4|J;@&s`%!9 zBx-HSouqQ_;~5`Ny@LtjB6;g0^^?1{-Y<8U%A77`7w3T2ee$DFfF8p)zP#4Hi}iyr zU=vBUKzssh-FO7$Li)uTQ01Vm1Sk+%)QmsB02m>q2Gk5iBSG*aSK0%{O1CP#!+ZK8 zI1pfQb-nvZKMc#e`Ff{1Q{tuzI{kr#Lh5;tPXv$0O)erIu2-TtT8m;(h4K;5)Zlml z7JNjTTGf0#ah_*b<*z@iq4J6E(Jaj!;fBR|ywj*TAW_lZum zq&7)dk+li}2-G`MLU@?Mogm+i%LN^-tl2U z<7=1?WN)ph<+J?_VUeJXgc}WHhmhvU{q<%-BHNE8Fvh}RCV0#6}* z6HsQ>_i3?D<2g#1oov{+-65VwbR*4R6y=el6$ zke-J2qepR0Z3i5HTyEYV7DZ&u?5Q6wE~U{xD~zOD7u5rt1MTEAU+?ZL$!$qf8g9hu zdbDO;@A-3ro=c+bQ6&hvU$#ax$T@Lu*}H6@ge>@~PetL}!rIIi+OJ$354impEkqKY zTE$}c`cn&x9fo8Igs>n*WyVKlQf_M6hjIn|vGHZM4GRoI@!PszL|S)Mjyrymt3I>! z#<19}>^6S3I}55Meopa{ z;TwAbO&^_~i&O>YG4rMk97(w8}Dt6TOuP?G7;f_;2GskfALoX@jE66-CgfERKU|H!=SrX&lgt=(WTB|El{NIe2b;y1nIMn_@drB zDSxC#8e+>khO9{73CZ>fjhLCR1Y=y*5NHcfloD{R&_Md~oc-hiFMcQQskmfTqb5Z& zjOYX<*Px%Czdt1W^=xK3M2_D87c0u2fue&GCAo8Ab@CzWKF!|m*HBAPT4e2eGuRle z69{H^Ens#j21T91=I2|JV)XkU#stxu;1WIYP)@HrQ!iIJQzrMb`aUS*qY|agj>4G4 zRW|jEr}*dgK71_AL3bO35 z-1Ui?yt8l?SV%BrSuiMk8$!;(Z9jj3;t z>x|mn3+{VEqq5C*DEH9;)=KPMC&pwB^c}yvxwG@`io=CprpC+EY7@;3K_N6(VWheM zOfC#cl!99IWOH{xH)Erud$%xJ`W8|dq3kyLSKb?F5%0nlP%JSLjGT0NrTK<-9@Z~tNO;3PK&DNUh>FajkUK|G zUr=#`-8V*KuNC~;k;FBi1LIa4K6g6;RVHdozMii=^~)LBCG-j>!X1vrJW=^BnLRUu z5!gs@DJe1!m#bDovv-}`i6=Axazmn=dWu_J9<(>Er$1{=gvB)@trG~h zvvw;P9p7GmvBiyc@jxW8?q}^Sr4h1$h2(jOwj6@$0fI%wczfgA zj%h1Kk-0yfu{qE6>(OrLCdW6>N^^EfdM_~+^@s%q*}O^Qwx=P@)!@4JQ#(uHzjuJn21R{T#Jj|R zZ+_rN9~|^&CBg7H6ZxXF80IBny_))L)?>qQI6o$oCRx#&flZAVAO-I4)aZIi`#K5%f$hLhg8CfSjr&$4dH{5q!o+Z4-&>7`5K=CNN6$u zyxk(q6LS#5SJPb%0n3nd0x$Hc;6HaGBy7EGz7HHiwhi=Uu%PU=Xa|?2!W?+St>^rAg=|Yj@PWVs z>|sOceK4qWldp`=sky^Z2K4HDTa*ukF`f$GILorL1zczvbh;X5^FSJ0ZXeu9yX>n# z3RW7X6sxFwMKjKgXS_o`5@QliMWLYqt+KS;rm2GQC+mT^41I0rmrPj##{ds2OffPm z1=hJSi$;13N9a%Ndxr!Yc^fR%rlmM`w9sFtkUNv!&=OZhDiv^XWf#;P zNRb1b8c|2@?(o=J#oR+NNdMtiQ}z+uDyGM}yyAC>%qmEA#yTxFOcUjZ?QVj@8$EW= zx!S1~-pR9wZGuY<7y}TDJwt&F<8PL@;T4(MTb6ImMkN+(reDwsg~FWPizGU0sXUk! zjqF*9@T-8e?B#7vi>BO*xvExliRw3uDIh*d8J(wqEdd<&Bj7uNe_I63TjW7J&`OPR zPI+fX`kc;h?Imv)h(7rO3!ko#$95K}wjCT%;*$jHl0S{Rn7AHb%N5fFT+Y1crJGbp) zGA~>D{m-k0QYocR0a@=`&(5&qQL=5f94$}>hvNUj1CMZ^)f$24btqo2O<#Dp{o5TK z%g^~jKcx8MX-cWZWjNnyC)XeD1Ngi%$+!;um`(l(Myz3%$|+p-9mx>81N}P+yGMOw z^&qK^WyVGLKqzj3=Ap|8e%bDio!mw`@HspW_tNW7W|4dJ-pvIZOXbPSW?t?(a$_Bu z6?>VN64yJ&5UBAisiq#d_x5knp!E9w7cu@*PTR9=rIu$4+;L)YkeV*o`uBAZAwdXst{wJ>|Wc-mjF;cubP_^*$`vtdFTzh*0856C0Ak~5*00Wo* z>Z~67wkIXiUdQQl8~WJ*AVr@7bGoy;FzNR>nNQ{qBn2SZ7eud}X2<_FgN3|7a$>0F zqOHe}g@PtP!0sEat=a<`#`1-vo<3=!IDel6A-SPK>XFjtF5O*%oJGLHV0{J>?n5{D z&<+7!ARt1!^hmm19ENOC4UENd7$R2}*`9~m^*H2#AqS+hXwd_ZUsHffAwUDB9vEDR zLXg$qC_U@PCHDq9-684db5HpS$1Y=a3rT;3M>3!+^1jn|U3@%sD&1|*3=bBfKuPJ* z5{R6ERKX{4hSj4Q?&~QTIq@n?J-b@_shZIo_`xNoujqyjKjs&Q*5D!m8*C_!KwE=> z&Xv8k^f&xL-w&*hPuMWO()R55FAPI5yj39naTH93XdPowuwF;=qo^EzV^)B~QZ508 z$0^x`batMkcD!c}PCc`FQh#uMZk+{|XtE_gz~r|eM-U0)-z)yEg9DNb>n#yO3ll!# zXuvK*De?KDQ!SWlV03(9{>S;;lUt>}FM}g;=phE-IVIeD9~@3@{X(%nH@BqARR2Bz zc@L3OTk7q7$Dk8BBpAXJYoppvS=qW&QDTP8Je6{98L+}J2@F7SX9?tqCXw4{{6?ja z``Aob#$+hn3Fh2)b6SUrK;I$YMo}Q8*cr?q5~ttc1oRzs$Q#uUEWKvwGMV3aXtgWI ziURE`4M)@wfdU&vfUKEp>TF}mAnOUx-6!OI1$7(jd9?<<9ZyTEU3Nrj|I;Y!m|^*( z0FyY93>H?;ie0UJ*U>V~w>p@>KQXNf^b%+YId|94?zTo>;}$m&F90J4lB~Q{+ z!*%u*&aBrPw&eZ04ADZM|A?^gH`(&GGL8DZP3?-1TIC@zCUC6oZm;7nUD@;xmu-`w z8TkWpKuiMnqYB8b#*?>T*hNq^Twcy+jB?^|N75&cD$2R8_;=h_LZOxr<@W*lgM6^K zTV~!6R>rggA=ma6tdf*rB2Q)>_Y=X8J*=o9lh_t~9B3;iL@T0Yp77m)YS8FR+tw(O z@H1%DfuKWjS5$??NkDy8lZU6EgV43JX>p(jhMs5mlvhNX1At%LHgy0zcZ1hFk4a%u z;i;4Gmz@d1r3&=XtY4fwza6W>1oF>8Bhwz45_4g=Xr@bfLP2P zJu5>AJ8mCO+AW@u$E1wF|Eo5qV0qO><7#4l(eX9Do%kUBi+NzOh<>Q==K}!~=(d4c zu`Nb>fRq>zAkoyew(0MwyGJoVnOtG1T|S*LxMml9Le=mMzyqnCSq2BA>>LGem3M83 zFoiq7P2)!4n+e0BIlq1qyd8#II{LR>8A(1cNV@)o48@_ixKm#?z!B}vgA8PHU=H+P zfb%Kfel&`Q{~6^~$&9^KkMSS-p7lzCit|7=fQVLqXD3%DX3twxr2~V2rL~_o?|+;b zKb3ny&mNY)um#nSlPH&%gQ!-(o#68nG-f0|W97a~fH_}8*60~j={_Yz$(N{Rtn?V) zZid(>lzIpkC5l}d;-chlJJl5#MQwuIn-5$snI9tQ{ZpLmdg97WI3W-rp%cdp3qn7E ziO!K!Z&>;81%N3fA4?819xskYdwu{R0cwK6By*n|LRIAY6m-J+H)${wC#~^B>g3eC zwC+1;b%Mi>3&+1h5JS4Let-sH4!Wc#ORo2Zd}&)7kFuN`HS`J-XX_Swo4GYh^$@MNg0%GW(5{D_$(cQ1bFmD&}4Okl! z2wB0Z_hF2AeA75v?jQ-QN}%ai{Pn5wiw(^Mf5?J-j=^pZDuAdF)-SLKsfl-ifpQxc z<0*!!fNpn4$FZ^dlUmx*+hpJIjrZ?=fB$ZLRj~A3S>EANJHgjO4k9lFxG%p}YH)id z7|JIke1C#8A%Lryrc$Gejz||5MafB$@zOr_xRX=H%QqjAN40K@?!K=S?TpWy%;9}7 zVzikwx1YA_?z8hNiJF40ATp=IRN!*;p5K`J+h5n_DmI#W8y^00F7A*M&HA3QytlAy z^!|$sM?9$zVXAnoW7t2p{f_b9uLoP4f(HY&2l<43MJDp9y@*bJ3?r!{xog^!t&+5{ z>=^wsh-^BM_rW}Q9T65nx=Hc&&alJugW=_2w?(J32hs6+=k2yd!=rBt9LRgfh0=<- ziqjO>C74aox)|-e6$6}+Rc!N!Ezi7nhytzQpM7uh((-S8CLc%`J?fcV@R3iS$-NmxJ^0k-uyHH3AVmPI%sP*RDO7(b{QV|Yv3qb{aVw*0 zW$A&(@br&(-kH(42W|faP4XWtX-X>X_fGtgk{{_Ec~AN4>1$`amz*`bIqtf=aA;H0 z9(GX0)eS_ly<5jQkehq%KXh|eEWfx!7!7q1zI*7;XkT7#v~2iBJN%Im8Ltid2+iB} z@8FM|ZW0mRW+i-z7AVT+!|=bx4xjz6zkrpke{8FtBA<}XR>yD4CvHZ{DwCUmeQUZ? zE0l8S+;hg`en*?jXY5&(S8Ka*Z)BBG`E9c&8TkinDtM_^mv<7TUk#OiO*-C3BMsiQ$$9l7ed&hQvTh>4pL?`WZ$E~1$f7KF}6vb&?EhT*=pi?4c1!s;;IEuH7 zFTQa3&09A+SDmOGsSc}i8;TEt;?7#j{F$w-N*%`w5?|Xxr_Xawy~X3HuD$BQ8w<~i znV8;FiUL{(<1!pZ{o}m+14)e%MGr#XAxs!&BN$GTa4v40f@R9GtsdxUn*y3`Bu=ZEgSh=U^NI_j}-G7>EN9B9p zJ5PRODy6}j~DOHY@{`^lD?kIaLc9Xd?k8(w`$Iv%}B_94f1wo4`G$NDsl z^xkCN4ky-NC7+=)lCkve+0C}DSigr#=WWkb?UpAH+W7uHv9{PyC+s~^&9UzZ4W=ih zwsE$9cCS)s)I3;7{r-l+cM#@yA_LbCl%;L~3ndva@6RDqZ zB*}wTmBa;fKWP>=YvJX4#hkvbzGc!kA5-xkKl-kF^GW_3%f5fY_tq2@yW)>6@3i=P zXceCjE(-8V+nQ?WT#S^SoM5ppy(@IESIK8KX?kjXGop4+@C`han{2GF&>v^o$zweS z|9b5+VNdD9VZF1(E^|EuIXW-g8FYRYrG|3-mMkid8>Qd4pjY-)^7H;!eD|BZL3YJc z>dbS~U;I5UnlO*;&Y!fnplYkUsk`}g|549LWsdxqbKjb&i;rDewf}HJ^HoxxMBhUr z2-XP`k{9#uQ}LShfR~F^wotF<3*FBx@{?-%5p^BwLRDA&#iBdCTMCOky}?d zs8Qz~hG-+%mn_&(m3i3$A)ZfUGWXm+dwJ;@8VSet>M)YHp&PdK*0R6llCNJ(g7lR)B!FVz!!$y$;p^T?I*9sWR+O_?DKa2`WNVV(9>>}ZyE)h_&FzE}L$MTpt z9K)(Ga?=pftr8jh!uPs4`YRs7yc^<2r_qF+~;`Ch|oXZFxfh1vhW!gFF|xs`UzTjzJo$H7tf z#cx{z2fT1;+<$+Wz(?4LC-D3$c%$me=@`B#$={@Ae}{g1!n?CA*o3+F);K))Q#9=< z1^3C|>Bg|{uhUKw{u1_0JmG4L&h+zAi(XF)swlmn{x)efYf{wiR%ORmoO|r5Lf)S`d9Q4*|gG}`xbEyPvZ@-@wf-B={U7O8WE{J9L8G5LigvVk;On75 zZ$GW$;f@c7i6Dn<*E!wejll?x6VK8K%5c|s$-+8&tT z`CE^xl3Th=Q`_4Ee?WkeDttK<+^&w8}hT@vht_eo<+~ly|&RwO#F2;d^KPHs=Bvn=auEid1`qUak^ z13!cCcANL2jqkb)6XyhnPqy|vu04jE0RG_!9EXE|#u z6UP1Ay=ZHFxt-YmgSzZRIpP7y6xsA$*&-=k_WcOUwvXp^+oywX^Od!AaK6tyD*HX} z=f_+xIlaGEn-rV;9a1hVi~RDFJ{UJf-Bj#2+IEvY!2 z{M77Me7~bPyEcP%$VjWzQbggg(GxXm*S=)8P#(7m{>U8SPf|30QlQ+5q`eg{_FZOe zuw`fVp3~Pu7hIuuqlZ<|^LbaPu+J*{;iadv9;iRMM=XD^_M!Iy0m>IKdYiJpVqb8J zoRU>y_J@Ry>#mLE9f&U2T_$@$qGZKD#wnC0Pf6JOsQMGzuPj;l?RDnAG>i(nn_7}L;Ibo8uU{fa2Rgs%y=;)(N4Sj^Kbp>Zy^j{{@K9aQt$E;M9yU6 zPt{|Hlx)7<(RaD`>-3=~Mk)3C6!kPq)<3SXjQU;+KQ}6P&%&r^W$$s-{^$Og?4-oV zPeNZc`wasrGgrj!Ky*5~cM34MfhYdUX%7thh83>o7kgLiY>i5?%Jy)hOWju8tAyKs z_IGWc6&FePVc))<(n|fbdxZxHis$1Dd|KFl`aCS7`{8k2!fX6F?ce-WnAXQ` zeR^V0R=BlkRulRr-=fK~0AnPboh9Ei%!{cS-9PSQKrh%Ac`Qku1CxBBf26zC&Vs{f z)_zTFWj=X#GkPv#ah_POYwE6V7&eFh$#S24(@)a|y(zk{XlkX-{%a~{{C$jh zbe+Emo+BEEKjk3OVm04T8SzVs-j}Y4fLhJz`NS=oy>iPDSSV%RYDF4|WraxqBp!>HEJJtvu)Gqegr0tln(WmPQ@P?L`XB{v|c( ziiaQe#tA+-fD0WTwMHdp0?kKQ?5uVGr8XD)W9jt>z!~L72PW{d%ifOD8mC) z>WWM<+~Md-ls)|8Fz)o6znkWDZ^ABW;9(RnNSMwWlcKrn5e^d^)2knZYF5wj<&7-U zw*OQ5{ILwBvGYltj|)qmd)la&mrejb3y+w&<8}leeC`b`tLP zEwTz5#@*N}6H;XBb7SLIgs|ACEuVP)p5WmdpGmG1PFf`r9cp&S+b&GGazblsHSLkx zhdAMSoOp$@<4Fen8%nY~siIz4SJAl%vP|f6J<81=ha((H`tN315Qd2#etP2|Sf7-Y zsoOxJ7e`V)e!8q!*}|NnFtw|BZdhE2EJo$T-$R=%;<{FAjgq%X{I-+W=lN4edmcVP z&FhPZgO>d1JzX6%R-&Jiy3bkK&8E=B2Mv$G8p+m)MPr7W)^MH&<@C~0=QC0Yh=k>c zR&EXWjlF&qsjbYR{zg*aQ6abdzml-ATXw?z_GmsUHlDxwZY-g2^0dLEsM`@%%;l>% z;r7TuY;kcK?L8P~Uc*bv$XOgidnq|Lg(|b4d$4D_dgEYIA|X(kW>!=zf|}n}Z{$c9 z?Qwm&Al&)O7fTH1+&ZYEw#WF%A5%D>%Pmmp2Xyv7Z}sRv)wISbFkpHk$F{s)3r!t0 zuKsu31bG|GD?C=~K6o*jE=d{tC{pLK2u61FtRaR~mevOt8b)V2jlqPoHh-M9bw0&K zelhlP|2?C&HWS)(kF9?v&5?Q`vPnAZSXnP&HKz6*nc(r0PWz=Dwx z|8cf3i4J61yyt1b;j^e75BEWCUY?GtX^;GEwybU!awpe6dLPX7Y<)W(;x9yeBAFNo zPphsdYGEej_pO|@XFv5{_x88_WocP1-TgmWDs9eg3-8bJ9+$x$-t3zQ4>>QePjFm6 zTa%TK+DWl>6Ty-&HNXDz{aj4lJ&Z!sOa4bzV~YW2__I@}@HbT5%1W4_xV!FXN0;G> zt|XlmW7l=z*=cjvK~A+x4oL;%iN4O22@h{5E0csCRSMena}^e9q9dn!hL7DOO}>M} z=Mv*_9RD9nR~Z#m*M;v4Dc#Z~9nvi&NGVDP(%s!50t1L3jUXZ&f=H*N(v6gK2+~rb zNY{7nyS^X((Y1#0-gEYTYHtdv{Tp;gCfrKzgCaAC`#VS(R4eUg@>4dQRXFcu#kl^{ zSqT3v1)ngSU+o^%m<-_@f+APPv>F7?&nHXuscQfsxQ;()ewc9ukHH;*55>rd>oOJO zB1K*g9@>6VR`+(4!yi-mQs+{vTD973cTZTgCZ_Eo4FRb(o$*;IjQG%co^jS#C7Uw;rz!D_2wWC)p|``I_qFAOqyPiRR9|BUP6eY;`%$r z+3(g1!o7bI(7wM%#66(iP-l2tuDcdsE9PG@9f_G84V3$8%K2Y=VeG9Qjo&jW$&Uw6 z)tJUAzQI}8eNR(-Gn-Q9W7piUT5OFCo1-T=pzL#330NrAf#WbktKKn_#*;JS?fl_6 zrlpa9s8g5A_OjQ)HES{MJ5HWBP}}NKC<@#uuXVgZ$Gpl}A2Vs3DW-9{6P7IUhR69AodPJ7BdQeNQ* z|B$)EZj66m=KW&5vGr>q0=m8lK6@6-G;QpgY2>@;&54xrxvaqvBw4i4T8wXx(aEyV zYj-Me7H0m|S=VIn%F;|zCu`h7Ts1@u9Bi0>03D&&99OIpR7B1a3$zOht`5Ze?>{a4 z6n@$+Q9N;bbC5H<-BjE~hsKWsL3&|~sSq6oOFcpcmk-(*85Jfo>3SL_LWQI2qFR_R z7dbcmEUP3F_cw+U;we*iB%L*iv?%o+W^%0`gvYQo5r$6@zT&;y@nPJLujPWSu2C8Y z40ya=Marj_r1@%rX!P>eK;Rez6z+q6gQ#_DIvU}$yrbpZJtJgA)`nQ(i?Ol|IzT}x5Pq7&BbO!nusu0A>^GXD_j3_6$SK58 zirf1wCDEwZbY9{LzvZ7ZQQj18Io#}D08}t;{AsO^O;HK8g{5&vH5JLD2*zrrNg zr~vpy3PRDnGbV+g^CgO#o$}_#&!9aHRNk-?t}o|H<)O6P0yOUj+q630P09yu7Tyez z1^X=QLQZZTXU^r*?dx<$bXj<$=Q`xh&6t1VlI zF$!z|dg^Vjqz`_2#oNd5bsC?t3hsnZbALt(aWnm`G-6X`9x+axz&bH#Ti$c`d)-gF zXMtonnqJi@o%S!HR~AbVC~x#eb(wDk5BtobmLnt>M&2h7$+5bBjKn6^7VwbUwR~2u z1|GX;&rX(vP^yaLz43fz#htC*sO6FyFL{%zkGNcBbW#iXyK=(xS~ZlkWBkNv;y)^C zjO7Fz%_=cq(JEPW&Goy69^g=1%@)3Sh#;fi&~L!U2k)cGZT>?CuugePJUfbb3MDxUGw$#4mOS`8|oW$I)?pZ5?A35S= zK+jWH?PiPV%_Di(mjk2X&rM@~DrKSO7T_(B6mh??yZ2$$pbcXV0U@1sy8Z;IYu8EK z1%hoq5a(c=S#eD}a=ohwqhmo1^)T~qhYg^}rUkkKj%9URwFjP1PLIx=N|p)SN}6of zBpA5@F#lDoIueFkfDCh{q{BGCGIJ_1G8FoZ zq{KKCJC)%mz3kbcK>tvoDwX+_O&vc}#u1W>@H|_&l zJr?JDV&kJuLZ~VJZivM^;QyfACw(4^^xAtox_6N^%L(6Ab`$;4;g?wQcbuYy|LF{N z9FjiQa4Q;;`E2~D9o95qg8|0N7-(>FIN@htY00-_F5b`pOib`%#>bm^H|s|!mnp&h zP+2!k!=;wTt@Q!VYlbpZX&Jk^{ytZw4O3DS#yTi-_&2x_LR}D9#rpMNe>Ls(^|mb7 zPxTWPwVKHVNkDUP6o?vOpTWL|1UxVikg;U?A3<)>6ueVH?BI(7*{}~(Ot0O8-4Xi9 z5$q72=a{v4m7d{mm;9~_Mj^dIo(0W6m@Be)6>jY{iD3c^hm0#ftVaE z^Yjmp2DsMHy52Yqyq^%6;d;;Ugp--zy_db9fcbNMH)Gs@dzA!usmYJOz_Bk{00!gY8Ke<^PnylC;8lMFh| zgJ@esNi}>df$+X<18XDJWT!IJbXi!t==tjVH{Z}iV8qafEX|*;eOE$ByPfSB8e9>X z;*b(+O{5a*ME6=nS6+!3kB{AW+@ztke?Ypl)AXfbb4A#zEiR{0q^*C8@0 zxlpAWwuMzMh0ZW4-9TgDo`C`B5+;0Q+q(i9t7}1pYQSJQX3!6)&2;tQ65s})DSWieai>?ee8vi}nR1&Vn}!ag?|Z3b4G;(&yHbc1QeSPAst^ha4Ug`V2YWtBjaF!DQb z|CJg!(36!TOgLSfPBWZDt29IBxz~O2nG=E%Ry>D~6~JqaD*j7iF}Ajl@$w-(P$RsA zD}iy7mg%0YESL#!&+|6Y^bFv9}zdWafFj_SkuU z5=`>LF8Czm3HIaruQ&HKbnXRLe6G>LJnr@q76mrc2!Z5v7~igLO3wPRWNcSK->PK4 zczgMa?Njc4%D2<+wv^(4Vj6t6R0%+q82arLScwo}7tM&=X#wJdwNr=-YV+|7dENu1 zTGHMNozv&ARMeRbo)tAKG2`dW-AJB$vbl2@qOEQd3jpbSUaEf}bocqb(;J|*z_(ct z$c9nKO<<%1SgH>~*js^=-Fl8^#Y(OJzSY@5lqa%;N^I7N7L#BMJ$(Hjx<>Ftj<)lA z_XHm7HKUddNN)V%Cb0fA00Ct{W%CUG(!BpFutVWd0Suw|S~o;UAg^yzgcz$Ul-Y-g zj*)(ZZ{`|ORyw1Za;KkX19xo|AkG)Z_M}zW&GO8rf-`x;6(eQOA-*DU|m20$N^G) z{Ct4N>PW}a?I+ImejWty2$onpfF`R`v|1qRZ;_5O2UoCN38_42d94JC%neq4Y)0;7 zyw7sH>A#jH{tk=-)#i0J>%d||Brr&qNSEddS8_q+rO*bXe525C5tmI5^s?7Ir22b6 zO^50D)wc)=$q9@-(3OA1$!;i8l&&zI0$ZA!@NzMWj8&4JV(46AfqC&(+jpRu#b9we zD&9_d-7ZOKmhqO4;M$QGIt7wlCmp%#@}TSVv6%@FDJF(u5@A@LCnf1F%FW-w)cVr1 zpjd!-+R@UJO004nJwTzo_tqq)col1%s1XNn5Ua1u@;Evy{u-FE{~D?~4L|NFjS@g~ zXHVNBh&Pe@k!gkrv`^YF^YD@4@pP)CT|a=a2VX{jt*m$5;>(Zd05XyC(&API#h0|r z4ktoxs+sfDIsf$q(UI(acFH&wG=rJ@)%Q!aEzn&1?;4e%a2E*;7^fmu03!-2jeKRd>KxGk@jbZ)N@9F>dB7xNOgA_o~XXnf@{gl@6GkCAapx zyRLKGD)C6Vc~$qbyYQSTssGWt@6uDp^0< z5qzyQ)W---&si;#g`8MbgRSl{%CQKh9@+4;OGGq|q(?YvFWf;?E8^uBw3CJeFqqj* ze?$x|jC>#%&VHDRhR^H8vFi16vG=G7JPa5uSXQ3^ixWAIcAM?(b%a1F;Lu6z^{T&( zziBN}kB;5JR5^`5Fj_fucF1(1QTl5$nkul`JA_+EsGdM)xes&_*s}QXu~JEL*rY>v zrx*i5ziFUr4J1N15q=-K)S*7_#LHY+06jBv7Eq=A9^+MTk=&Upx(5ZMN3_g6a^-!VR(q1<)MhAV zTk7{~BZi#s7bW8@NTx(YZax=KC2wEYRV_9|O9SiYnEtJ$45c4=mPDo$%c4!l?%eIK zQ~sh~>Vt{{O@Wwa+#|qPO_80VQ5P@#8ym7wsDkdznPsD?awEWi;9UR&Yw={gjYW;R;`PzD9hN&{#^tSlDvqd2_DUzvoUa#(;EafHo$Ibj zyaAErb46Ro&tlQu@`=XxDY(YN;RCt|eZ=iNwp3oSZ>7I}TFL;Bk%f*Z?&yIUwANi8C5{FX-=)?lSc#tEILd4P{$vYnkNl$|ZL8 z_6o&X9+|Q&jX(b#Xl7r0MI4_V0Ew@!-c*(ukPUm9*sU*=Mcl|oHOUM;Qhc1zq1BF+zD?T`zqUl#IS@=!qnXF3c_1R=r%e8L5RE1X(<&% z(D0)Fx z1ALR>e6)G;(Qmz*q;}W9KNxJk?Xz>v=p_^J(HePx*kr7f>im#Cwm9jds|K_;*bJ-g z>r;OxXPg+C2Bgnokr|Ncc>P5Nl%q~ zSO=WTzKA&D$tG@fqvjV%00t~F*#@8qFqzrOFqKvic}O&lSlsX3zyLg5P9kE`g4t5^A_C?h(3Knt~X_&dK*VE zc90K{>8*jnB`I0cqaL$4DwaE=tTJGWgKg%eOwDL0?RSgcBrH*mlq}m-yEO`crW7E> zVT|jc<&)yEGz7nBs-FffJwR(v zBhJlu5ZR+{sa?T=J#}0yp2{BR$qP`F(E1oqQzHBU$6FlbQIB(^%;{iuuvrxqw&R5U zvk9}=(b}fVENKGflYsBSH2 zTNr`>hId_i{8eH_xPr&2HZpcKoI#Zg1IOzPtfl0B()n8KSC-_GN%O#++yD~f6UZjW zs|4`1sE%o|itp17&hqZO3bvIem#LT(`tBbl{Xwo392pD<=-|4RsT)5H3XIK>qQ@*2 zPYA(vXM5uEJtqf9P8x$6<7j6mbjkq*KuMlW0@i`#8z#A8a1+gKe%Wy+bT^XxBS=y3+!w`L;WuNa0=;ME56E;_7a4Nm|$%`U|Q*nfK%_u$AJk}pNGE23P2R1N7 zG;L*tiC+BpsTZDhAt$Zn_3Io#A5-s^vI`9J!`0safYTp{>ZT~XqmsEp86>kv|63q4 zW3rgP+(TV$rpp`+>jqaBy?$8q<0%_IsW;tlxd;TBG_MOa4j&h{1#=4f6E;D{0p@NT z7Ea)+`~Q|0q2xx^3K*=MKL?3}wYG58*T)fL9A(r`%&_hoqJ+8U&IM2daUhim{^2FcPq8_V;tWGtkz2?EkgY^IAVLJK_TuTU$%^4|Paj4bT=2@@`)RPl5(@ zy>+k{>Wb@ObIql54s=5Mc$%S!QYJDhg6eN|K$48cwExH+*|I~`ma?_yY7j=xIx~-fY=;$IFjvOs`@+?5z<|S$ z=2U(y#mXT_vO`pk(Ipl*HqZG5dRKGW1aDIl8e=0FHx4Rg{+vC;BE~Slbrckw8pill zO=)TtX{}!g>=U}TGm&~1a;f2x49lH%1KYYzX15|(?;z+=$m9(>#nWaAzna;E^+9o; zI_{gjlt)gsowAi4N*F;HXZcdb2aH4@ z#%4Ef;k}e}nOd^oIbJdQpWWQF&lBFO>ExRNUKRBy*P2(yT6+yJ8nwYa@9LXIssGN0 z4z-A&YI!b@^*-t)3%*;cyyN(MBT-&QJ3o0{u0Be43P&4~^H{`)!gCM?_|w%d3c4K# z5CwpMl=T%a;&?LQa}Xs)6F5!3!c~b?zv3Z zjT#ENKBii)jZ^%$K?{WBWQGUy#n~h1dk`MphCDFH)NZ8Dd(hI#dzMh>C4rPJo^<|Ps(jDqK8(A*ECB(t)0A; z&_SrTtWk-DwMabAiC*;Yx4ljUvdg^GLZ=>+MdMlW_^(4xARu~_3{Abb`0&=-KTtKQ zXcE(Vw(3?%BV2!YF_&G=B zXs*Z66(|01806N=)17vQz?s0T0@h;w%jhuw0paCEd4lL~EM|BjAZTzHG>8MMT(LiG zz;Z1JJ7TYTXJN~9)EF|M3KJN%lw#L-#sYE~<3PHFQ@7iFqQ~fUV`yF46~%^H(?ib2 zxu@(x_bE~wHp+3}g8A*Mgk|?bGW3lrFJ#LnG7Fmzv}&}^X@!S4ayf|KRjx`j$QCl3 zwtY?YaRnfG7Qln!47x;sX@|w$4=Q+j2$)fJVd#E0$-4ht8!zwb-9(+}PSzem@{Uue z=AaPR;%xh?VYg#a5|fE)plF@1iLosPvhjE*7?vFYjT-w(R*Rps0ijPNMskIz?&}eV z+kju)$?Zx8WJ#_=IMe}jvD#C;C!RQufcgJT#)e`3E*z2u?xCh=Tp*B3Rh?`Vq89Jc zd4qE-d81T627=1jOxXy zh!IW{Jv31;t&3wS72&_bT>d3xerG~=M(c1Z!Y|7x8{Cp=0LtL7=o%cE0aHsH(LWp%=Gh##g_G@jNeSfy$CY|aeW9R>v0%{Ryt z7!Q#=d}{QQN7?`;nx(~^F7h0 z%Wn`g$qP_cu$WvIy)OkYA~jjLfNm=#E+B!bK!k>~Ja93G-6VL_U_3dX9fuZp)L!v) zIc*MPy3>%qcl(g4sn)Vr-M5?iz{9ERxsDN=F_8WKu%8rDI zMmq8Y@73YBt>2SN4=)pNH~}WT^UDT$&xeAOs_`mI^}%ON|0RTA)*VL&ry4-&-5QJxf=!6A1X@`6?SG&*is&58ST_` zj0mf^HsX9nB^ITp9#xD9o&spFO%pFW7pF#RK+&#v}4JXXP(i^@F0QU?O=SZ2cegfpf5-Rm4 zeyE<=q%MXvC03CHf?VMBvPwo+DgOYe#>pm>_MipR%qqlzPhM0Z54=!L-&Ed>^BE0x z-du0G*ZvMb?OqbvxIxz%i$yE)o@Ze<_Pm=a)ya`7O*L~{J8=ijAN<$}7>?VLwmlbNFQ#W5MnbXCi6rA zzGvM#_Ji!=N8rfrgaLnpEE|Z?9i1F~tLp=)r|pCWpaE{|Nh}IT?PW5Z1GlPy8S~pJ%>zfyUGULQPf}|GqO5fsBWSO?F{j z7yxPg)}wEQ7ri8;EY7y-RhjtPBz6k?k<%agK-y%Mw(pp6CpB`r1!s=hATL9CgI#Tn9x zGe7$xxy^$}pPVMZ;rR6D*~q6OWqw&{>UMP2 z2XB@)GhE`0>ac(YuAcHh$otKaHvoTjgW1N!kBk}hvMWfTNaCKbGrO8hvD39be4}<1Ut>2tBd?hNZs;8fyO?Xq( z@6=?^U%DKkL2o4Vt5P6wWp{On)2p-!b2|zy5x{R@w++5bcYMx^3Jy=) z+$&*Jn2y>?$RPo#Ji(i;B0=cu-vzR&TjkoiqrH+O+lJ(;tV>=5Y8ur|daTb}^>*a- zIth4xnRxy2>h#&`55g|`24;$K1Rg6Z(-9IU_ocC-0Lw{*!K$< z`SZn@)7}>JGRe5*FLm7EK93+jF4(!7L&*93+}>ISQWz0GTymuejeg@&xY^XVd9Veaf!~I{w=#eNjvpOB6v`XOs%WzoNygM@ZkU@6-t42xGBRfs1k-Np)fj3mcQEi415{+ z{RW6q6J7WhtJ_nG6j)zrKmyC5-pQD!*+*>P|DmBz;n~D|jHu~{9=1S-&iV$4)=C11 z2M2hc|8|&~l-;?ppxU%r$#&_|WT-HJs|N4or~#S0q!EF1lrczNkS82`0bbrG7yJ*5 zW;g}=-`U2%P;$XjaX(E(L!ve&)%Gf88XJqa{y#m0c*hPiL37NU_j4^c|M{k*ttRD*$^d$)f(IW7kEyp!Ui9L z%mC$!?XR07S>PiE&YHMb>1H^&cHU_OmDylF*i~ucRG~a5I9r(sB+g`S>z#2deiQ~| zr2WrAipFb?TOsJlQtI`Twcep-*2Tqb`SniYn|`3W^S{PtvoYZ+7~YPA}`!@YLG{zQA% zb~f^j(@pGvJt)6dpZ~0U^|iJ{Vyd?ti48V**Y_B3{CtXib1IF}L7~<9D?NT5)!GeG z59N9*tmc;HhAtJ_6hmpB+%h{J%|2!b`3YJsJY<1k;luX=907{|BvV-ag=fR?RjSg- zMbQTf7nr`T&|Bk=I=HzE>-~9Caj*W=0Rs|;s#iq8$eZXgb|~?mpDxN{HbyE3xZUk> zLM4W1&s}sCAL^iWZ{ljpG1c#skolnQJc4Yg?=WnPqadXmI9~CM+#vApn&_g-9MF2| zL^s++)M~Uqs#{ULoV@%tx7vs0Z581&>6kKDPW3+Ajc0fRMt8^-S}9kzAU^ad zRl;JTZKTFaDj8P}Iq40glW2j6yZ?AXi48BEYs>1&YJ-*{B_joNGv&bxa7aUw0L{kq zj?+u2?=_D=DRppf3SNZT(3TG=?mYk*x7^&uMA+2M2P5jKEA}I_X`0da59D>?Zyyic z32Wl&1SzT4#CLhGlU4~nH#==WBN!2n{}GE9S)ijYV^5I7tpJwt$^V;I zgq@(v9oOr0_$0s*)qkQYbO44?wg&jGA$C*goOaol96!@;B@G2&5LwVd57GMSWAQg> zcKJ_2z%-mO)mSYe2!S?qZN6kjpOpTT!K9yk^&u9dcV?O5%4=R0FI!tr-a8h1cmpye zUg43me1|+F?;5Cf&257#_B!?mKB|+h5k*Wfu?N7$Nn(eJnU95Lae@7wUi)=W5q(xP z24d=Y(g`M6e?Vwt^yUjz=)iyc23`{I5+obzW0kzRu|+ZFDgliEgs%ibZD~ub=6ODF zdufkpE&)QfZ?%T+8U^ZevE)7i0B|nXY4=uPkpnM=nx5tdsZ@xwzhmU*efL`CFEF3& zu=(~er(Oa(=W`jBZRubjuU|M0Dhv{8*5_;a0Jo=(=b-7oH}- z30!nh@%iCY+Qvn6LLVC;e0JEklJV*qB%E~5gF!>`-+UI#cetq};&K}w2+=N4AnDJ= zV*4m>#PZ8+rM>j^Fumz^BlrwFH= z%Citu3f)%wm4LN7l{&mxuIJ<1lAmjs!hd=6s-?d2exz97&5|d&2L_wo0=AgkpY;0d24o=Njs-Ix zWr0XN)$p>&6g41&0qJ!OPMqCz*+rEPNwTe2o6+%8P3qw~?=w*o%8=@r;e{2cE})4n z3}=}FRw+BhvhxrP-1o5Kjc#rXR~(tXUO_aDJBwqe0zCgD;;mfq!iVxIO3KzDC~E{q z(}LfW`KXowS&$^nAO!?1Sn*A3r;&5uNaPbh@fQY-V{Z)xR9l@CSE>*R5QIUMnO$FT z{@L|M)1Uob*I*h2)`^{#`W(Flibneq>MKq>>~wteF`~FJ;2#fv1l#%;!!RGPKFUR> z^~iTOcK>Eq>!Ib0SMa2M*u2|Y^-m3svrQSTQcNBt7Gt?ZjKarWO6BHvEOq_a?)V4v zpxh>Z<*VQubELlmMoPD=F}{Li&89QCeGPB5q#nx|_)nsDJH3M4qgKEu;ak$z5 z7aE^)%XQ%ca$HJbdu5R56mAzAs&LWcd&I-=Oe1wu5Mjs!_6h@YWsPIU6wj-ZJ77Ua z7I%i`!r_!6F2CIosgH9CW=J6>KY>|y6s-`O9E2kIE|?0fIBE8}W;*L`GeD>s&P^6u zI_9q-fdU1WRKXJRK!=N0>-l&j+AU z-&Sc`<^Px~jQas)0*gC1Ph?sj9;*5U6RRUl8a~^PiboTp&BZc1f{!}cz^hjmK+R0C zI%tEO*nlX-&GqVjG#>^G^M-_Y9M?caTf**Y%8J{f?;5wd2({1UmM)nTXSgt3tvqJ;z9I)O`pY`=$kw1G%5? z*}f-F-U&6x9{&ab;6zX@pJwEn^Yu?-9X+{i^xfnx$gKAFsZZY}s&xz(0@3#)qDq|L zsq5X%Xi$1@)<&)eThDQgrM|EpZjhxBppdI!!M;HHp~A@`6)VMf;q`RtGgvmqWbb-f z8XA-AP8?C1`F1r%soovTguqK6eiQEKM^=`Sygym=(RGq{v%8M~J$?}229nr_fAlr4 zv}yIa+gp+^Fy_YTU&Lr`ol}K_vA{_bD{#v!>O8)$zapP@L>DKdM1l3Mr-C-ji|x@~ zH!o-TUxn&drON^hg|mriKVT*im@>MI4wmnEY5MD2kFB`|9#j^fo= zdOkHvgK{8QRF1K?Q|I0Fuy5gM#$`0$BPX^5L#MI5<@S8j zb*7>^Dh$qBCA!?JufA?88f=)sV*zY4omQFcw>vS1^<{B~18{?)i~s)1Sf#D<{`TVu z9%+kNQ`~FDt5ttA^qu6c2Si2OpLY;&fz$i7y7s^|6jv~P9Pag>J3<3>|%3w=H z^?G%#0*se9ynUQGdkBUU;RPh2oM0XVHCZsTd&^O0;3`rH)hN`Bnw8Czy{2!uIj+gQ zFii|vWzYqiC$?7!no8R7?3^18?3#kmr9RN&C%ZNLqQd+g!kx=pa(~)8?WPcRk7lI{qhWi`PT=%%d7Uh!S-Uf! z*^CJRZZkPa%02{Sus)z!dVrwcdbn@v9bm0*<2V7SW&jCtoa`W414j##p#|R=eNrbK zAmjwh#2HpujDRL+7VfenV_H)2^JK0{Oe8U&80hQEqb`&`l?L*``X3zb)<5UHBHpIr zP#;&{mesl|*tbx(WBxV{H!eCnI_Z4`d1r{$EKOm8fuUIuAHmZgtmB#?TLj6m?aW!M zh>Ce3At_W`=V{uU(z{bNvXu8CoqdGKf&6k`JT%W$1n2xFQy_8>`2jAC@UZwu(m9y_ zrho@n3qV8tXVWDf)(IJmp7Xd-LZe`s?qe6aXP90rHmU;D;YI>@^iZm%i5Ms(KT>@P zH+s~CFmq6|A080s5p)4(K-$jzx?=*(G9Ugh$qfWYCK$o2jh}u9G}+0PE&2o7O^pXk z@z0yUc+$WeX9N)z;mY*2blKdj_HOR|^j95CZw9!mfrbpy8_6JW@ziL&ska^5EqJDk z^oQ6FA>6_-X=oI|=v6i(=j@C7FuCQG=>Ts(UeJ#oo!~fL=mv8MYt4TKDZif5XN>Xg=2n0lhui<406IL~$!dLa-_X~v z%uxysFR(x+G$^-NB*`wMB#L;F@(TyzRBqyg! z`6te<>`P)9e0v$E4rb%wbqU$^p_2A@|2dM>RCa!#vC+X)ps{;kB1XrB7mP@ zW(n1JM?q0eQqipClEAJ7o4}4nE0+a;x+Zn^$0mSmSXi~-7j*Ax!5Qlu5&|dDiKvS= z0VHYTUzKq28nPR0^{l6(6n80%St8bpmwy=Hq>mJH=t zOZ-IDGA=sk*yoHrJ`0yJreO}cV9g2*$2~R1OspAbZD$8MAPy4h@yZv!Jx4wfWoIAN zim@rUYYJ2m;FG@v$4rYvpZHd_GpdAZT16*c*1C)Wb1coX+eZ{0mgYT z;Saa?MkYBcH%6-pms>9yl_7Wt?jw-hzko0uXKmJ^xc@PLv-(x z>6n=2Wv!#2Qd@wtnrcSW+uQA>+%IyMhizRIHzX@>nIyKSaA80!N#|Nc;xyhF)rkoK z_O%E)m3B9s@yiP^dj+g!cx)vwYoZkcf+w^TK1S`(JoZX`TlsO&4)Y4@U+YoPW>Z!7 z-1zZnh-Fb{`>$bT_ilv)`?nhq_sc-Cy0<0Y|M?mmV?@aJAbf_L<^C zZRXNnk-zr)b8`an4iMBJiS&BbQ7cEecUUjH15c%0a$eFpk6YuAoRp(LfA{JgPlcf} ziDVSWV&TV!ysq>#4+!?y`)e|r{Ak}=uOitw`a?hIRE5`5$T3RyZ(H#BNUq_T=L-;E z_?q>;)j_<$f6vv)QvqRR*psC!Opg87oJ;?E>B8Ey<4~x+^b?Qod{XSCJp;VD4+ZRy7cO(Ca^StE$(Cx z9+vA{F=EaU;})_fD6vohXDoyOE|$AqlR-cu}`LyJ8XZBux0!O^}Pbf zgk;-Z1Sq>;6_~sNEHkmLK8t4=)1DDcj3P%pw|=0<+O+qAQ%P45lT?mr<*Go#!_VLq z23`BWq&=sFuw$R1Sc=b}I&h)h0BFbpracfS?u>jx#_4&3o9b76+f4byHdR&}iWds! zhkNY=&~jQ{kr|egG(pZWEGgSbq~Hin|!RNKz!Ld z_H&a+!pdpyl`a^~^5Y%4`m&z{uXQ2VdqUcgI$j;-6+|X2B^V-KxvDm(=YG>rwf(M4 zhn1yoZk^R&j+t z;7=r^J7j2^PTn%RpWgXOp_5J4fUWJT4Fp9{{+8zY3s3qoz+>aN6obb#E1v7W59k$g zFT_sQ&S`E8%A5Bko8t=Q5<8+bZw**+gC!QSmsgsB^K}}O$W)vt;dxvmhADfQZE` z+rulvJ#xBFiwzc~tJIQ+j-i}f%jngbzU33Z*5bXX|_!@;Dn9OJ{sV|%IO#!3^V(qNC?bX88B!Dn+> z(J87z${VaK;h1lBSp0cd_6VYNDl)_ z`D0HeaG{0Dm2$w|=lTR>M6F1pRw=JI>_T2C!S;z7)caLSd|qcM?IgH zb}%4_=<@N!$(fW-@0{bMSv2p>w^O8OTkPMpQC7lCX~(A9l4xzn3CS$dn0KX=<1T#- z`6NT~4Xyme)zqYl79g7<&1i0)b4k@+bw65yNYxG7mp7+|J7kJFeR^;cEdf&xs`Xa~ z?xCqEZlUb6aU?utTPd|W6f^0{bn+%Oeu3nfn3-67OB!p@?(A6@$Gia=&z)%tazaFZh>!h@^@;a1D=5HEPq?&ixw=37Ji%XM&v&dT8h|+ zi3V)>y?>o(ATa9FpX=qD57T%c*2Fg0WX}HB%%GXd#>9Myy#ewpQBK4>AjHjil1n2t zVyikmLbVc=@(HV7$#vr)1|)d+44-{+lk-WMMc?jc0Ib8QyLw7V>N&_p80EFzZG9-K zo`3brt-bNJ9`>_Jui8IfebzB+Z3&+dW%HUH+gM&>7TEGVdNAFi(##$CLfW`~cNJna+$1uE{3Qxp41^O|(n?c=amZZb2)IPF6)SGpv#pdCBSQHgP50bY*1S@ zqOggU_mk%@h#4Kj)V37=3qwazN#{L#X+4GBZ(!xQCD@eDk%PH;Vt{MZqzUc!7Ni?< z^PupZ^G*#{1!#P~;Zz<7H!L#ZQF%)_XGreWvFz1&*x=@ZFZ}4#lRH= zng9$UN$g^;|5PWCvbJsg=3f1tboZvtB6R{-lQ{q^7!qKbTLjrW!z$a49{v$0R!X@z z#dsk#<{y}AE(b^5h3V7q>fUBPHcj%8gaa#=uxKEHXRh8KrC3VN6`FSE1ibhnXjeTP(DS8z*7;ER6PKHem2_YSx;i`~>Qf(( zuaLUxkM577-Zgh240tzH6P)r${bKL7ed-Fx0m47D$;tB1@5V^?u1>tJ=ak?orJ0Xc zO@t_6*&ne`JRRe0=0RZ)9$%rEwUHfAd5rq~x@A_gySeIPuz%VUCE3^y=I!A%p+2p& zmtN1N?j~=V9|=DUA>Bt@Tja1YD2Z9;QjJ(c!K;=Y4vJ)s2U&7qA6xtOZ&BowmuxF2@eE=7q9{8OTtw`~J@~rs zSeK0v#TP=ASF7N4u{&uI^ZJ;RoEZ1)Nx0QE(?cCIj z-yo$uPB;ZAp}V|}RB67pzUnC>nq(7r1KEX}czpnVryxjwB1?ce_W}49@KgYZAZMNb zqWaICfyKJ*6uGE7beU;VBux}rH!}VIji!XAPhVr2SE&nI*Yugt7)@^{ybtJSB03)5 z$#R$GD=nfT_E94qP(3pFDWRu8FB!am?%YvKXUxxraoTa*v4TXFMp9!E;9AX?N6BQ? z)^&T=`}3rCNkogk{ln5t`ydcJa|ey?t#&1(;cuLs#V3o)SF6%PTzv`uE9$)CsqWwS ze~y_I%H9eotCA7PDnd$RJ4R-9Wn~^DyT~SyGBS?6w?bt_IQA?fvuwie`gDJPzu)bT z?#KP`=;nMr?{Qt%>-Buya&L*jwyfnI7tB#|HS4vy`j+Qs9U7JYR^PYeDK!o|7DQo_ zMEYqi*uCLunCZkMF162(mVGOksDx2lO&e_#Xh(e>T7E&1>YUN&Qg9k^O#VpsNe^IW zU+#cu_^>`d>BnB_{muuyj5kwKp2rE>wRQwq2?Yt2n~P}F<}HmJ*F3+zieBxTT(uoL zH%xX|7uGP|(+wrKI8_kiX#0|OY~aPu92{;-=>EIW>3J0xp+eX5L9(f#D~W|fs4)W1 zaaa7JwfRGD3Z}8*cppb$)Re2f684PUd0C>{8r#=M5B9tw#p*z*@_RljTcgJ9{#0gW z`jg@^QO9t%o}|6PE=5l0zxUJ~R{nD$z^MRDEQ0f z2)JIx4~fgUnf;V7ZXarMl-Doj3`%}lNgkxWvuO3fh|gBcT(kD@fkeFN%yQzZ03y`X z(KFvlw8<5V!L{a*qss~z%v`$I-aw)B3e!J-m+dFZRVzfFvc4uv$NCp3DeN)~dHVy( z!m<=FMgU*CYI)Qn8Gdl8?fI5^YS%f|8@7pJ4@yu?j!)Ko-(D(O%f!3IcnK4%)-g@> z5dVipJZunZPwZGHDB$V_Ydu`vEP)*<#g{?$JqUm<5onfgN*OcCcSu}7gbpI7w(yb% zO7~Rs1v;v)DM-Q|N|$M-^f8Q{j>mLd zJD3+n6%h|va@Nh|#j6zcjCT}$=|UF~}W(p`o?G@w%a5hCKIjpjCw(`}Rzcbg~Y=rEalcKt;c-L~#8@p>iuR za-B}=O44Q3ltXDFJ@DyxdOdP{VMj)@)%-DX~o7_$-I1$ld2B8Fpa= zr}@K2ZLTZ9b(rqrsMyf>VP=g*SXzSKH)88;_v+S3&Bw+D=#%23KDFF;zI7 z1yzw&*(sxWkRZG0PtS8zavKN}{@f=XdRkKc+p;VC&gnrr7_gn!8?BGhmiuw}Hky?S z!Rx>WiS%|6FWgy%!OZor>?R)S+HmAC;w)*?>y;5?)m-OPX>s(#w{`C$Y)wan>FeUR zwMzEHI5!uSN5vcN(%ohGEXQRk@9E_8h>Hpdw+T$ml`-3URSH(dtk(|)ulD;;y=JXr z6z7hfw`!(4D|CfGs1pAL_@fA;4OR`Dhzp%hC$8x-p_bH5Q&`?eOmH}g-s&nc?Rk+u z+#pZ!>_f6;xY?WH*X^>iPl!?9j)?tVhYwB+Cf}4RrrAbFt3E(54F|8reY!!wpn#*0 zZcA2#W15rv(IDZaI7C8~FF8!n6Ui_~?!t@H4ug#`7dPOl=1i ziZ7f=%LK6<8Ys$zI>`m(t{6~`05#Z^WHo@{4SmSR>p|mWjoTk(Sg0JRqK(W&mi-#Z z&9N5U@?XbB#G*>Jzb>Ha{s=CYTdEfbezfXg?hsy|bko9abYSkfiScdkJVl>ijmVlM zk5QD1`GKo3m{$)g&)gRx^n`cIAV(-K#Q&AU_WE?X+gsUDsZl{_Ytcq}Dw57Fxp(QKj zu5s*C9Qrxm3wO@Nx$?$fKlY9-n)f2hwH zzagb-=0Kn1_QsNaih)S4%q~3eX4zm@$Hb6J0bS<59$0V2c_ow%Db$*Y;Dr8*1E&)_ z>;6rm>6cUj#a9@Dqhu)guWR_=P_KHTSQk`LP2mbV59kFymo#;ei(-8IrOn+J{Ul~s z;=j1*J4(#2q7Aip4AY18oLhY>YD*5<#3L`kdLNj=<$TSp!c2xik$|9E1ZoD4Vtc8@ z3nG^6jV^XZS>D&(JMdlce`^N3bg3%OH)7dPkdp@@*wEqpitC+d>O%j zkUt%_38;P>U(%eG&aPC|^7=fIB;z;dHv28JGiO{|P=lXwQuAQAqN%p!x;xJl3%dHr z3hYh$>m3(GTyJ8b##|OOX}i=W?X3jhj`MtX7`6)ap1zy7QDIj;!*l(t*KF;E`$@%} z`kGTGoRj`l`JA4#8uj#urLTQciew(czayi znh~Yf6}pu8P(ow5nvm5Z=(EO`?K+otLG=+|$27QWx2?h(#Y9U8WONgAW}p2yF=)q* z-*+C`cbUp9@PLxt-Vy=-D5^%_>*}@=P&lA; zR0B2{;#YCLbsNX`=dal;*b!toQBjyGsbrUhK9jjj17&rmNK@y5(o%|(Tx>C*JM7P> z1orbU)s!Qmv)HOYZ3XYPfe`=OD`T4-L>S@Za_QWP+Dcyn#|LR@&HDGAuQV8aP5d}Q zwfQKzX6fP7YVtYiJ?ch5$A^yD@j!27wiP&N{idKC=6CI&>V!c)s;|35=wi_@?TM^q zubB(`SJ`&wS|km z4jt!j?NFyz%#?z7BY??0^P6Ton-s1{4EpuNe+_kS;**nl#sVBC=I_ot@{K%!4vvw# z&dUyc|FMm);|uEshow8u%R}9WE5#>D(poP2_}&WkP2&k5glO zhnHMk#f{QdLkQNrKZY&2K4~)G-Pm9z)e}k-_-g!%&8>%k!`sccyjlEFyxHc+ihJa1 z8({&7Jzf|~f{?g;_xlw?idAICS&`h=UMum1VztMO!}Sr$90Z{;;CMkeRGyO)ES{*uH{;Ad!g>~=~V{>0bI zRei1_u$Nrcq?Dmdc0ps+m`5q*!o?5vBxU5loDl@>))|0xSYbgdl(SWmd0Zg*B~{G8 z#a??w81;l&R^-C5YJgm2hg;+1g~R$m3Zdj30xDzazqsO;7_T1&#pWa&-vZ!pz1B|W zcn>RIoOc7Oy(DsXr+rI(+PPB4BXukVS4{KpIaD|2r`mni93H2x^r6U2=Is8K{{4gB z?L)|_D1!Xmml<}o7P@QGzpMoLUD_?TJ4-(#JH}Y_2(x$mH14LXewPovhtzvorsL7i zb&D(J{aGXCTFV~Ga9@F;(Wh5%O?dBlCC~bnihO7FkGrP&%Bv4?CisBSJhV%ARjtFU zZ<>Hd#qs1)PmW0RYI>*aB}A&531d!Xl&yK+kT9?Oh87_fXsG(I+k6&jlE1)~cWW`n zq3FhY>731%tdZfTPcJbdqM6XyLtt%uAmj{bo8wmG9Q7Cn=qX0gUtgo5`Gids(|Ahg znY-4>n5z8}=Df^zRoq5#4fs~u4;Ky&Ra%3!xP3*}eU)iZqVlg^v92^0|c{-;` z5<$gMx{p&om8V9i48JwmmC3eq%rKyg;)xdXBMpKj@=~xkOSikqP82ej`f=I$c|EIU z1|Amw%%NC1R8-8HjN0in?|W|;Orn^Ib`i*mzB(Z91O|l1^2J9L>?i#gPt^L~!=2?} zy+UW@AZ7vYpL>#zR8yOh z*TzK68OIx?+wPuX)W)mHt4|*5i+1o=*<&qj>gmFt#*_`i;R+w>ugudgv=?kG zZ+v88^iwyP&_a;?MP`9_ELGr&*(C;LXdZ%q9Wg$bDTIe)~Ab!}=>}#BA$Mahe z4*|th^Yi1OLpkBDVJqomJ z$)5~JQ2HET(Q$1knlrs)sqXQxJc_P&D!&!{&#?%E3mntHZBs>tmVQ}w*xsXWhn*}J zQHuaPfm%;hMx!%H4PsglbUOPX)RLzbPiEaPE+MB|p;iHqX19hoCs$7zF9;|vgB1-K zRe%|(Mg@BZ`=Sr$u9qMn{jt(6po->?JNoosOj6OA3XbKVA7{)H9&cn=j}y$Ev1L50 zmiUpo9CvqkxI4kM@@l`%b~s$e;4WmO>dHo0doUs8HeK&-z3&*tj15n*J#YRx6NOzF zSy6Yg_8I#G@|j=4HpDk{e#|?17^zq+v=R*Qv}C%R(5bR+y|?}CM?-!{{Dt)Onxncu zrzP_fkH0rRk{ouG8&&!3{Oso3^3cl;R23#S zLANz7-+$btI#cf5c=5bAj5hx)x~>Mbq~P8jz1`m}M;Q$pwm_uNyv5B&y*Up*lq z`d>N(5N(A| zKR^D0G98t08fSu2kl5u~o6M*4gCOX#Da@)j0?s|lqH35HYz-K#Q2Tk1deU#d{ZVP$ zHAsVyh9Ry8;e`QO@+%!8Txz-OrudtxskoD*aNRTl`MqdY z#*I^npf~%&Qt&079G&{v_TZ}A-9LxMX{iE4Wf@B5mIu?itz_Tq&SG2cDy!csUn+K- ze^@B6v;QQ8+Wn40l5inyK)v|#LidXw_E|yWv48$({x%5RAg?tOv z-D$XvFyhk~zP+c!u3~k^W-6LgZHa)SHp$fFo%+Sr;eK{4Pk+&+5Sv;*??*6i`KNk> zf&_r*#xBe=vdk!$6Yc;NH90Yu`>uC`I;;T}wktfhI2gsSVOw*8)=dgB5(}6e_JTUOY~W7 zYJtAWe3DVu?h!9)iau>|KTAF(U3%Jbetm^&T}G(`X8jbK-`K%&R=X#2`0xmNrDqR;e*n(| zk*I)`{0ov1@c%WepseE?U;1^I00qmt8S!(l?B^nTkZ8*9t?b!z`PqPUY7ewD5>oiu79c8pW*B^ZD?8U^E#MHjPHW_61)jy<#9eg7yMjMaHqOEoAO|st3ddp=FfQI4T4nR z(^kq-I-yQC{f%R*YHv=pvI)^oIuuG&Zd9HX(@mSw{OM-cWSp?tGyA$|xsd(XAd4RL zD=WCBi98-f)3#<5=DJs9)#E_tU~aDb%>J*Q_$agi0qTOLF!hCfxSIGIS_1hhQ>?REB5y<7&3}>jOY_`kW zXyppJE?8*r#>2;O}nu^&(VU@^=A|)?zg+wn>Tyo%a%jjChqcB zSiOC)Rc)<6Xk%5oq~1#N&z~00&k?+1mlATPG!@c#NT2}yw#HYRewP`skxWV#btE0o z99^L)x{or|nG9X(+YHpky@c5t?&DWVP6!`V#4`9tr+ATgxo#PwA8hyRiHg(Zr(gEN zY+KrMS?|ZSJ?flUsR+RDcob~vt7Bz$#t+&Y7^?j|MC>h`!D@%=qFxUF*Ifd*0a)#P z`||YvzC)uhY^~8xuP=Io`04|QLUA9I7NMyJ0T#&N4*S^7Y1$JyVti~7Z6|kZq%^@@ z_{^NX51tKj2LNF(Kvwvn2>FR*gTa-68I<#&7u00CopV4-_M1C!!G&5Z^i}>&S%Oy# z>(x-tk?~5`=uG-5J-22%d<^xZTI{U$!so}34kzX<nx0=bEp%U6Ta_aBeXUQ% zo}Lh%gr5r)<9;-s=vM!2LS!4|?w(PvlakN(9+{Zc^LN@E{y(5%ukwepCM6$=Blt2T z69sfn&z46{|0H>+m}QhLU)Y;jt3P!lTUbCOGD#^>%XQNZqwg}+?S*A`7Om_BTL-JV-)=9b!j@ zL$S;j>$ju2RvOZ`|F-rFwfk0EW(Zr))KJt(-3?Rp z>y-=j1$+DWH9&t>fi? z$IGM29OZuSi6TlkGo)fXg7L{u22U>$k81PWtM~5YGHvF~997=mxm#SRzaG*kY2pxa zDgsL}Rac@^hk9FH<*FPbytUTqB3xeZL;HR8AO?RIpV*G8t1j&DO-l(;Rcudk5_;i~ z{&FeXbol%zR~7p;1rOAB&EaFSJ7DC%(g;R zF@*<`sbR>)?4IOL_sYC$I5&2Vg;;TrhCMhaQiN z008-ffPp~qv*`sAv0Uk#ha!xyV=$Nq=sb3CUm;Ep^=u1Kh)X4rd%yf+J_}d>zO(yP zaDu?Eb*U?ucOlTbTSKIBK0=&si0Nods-pN@Md>srB4oPdevy~CM{V(IJ{3bdJ^%35 z?b^q49F!GPRZk=f?^u3Nz>TY%dhOj;eXME!Ojkau>Yaw$;*FCxmgK7M<9}{h8}p>=EaAGx?pZ993+6gGX zrBq)PS-sw9`1yQyNShh+bz!+qnR$DmlBLe~?6307SlK$P-no`q-P56n?VR6h%FFV8 zfI`s|vzQ6vFVSk#&;SIG{ul@bh})9%QaX@P5U4L~>uR5{9E;J&6cZ@qt&K1ZGf+Nr zLD(Li!T&HIfZgMWDn3hQ9tde1aI1<&ypbbHr}fb=rtX+C!)XpDAL|7xRC-lDvDEEy z`oQSEs$y0oD(QYaM%&;WlCzfQ@_7&NyzdU*9w3O`SV)IG)Fh1_A1c}#NuNTnZlOOv zefpTWx6)l=m-cI5+QW78{C3Km>0k4?Bk+O~pxnyLh~p#b9U zj?4#TvX@YwVZ@n**kAirfrj#7Dfbn$cc0g+-w-?j!6L-V2kNFFbeSn=bN00nX|z#fGBXXIi71qY}{Geb30HmugC z4cpHfUfUIARkXdyg1az6o?|~(E_BTipJp_v7P9Zn+}@B|+g0&B6yTFc2NP*2!#eSr zl!>zM7WXP)DEhsZ!R8*Qw%lydEAD7mLF3hRjPacC-4+fy&PHJ~?~fQSg;qF^csxwW zsG_5)rW0G@H7Ie(9x(K9wO8Zi_HfeFBu>$&qnSonPyT za;R~T7eE_%Ynhmfkka988J>ECw~c(&Ho3k*rsrDrr4oE5zNciniJ{Ch$Xh?$NnNN@ zrE%F{lV$d)1L^Qi6koHI&QNLEyv3fw3pVp=wFoLahXXyrwQ`wd@an*ADp~@o+T>7!0#t9r{hGx&r%PcB3sS z9Z)aP1Wn5uB*>+ z{cFv|7ntk@0SBULJk9*IwWRUo8Npz5QGSsO=kf`P*2c`)4ZP)&XN}LBe6mp*A$Or~ zZ?xmdP_g<>%yNw5esBpc_;v5dyslmHgmd)mrIo)iXe`|Q4v@?GaH^u)ONf+lT~+)P zyd})YMB4=EgkKl9yNig+y<8oJ&l3{>YHn{&ZGRxApe%2aSt6no{YO)K%y{fL*5{!bj~Cb6*Bi zE+E+|Kor){i1YM!kbiQ5R9*9ibo_(O5ox#iVDD81CJ)zJ+s|DqNyj=@1y&41c=;@- zCk~HYcb44K zhejgfk8)9{Fe!HSAXdW5$tN3no)bMC-o1Re6uR!cCblNy0;fL`qgInk?Jl z@_p7!vwtRnYBe~&;e)sy!Ofg;^7Zd%l$u&^=q2~urCiO^oC^oApHl@E z9StCqS!gRizez)KdUQD5oakWk{U_iQb-&`A{zZ+h+6*S^xrs1`e4x7YD?z z@PK$`oIy^csNreq3p#s#nlIR)os6qf{ulbf+QxEBB$}G%S zA?=4k&G|n!(F7cMCA!xUg!`l?y^f<)Jzqpu0kN-}^e3U)j?tS2(Ff*{*s-Ly^i0BH z21UWSGPjImcQbECEx#V%$Z_--(BvSYJL~nWdN4=)^P*}*0OPI4x|y$L+q(v;MHcBu!n}*n^oYZPV1zvP`*Hc<|Yn z)O%8M`l0dEC*wT(6WWzTWzHWrN1zAVI#7SNghDrasCV?eCPAmY^K1HxhTH`dEl+)m z+E+um2KhSYnX(gZhQ*yHWt*~B%Tm{V=cm%KE1Qhw2MWuk)B1<+oTB>(77VO3nX@|} z9zar?5YrE^F=9dVZV6*asGP!T*3o+4usu8gctq$qPHJ8&mICyLu^+lLSg-W@DTiAm zPiXlWYYBhxWmXf=E{IDEwRcNkM0^EGE|~{Xm%=Ft+Jn{(%l)2%pODlC>FX@wECH1| z?Ov#moryhF!O@NDBaeT05{oeiQ+nk z8MnQ9eD84M2nV?UTqu&(34DYH{ZMj_J-)Q1L#?SndAnXIB4o9b;cskg>+8+f-SbKrb|*K(Xi)npK14!Z(RLD3TQ91jZgec`fAN zHcTESnwJqZ6h1bxC7duL(j%oEFJjI|M)L0>(g-GOz+KSOA!|w48O{)Y8zg~`y_130 z>!Y3Tv+6szvV*l3bjv;Sa^e%VAV#Z<$5Q#Q*0_aS0H^l8^deG^`D$0Nc<8r5I{>qfr$C(jqf!cPy#`d|JSu3BDpizBs00= zjY(7>jI{vTdmlx3Mv>dDw1n0seZ4F=&TfBpU$ypN)?3n;;9@4f53^{;*CUY~-OHbU zl_7a$f^YQ~n;Ga_#ZXNcyAol04S$`5(pdJK1gg9Q#l-Q(3V{F|p(*fU2r!lekNXm? za2HczQ%7sB&Z^0{@0hhytx6?yF*}!wt&%jg`=jQnpwQX7fchu@M3i&jT6%v)7@7Bp zT8SsqtaIm=nll<+!+cE5q)dtbI<9v?{+s&G&CAJu!b-RHkBygdgikwXMvrOg%+$F( zHo17J)ZDO^`G;pzRC#*SSVZv3u&HveaGX;Tb;qZn;uPKc^~8h#DeCg$`s)ZZJa}yp z7v5(Cl>rVr*V9;*$m=3s(&?dVkFxl>Zw$`>+W7Bbl8D^fYL7u%%l|v@yr@rvSH|Jn zXN~Uf^&nFLs8CmbAF4>38E&EpZ3q$(%XW5^hhh1`=6^<87vL(!xG+-U606kUay z!92g}rN;dV3Z$>lONlyB|M-hgP0xkTw^Z_e80UaDADF3M%~o(5!&e(q*F@qN=E_-q z8pq@WI842)WnE#==l7IOxsm@vOcC?-?!*LzBi>?Q^86QvlZrEz4L=$fJ7qe+#yw$| zuxC&!e5tMF0;}yvR_uc;^PAT3+|Kx)byB{COJCgTaHa3OQfn%{86U40p9TMVHCoD8 zUVk+#4e!xnNxK}DzqD>!@S%Q?7EN^}!R^@i32|qVnGCDZuZaQaswwH#?V(F1Gh=8X zaA>(6Az?2NVe?Pv0fY&p@PmR73Av%+yi*JN)vg25$#9Fi_pL0rcOdO6;9dVfT(B^b%5pi3r^;Ewh(Mh_ z@~|8vZ^@-LWU-*BR5zb9A4pv7Y^B1it1gztT0Aayn#)VR=PUG(1l9^?mB6)C)zd-o z9tQnQ*l9>WB#*Q?#$~pz++J6WT|D>Ye9?W8`_r1tq&mzF4{u^$*gdpgd0xMrD0Hjj zEU^UR1Tn!Xw;!*zRG~oiz?0twBq)%@?ISnBE0Whx=!?mNABncVGmt0Lm#KGYay60f zF1wmBr)0J~tHX)y?59T=I8VPI#Qk8L=4EiLN00rE>$v^fThKW`-RwzgJ?YaP%#MtX zh*_$bX*bzI@u<}mQX7)7I5l?*t7G9E6vF}hzR*dx_+tTzrilSTMBfNq5kL&9ft3~5 z5Uw|9UTCjN2^-L?pKC>1?_GWYr|Fdy>LA%WJ~k-G9kt_eh6cJJ0hE78Kk;9p&wpVk z2#MWIhAttuP3;|uNcgW82uJQ$67c}<4N81Xz|y|*@6IPqes&KkAen)xhVgyE zm+-5x*Nr9?8@H7RRi@_(lTQB`*4m||x~w^amTc0eWEm9uI^78LT`+X1()K>RmtmZ{ zlzED9Yf4P5}TTHh}k zF?I^Ox>3FQc(K8KOt}S-gW~kA9|Njt zU$i?qbBHF7w`i&vawZL`;LZi86xX@C8{=XFiMQt2$<+X!0jBa>Kps6ry;5Iqg$?;O zLZ0_yLETcSi3c_xyx-6?8Cj8lM*}&`op2B$32IP|m#stoDFgVLp_la&(vo1f@h=4r z(%Fz~#aR(QHE?|1mmGpH0{Au0=YB<6>4<+6{(yor@4(c-+Vbli9;ZQIQQ&k;?>`8l zA3T_(N7tN@cZ@1j(CGPN!*z+}mLqMQWv%a0Ul47u%*C;F?nIQ^e)Zgm>c=E@ z2UwC#i{*18hQWdNg2cWB0=(VNJC~lW;7kXO?o5bCd3U_gbAEQfF-yOyFNb4as=j?C zRl2;bgj;P<$cVPaHNc2fI2PQ17J$A=x|T$9Nc=0z0!nLybp~b-+fdFGYF?I2@XAyY zeQIpZcC36xc&LI(M}m2f6c9GpKHzb{XzG>TA}hJT3B-qotcFBtK{9)3d1KvJdC(<6}d(@`+SuD5_qI9SSiOac92>~d(tFr9aWyxW(#pU zq+o3P*>XH!PVCHWky!-AGc5|#CMt^)<2i;Yovqx2(gVP6BFDVl{KnusRm&%1ti&I$ zS^h-P>tZ#TwfMKzWV5IGaR$)|Af{A}FQxsmDwRCGRB^o0;8*p?K!`f{;T4Wo0D6NT z?w~O1?fOi)i>wYL-XoSa9f(RG)mFM)^jaLI(qHF{hvLKpZmE&|c+_UN`ceJ+lkkJ~ z%(16W$@H@CAH1%!ngHeEV6$4pFURn2yrVBt@Wd-lc-?A|IwGv`Fv%cKh}}Sg&M0OB{Z6mI6T;G zkp>!MoJ_(6-~a?pR3|pw*~xHLWLFCP}Hc7OXNC2rc$_>Ju8k-=qLrHJrV?z|P<@1*|T@ zx`bIMS^I_ePPm&${|PoMB}xe5_VHL6YI_4yaqks7lLLdCg5^!P#-A?~i3S=jj8NnL zq=Cp8uILY0DDC8}w`GBoK7I!#d+f;oy0+0Hyp{Q1be{&YBqaGqI5o`xSDd{47=d)i z|D2qPK{$qvX&*bm4-fG4gt^M}?>wh0(`2E2(o&Mt^9O&1z%w z++7)Z)r1OGYctkJ2J83vpoBo0mlW>jhI9WGjB?Li`{b@Amp_T3E<5=w6Mjzw)E~?n z%ypZ1Qg@u?*1dNdV>C=PH=lJ=WsBqcDge}|t~h;T9sk9__F4rvu7SB710)#Hgn$24`_XhSRTjEQdHC&*+I@AZ1HF1tY%t-$@Tm zA3^L=A0d^yf?G8i4*>=(Y~MtHiKcphB$gOftYMD!%^QpOtnB~o8i+dJ-&HZJihsn| z|E9*2OC6%+>;JIQzsw>?`kb9~sNQm67B>0Kv0)yV0~0f>ZXm%s);( zIJyqGNO@q`HjqthMQ`EqeB`U|y~$c*Sm|0swr<{^=B;#>>TDhr-ljP4shG;X;UsNm z&!FKEq&a+N+^)81MDZQ|@w@l+Lxdut;*6-F{CETjrtp>d?uNBKUx!>(%Jum2bXIqC zl#j97-v_POhNF=^Fbg}nb`WX#!Y&Aj znZLpa6@sBCBPY0;D~3Whlc#Uar_XS?&0qg_7uFvKKWF#$U9 zwd@gUI4O%h&5{u}cr|DIIQt`E;%ufIzHRqCUGCCd)7FsfNikm;3GH)DKIZktdItbi`=A@#Jry}n+woXW z=|wT~4px0>)_N$C(YJwklJ21%>zmU%0Teag==GbCYpI|o)b145sYEjxV4!!Z(?gR} zj^o)tbnJ8T_Ye66L6UZCsyvcL{@?C3fc^v(FY|YXmi4&$OdcjTp|~ zF=N<0=oB8lN^)OC|944?>c%-R$LS^F~td$0c<~nID^o4r-|f&{98^ z+=jk2!r?^#z96=H3}y-h(Rj*s0S?uk;pptbtS)S(oSdVIRZ-C&)&D9vY_Qp`}qy=olo6;Xq%AC`hX0m)u>!bQnbb0Mmx5d`Qm%hzMao| zU)egMR5UBB_O?~=`9biD3D2GHPfH0tFLjtC2mx%!(yT4*uo!tLJBYTydHvQf}d2IOKm6ncWrfo z)G3@3LFB^iM3oT-#-(R%$0AhpHUWAt4*)D=Zh;Mi^dS)(RvgUt3kU>RO0{>KE6ntb z+4&Xtp4~a3YUqe~AQ>h4xG(eJ#ea1c68!@JF=XA`nB^XZXk-YiT&ye%I!L(m3h9m1 z+|;|*8D-zx1FhY<{-y2D%^Ier;nkV@80Vx@SyuVq$8XbNQ;1LCs4ls+x^(E|%_xx) zvYEc+pK%N7!Oi@>wh+O(pR3hpMmzB5n%ODT@QvRi>U3e3>{IL0;Dw_z)obg7q54lD z6o2klwchLNZ28-fzf-z|(szOA?`nTXQEjDkQ&lAQ|7$cORqYSNL2yD5*uz6J80?^aJ@mRG*hlW zAEvZ9dEV9*zDMR7+bN|ECkA63}0A zow8@46bPy=K9^WBX9&*EM_&fF@nRZZKdUzi8lo6bcZp`~d*`d3jKJLs?jXGjT5fMR zk9#j6^51QUvxMQ8*ObBD7>J~tK7%@|+~Cy4Zh!YXohov!B10lv^9$`I!(RThtHk$` zxTwv=)DlK9K2lHcQRLskIG(>43?TEBz*n=M{dnSu5VB^j8kqPgo!(AX%(n?DTAuzG zrD0>%6qb7mFPxW$+&fy+c8w)yLcJJTWp@Y_kC=`zIvCL zc;Rwpw4-wvhSLI{=qmjzK-}8fyodK~LAgEdj*H*yU#qvhga{Wv0#*YW1jP$`$cLT8fdy^2y# ziYqFG?@!cb-82f1$YXk12(LxK=Dfzx}H4p^a49_La zk4$mDk9YTAS{*rO>GR#OWk}4><>Z+8wR~JQBwiwMe84;oFluA?TOAo8442LK^?U1a zO6F-~s3|){9P456Xx0B;T;q!UZZHzcwKW59#(OoK$sGO2HUYQOT$a-TWJ_CVV8WLm z-PJ(R>7C7Zy_^^l1_{nETTPv=61^#nH((X^_PpBcw6|o?N58K3K-gOfp|7(2!FAcV z+R3Gg57)>~Q*W7oRuS~s9T9;iR-nTE*ff_RomSB!f?QJDllBnXWhsTOvI9Qc&>XoW zBS%fVt8|*sL6c;dUF*KSpe2XJO^0NC)5m=MG;_L?I*v)VGVJ4HNM87@jP`5-l}zftU`Fwf^Nf5=%Rea=w?ja$jx-b}H(`owF8L zvXJfIetnu!>V}sR%~Gb#fK_3m^T$wu@RD}J@?HHKp=X(kV^*Z+0hLaBWeum5+p42emKpiEu)22&<+%$^SH z-+B!g)$Q(EI=o<39uj>+U^DP%@GRE##@vh!H?<8AMs0T{8nd-)+|GTPLnKvDN18Ir z1gaAjjQzNK39sPve6EY!;={cNUZ81wh%bN^YfEt$owQez3fzG35@{{rgP#J31=uXU zTRK!-!*qgATdlz3FoMupQ{X8|Ilfq7vVDKNa%O5@o_tBWB}G3)OKYe zvN{SGTw&qL&|9*y(;@+hD$rQZ1Rj_lJIe1Nuh7v@&t^CZC2GkU?$4V=aQ9)q81 z^KlCrJDYtJ4!P4AX&{Ho{R<%is!-8{uGruDqM!Zw?2VE<^(whNiCs+%UI8T?S|21Y zI-bfezVW}W>k@L#|JLMCN$Ib6h}!9k*X`!*&Glqu>adwRa_N?u^T0e1o^E|>$MIf96fLDA~RNg@r~4s;HU(M>SV zMk16z1z;z|nW4y0L^fEFa*23^K)_gVRPXPBvvMmd;*uC`c)HZqGb4IXN{Bj$GknDy zu^qfx8LNrNK%yAG0YYi&a)`eMIEKjmN?xjedfR{nJ3e{P1?n|mGn^z`@^cEW?~*FI*CwfR8SlhdsEg*ljcR;XBgO^hVu;-egl=Fa zgc)B@{;ncHtpzC<7I0C8Oqc&sMUmk|c@YwAztjF6{G^U)XeOw@k->z%1FI?#MY@>@ z35x3^Wj{TyaSvEO3aJ>cvxDYZ)=s^PksK8-7xl5%!$y-6);NeCelp25lScxLGaN~i zJBxr@6JBzEtig#{b=wMdJd2qv)G~Nzufp1!91EYTk&sQt-B-#DpvBuudG)a33}G@-P>G*VBW= zkunTyC>o>zvv-VCD+rLs8VX3^H3|_wp?_bPfhqY)JrCyz!*$c-R+Ii6TmqsMWDiA< zi{GB+@Vq*EWN(sy+39*aE=7k@$zT;AM?H9zq@~A;nn6;M9c@l(ibLd5Fapf zPF5x$Pap;|2gwg%Z@bWN_Hr#K%#w(__rRuvj77s`S?!?azd&F?LFF}b%`YC82abNV zo>X-p-mFuTly&~Cc751^GKK$%p0A*6Ji19Y;3|`!^U~^%gtEvOq`#{n38gH$^odDPv*!E&56&nG*+ZN@|N{yJC#Vm9zDfsCdr* zehcUkSvDZSlt}2Xt$++&Je2eM=&5&3D@(%zcNq@CgW@JV#%<}4t@xjCdEg?CdaRWr z`{>YyWq+y=A@+YCh8_vZ9{%^?puBeoc>to;SAncEH(AetXfwU_S8@~3P5(aYe;)(<(1oF&@khsmsL|go V{kEO@lqmQ|<>s9mI0e&y{{!)3u>Al4 literal 0 HcmV?d00001 diff --git a/Barotrauma/BarotraumaShared/LocalMods/[DebugOnlyTest]Character override and variant tests/Human.xml b/Barotrauma/BarotraumaShared/LocalMods/[DebugOnlyTest]Character override and variant tests/Human.xml new file mode 100644 index 000000000..6c7b98bee --- /dev/null +++ b/Barotrauma/BarotraumaShared/LocalMods/[DebugOnlyTest]Character override and variant tests/Human.xml @@ -0,0 +1,312 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/Barotrauma/BarotraumaShared/LocalMods/[DebugOnlyTest]Character override and variant tests/Mudraptor.xml b/Barotrauma/BarotraumaShared/LocalMods/[DebugOnlyTest]Character override and variant tests/Mudraptor.xml new file mode 100644 index 000000000..84e84bd6f --- /dev/null +++ b/Barotrauma/BarotraumaShared/LocalMods/[DebugOnlyTest]Character override and variant tests/Mudraptor.xml @@ -0,0 +1,68 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 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 new file mode 100644 index 000000000..415f50330 --- /dev/null +++ b/Barotrauma/BarotraumaShared/LocalMods/[DebugOnlyTest]Character override and variant tests/README.txt @@ -0,0 +1,32 @@ +This mod includes a couple of differently configured character overrides and variants: + +- Human: overrides the human character with a broken version whose ragdoll fails to load. + - Expected behavior: loading the human character will fail and cause console errors, and the game will load the vanilla version instead. + +- Mudraptor: overrides Mudraptor with a broken version whose ragdoll fails to load. + - Expected behavior: loading a Mudraptor will fail and cause console errors, and the game will load the vanilla Crawler ragdoll instead. + - Variants of Mudraptor should fail to load as well, and switch to the Crawler ragdoll instead. + +- Crawler: overrides Crawler with a green version with sunglasses. + - Expected behavior: Crawler spawns as a green version with sunglasses. + - This change should also affect variants of Crawler: Crawler_large should also be green and have sunglasses (even though the mod does + not modify it directly). + - Crawler_hatchling (variant of Crawler) should look unchanged, but load correctly (despite it being a vanilla character whose base + character has now been overridden by a mod). + +- Testcyborgworm_m: adds a variant of the Cyborgworm (identical to the normal Cyborgworm). + - Expected behavior: Testcyborgworm_m looks identical to Cyborgworm. + - This has previously caused issues, because the Cyborgworm uses multiple textures, some of which aren't in the character folder, + and these used to load incorrectly when the character is a variant. + - 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 + (not in the usual Ragdoll folder, but a hard-coded path to a ragdoll file in the character's folder). \ No newline at end of file diff --git a/Barotrauma/BarotraumaShared/LocalMods/[DebugOnlyTest]Character override and variant tests/Spineling_morbusine_m.xml b/Barotrauma/BarotraumaShared/LocalMods/[DebugOnlyTest]Character override and variant tests/Spineling_morbusine_m.xml new file mode 100644 index 000000000..8a7839df0 --- /dev/null +++ b/Barotrauma/BarotraumaShared/LocalMods/[DebugOnlyTest]Character override and variant tests/Spineling_morbusine_m.xml @@ -0,0 +1 @@ + \ No newline at end of file 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/Testcyborgworm_m.xml b/Barotrauma/BarotraumaShared/LocalMods/[DebugOnlyTest]Character override and variant tests/Testcyborgworm_m.xml new file mode 100644 index 000000000..3c3c7f0fc --- /dev/null +++ b/Barotrauma/BarotraumaShared/LocalMods/[DebugOnlyTest]Character override and variant tests/Testcyborgworm_m.xml @@ -0,0 +1,3 @@ + + + \ 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 new file mode 100644 index 000000000..671772446 --- /dev/null +++ b/Barotrauma/BarotraumaShared/LocalMods/[DebugOnlyTest]Character override and variant tests/filelist.xml @@ -0,0 +1,9 @@ + + + + + + + + + \ 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 0000000000000000000000000000000000000000..be65f40e548316d9eeabe582d1de4c935f31357c GIT binary patch literal 9607 zcmXw;18gPE_x5X>x7ynFEw;98+ve8T+O}=m*0x)_z4g|1w|&3+d-I=Uo_TU|CX<|G zGM||tj)nvKUqiU`J9XTgNC!PbKLvpB0W%CrE?PZ2e~>_W{Q#-1-nPY8K+0&-D6+VX z)Rg4c$LFEgIjY&$ zb8o~>zSA7b_vt3LYcYaSW5YhdM!iM;`8%aDr&lq=0~S>Alvv4PniOp=4Ae%Xo&2q# z4F36jikTypYC*}#;Y+Qd+dGE#(4NY+dGnNURfEw{!W@FA#TMr179$H7ApgG^`{x;6h6-b zzL@3T4-f`c=f*1;$^+1}*H--@{h@|kO8D`<5B$Wu{7`+wczsXKbM&f# z<(o#@bkBgVcS4DtTZ$ddhI4o;4gB!DeNm61AqMlhz`V4fcyHKS-AB>%fi5RYCr+jb zqQ+}O_N@NOvl3)`rInY^ghZ!6eMF`f4cOkXz`!YHUn{Kc8z>(_s7}}H_F*Kx6|b(2 zSrt{?{bc9i>ks=i5_HC640D~!KLHy5!-Rd~l8haYMD9!aSItwQrQR6qhByAJ)JWU^ zT_60^71&3aP(HY!D9+Rz6ytz60RaY{?LQ6Tl7<8#Im65N#@->}CV0BVcf#E7!Tku7 z4xqX@)GI5>kEU8P?iq--r44hn%-NgP{|@KQyOXa+AComO+ob6J(P;Dgto%uq&*R(f zFg?}jB2Uj?ku>q%5`xf>+ob*&y59Ja0d@We8j5GH9gwF19eQ7#+vL&FW>ey3UlZ&C z_3TR!mhBT~3~Xc7zSfd)O~*?MOpQJgocjHy%f?-A74eXcyVrm6%>CA!30+vEwfZd5 z@__#pLqZw)Z*;>KfiiYPEN>67_=oVmk?G}b(&u$R>@YJe!ztP1lE_)~^3TfVTCOgy z@VSngNr!+rkft8vhe~cDJ>DRXs$-=ASe_kQwliFGBIJ+FxDk+4a5I3IrDZxrITVuY6JJ+EQ(e>0FIrIFHGWzjX+!7!V8 zNZzzV&?CZ8UC_qBBgm5X?l0;)HUy0P1Np&pdq0T)XnklmLD8%;U%@?Cfb-<6;;`AU zNub0b&%A)x&F7-(lz0`<0gjUKG>nX0kPsN|Tr)ec=($Ovf#s7gGKyP${21aHkxPI8 zRn|Llc=gl4?*8N&cas_xyI$hQ?}5{LX)I6`$Hx&vpK3Yr6&eaPaosl%3g?6tr%G_= zTZARNLmOKb5R6~O1z*rzYfAU#8P%q)k_L7 zIhq;1(l+xG%P9OB#K}>^{**r)+smHI#*uQN zd@*jM2dKgGp+5YuZ*tq9c0J@97&j?E$-?1Cyk@^iLlnYK|JD#s`(;k-7D=kWD5m0i z?yo{r$F+OU;dC%6e&2omUYtzj=VEU^-wHh*>zwP{r+<>x&XyL~Ty3d!ctvKP(PF$1 zCQY!TYrkfg2_4AOVhOfb6alM}AT3MNu|P;m=u{JS7JuC zt%@?Bc?32k8P-^i1udyOhxI7;PkLNYSh6*q<+moJ_+%Fk!HZT$^)V1%fWHXIa+2Y5 zFjG{;CJCZigtQt)84DicMj1ZpkKy96B{PoDm@W4!bE4$S_fN~qdXMb*bD0&#b?;F! zIk~x%h+SW1Q0C)$I>-@Asi2=QW@}X;8Lk@gqNRD#rlhw;9Kfz)4!mM%^}3AvDPJ3f zsu9t#UIhXdU&;p_pQ$y`7@e5e83`y3Xnspb15R`Rwy#1q!Vv`U?W;+;oCP;bAb77&r!`AFWw^qp@KaEWqBVhPpin8=k#JjuD!W&HOFZs|Ap$;k(?dT zngHD1OMS12iGk;R!!wVm*WJAa-IQ&q?h>c${fQOja8hm7eGqaUtqsP5eVc#q;RK#;xSB z!_k2pcU^#!@07EE3eb>gY`+<8QS&Vr!Jsv7(z9A9SvPjeCU+)~tR25| z`ANi1XO<`?wG$Gc=@WK@;Ym`Om147xQldso;>n_UjAG<;iC3pM%k9#acpu9F>(WO= z=-8lfjEq*W%HAQ~6XXSX3$u?rLVz-eXIVdmf`sa7`->5HP;o@r+EYX5g2Jw6;Wcy9 zu8X(kq%np`kh2ojx|wA}IEq4|woy)>^j9sdbVed6CC69%Kn(N@(u09w6tHf<~ z7ACiy5DD8Oj;hru9x>=dltBPEmGvNlub)@RFe zQd#1WxIL}Y!o>m~si*mPg?)7Jh zF2szT8btFXxf1gJrN;DvHVvIkZ&{bgWKpNQE;VIC^(%-2c7YHjfx9NB)8Z85#a_|D z|NjdW!F@sL=yjZ6yB&aBTup9G1{pbzAw*^g4>c7Ai`E$&c^dN27W{6Yr5$qZ|z zP$9QR3wJn|y2E~~DqZ8B>AO1R6o`9hr6OB|+@=7*o&UELA~4<3G4*H>1Qg6CQTieY z@T@JZv-qv@MV!+|AOsZHBjHZWAWoo;u#{ckx{txCW^-WUkdCHllEa9rOZM)836%F% zT~mG0hO-js_0d2~-ZTMLbHkVezSnZWneXDoq zgUSk^e_X=-@{db!PKi*|;t|+`d?-VRi@VhZ5N9jHtIvIf#kV{O*3rrx7;V-XNG)1P zTfn2aj0){a3iN_o-30lEgGvUh;mP zgrW{cwRLJkjeLoDEgbMg5G=PFC{3B*gZ}d?MOSSQCQmyZVO$U)2dI-wiAxUQySsy@ z>iu&TEpo3yEc!`!ajkapz9H}|Eul!(dg#oz(GITs8_}!n2!>GGh z%nQQquv|+^r{7oResRMu*AzN7>0yKY1iFu}@1qLCqWF764Ksins4Fj0lLFnBnz3R} zyj~YtViBx(l~=S9u@#!aB;01O=e_yJ4SMwD2XZwx?9pws{b_bUAnmRv86S=pk>w|j=3ENV~B z`F-YLRe$52xjQ_+k=QZTAKb2S2Iw@o<|c%~aLbHLGV@O>xARO19u9#ckVU9CRRV1L z0y4=ypf@jyJ2RGVQCguF!UzR5|CP&wUu?!GYGKcQO4joL7(x&EVIGSFj@yebnUQX^ zJ&f_!a^`&TNRaY3F!TTg^9yUVb{|>Rr3~QQD>Hf%l7GfvgNt#v z;TCM(Z5%%RDw8Y%juRCCW={{JT6kONl3{FWI~O*5*c7Um{J_oq0LGx(Hfz_%PxbT_ zE4)0F-*UI>cxNEN$KU5zE6D!rOHMu3r*R2n9jku^YVCE5Bc1;NI%9@H{5C6cZVZYD z34(%w9nJ-{`NU*RHzu96)KJ^@@9)%;khUjIz)Y2!(o?GY~;xXadE*ZAsF~)G{UDu>q6zhUo*Mn3Ax?$l)4mO}o zSh9^+-WikD#$Xms9K=uvuPT{~7Z4*9t)~@xgY#X4`2PL@oUwGmlWPyvlm3*9RH7eME5^@7R0JK#H#vR^Sgc+X77Bwk#YhQEysGfqfP?#W4A z#Pr6KN&MBc{0M!MHB0oB$Qhpa44x!{X2(M6x2(ylX&%>;b9h9-e8PK)gVMb1_u$gE zT)N&>40D1u=8%EX$uU_f&Gf4`8L)D-V3!*)D31#l4$Xw>Vx1(Lte17TK#_h7UR4r) zzVNVPbL@B3Z_@zoQAAiI=WiFKR9@j^S!DUD*g)^<8u6w6se(hAtqapw*0M4{89 z_roz#Zj?^v!^Ups1g*AW0vR3d$w*Oe>y&hff8vqg{1f(k#CfKb&-o_JUbGxxM_PdJ zZjX@Q7m$WHZc%`5d|kmsA>Ne8{BDQP&>+s#almJh7akUc`&XOyvs6 zGQM7T+A+%spxG(sNgD`|jsHQhj9tQ{o_kiEO>ua zcufr+21P=hHp@=|Oe=UT87JV7ZpTJqjWv+WW!iMxw$(+a_1$kDa!2sYf;HZ3g*fJ4P5gjOE96IFti%4Yn zB;THp^miesbjoSWIvkqqd5y^$M?y>@kEb%Mf|Fn}tn3W9D8(q&9}3JC!<%?JsT zQ%mxr^eiCpw$rTU`$&3+<>pKVVmd4x+6_>zV+>YsPxV#2?Nl_r+Gc0TZ+4cvp$cKB zXWcW;Syy+3Tw&I>wT_Ji2`tI+ES;XP98#?2dDUviGp&a(e*Ll#V&Ez{?>-ZaAF4&d zp9ln*A`VPNm8FBv%#J!%df4UKch+#XAMqlFs}d|&$;NYJWG(cwire%_udb&_Yd+@Y zIGCUIZ%tub5QiDFf8cJKbkXERDAU8CSus#-LE7>9{Coy|0#qV3owG{ zK7j1TjEcZa!Gcz7l=1lfH1>T2J_3^vpW2F8%JOCAw`U~=8a+vApcXhSbP1MS7q(}p>P>6SksRrs-*iijg*`zu-h{n^I?9-tXkDz$rjHZp(}*D z@A22%;CyJS&X#3(QJRk`9S=`tQb{IkL6kk^N3Csbch@wM113F*hEUCai!}y^m_pau zhWnIF%h{&a1R|B1(NK!g{+rSTFY%lAb*ynf&*pl8Uo0jN0L}_w0yP4sanYVI@`=Ko3i5PAxM5;tPz=x>b z@2cdd*f-O|j$t`}Uo`>wt7)BWZ#Dp`ID?4B!$B6+g;4Je*HLM=bIxEY_-E5B;Z$n! zM&6PbC`7T z=d;iVxL5!Ta0}j`d%Wq`C5K|!%o5o%&mH+jhnf#x3h1Vw4_R?~KY!HdsT!qKIf{cI z$B4c|gSOL<5#Wa4W9({Qamk>VWJSj0CL*Gx#^<;4M<>v?@>lBz3u{;2*_g#eT^Qoi z%Kr8J+bL07^DOQGcNeq){WD>J#?^#Cn?6aTi)f5vygOQo-LA!r!&1uQqvjhsk5{^0 zIL}IAD)f4KRueJxqv?InObHIjWY1(%EOgPK{73M9%++GC=l5c{r)HnFlTzd@bqS zC5&c1e07ByjZR*Pk`eLK#{ljxe^|4Ukp?DuU$o?Jax#A0sZyki2I45VxUIu22HqCX zfau>M&Jg|se&56q%dHW>c00;>V4{5ELr@Zv+K#1nW^rPB~S`=gWHa9mj} zaG`=W7Byw8iO5U7fFloewgWa|n4bTbTj>5u1r08^23USpm@dusp}O7scKM(Ju`I3f zfplW#g*oIwCtVoC7VuqQTN*6H_$)7Oj%Ut)im;!JRQ*{R09NXkr4DW=;hL)m>LY}U zzAQp#=*|ztZgn7v-Ejr;kGrNZvEP@8WAoM>tdC*09%zE3o3+G}68epX&R-!YG$TV# zyD6bk;Qxgpaq9$ys5p0!-$1AZ$$z^Dti5^0@}4Y>!9Z8b|HMBLPAVb!A*qNda<9Tp zE$+`t2W?osM-*sT6zeMuR$)U6)}uG#_Xz(8W0v# z)T=f8Sj|uo5}ELl0Hvr}z0HYa)Vv=I(vJWDa0F~kxHgp46gq)g-{{Opcm@~>%%-3p zJ}J)2??|Nv$G{TxUKiogp71P44WXcd$WcQj%8bPkn`kDZwS|&E=#jjcWL31f6%=S# z!J-+4sMF*A94LqKSsxNQ2AtoN&l8<`{sAS#bmeVxUk3egShSG#vzY2c!2j{&BoDY! zQA`cpwelrS*WmsgD+8plq31M6PA zC%dFg=ke~kS38w1D2G_>=YdDxA|BqLPN!?`wS%n1AGdHQ416golER=+n12R{Q#feQ z*`9BDiuC?&FKR{8^0M@-PQ-`z%Y?$NI-soO3;6YEEt;N^_tKzTM2rxT@x4wuxJ za>2Hgym)r8=8IQXTDdq7gE`*+{Gue!(&QOB8ZU0Nd)5K$yl-2e@|f(sF`VezN3O+0QcPq~G_Ta8nVpR2OGA-8SvZOom}l)!%G%opz;; zn}hV;!1zJaZmwLQ1mgCh(07~azNE2jygzdBXKmacL@O}ga z65Nq@GBqcf5AY*7f#8ll!JnCLFBHK+$FJL*0g_=SsY$7jO8=zz|E4`7M2wE2GND8y z2|=3fu&^>=I!enIVU+0j7Za1oEn14|6dexTbN5|~4`5bMbay4Mh91AVp9YbvyGmN_7SMa|PC z(n5KtH1-X7FVQ@eN8O}rhY^>e8q$W#y!%_!m2 z9KP;k40;O$8KJh<&4+t?Epua)s}R(C_M;3{Et-9iUt;FVjn#31ACKcs0mG#fLm8epovwc z&4l7@Vw~=5ufM9L#<&o9VX-|YZr>v;0&8Q6fyOgGRRunWlAu3D+zJ_@mhhS@`18govMD<^)@zBP?uz$(g&tdlo>r5BDlK~_P1eP4Pq%!x%cV` z+r=ui1BYBq#-bh%EfQ=XRo@PAc^1TZ^QfFGTpuvvyqqG@23Ed-m^%kKQ%Aji#$=gscY`gsFNUt=lzS5%HPu9aY}H}5?G z-~AiRY|n!Gdull!w5O}mH$Pv!b2npxIDr)@R=}7bAzmjl1fYy`oraG^^{k@%7TLqH zm_W^TqDuHMD3puPB$n0(CT29-QAX$`6dg7spO!Fl5V14_g4QLc#wPwd)-Es(Z2k^> zF!awbcla7sWrY=YWMTyh#pn*o_DlS;rezGH4?74cao=gL!Cjq`=L*^V%8dnLbDObGO#2^(7k z-veNz??p_G zAn#^{a~4Dl%}JQ67Oj?Q{GsnIZ$JcI^TY}b5~YM%{_^qP3h&Pko!DWl32Yk0+S(^*A5#cl05g1<8=b`$-=il_LuLS>1%wJ zcUw+c5Itp>75xD!3}g+N=|+jpB-FbzXzElU%RcSztR z$C@g?v(odp1Fi@dEZG;bn{AG}t$8VaC2~kEYzfG?QavjmU%6&yDljJh3lU1Y+B&ls zPQjuj)ZtSirH{7|IW~$W9dAgv+SUo|5K9Srs4 z{K9hK=;@j!hU)En>T8v{o*iRY@*H!{C_coVcyC~)?`WTez8f^!KKKS8{Emralsyvx zW<*CV#Et0tXuOyddl+~XK&3a=(jZyh9JfTGYPt2W#|$-?6(~_Q4eUVXd70&@*xDL$ zezDu*^ntV%`wQOD2KsnJOb!{sRSm%I%P=bN3`!s}{7itrR}JqYmPU`4JhzkYa%pqR zEr6gpp(PZ zpSZ+hm+Jpw@bK0|wKMK;YB0I8l=vbXp;mD73Gm3)L^qdwTB0a5z>>)XD$y(?Yc|gR zGCAgWpsXK|HmU!YIgz-4_6m$L@_TlfDmrUfH6N&kqEdHbrGIAgHlLsuHNYby6B5(W z969FYZY1N9A`=svXqBzoE>024%DgUnuBt$rGYymdNz9x5{3*Y4oD~7dIEq9C2l5Nt zMr={Wu#m2yon$xNK+8i3qVU2_do5WmvgSFc&Aj;(C>fYbAK!$b{`>GWgLMTCJmF3aA8X#u1xebSYq|4v5^(!j}zhHIgboOLng&v zC?KtmztcTj%X$Y(Meg(d88KmbHj_6L^OORj>R!%{_iqa9=oiIUTas3UNeT!djD| z$u#fwtH!&R2&8K<644fl5vE5Qrjk@Hp@IH{0G{d^->m}9Xy%}7d zFYQ6`&6Xn3%TuFcnlNnNd#$X044ak?v(&f!uO%1Y-!r|{a>51>&Fl-=oJ+J*ZPV4- zqFb$@5pu?aM4rVRhvVepMMybbH*`&Z*Rsli+<*hzJeIUPS63v0B+Ir*KNs$MDsr zc?_?_w7!Tg&HqB}XAHZVvf~&1S#N4hiO3|KR!s4Bub;JNW^>&`mnVjAKw8iG4M&h_eNdt0+EjqT_nVVE;dqZiTaMF>Nkq;_zv_e3HTn{F^z| zA?Q`eGbGA-+VMvAaqHj$s`Oh2w`Z`kXPc$=>pLs|8@yM#h>-LuYdk7jJAmWyd|^lL z9`bJFEG`W#eX-;Dl(VYqhsq62!5FtL-a@axfjw;uGjR`dDU^{X>XrJR_V`QL!kh*5|U*a+l zc_VimSi&yZ(MVSXC|;(i>Ie}|`x?rSsrma;q$-LzjwA8kA33@j pnyI*UgV%?y#&A{M+Mt-h=PimG74UZ;?boAV0dn*l?>hw8{{t@LQ*!_S literal 0 HcmV?d00001 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/README.txt b/Barotrauma/BarotraumaShared/README.txt index fe1f66f2a..6ba44b3f5 100644 --- a/Barotrauma/BarotraumaShared/README.txt +++ b/Barotrauma/BarotraumaShared/README.txt @@ -2,8 +2,8 @@ http://www.barotraumagame.com -© 2018-2022 FakeFish Ltd. All rights reserved. -© 2019-2022 Daedalic Entertainment GmbH. The Daedalic logo is a trademark of Daedalic Entertainment GmbH, Germany. All rights reserved. +© 2017-2024 FakeFish Ltd. All rights reserved. +© 2019-2024 Daedalic Entertainment GmbH. The Daedalic logo is a trademark of Daedalic Entertainment GmbH, Germany. All rights reserved. Privacy policy: http://privacypolicy.daedalic.com See the wiki for more detailed info and instructions: diff --git a/Barotrauma/BarotraumaShared/SharedSource/AchievementManager.cs b/Barotrauma/BarotraumaShared/SharedSource/AchievementManager.cs index 2cf5ccbb5..0e03c7356 100644 --- a/Barotrauma/BarotraumaShared/SharedSource/AchievementManager.cs +++ b/Barotrauma/BarotraumaShared/SharedSource/AchievementManager.cs @@ -1,4 +1,5 @@ -using Barotrauma.Items.Components; +using Barotrauma.Extensions; +using Barotrauma.Items.Components; using Barotrauma.Networking; using Barotrauma.Steam; using FarseerPhysics; @@ -15,6 +16,84 @@ namespace Barotrauma static class AchievementManager { + private static readonly ImmutableHashSet SupportedAchievements = ImmutableHashSet.Create( + "killmoloch".ToIdentifier(), + "killhammerhead".ToIdentifier(), + "killendworm".ToIdentifier(), + "artifactmission".ToIdentifier(), + "combatmission1".ToIdentifier(), + "combatmission2".ToIdentifier(), + "healcrit".ToIdentifier(), + "repairdevice".ToIdentifier(), + "traitorwin".ToIdentifier(), + "killtraitor".ToIdentifier(), + "killclown".ToIdentifier(), + "healopiateaddiction".ToIdentifier(), + "survivecrushdepth".ToIdentifier(), + "survivereactormeltdown".ToIdentifier(), + "healhusk".ToIdentifier(), + "killpoison".ToIdentifier(), + "killnuke".ToIdentifier(), + "killtool".ToIdentifier(), + "clowncostume".ToIdentifier(), + "lastmanstanding".ToIdentifier(), + "lonesailor".ToIdentifier(), + "subhighvelocity".ToIdentifier(), + "nodamagerun".ToIdentifier(), + "subdeep".ToIdentifier(), + "maxintensity".ToIdentifier(), + "discovercoldcaverns".ToIdentifier(), + "discovereuropanridge".ToIdentifier(), + "discoverhydrothermalwastes".ToIdentifier(), + "discovertheaphoticplateau".ToIdentifier(), + "discoverthegreatsea".ToIdentifier(), + "travel10".ToIdentifier(), + "travel100".ToIdentifier(), + "xenocide".ToIdentifier(), + "genocide".ToIdentifier(), + "cargomission".ToIdentifier(), + "subeditor24h".ToIdentifier(), + "crewaway".ToIdentifier(), + "captainround".ToIdentifier(), + "securityofficerround".ToIdentifier(), + "engineerround".ToIdentifier(), + "mechanicround".ToIdentifier(), + "medicaldoctorround".ToIdentifier(), + "assistantround".ToIdentifier(), + "campaigncompleted".ToIdentifier(), + "salvagewreckmission".ToIdentifier(), + "escortmission".ToIdentifier(), + "killcharybdis".ToIdentifier(), + "killlatcher".ToIdentifier(), + "killspineling_giant".ToIdentifier(), + "killcrawlerbroodmother".ToIdentifier(), + "ascension".ToIdentifier(), + "campaignmetadata_pathofthebikehorn_7".ToIdentifier(), + "campaignmetadata_coalitionspecialhire1_hired_true".ToIdentifier(), + "campaignmetadata_coalitionspecialhire2_hired_true".ToIdentifier(), + "campaignmetadata_separatistspecialhire1_hired_true".ToIdentifier(), + "campaignmetadata_separatistspecialhire2_hired_true".ToIdentifier(), + "campaignmetadata_huskcultspecialhire1_hired_true".ToIdentifier(), + "campaignmetadata_clownspecialhire1_hired_true".ToIdentifier(), + "scanruin".ToIdentifier(), + "clearruin".ToIdentifier(), + "beaconmission".ToIdentifier(), + "abandonedoutpostrescue".ToIdentifier(), + "abandonedoutpostassassinate".ToIdentifier(), + "abandonedoutpostdestroyhumans".ToIdentifier(), + "abandonedoutpostdestroymonsters".ToIdentifier(), + "nestmission".ToIdentifier(), + "miningmission".ToIdentifier(), + "combatmissionseparatistsvscoalition".ToIdentifier(), + "combatmissioncoalitionvsseparatists".ToIdentifier(), + "getoutalive".ToIdentifier(), + "abyssbeckons".ToIdentifier(), + "europasfinest".ToIdentifier(), + "kingofthehull".ToIdentifier(), + "killmantis".ToIdentifier(), + "ancientnovelty".ToIdentifier(), + "whatsmirksbelow".ToIdentifier()); + private const float UpdateInterval = 1.0f; private static readonly HashSet unlockedAchievements = new HashSet(); @@ -42,7 +121,30 @@ namespace Barotrauma private static PathFinder pathFinder; private static readonly Dictionary cachedDistances = new Dictionary(); - public static void OnStartRound() + static AchievementManager() + { +#if DEBUG + if (SteamManager.IsInitialized && SteamManager.TryGetAllAvailableAchievements(out var achievements) && achievements.Any()) + { + foreach (var achievement in achievements) + { + if (!SupportedAchievements.Contains(achievement.Identifier.ToIdentifier())) + { + DebugConsole.ThrowError($"Achievement \"{achievement.Identifier}\" is present on Steam's backend but not in achievements supported by {nameof(AchievementManager)}."); + } + } + foreach (Identifier achievementId in SupportedAchievements) + { + if (achievements.None(a => a.Identifier.ToIdentifier() == achievementId)) + { + DebugConsole.ThrowError($"Could not find achievement \"{achievementId}\" on Steam's backend."); + } + } + } +#endif + } + + public static void OnStartRound(Biome biome = null) { roundData = new RoundData(); foreach (Item item in Item.ItemList) @@ -53,12 +155,32 @@ namespace Barotrauma } pathFinder = new PathFinder(WayPoint.WayPointList, false); cachedDistances.Clear(); + +#if CLIENT + // If this is a multiplayer game, the client should let the server handle achievements + if (GameMain.Client != null) { return; } +#endif + + if (biome != null && GameMain.GameSession?.GameMode is CampaignMode) + { + string shortBiomeIdentifier = biome.Identifier.Value.Replace(" ", ""); + UnlockAchievement($"discover{shortBiomeIdentifier}".ToIdentifier(), unlockClients: true); + + // Just got out of Cold Caverns + if (shortBiomeIdentifier == "europanridge".ToIdentifier() && + GameMain.NetworkMember?.ServerSettings?.RespawnMode == RespawnMode.Permadeath) + { + UnlockAchievement("getoutalive".ToIdentifier(), unlockClients: true, + clientConditions: static client => GameMain.GameSession.PermadeathCountForAccount(client.AccountId) <= 0); + } + } } public static void Update(float deltaTime) { if (GameMain.GameSession == null) { return; } #if CLIENT + // If this is a multiplayer game, the client should let the server handle achievements if (GameMain.Client != null) { return; } #endif @@ -73,7 +195,7 @@ namespace Barotrauma UnlockAchievement( identifier: "maxintensity".ToIdentifier(), unlockClients: true, - conditions: static c => c is { IsDead: false, IsUnconscious: false }); + characterConditions: static c => c is { IsDead: false, IsUnconscious: false }); } foreach (Character c in Character.CharacterList) @@ -221,11 +343,6 @@ namespace Barotrauma return false; } - public static void OnBiomeDiscovered(Biome biome) - { - UnlockAchievement($"discover{biome.Identifier.Value.Replace(" ", "")}".ToIdentifier()); - } - public static void OnCampaignMetadataSet(Identifier identifier, object value, bool unlockClients = false) { if (identifier.IsEmpty || value is null) { return; } @@ -235,6 +352,7 @@ namespace Barotrauma public static void OnItemRepaired(Item item, Character fixer) { #if CLIENT + // If this is a multiplayer game, the client should let the server handle achievements if (GameMain.Client != null) { return; } #endif if (fixer == null) { return; } @@ -242,11 +360,27 @@ namespace Barotrauma UnlockAchievement(fixer, "repairdevice".ToIdentifier()); UnlockAchievement(fixer, $"repair{item.Prefab.Identifier}".ToIdentifier()); } + + public static void OnButtonTerminalSignal(Item item, Character user) + { + if (item == null || user == null) { return; } + +#if CLIENT + // If this is a multiplayer game, the client should let the server handle achievements + if (GameMain.Client != null) { return; } +#endif + if ((item.Prefab.Identifier == "alienterminal" || item.Prefab.Identifier == "alienterminal_new") && + item.Condition <= 0) + { + UnlockAchievement(user, "ancientnovelty".ToIdentifier()); + } + } public static void OnAfflictionReceived(Affliction affliction, Character character) { if (affliction.Prefab.AchievementOnReceived.IsEmpty) { return; } #if CLIENT + // If this is a multiplayer game, the client should let the server handle achievements if (GameMain.Client != null) { return; } #endif UnlockAchievement(character, affliction.Prefab.AchievementOnReceived); @@ -257,6 +391,7 @@ namespace Barotrauma if (affliction.Prefab.AchievementOnRemoved.IsEmpty) { return; } #if CLIENT + // If this is a multiplayer game, the client should let the server handle achievements if (GameMain.Client != null) { return; } #endif UnlockAchievement(character, affliction.Prefab.AchievementOnRemoved); @@ -265,6 +400,7 @@ namespace Barotrauma public static void OnCharacterRevived(Character character, Character reviver) { #if CLIENT + // If this is a multiplayer game, the client should let the server handle achievements if (GameMain.Client != null) { return; } #endif if (reviver == null) { return; } @@ -274,6 +410,7 @@ namespace Barotrauma public static void OnCharacterKilled(Character character, CauseOfDeath causeOfDeath) { #if CLIENT + // If this is a multiplayer game, the client should let the server handle achievements if (GameMain.Client != null || GameMain.GameSession == null) { return; } if (character != Character.Controlled && @@ -310,6 +447,17 @@ namespace Barotrauma UnlockAchievement(causeOfDeath.Killer, $"kill{character.SpeciesName.Replace("_m", "")}indoors".ToIdentifier()); } } +#if SERVER + if (character.SpeciesName == "Jove" && + GameMain.GameSession.Campaign is MultiPlayerCampaign && + GameMain.Server?.ServerSettings is { IronmanModeActive: true }) + { + UnlockAchievement( + identifier: "europasfinest".ToIdentifier(), + unlockClients: true, + characterConditions: static c => c is { IsDead: false }); + } +#endif if (character.HasEquippedItem("clownmask".ToIdentifier()) && character.HasEquippedItem("clowncostume".ToIdentifier()) && @@ -317,6 +465,12 @@ namespace Barotrauma { UnlockAchievement(causeOfDeath.Killer, "killclown".ToIdentifier()); } + + if (character.CharacterHealth?.GetAffliction("psychoclown") != null && + character.CurrentHull?.Submarine.Info is { Type: SubmarineType.BeaconStation }) + { + UnlockAchievement(causeOfDeath.Killer, "whatsmirksbelow".ToIdentifier()); + } // TODO: should we change this? Morbusine used to be the strongest poison. Now Cyanide is strongest. if (character.CharacterHealth?.GetAffliction("morbusinepoisoning") != null) @@ -344,8 +498,14 @@ namespace Barotrauma } } } - + #if SERVER + if (GameMain.Server?.ServerSettings?.RespawnMode == RespawnMode.Permadeath && + causeOfDeath.Type != CauseOfDeathType.Disconnected) + { + UnlockAchievement(character, "abyssbeckons".ToIdentifier()); + } + if (GameMain.Server?.TraitorManager != null) { if (GameMain.Server.TraitorManager.IsTraitor(character)) @@ -359,6 +519,7 @@ namespace Barotrauma public static void OnTraitorWin(Character character) { #if CLIENT + // If this is a multiplayer game, the client should let the server handle achievements if (GameMain.Client != null || GameMain.GameSession == null) { return; } #endif UnlockAchievement(character, "traitorwin".ToIdentifier()); @@ -400,16 +561,21 @@ namespace Barotrauma foreach (Mission mission in gameSession.Missions) { - if (mission is CombatMission combatMission && GameMain.GameSession.WinningTeam.HasValue) + // For PvP missions, all characters on the winning team that are still alive get achievements (if available) + if (mission is CombatMission && GameMain.GameSession.WinningTeam.HasValue) { - //all characters that are alive and in the winning team get an achievement + // Attempt unlocking team-specific achievement (if one has been set in the achievement backend) var achvIdentifier = $"{mission.Prefab.AchievementIdentifier}{(int) GameMain.GameSession.WinningTeam}" .ToIdentifier(); UnlockAchievement(achvIdentifier, true, - c => c != null && !c.IsDead && !c.IsUnconscious && combatMission.IsInWinningTeam(c)); + c => c != null && !c.IsDead && !c.IsUnconscious && CombatMission.IsInWinningTeam(c)); + + // Attempt unlocking mission-specific achievement (if one has been set in the achievement backend) + UnlockAchievement(mission.Prefab.AchievementIdentifier, true, + c => c != null && !c.IsDead && !c.IsUnconscious && CombatMission.IsInWinningTeam(c)); } - else if (mission.Completed) + else if (mission is not CombatMission && mission.Completed) { //all characters get an achievement if (GameMain.NetworkMember != null && GameMain.NetworkMember.IsServer) @@ -424,7 +590,7 @@ namespace Barotrauma } //made it to the destination - if (gameSession.Submarine.AtEndExit) + if (gameSession.Submarine != null && gameSession.Submarine.AtEndExit) { bool noDamageRun = !roundData.SubWasDamaged && !gameSession.Casualties.Any(); @@ -454,7 +620,7 @@ namespace Barotrauma if (charactersInSub.Count == 1) { - //there must be some casualties to get the last mant standing achievement + //there must be some casualties to get the last man standing achievement if (gameSession.Casualties.Any()) { UnlockAchievement(charactersInSub[0], "lastmanstanding".ToIdentifier()); @@ -517,26 +683,28 @@ namespace Barotrauma #endif } - public static void UnlockAchievement(Identifier identifier, bool unlockClients = false, Func conditions = null) + public static void UnlockAchievement(Identifier identifier, bool unlockClients = false, Func characterConditions = null, Func clientConditions = null) { if (CheatsEnabled) { return; } if (Screen.Selected is { IsEditor: true }) { return; } + if (!SupportedAchievements.Contains(identifier)) { return; } #if CLIENT if (GameMain.GameSession?.GameMode is TestGameMode) { return; } #endif #if SERVER if (unlockClients && GameMain.Server != null) { - foreach (Client c in GameMain.Server.ConnectedClients) + foreach (Client client in GameMain.Server.ConnectedClients) { - if (conditions != null && !conditions(c.Character)) { continue; } - GameMain.Server.GiveAchievement(c, identifier); + if (clientConditions != null && !clientConditions(client)) { continue; } + if (characterConditions != null && !characterConditions(client.Character)) { continue; } + GameMain.Server.GiveAchievement(client, identifier); } } #endif #if CLIENT - if (conditions != null && !conditions(Character.Controlled)) { return; } + if (characterConditions != null && !characterConditions(Character.Controlled)) { return; } #endif UnlockAchievementsOnPlatforms(identifier); diff --git a/Barotrauma/BarotraumaShared/SharedSource/CachedDistance.cs b/Barotrauma/BarotraumaShared/SharedSource/CachedDistance.cs index 068a4d320..e8e603387 100644 --- a/Barotrauma/BarotraumaShared/SharedSource/CachedDistance.cs +++ b/Barotrauma/BarotraumaShared/SharedSource/CachedDistance.cs @@ -25,4 +25,6 @@ namespace Barotrauma Vector2.DistanceSquared(EndWorldPos, currentEndWorldPos) > minDistSquared; } } + + public readonly record struct CachedLocation(Vector2 Location, double RecalculationTime); } diff --git a/Barotrauma/BarotraumaShared/SharedSource/Characters/AI/AIController.cs b/Barotrauma/BarotraumaShared/SharedSource/Characters/AI/AIController.cs index aa47e8d51..053ef35c7 100644 --- a/Barotrauma/BarotraumaShared/SharedSource/Characters/AI/AIController.cs +++ b/Barotrauma/BarotraumaShared/SharedSource/Characters/AI/AIController.cs @@ -325,7 +325,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 +358,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 +386,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/AITarget.cs b/Barotrauma/BarotraumaShared/SharedSource/Characters/AI/AITarget.cs index 852d07842..6501c1333 100644 --- a/Barotrauma/BarotraumaShared/SharedSource/Characters/AI/AITarget.cs +++ b/Barotrauma/BarotraumaShared/SharedSource/Characters/AI/AITarget.cs @@ -191,7 +191,7 @@ namespace Barotrauma { if (InDetectable) { return true; } if (Entity == null) { return true; } - if (Level.Loaded != null && WorldPosition.Y > Level.Loaded.Size.Y) + if (Level.IsPositionAboveLevel(WorldPosition)) { return true; } diff --git a/Barotrauma/BarotraumaShared/SharedSource/Characters/AI/EnemyAIController.cs b/Barotrauma/BarotraumaShared/SharedSource/Characters/AI/EnemyAIController.cs index 37ef8f1b9..5caa4e074 100644 --- a/Barotrauma/BarotraumaShared/SharedSource/Characters/AI/EnemyAIController.cs +++ b/Barotrauma/BarotraumaShared/SharedSource/Characters/AI/EnemyAIController.cs @@ -11,7 +11,7 @@ using System.Linq; namespace Barotrauma { - public enum AIState { Idle, Attack, Escape, Eat, Flee, Avoid, Aggressive, PassiveAggressive, Protect, Observe, Freeze, Follow, FleeTo, Patrol } + public enum AIState { Idle, Attack, Escape, Eat, Flee, Avoid, Aggressive, PassiveAggressive, Protect, Observe, Freeze, Follow, FleeTo, Patrol, PlayDead, HideTo, Hiding } public enum AttackPattern { Straight, Sweep, Circle } @@ -23,10 +23,21 @@ namespace Barotrauma Heading = 0x2, Steering = 0x4 } + + [Flags] + public enum EnemyTargetingRestrictions + { + None = 0x0, + PlayerCharacters = 0x1, + PlayerSubmarines = 0x2 + } partial class EnemyAIController : AIController { public static bool DisableEnemyAI; + + public static EnemyTargetingRestrictions TargetingRestrictions = EnemyTargetingRestrictions.None; + private EnemyTargetingRestrictions previousTargetingRestrictions; private AIState _state; public AIState State @@ -35,15 +46,14 @@ namespace Barotrauma set { if (_state == value) { return; } + if (_state == AIState.PlayDead && value == AIState.Idle) + { + // Don't allow to switch to Idle from PlayDead. + return; + } PreviousState = _state; OnStateChanged(_state, value); _state = value; - if (_state == AIState.Attack) - { -#if CLIENT - Character.PlaySound(CharacterSound.SoundType.Attack, maxInterval: 3); -#endif - } } } @@ -70,8 +80,25 @@ namespace Barotrauma private bool IsAttackRunning => AttackLimb != null && AttackLimb.attack.IsRunning; private bool IsCoolDownRunning => AttackLimb != null && AttackLimb.attack.CoolDownTimer > 0 || _previousAttackLimb != null && _previousAttackLimb.attack.CoolDownTimer > 0; public float CombatStrength => AIParams.CombatStrength; - private float Sight => AIParams.Sight; - private float Hearing => AIParams.Hearing; + private float Sight => GetPerceptionRange(AIParams.Sight); + private float Hearing => GetPerceptionRange(AIParams.Hearing); + + private float GetPerceptionRange(float range) + { + // TODO: make adjustable + if (State is AIState.PlayDead or AIState.Hiding) + { + // Intentionally constant + return 0.2f; + } + if (PreviousState is AIState.PlayDead or AIState.Hiding) + { + // Significantly buffed + return range * 1.5f; + } + return range; + } + private float FleeHealthThreshold => AIParams.FleeHealthThreshold; private bool IsAggressiveBoarder => AIParams.AggressiveBoarding; @@ -87,11 +114,22 @@ namespace Barotrauma if (_attackLimb != value) { _previousAttackLimb = _attackLimb; - if (_previousAttackLimb != null && _previousAttackLimb.attack.SnapRopeOnNewAttack) { _previousAttackLimb.AttachedRope?.Snap(); } + if (_previousAttackLimb != null) + { + Character.DeselectCharacter(); + if (_previousAttackLimb.attack.SnapRopeOnNewAttack) + { + _previousAttackLimb.AttachedRope?.Snap(); + } + } } else if (_attackLimb != null && _attackLimb.attack.CoolDownTimer <= 0) { - if (_attackLimb != null && _attackLimb.attack.SnapRopeOnNewAttack) { _attackLimb.AttachedRope?.Snap(); } + Character.DeselectCharacter(); + if (_attackLimb.attack.SnapRopeOnNewAttack) + { + _attackLimb.AttachedRope?.Snap(); + } } _attackLimb = value; attackVector = null; @@ -116,10 +154,10 @@ namespace Barotrauma } } - public AITargetMemory SelectedTargetMemory => selectedTargetMemory; - private AITargetMemory selectedTargetMemory; + public AITargetMemory CurrentTargetMemory => currentTargetMemory; + private AITargetMemory currentTargetMemory; private float targetValue; - private CharacterParams.TargetParams selectedTargetingParams; + private CharacterParams.TargetParams currentTargetingParams; private Dictionary targetMemories; @@ -127,6 +165,7 @@ namespace Barotrauma private bool canAttackWalls; public bool CanAttackDoors => canAttackDoors; private bool canAttackDoors; + private bool canAttackItems; private bool canAttackCharacters; public float PriorityFearIncrement => priorityFearIncreasement; @@ -147,6 +186,12 @@ namespace Barotrauma private float aggressionIntensity; private CirclePhase CirclePhase; private float currentAttackIntensity; + + private float playDeadTimer; + ///

+ /// How long the character has to idle without a target before it can start playing dead (again). + /// + private const float PlayDeadCoolDown = 60; private CoroutineHandle disableTailCoroutine; @@ -156,14 +201,13 @@ namespace Barotrauma public SwarmBehavior SwarmBehavior { get; private set; } public PetBehavior PetBehavior { get; private set; } - public CharacterParams.TargetParams SelectedTargetingParams { get { return selectedTargetingParams; } } + public CharacterParams.TargetParams CurrentTargetingParams => currentTargetingParams; public bool AttackHumans { get { - var target = GetTargetParams(CharacterPrefab.HumanSpeciesName); - return target != null && target.Priority > 0.0f && (target.State == AIState.Attack || target.State == AIState.Aggressive); + return GetTargetParams(Tags.Human).Any(static tp => tp is { Priority: > 0.0f, State: AIState.Attack or AIState.Aggressive }); } } @@ -171,8 +215,7 @@ namespace Barotrauma { get { - var target = GetTargetParams("room"); - return target != null && target.Priority > 0.0f && (target.State == AIState.Attack || target.State == AIState.Aggressive); + return GetTargetParams(Tags.Room).Any(static tp => tp is { Priority: > 0.0f, State: AIState.Attack or AIState.Aggressive }); } } @@ -208,11 +251,11 @@ namespace Barotrauma } = new HashSet(); public static bool IsTargetBeingChasedBy(Character target, Character character) - => character?.AIController is EnemyAIController enemyAI && enemyAI.SelectedAiTarget?.Entity == target && (enemyAI.State == AIState.Attack || enemyAI.State == AIState.Aggressive); + => character?.AIController is EnemyAIController enemyAI && enemyAI.SelectedAiTarget?.Entity == target && enemyAI.State is AIState.Attack or AIState.Aggressive; public bool IsBeingChasedBy(Character c) => IsTargetBeingChasedBy(Character, c); private bool IsBeingChased => IsBeingChasedBy(SelectedAiTarget?.Entity as Character); - private static bool IsTargetInPlayerTeam(AITarget target) => target?.Entity?.Submarine != null && target.Entity.Submarine.Info.IsPlayer || target?.Entity is Character targetCharacter && targetCharacter.IsOnPlayerTeam; + private static bool IsTargetInPlayerTeam(AITarget target) => target?.Entity?.Submarine != null && target.Entity.Submarine.Info.IsPlayer || target?.Entity is Character { IsOnPlayerTeam: true }; private bool IsAttackingOwner(Character other) => PetBehavior != null && PetBehavior.Owner != null && @@ -230,7 +273,7 @@ namespace Barotrauma reverse = value; if (FishAnimController != null) { - FishAnimController.reverse = reverse; + FishAnimController.Reverse = reverse; } } } @@ -250,7 +293,10 @@ namespace Barotrauma targetMemories = new Dictionary(); steeringManager = outsideSteering; //allow targeting outposts and outpost NPCs in outpost levels - TargetOutposts = Level.Loaded != null && Level.Loaded.Type == LevelData.LevelType.Outpost; + TargetOutposts = + (Level.Loaded != null && Level.Loaded.Type == LevelData.LevelType.Outpost) || + //the main sub can be an outpost in the editor + Submarine.MainSub is { Info.Type: SubmarineType.Outpost }; List aiElements = new List(); List aiCommonness = new List(); @@ -319,6 +365,10 @@ namespace Barotrauma requiredHoleCount = (int)Math.Ceiling(ConvertUnits.ToDisplayUnits(colliderWidth) / Structure.WallSectionSize); myBodies = Character.AnimController.Limbs.Select(l => l.body.FarseerBody).ToList(); myBodies.Add(Character.AnimController.Collider.FarseerBody); + if (AIParams.PlayDeadProbability > 0) + { + Character.EvaluatePlayDeadProbability(); + } CreatureMetrics.UnlockInEditor(Character.SpeciesName); } @@ -345,60 +395,72 @@ namespace Barotrauma return _aiParams; } } - private CharacterParams.TargetParams GetTargetParams(string targetTag) => GetTargetParams(targetTag.ToIdentifier()); - private CharacterParams.TargetParams GetTargetParams(Identifier targetTag) => AIParams.GetTarget(targetTag, false); - private CharacterParams.TargetParams GetTargetParams(AITarget aiTarget) => GetTargetParams(GetTargetingTag(aiTarget)); - private Identifier GetTargetingTag(AITarget aiTarget) + private IEnumerable GetTargetParams(Identifier targetTag) => AIParams.GetTargets(targetTag); + + private IEnumerable GetTargetParams(IEnumerable targetingTags) { - if (aiTarget?.Entity == null) { return Identifier.Empty; } - Identifier targetingTag = Identifier.Empty; + foreach (Identifier tag in targetingTags) + { + foreach (var tp in GetTargetParams(tag)) + { + yield return tp; + } + } + } + + private readonly List _targetingTags = new List(); + + private IEnumerable GetTargetingTags(AITarget aiTarget) + { + _targetingTags.Clear(); + if (aiTarget?.Entity == null) { return _targetingTags; } if (aiTarget.Entity is Character targetCharacter) { if (targetCharacter.IsDead) { - targetingTag = "dead".ToIdentifier(); + _targetingTags.Add(Tags.Dead); } - else if (AIParams.TryGetTarget(targetCharacter.CharacterHealth.GetActiveAfflictionTags(), out CharacterParams.TargetParams tp) && tp.Threshold >= Character.GetDamageDoneByAttacker(targetCharacter)) + else if (AIParams.TryGetHighestPriorityTarget(targetCharacter.CharacterHealth.GetActiveAfflictionTags(), out CharacterParams.TargetParams tp) && tp.Threshold >= Character.GetDamageDoneByAttacker(targetCharacter)) { - targetingTag = tp.Tag; + _targetingTags.Add(tp.Tag); } else if (PetBehavior != null && aiTarget.Entity == PetBehavior.Owner) { - targetingTag = "owner".ToIdentifier(); + _targetingTags.Add(Tags.Owner); } else if (PetBehavior != null && (!Character.IsOnFriendlyTeam(targetCharacter) || IsAttackingOwner(targetCharacter))) { - targetingTag = "hostile".ToIdentifier(); + _targetingTags.Add(Tags.Hostile); } - else if (AIParams.TryGetTarget(targetCharacter, out CharacterParams.TargetParams tP)) + else if (AIParams.TryGetHighestPriorityTarget(targetCharacter, out CharacterParams.TargetParams tP)) { - targetingTag = tP.Tag; + _targetingTags.Add(tP.Tag); } else if (targetCharacter.AIController is EnemyAIController enemy) { - if (enemy.PetBehavior != null && (PetBehavior != null || AIParams.HasTag("pet"))) + if (enemy.PetBehavior != null && (PetBehavior != null || AIParams.HasTag(Tags.Pet))) { // Pets see other pets as pets by default. // Monsters see them only as pet only when they have a matching ai target. Otherwise they use the other tags, specified below. - targetingTag = "pet".ToIdentifier(); + _targetingTags.Add(Tags.Pet); } - else if (targetCharacter.IsHusk && AIParams.HasTag("husk")) + else if (targetCharacter.IsHusk && AIParams.HasTag(Tags.Husk)) { - targetingTag = "husk".ToIdentifier(); + _targetingTags.Add(Tags.Husk); } else if (!Character.IsSameSpeciesOrGroup(targetCharacter)) { if (enemy.CombatStrength > CombatStrength) { - targetingTag = "stronger".ToIdentifier(); + _targetingTags.Add(Tags.Stronger); } else if (enemy.CombatStrength < CombatStrength) { - targetingTag = "weaker".ToIdentifier(); + _targetingTags.Add(Tags.Weaker); } else { - targetingTag = "equal".ToIdentifier(); + _targetingTags.Add(Tags.Equal); } } } @@ -409,31 +471,30 @@ namespace Barotrauma { if (targetItem.HasTag(prio.Tag)) { - targetingTag = prio.Tag; - break; + _targetingTags.Add(prio.Tag); } } - if (targetingTag.IsEmpty) + if (_targetingTags.None()) { if (targetItem.GetComponent() != null) { - targetingTag = "sonar".ToIdentifier(); + _targetingTags.Add(Tags.Sonar); } if (targetItem.GetComponent() != null) { - targetingTag = "door".ToIdentifier(); + _targetingTags.Add(Tags.Door); } } } else if (aiTarget.Entity is Structure) { - targetingTag = "wall".ToIdentifier(); + _targetingTags.Add(Tags.Wall); } else if (aiTarget.Entity is Hull) { - targetingTag = "room".ToIdentifier(); + _targetingTags.Add(Tags.Room); } - return targetingTag; + return _targetingTags; } public override void SelectTarget(AITarget target) => SelectTarget(target, 100); @@ -441,8 +502,8 @@ namespace Barotrauma public void SelectTarget(AITarget target, float priority) { SelectedAiTarget = target; - selectedTargetMemory = GetTargetMemory(target, addIfNotFound: true); - selectedTargetMemory.Priority = priority; + currentTargetMemory = GetTargetMemory(target, addIfNotFound: true); + currentTargetMemory.Priority = priority; ignoredTargets.Remove(target); } @@ -456,6 +517,15 @@ namespace Barotrauma Character.HeldItems.ForEach(i => i.GetComponent()?.GetRope()?.Snap()); } } + + public void EvaluatePlayDeadProbability(float? probability = null) + { + if (probability.HasValue) + { + AIParams.PlayDeadProbability = probability.Value; + } + Character.AllowPlayDead = Rand.Value() <= AIParams.PlayDeadProbability; + } public override void Update(float deltaTime) { @@ -466,6 +536,9 @@ namespace Barotrauma IsTryingToSteerThroughGap = false; Reverse = false; + //doesn't do anything usually, but events may sometimes change monsters' (or pets' that use enemy AI) teams + Character.UpdateTeam(); + bool ignorePlatforms = Character.AnimController.TargetMovement.Y < -0.5f && (-Character.AnimController.TargetMovement.Y > Math.Abs(Character.AnimController.TargetMovement.X)); if (steeringManager == insideSteering) { @@ -560,28 +633,21 @@ namespace Barotrauma } else { + if (TargetingRestrictions != previousTargetingRestrictions) + { + previousTargetingRestrictions = TargetingRestrictions; + // update targeting instantly when there's a change in targeting restrictions + updateTargetsTimer = 0; + SelectedAiTarget = null; + } + if (updateTargetsTimer > 0) { updateTargetsTimer -= deltaTime; } else if (avoidTimer <= 0 || activeTriggers.Any() && returnTimer <= 0) { - UpdateTargets(out CharacterParams.TargetParams targetingParams); - updateTargetsTimer = updateTargetsInterval * Rand.Range(0.75f, 1.25f); - if (SelectedAiTarget == null) - { - State = AIState.Idle; - } - else if (targetingParams != null) - { - selectedTargetingParams = targetingParams; - State = targetingParams.State; - } - if ((LatchOntoAI == null || !LatchOntoAI.IsAttached || wallTarget != null) && - (State == AIState.Attack || State == AIState.Aggressive || State == AIState.PassiveAggressive)) - { - UpdateWallTarget(requiredHoleCount); - } + UpdateTargets(); } } @@ -641,6 +707,9 @@ namespace Barotrauma case AIState.Idle: UpdateIdle(deltaTime); break; + case AIState.PlayDead: + Character.IsRagdolled = true; + break; case AIState.Patrol: UpdatePatrol(deltaTime); break; @@ -659,7 +728,7 @@ namespace Barotrauma case AIState.Avoid: case AIState.PassiveAggressive: case AIState.Aggressive: - if (SelectedAiTarget == null || SelectedAiTarget.Entity == null || SelectedAiTarget.Entity.Removed) + if (SelectedAiTarget?.Entity == null || SelectedAiTarget.Entity.Removed) { State = AIState.Idle; return; @@ -681,11 +750,11 @@ namespace Barotrauma else { bool isBeingChased = IsBeingChased; - float reactDistance = !isBeingChased && selectedTargetingParams != null && selectedTargetingParams.ReactDistance > 0 ? selectedTargetingParams.ReactDistance : GetPerceivingRange(SelectedAiTarget); + float reactDistance = !isBeingChased && currentTargetingParams is { ReactDistance: > 0 } ? currentTargetingParams.ReactDistance : GetPerceivingRange(SelectedAiTarget); if (squaredDistance <= Math.Pow(reactDistance, 2)) { float halfReactDistance = reactDistance / 2; - float attackDistance = selectedTargetingParams != null && selectedTargetingParams.AttackDistance > 0 ? selectedTargetingParams.AttackDistance : halfReactDistance; + float attackDistance = currentTargetingParams is { AttackDistance: > 0 } ? currentTargetingParams.AttackDistance : halfReactDistance; if (State == AIState.Aggressive || State == AIState.PassiveAggressive && squaredDistance < Math.Pow(attackDistance, 2)) { run = true; @@ -707,7 +776,9 @@ namespace Barotrauma case AIState.Protect: case AIState.Follow: case AIState.FleeTo: - if (SelectedAiTarget == null || SelectedAiTarget.Entity == null || SelectedAiTarget.Entity.Removed) + case AIState.HideTo: + case AIState.Hiding: + if (SelectedAiTarget?.Entity == null || SelectedAiTarget.Entity.Removed) { State = AIState.Idle; return; @@ -728,14 +799,14 @@ namespace Barotrauma if (c.Submarine?.TeamID != Character.Submarine?.TeamID) { return false; } if (c.IsPlayer || Character.IsOnFriendlyTeam(c)) { - return a.Damage >= selectedTargetingParams.Threshold; + return a.Damage >= currentTargetingParams.Threshold; } return true; } Character attacker = targetCharacter.LastAttackers.LastOrDefault(ShouldRetaliate)?.Character; if (attacker?.AiTarget != null) { - ChangeTargetState(attacker, AIState.Attack, selectedTargetingParams.Priority * 2); + ChangeTargetState(attacker, AIState.Attack, currentTargetingParams.Priority * 2); SelectTarget(attacker.AiTarget); State = AIState.Attack; UpdateWallTarget(requiredHoleCount); @@ -743,31 +814,48 @@ namespace Barotrauma } } } - float sqrDist = Vector2.DistanceSquared(WorldPosition, SelectedAiTarget.WorldPosition); + float distX = Math.Abs(WorldPosition.X - SelectedAiTarget.WorldPosition.X); + float distY = Math.Abs(WorldPosition.Y - SelectedAiTarget.WorldPosition.Y); + if (Character.Submarine != null && distY > 50 && SelectedAiTarget.Entity is Character targetC && !VisibleHulls.Contains(targetC.CurrentHull)) + { + // Target not visible, and possibly on a different floor. + distY *= 3; + } + float dist = distX + distY; float reactDist = GetPerceivingRange(SelectedAiTarget); Vector2 offset = Vector2.Zero; - if (selectedTargetingParams != null) + if (currentTargetingParams != null) { - if (selectedTargetingParams.ReactDistance > 0) + if (currentTargetingParams.ReactDistance > 0) { - reactDist = selectedTargetingParams.ReactDistance; + reactDist = currentTargetingParams.ReactDistance; } - offset = selectedTargetingParams.Offset; + offset = currentTargetingParams.Offset; } if (offset != Vector2.Zero) { reactDist += offset.Length(); } - if (sqrDist > MathUtils.Pow2(reactDist + movementMargin)) + if (dist > reactDist + movementMargin) { - movementMargin = State == AIState.FleeTo ? 0 : reactDist; + movementMargin = State is AIState.FleeTo or AIState.HideTo or AIState.Hiding ? 0 : reactDist; + if (State == AIState.Hiding) + { + // Too far to hide. + State = AIState.HideTo; + } run = true; UpdateFollow(deltaTime); } else { + if (State == AIState.HideTo) + { + // Close enough to hide. + State = AIState.Hiding; + } movementMargin = MathHelper.Clamp(movementMargin -= deltaTime, 0, reactDist); - if (State == AIState.FleeTo) + if (State is AIState.FleeTo or AIState.Hiding) { SteeringManager.Reset(); Character.AnimController.TargetMovement = Vector2.Zero; @@ -795,7 +883,7 @@ namespace Barotrauma { disableTailCoroutine = CoroutineManager.Invoke(() => { - if (Character != null && !Character.Removed) + if (Character is { Removed: false }) { Character.AnimController.HideAndDisable(LimbType.Tail, ignoreCollisions: false); } @@ -816,16 +904,16 @@ namespace Barotrauma } break; case AIState.Observe: - if (SelectedAiTarget == null || SelectedAiTarget.Entity == null || SelectedAiTarget.Entity.Removed) + if (SelectedAiTarget?.Entity == null || SelectedAiTarget.Entity.Removed) { State = AIState.Idle; return; } run = false; - sqrDist = Vector2.DistanceSquared(WorldPosition, SelectedAiTarget.WorldPosition); - reactDist = selectedTargetingParams != null && selectedTargetingParams.ReactDistance > 0 ? selectedTargetingParams.ReactDistance : GetPerceivingRange(SelectedAiTarget); + float sqrDist = Vector2.DistanceSquared(WorldPosition, SelectedAiTarget.WorldPosition); + reactDist = currentTargetingParams is { ReactDistance: > 0 } ? currentTargetingParams.ReactDistance : GetPerceivingRange(SelectedAiTarget); float halfReactDist = reactDist / 2; - float attackDist = selectedTargetingParams != null && selectedTargetingParams.AttackDistance > 0 ? selectedTargetingParams.AttackDistance : halfReactDist; + float attackDist = currentTargetingParams is { AttackDistance: > 0 } ? currentTargetingParams.AttackDistance : halfReactDist; if (sqrDist > Math.Pow(reactDist, 2)) { // Too far to react @@ -902,7 +990,18 @@ namespace Barotrauma private void UpdateIdle(float deltaTime, bool followLastTarget = true) { - if (AIParams.PatrolFlooded || AIParams.PatrolDry) + if (Character.AllowPlayDead && Character.Submarine != null) + { + if (playDeadTimer > 0) + { + playDeadTimer -= deltaTime; + } + else + { + State = AIState.PlayDead; + } + } + else if (AIParams.PatrolFlooded || AIParams.PatrolDry) { State = AIState.Patrol; } @@ -920,10 +1019,10 @@ namespace Barotrauma if (followLastTarget) { var target = SelectedAiTarget ?? _lastAiTarget; - if (target?.Entity != null && !target.Entity.Removed && + if (target?.Entity is { Removed: false } && PreviousState == AIState.Attack && Character.CurrentHull == null && (_previousAttackLimb?.attack == null || - _previousAttackLimb?.attack is Attack previousAttack && (previousAttack.AfterAttack != AIBehaviorAfterAttack.FallBack || previousAttack.CoolDownTimer <= 0))) + _previousAttackLimb?.attack is Attack previousAttack && (previousAttack.AfterAttack != AIBehaviorAfterAttack.FallBack || previousAttack.CoolDownTimer <= 0))) { // Keep heading to the last known position of the target var memory = GetTargetMemory(target); @@ -950,19 +1049,22 @@ namespace Barotrauma } } } - if (pathSteering != null && !Character.AnimController.InWater) + if (!Character.IsClimbing) { - // Wander around inside - pathSteering.Wander(deltaTime, Math.Max(ConvertUnits.ToDisplayUnits(colliderLength), 100.0f), stayStillInTightSpace: false); - } - else - { - // Wander around outside or swimming - steeringManager.SteeringWander(avoidWanderingOutsideLevel: true); - if (Character.AnimController.InWater) + if (pathSteering != null && !Character.AnimController.InWater) { - SteeringManager.SteeringAvoid(deltaTime, lookAheadDistance: avoidLookAheadDistance, weight: 5); + // Wander around inside + pathSteering.Wander(deltaTime, Math.Max(ConvertUnits.ToDisplayUnits(colliderLength), 100.0f), stayStillInTightSpace: false); } + else + { + // Wander around outside or swimming + steeringManager.SteeringWander(avoidWanderingOutsideLevel: true); + if (Character.AnimController.InWater) + { + SteeringManager.SteeringAvoid(deltaTime, lookAheadDistance: avoidLookAheadDistance, weight: 5); + } + } } } @@ -1121,7 +1223,7 @@ namespace Barotrauma private void UpdateAttack(float deltaTime) { - if (SelectedAiTarget == null || SelectedAiTarget.Entity == null || SelectedAiTarget.Entity.Removed) + if (SelectedAiTarget?.Entity == null || SelectedAiTarget.Entity.Removed || currentTargetingParams == null) { State = AIState.Idle; return; @@ -1204,7 +1306,7 @@ namespace Barotrauma } Character targetCharacter = SelectedAiTarget.Entity as Character; IDamageable damageTarget = wallTarget != null ? wallTarget.Structure : SelectedAiTarget.Entity as IDamageable; - bool canAttack = true; + bool canAttack = !Character.IsClimbing; bool pursue = false; if (IsCoolDownRunning && (_previousAttackLimb == null || AttackLimb == null || AttackLimb.attack.CoolDownTimer > 0)) { @@ -1222,7 +1324,14 @@ namespace Barotrauma switch (activeBehavior) { case AIBehaviorAfterAttack.Eat: - UpdateEating(deltaTime); + if (currentAttackLimb.IsSevered) + { + ReleaseEatingTarget(); + } + else + { + UpdateEating(deltaTime); + } return; case AIBehaviorAfterAttack.Pursue: case AIBehaviorAfterAttack.PursueIfCanAttack: @@ -1396,6 +1505,10 @@ namespace Barotrauma if (canAttack) { + if (AttackLimb is { IsSevered: true }) + { + AttackLimb = null; + } if (AttackLimb == null || !IsValidAttack(AttackLimb, Character.GetAttackContexts(), SelectedAiTarget?.Entity)) { AttackLimb = GetAttackLimb(attackWorldPos); @@ -1493,12 +1606,32 @@ namespace Barotrauma // Check that we can reach the target distance = toTargetOffset.Length(); - canAttack = distance < AttackLimb.attack.Range; + if (canAttack) + { + canAttack = distance < AttackLimb.attack.Range; + } + if (canAttack && !Character.InWater && Character.AnimController.CanWalk) + { + // On ground, ensure that the monster is facing the target, so that they don't hit the target while standing with their back towards it. + // In water, we don't want such checks, because it's ok for the monsters to attack targets on their sides and even behind them. + if (!Character.IsFacing(attackWorldPos)) + { + canAttack = false; + } + } if (canAttack) { reachTimer = 0; + if (IsAggressiveBoarder) + { + if (SelectedAiTarget.Entity is Item i && i.GetComponent() is Door { CanBeTraversed: true }) + { + // Don't attack open doors, just steer through them. + canAttack = false; + } + } } - else if (selectedTargetingParams.AttackPattern == AttackPattern.Straight && distance < AttackLimb.attack.Range * 5) + else if (currentTargetingParams.AttackPattern == AttackPattern.Straight && distance < AttackLimb.attack.Range * 5) { Vector2 targetVelocity = Vector2.Zero; Submarine targetSub = SelectedAiTarget.Entity.Submarine; @@ -1535,7 +1668,7 @@ namespace Barotrauma { if (Math.Abs(toTarget.Y) > AttackLimb.attack.Range / 2 && Math.Abs(toTarget.X) <= AttackLimb.attack.Range) { - humanoidAnimController.Crouching = true; + humanoidAnimController.Crouch(); } } @@ -1612,11 +1745,11 @@ namespace Barotrauma if (targetCharacter == null || targetCharacter.CurrentHull != Character.CurrentHull) { var door = pathSteering.CurrentPath.CurrentNode?.ConnectedDoor ?? pathSteering.CurrentPath.NextNode?.ConnectedDoor; - if (door != null && !door.CanBeTraversed && !door.HasAccess(Character)) + if (door is { CanBeTraversed: false } && (!Character.IsInFriendlySub || !door.HasAccess(Character))) { if (door.Item.AiTarget != null && SelectedAiTarget != door.Item.AiTarget) { - SelectTarget(door.Item.AiTarget, selectedTargetMemory.Priority); + SelectTarget(door.Item.AiTarget, currentTargetMemory.Priority); State = AIState.Attack; return; } @@ -1690,36 +1823,36 @@ namespace Barotrauma // Sweeping and circling doesn't work well inside if (Character.CurrentHull == null) { - switch (selectedTargetingParams.AttackPattern) + switch (currentTargetingParams.AttackPattern) { case AttackPattern.Sweep: - if (selectedTargetingParams.SweepDistance > 0) + if (currentTargetingParams.SweepDistance > 0) { if (distance <= 0) { distance = (attackWorldPos - WorldPosition).Length(); } - float amplitude = MathHelper.Lerp(0, selectedTargetingParams.SweepStrength, MathUtils.InverseLerp(selectedTargetingParams.SweepDistance, 0, distance)); + float amplitude = MathHelper.Lerp(0, currentTargetingParams.SweepStrength, MathUtils.InverseLerp(currentTargetingParams.SweepDistance, 0, distance)); if (amplitude > 0) { - sweepTimer += deltaTime * selectedTargetingParams.SweepSpeed; + sweepTimer += deltaTime * currentTargetingParams.SweepSpeed; float sin = (float)Math.Sin(sweepTimer) * amplitude; steerPos = MathUtils.RotatePointAroundTarget(attackSimPos, SimPosition, sin); } else { - sweepTimer = Rand.Range(-1000f, 1000f) * selectedTargetingParams.SweepSpeed; + sweepTimer = Rand.Range(-1000f, 1000f) * currentTargetingParams.SweepSpeed; } } break; case AttackPattern.Circle: if (IsCoolDownRunning) { break; } if (IsAttackRunning && CirclePhase != CirclePhase.Strike) { break; } - if (selectedTargetingParams == null) { break; } + if (currentTargetingParams == null) { break; } var targetSub = SelectedAiTarget.Entity?.Submarine; ISpatialEntity spatialTarget = targetSub ?? SelectedAiTarget.Entity; float targetSize = 0; - if (!selectedTargetingParams.IgnoreTargetSize) + if (!currentTargetingParams.IgnoreTargetSize) { targetSize = targetSub != null ? Math.Max(targetSub.Borders.Width, targetSub.Borders.Height) / 2 : @@ -1737,28 +1870,28 @@ namespace Barotrauma strikeTimer = 0; blockCheckTimer = 0; breakCircling = false; - float minFallBackDistance = selectedTargetingParams.CircleStartDistance * 0.5f; - float maxFallBackDistance = selectedTargetingParams.CircleStartDistance; - float maxRandomOffset = selectedTargetingParams.CircleMaxRandomOffset; + float minFallBackDistance = currentTargetingParams.CircleStartDistance * 0.5f; + float maxFallBackDistance = currentTargetingParams.CircleStartDistance; + float maxRandomOffset = currentTargetingParams.CircleMaxRandomOffset; // The lower the rotation speed, the slower the progression. Also the distance to the target stays longer. // So basically if the value is higher, the creature will strike the sub more quickly and with more precision. float ClampIntensity(float intensity) => MathHelper.Clamp(intensity * Rand.Range(0.9f, 1.1f), AIParams.StartAggression, AIParams.MaxAggression); if (isProgressive) { float intensity = ClampIntensity(currentAttackIntensity); - float minRotationSpeed = 0.01f * selectedTargetingParams.CircleRotationSpeed; - float maxRotationSpeed = 0.5f * selectedTargetingParams.CircleRotationSpeed; + float minRotationSpeed = 0.01f * currentTargetingParams.CircleRotationSpeed; + float maxRotationSpeed = 0.5f * currentTargetingParams.CircleRotationSpeed; circleRotationSpeed = MathHelper.Lerp(minRotationSpeed, maxRotationSpeed, intensity); circleFallbackDistance = MathHelper.Lerp(maxFallBackDistance, minFallBackDistance, intensity); circleOffset = Rand.Vector(MathHelper.Lerp(maxRandomOffset, 0, intensity)); } else { - circleRotationSpeed = selectedTargetingParams.CircleRotationSpeed; + circleRotationSpeed = currentTargetingParams.CircleRotationSpeed; circleFallbackDistance = maxFallBackDistance; circleOffset = Rand.Vector(maxRandomOffset); } - circleRotationSpeed *= Rand.Range(1 - selectedTargetingParams.CircleRandomRotationFactor, 1 + selectedTargetingParams.CircleRandomRotationFactor); + circleRotationSpeed *= Rand.Range(1 - currentTargetingParams.CircleRandomRotationFactor, 1 + currentTargetingParams.CircleRandomRotationFactor); aggressionIntensity = Math.Clamp(aggressionIntensity, AIParams.StartAggression, AIParams.MaxAggression); DisableAttacksIfLimbNotRanged(); if (targetSub is { Borders.Width: < 1000 } && AttackLimb?.attack is { Ranged: false }) @@ -1766,7 +1899,7 @@ namespace Barotrauma breakCircling = true; CirclePhase = CirclePhase.CloseIn; } - else if (sqrDistToTarget > MathUtils.Pow2(targetSize + selectedTargetingParams.CircleStartDistance)) + else if (sqrDistToTarget > MathUtils.Pow2(targetSize + currentTargetingParams.CircleStartDistance)) { CirclePhase = CirclePhase.CloseIn; } @@ -1781,8 +1914,8 @@ namespace Barotrauma break; case CirclePhase.CloseIn: Vector2 targetVelocity = GetTargetVelocity(); - float targetDistance = selectedTargetingParams.IgnoreTargetSize ? selectedTargetingParams.CircleStartDistance * 0.9f: - targetSize + selectedTargetingParams.CircleStartDistance / 2; + float targetDistance = currentTargetingParams.IgnoreTargetSize ? currentTargetingParams.CircleStartDistance * 0.9f: + targetSize + currentTargetingParams.CircleStartDistance / 2; if (AttackLimb != null && distance > 0 && distance < AttackLimb.attack.Range * GetStrikeDistanceMultiplier(targetVelocity)) { strikeTimer = AttackLimb.attack.CoolDown; @@ -1811,9 +1944,9 @@ namespace Barotrauma { CirclePhase = CirclePhase.CloseIn; } - else if (sqrDistToTarget > MathUtils.Pow2(targetSize + selectedTargetingParams.CircleStartDistance * 1.2f)) + else if (sqrDistToTarget > MathUtils.Pow2(targetSize + currentTargetingParams.CircleStartDistance * 1.2f)) { - if (selectedTargetingParams.DynamicCircleRotationSpeed && circleRotationSpeed < 100) + if (currentTargetingParams.DynamicCircleRotationSpeed && circleRotationSpeed < 100) { circleRotationSpeed *= 1 + deltaTime; } @@ -1923,17 +2056,17 @@ namespace Barotrauma float GetStrikeDistanceMultiplier(Vector2 targetVelocity) { - if (selectedTargetingParams.CircleStrikeDistanceMultiplier < 1) { return 0; } + if (currentTargetingParams.CircleStrikeDistanceMultiplier < 1) { return 0; } float requiredDistMultiplier = 2; bool isHeading = Vector2.Dot(Vector2.Normalize(attackWorldPos - WorldPosition), Vector2.Normalize(Steering)) > 0.9f; if (isHeading) { - requiredDistMultiplier = selectedTargetingParams.CircleStrikeDistanceMultiplier; + requiredDistMultiplier = currentTargetingParams.CircleStrikeDistanceMultiplier; float targetVelocityHorizontal = Math.Abs(targetVelocity.X); if (targetVelocityHorizontal > 1) { // Reduce the required distance if the target is moving. - requiredDistMultiplier -= MathHelper.Lerp(0, Math.Max(selectedTargetingParams.CircleStrikeDistanceMultiplier - 1, 1), Math.Clamp(targetVelocityHorizontal / 10, 0, 1)); + requiredDistMultiplier -= MathHelper.Lerp(0, Math.Max(currentTargetingParams.CircleStrikeDistanceMultiplier - 1, 1), Math.Clamp(targetVelocityHorizontal / 10, 0, 1)); if (requiredDistMultiplier < 2) { requiredDistMultiplier = 2; @@ -1968,7 +2101,7 @@ namespace Barotrauma } if (updateSteering) { - if (selectedTargetingParams.AttackPattern == AttackPattern.Straight && AttackLimb is Limb attackLimb && attackLimb.attack.Ranged) + if (currentTargetingParams.AttackPattern == AttackPattern.Straight && AttackLimb is Limb attackLimb && attackLimb.attack.Ranged) { bool advance = !canAttack && Character.CurrentHull == null || distance > attackLimb.attack.Range * 0.9f; bool fallBack = canAttack && distance < Math.Min(250, attackLimb.attack.Range * 0.25f); @@ -2069,7 +2202,16 @@ namespace Barotrauma if (attack.CoolDownTimer > 0) { return false; } if (!attack.IsValidContext(currentContexts)) { return false; } if (!attack.IsValidTarget(target)) { return false; } - if (target is ISerializableEntity se && target is Character) + if (!attackingLimb.attack.Ranged) + { + switch (target) + { + case Item when attackingLimb.attack.ItemDamage <= 0: + case Structure when attackingLimb.attack.StructureDamage <= 0: + return false; + } + } + if (target is ISerializableEntity se and Barotrauma.Character) { if (attack.Conditionals.Any(c => !c.TargetSelf && !c.Matches(se))) { return false; } } @@ -2085,6 +2227,21 @@ namespace Barotrauma float angle = MathHelper.ToDegrees(VectorExtensions.Angle(forward, toTarget)); if (angle > attack.RequiredAngle) { return false; } } + if (attack.RootForceWorldEnd.LengthSquared() > 1) + { + // 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.IsKnockedDownOrRagdolled: + case Item targetItem when Character.CurrentHull != targetItem.CurrentHull: + return false; + } + float verticalDistance = Math.Abs(attackWorldPos.Y - Character.WorldPosition.Y); + if (verticalDistance > 50) + { + return false; + } + } return true; } @@ -2150,14 +2307,15 @@ namespace Barotrauma { float reactionTime = Rand.Range(0.1f, 0.3f); updateTargetsTimer = Math.Min(updateTargetsTimer, reactionTime); - bool wasLatched = IsLatchedOnSub; Character.AnimController.ReleaseStuckLimbs(); if (attackResult.Damage > 0) { LatchOntoAI?.DeattachFromBody(reset: true, cooldown: 1); } - if (attacker == null || attacker.AiTarget == null || attacker.Removed || attacker.IsDead) { return; } + if (attacker?.AiTarget == null || attacker.Removed || attacker.IsDead) { return; } + AITargetMemory targetMemory = GetTargetMemory(attacker.AiTarget, addIfNotFound: true, keepAlive: true); + targetMemory.Priority += GetRelativeDamage(attackResult.Damage, Character.Vitality) * AIParams.AggressionHurt; if (attackResult.Damage >= AIParams.DamageThreshold) { ReleaseDragTargets(); @@ -2181,14 +2339,33 @@ namespace Barotrauma } return; } + if (!isFriendly && attackResult.Damage > 0.0f) { bool canAttack = attacker.Submarine == Character.Submarine && canAttackCharacters || attacker.Submarine != null && canAttackWalls; - if (AIParams.AttackWhenProvoked && canAttack && !ignoredTargets.Contains(attacker.AiTarget)) + if (canAttack) { + switch (State) + { + case AIState.PlayDead when Rand.Value() < 0.5f: + // 50% chance of not reacting when playing dead. + return; + case AIState.PlayDead: + case AIState.Hiding: + SelectTarget(attacker.AiTarget); + State = AIState.Attack; + break; + } + } + if (AIParams.AttackWhenProvoked && canAttack) + { + if (ignoredTargets.Contains(attacker.AiTarget)) + { + ignoredTargets.Remove(attacker.AiTarget); + } if (attacker.IsHusk) { - ChangeTargetState("husk", AIState.Attack, 100); + ChangeTargetState(Tags.Husk, AIState.Attack, 100); } else { @@ -2199,7 +2376,7 @@ namespace Barotrauma { if (attacker.IsHusk) { - ChangeTargetState("husk", canAttack ? AIState.Attack : AIState.Escape, 100); + ChangeTargetState(Tags.Husk, canAttack ? AIState.Attack : AIState.Escape, 100); } else if (attacker.AIController is EnemyAIController enemyAI) { @@ -2227,18 +2404,23 @@ namespace Barotrauma ChangeTargetState(attacker, canAttack ? AIState.Attack : AIState.Escape, 100); } } - else if (canAttack && attacker.IsHuman && AIParams.TryGetTarget(attacker, out CharacterParams.TargetParams targetingParams)) + else if (canAttack && attacker.IsHuman && + AIParams.TryGetTargets(attacker, out IEnumerable targetingParams)) { - if (targetingParams.State == AIState.Aggressive || targetingParams.State == AIState.PassiveAggressive) + //use a temporary list, because changing the state may change the targetingParams returned by TryGetTargets, + //which would cause a "collection was modified" exception + tempParamsList.Clear(); + tempParamsList.AddRange(targetingParams); + foreach (var tp in tempParamsList) { - ChangeTargetState(attacker, AIState.Attack, 100); + if (tp.State is AIState.Aggressive or AIState.PassiveAggressive) + { + ChangeTargetState(attacker, AIState.Attack, 100); + } } } } - AITargetMemory targetMemory = GetTargetMemory(attacker.AiTarget, addIfNotFound: true, keepAlive: true); - targetMemory.Priority += GetRelativeDamage(attackResult.Damage, Character.Vitality) * AIParams.AggressionHurt; - // Only allow to react once. Otherwise would attack the target with only a fraction of a cooldown bool retaliate = !isFriendly && SelectedAiTarget != attacker.AiTarget && attacker.Submarine == Character.Submarine; bool avoidGunFire = AIParams.AvoidGunfire && attacker.Submarine != Character.Submarine; @@ -2391,12 +2573,12 @@ namespace Barotrauma // Halve the greed for attacking non-characters. greed /= 2; } - selectedTargetMemory.Priority += GetRelativeDamage(attackResult.Damage, damageTarget.Health) * greed; + currentTargetMemory.Priority += GetRelativeDamage(attackResult.Damage, damageTarget.Health) * greed; } else { - selectedTargetMemory.Priority -= Math.Max(selectedTargetMemory.Priority / 2, 1); - return selectedTargetMemory.Priority > 1; + currentTargetMemory.Priority -= Math.Max(currentTargetMemory.Priority / 2, 1); + return currentTargetMemory.Priority > 1; } } } @@ -2446,7 +2628,7 @@ namespace Barotrauma float margin = MathHelper.PiOver4 * distanceFactor; if (angle < margin || dist < minDistance) { - var collisionCategories = Physics.CollisionCharacter | Physics.CollisionWall | Physics.CollisionLevel; + var collisionCategories = Physics.CollisionCharacter | Physics.CollisionWall | Physics.CollisionLevel | Physics.CollisionItemBlocking; var pickedBody = Submarine.PickBody(weapon.SimPosition, Character.GetRelativeSimPosition(target), myBodies, collisionCategories, allowInsideFixture: true); if (pickedBody != null) { @@ -2461,7 +2643,6 @@ namespace Barotrauma return true; } } - Character t = null; if (pickedBody.UserData is Character c) { @@ -2475,6 +2656,16 @@ namespace Barotrauma { return true; } + if (pickedBody.UserData is Item item && item.Prefab.DamagedByProjectiles) + { + // Target behind an item -> allow shooting. + return true; + } + if (pickedBody.UserData is Holdable holdable && holdable.Item.Prefab.DamagedByProjectiles) + { + // Target behind a blocking but destructible item -> allow shooting. + return true; + } } } return false; @@ -2536,26 +2727,24 @@ namespace Barotrauma private void UpdateEating(float deltaTime) { - if (SelectedAiTarget == null || SelectedAiTarget.Entity == null || SelectedAiTarget.Entity.Removed) + if (SelectedAiTarget?.Entity == null || SelectedAiTarget.Entity.Removed) { - State = AIState.Idle; - if (Character.SelectedCharacter != null) - { - Character.DeselectCharacter(); - } + ReleaseEatingTarget(); return; } - if (SelectedAiTarget.Entity is Character || SelectedAiTarget.Entity is Item) + if (SelectedAiTarget.Entity is Barotrauma.Character or Item) { Limb mouthLimb = Character.AnimController.GetLimb(LimbType.Head); if (mouthLimb == null) { - DebugConsole.ThrowError("Character \"" + Character.SpeciesName + "\" failed to eat a target (No head limb defined)", + DebugConsole.ThrowError("Character \"" + Character.SpeciesName + "\" failed to eat a target (No head limb found)", contentPackage: Character.Prefab.ContentPackage); - State = AIState.Idle; + IgnoreTarget(SelectedAiTarget); + ReleaseEatingTarget(); + ResetAITarget(); return; } - Vector2 mouthPos = Character.AnimController.SimplePhysicsEnabled ? SimPosition : Character.AnimController.GetMouthPosition().Value; + Vector2 mouthPos = Character.AnimController.SimplePhysicsEnabled ? SimPosition : Character.AnimController.GetMouthPosition() ?? Vector2.Zero; Vector2 attackSimPosition = Character.GetRelativeSimPosition(SelectedAiTarget.Entity); Vector2 limbDiff = attackSimPosition - mouthPos; float extent = Math.Max(mouthLimb.body.GetMaxExtent(), 2); @@ -2609,10 +2798,16 @@ namespace Barotrauma else { IgnoreTarget(SelectedAiTarget); - State = AIState.Idle; + ReleaseEatingTarget(); ResetAITarget(); } } + + private void ReleaseEatingTarget() + { + State = AIState.Idle; + Character.DeselectCharacter(); + } #endregion @@ -2683,30 +2878,48 @@ namespace Barotrauma //goes through all the AItargets, evaluates how preferable it is to attack the target, //whether the Character can see/hear the target and chooses the most preferable target within //sight/hearing range - public AITarget UpdateTargets(out CharacterParams.TargetParams targetingParams) + public void UpdateTargets() { - AITarget newTarget = null; targetValue = 0; - selectedTargetMemory = null; - targetingParams = null; + AITarget newTarget = null; + CharacterParams.TargetParams selectedTargetParams = null; + AITargetMemory targetMemory = null; bool isAnyTargetClose = false; bool isBeingChased = IsBeingChased; - float maxModifier = 5; + const float priorityValueMaxModifier = 5; + bool isCharacterInside = Character.CurrentHull != null; + bool tryToGetInside = + Character.AnimController.CanEnterSubmarine == CanEnterSubmarine.True || + //characters that are aggressive boarders can partially enter the sub can attempt to push through holes + (Character.AnimController.CanEnterSubmarine == CanEnterSubmarine.Partial && IsAggressiveBoarder); + foreach (AITarget aiTarget in AITarget.List) { if (aiTarget.ShouldBeIgnored()) { continue; } if (ignoredTargets.Contains(aiTarget)) { continue; } if (aiTarget.Type == AITarget.TargetType.HumanOnly) { continue; } - if (!TargetOutposts) + if (!TargetOutposts && GameMain.GameSession?.GameMode is not TestGameMode) { if (aiTarget.Entity.Submarine != null && aiTarget.Entity.Submarine.Info.IsOutpost) { continue; } } Character targetCharacter = aiTarget.Entity as Character; //ignore the aitarget if it is the Character itself if (targetCharacter == Character) { continue; } + if (TargetingRestrictions.HasFlag(EnemyTargetingRestrictions.PlayerCharacters)) + { + if (targetCharacter is { IsPlayer: true }) { continue; } - float valueModifier = 1; - Identifier targetingTag = GetTargetingTag(aiTarget); + // monsters can ignore a player character, but attack a diving suit equipped by the character + if (aiTarget.Entity is Item item && item.GetRootInventoryOwner() is Character { IsPlayer: true}) { continue; } + } + if (TargetingRestrictions.HasFlag(EnemyTargetingRestrictions.PlayerSubmarines)) + { + if (aiTarget.Entity.Submarine?.Info is { IsPlayer: true }) { continue; } + } + var targetingTags = GetTargetingTags(aiTarget); + + #region Filter out targets by entity type, based on contextual information. + Door door = null; if (targetCharacter != null) { // ignore if target is tagged to be explicitly ignored (Feign Death) @@ -2715,26 +2928,6 @@ namespace Barotrauma { continue; } - if (targetCharacter.AIController is EnemyAIController enemy) - { - if (targetingTag == "stronger" && (State == AIState.Avoid || State == AIState.Escape || State == AIState.Flee)) - { - if (SelectedAiTarget == aiTarget) - { - // Freightened -> hold on to the target - valueModifier *= 2; - } - if (IsBeingChasedBy(targetCharacter)) - { - valueModifier *= 2; - } - if (Character.CurrentHull != null && !VisibleHulls.Contains(targetCharacter.CurrentHull)) - { - // Inside but in a different room - valueModifier /= 2; - } - } - } } else { @@ -2762,12 +2955,10 @@ namespace Barotrauma if (hull.Submarine == null) { continue; } if (hull.Submarine.Info.IsRuin) { continue; } } - - Door door = null; - if (aiTarget.Entity is Item item) + else if (aiTarget.Entity is Item item) { door = item.GetComponent(); - bool targetingFromOutsideToInside = item.CurrentHull != null && Character.CurrentHull == null; + bool targetingFromOutsideToInside = item.CurrentHull != null && !isCharacterInside; if (targetingFromOutsideToInside) { if (door != null && (!canAttackDoors && !AIParams.CanOpenDoors) || !canAttackWalls) @@ -2784,7 +2975,7 @@ namespace Barotrauma continue; } } - else if (targetingTag == "nasonov") + else if (targetingTags.Contains(Tags.Nasonov)) { if ((item.Submarine == null || !item.Submarine.Info.IsPlayer) && item.ParentInventory == null) { @@ -2793,7 +2984,7 @@ namespace Barotrauma } } // Ignore the target if it's a decoy and the character is already inside a sub - if (Character.CurrentHull != null && targetingTag == "decoy") + if (Character.CurrentHull != null && targetingTags.Contains(Tags.Decoy)) { continue; } @@ -2808,23 +2999,159 @@ namespace Barotrauma if (s.IsPlatform) { continue; } if (s.Submarine == null) { continue; } if (s.Submarine.Info.IsRuin) { continue; } - bool isCharacterInside = Character.CurrentHull != null; bool isInnerWall = s.Prefab.Tags.Contains("inner"); if (isInnerWall && !isCharacterInside) { // Ignore inner walls when outside (walltargets still work) continue; } - bool attemptToGetInside = - Character.AnimController.CanEnterSubmarine == CanEnterSubmarine.True || - //characters that are aggressive boarders can partially enter the sub can attempt to push through holes - (Character.AnimController.CanEnterSubmarine == CanEnterSubmarine.Partial && IsAggressiveBoarder); - - if (!attemptToGetInside && IsWallDisabled(s)) + if (!tryToGetInside && IsWallDisabled(s)) { continue; } + } + if (door != null) + { + if (door.Item.Submarine == null) { continue; } + bool isOutdoor = door.LinkedGap is { FlowTargetHull: not null, IsRoomToRoom: false }; + // Ignore inner doors when outside + if (Character.CurrentHull == null && !isOutdoor) { continue; } + bool isOpen = door.CanBeTraversed; + if (!isOpen) + { + if (!canAttackDoors) { continue; } + } + else if (Character.AnimController.CanEnterSubmarine != CanEnterSubmarine.True) + { + // Ignore broken and open doors, if cannot enter submarine + // Also ignore them if the monster can only partially enter the sub: + // these monsters tend to be too large to get through doors anyway. + continue; + } + } + else if (aiTarget.Entity is IDamageable { Health: <= 0.0f }) + { + continue; + } + } + #endregion + #region Choose valid targeting params. + if (targetingTags.None()) { continue; } + CharacterParams.TargetParams matchingTargetParams = null; + foreach (var targetParams in GetTargetParams(targetingTags)) + { + if (matchingTargetParams != null) + { + if (matchingTargetParams.Priority > targetParams.Priority) + { + // Valid higher priority params already found. + continue; + } + } + if (targetParams.IgnoreInside && Character.CurrentHull != null) { continue; } + if (targetParams.IgnoreOutside && Character.CurrentHull == null) { continue; } + if (targetParams.IgnoreIncapacitated && targetCharacter is { IsIncapacitated: true }) { continue; } + if (targetParams.IgnoreTargetInside && aiTarget.Entity.Submarine != null) { continue; } + if (targetParams.IgnoreTargetOutside && aiTarget.Entity.Submarine == null) { continue; } + if (aiTarget.Entity is ISerializableEntity se) + { + if (targetParams.Conditionals.Any(c => !c.TargetSelf && !c.Matches(se))) { continue; } + } + if (targetParams.Conditionals.Any(c => c.TargetSelf && !c.Matches(Character))) { continue; } + if (targetParams.IgnoreIfNotInSameSub) + { + if (aiTarget.Entity.Submarine != Character.Submarine) { continue; } + var targetHull = targetCharacter != null ? targetCharacter.CurrentHull : aiTarget.Entity is Item it ? it.CurrentHull : null; + if (targetHull == null != (Character.CurrentHull == null)) { continue; } + } + if (targetParams.State is AIState.Observe or AIState.Eat) + { + if (targetCharacter != null && targetCharacter.Submarine != Character.Submarine) + { + // Never allow observing or eating characters that are inside a different submarine / outside when we are inside. + continue; + } + } + if (aiTarget.Entity is Item targetItem) + { + if (targetParams.IgnoreContained && targetItem.ParentInventory != null) { continue; } + + switch (targetParams.State) + { + case AIState.FleeTo: + { + float healthThreshold = targetParams.Threshold; + if (targetParams.ThresholdMin > 0 && targetParams.ThresholdMax > 0) + { + // If both min and max thresholds are defined, use ThresholdMax when currently fleeing to the target and ThresholdMin when doing something else. + // This is used to make the fractal guardians target the guardian repair pods with a different health threshold: when they are already targeting the pod, they will use ThresholdMax to keep them generating until (nearly) full health. + // And when the guardians are outside the pod, they use ThresholdMin, so that they don't target the pod before falling under certain level of health. + healthThreshold = currentTargetingParams == targetParams && State == AIState.FleeTo ? targetParams.ThresholdMax : targetParams.ThresholdMin; + } + if (Character.HealthPercentage > healthThreshold) + { + continue; + } + break; + } + case AIState.Attack or AIState.Aggressive: + if (!canAttackItems) + { + continue; + } + break; + } + if (targetItem.HasTag(Tags.GuardianShelter)) + { + // Ignore guardian pods completely, if they are targeted by someone else (is or will be occupied). + bool ignore = false; + foreach (Character otherCharacter in Character.CharacterList) + { + if (otherCharacter == Character) { continue; } + if (otherCharacter.AIController?.SelectedAiTarget != aiTarget) { continue; } + if (!Character.IsFriendly(otherCharacter)) { continue; } + ignore = true; + break; + } + if (ignore) { continue; } + } + } + matchingTargetParams = targetParams; + } + if (matchingTargetParams == null) { continue; } + #endregion + + #region Modify the priority dynamically + float valueModifier = 1.0f; + if (targetCharacter != null) + { + if (targetCharacter.AIController is EnemyAIController) + { + if (matchingTargetParams.Tag == Tags.Stronger && State is AIState.Avoid or AIState.Escape or AIState.Flee) + { + if (SelectedAiTarget == aiTarget) + { + // Frightened -> hold on to the target + valueModifier *= 2; + } + if (IsBeingChasedBy(targetCharacter)) + { + valueModifier *= 2; + } + if (Character.CurrentHull != null && !VisibleHulls.Contains(targetCharacter.CurrentHull)) + { + // Inside but in a different room + valueModifier /= 2; + } + } + } + } + else + { + if (aiTarget.Entity is Structure s) + { + bool isInnerWall = s.Prefab.Tags.Contains("inner"); // Prefer weaker walls (200 is the default for normal hull walls) valueModifier = 200f / s.MaxHealth; for (int i = 0; i < s.Sections.Length; i++) @@ -2832,13 +3159,13 @@ namespace Barotrauma var section = s.Sections[i]; if (section.gap == null) { continue; } bool leadsInside = !section.gap.IsRoomToRoom && section.gap.FlowTargetHull != null; - if (attemptToGetInside) + if (tryToGetInside) { if (!isCharacterInside) { if (CanPassThroughHole(s, i)) { - valueModifier *= leadsInside ? (IsAggressiveBoarder ? maxModifier : 1) : 0; + valueModifier *= leadsInside ? (IsAggressiveBoarder ? priorityValueMaxModifier : 1) : 0; } else if (IsAggressiveBoarder && leadsInside && canAttackWalls) { @@ -2901,27 +3228,11 @@ namespace Barotrauma valueModifier *= 1 + section.gap.Open; } } - valueModifier = Math.Clamp(valueModifier, 0, maxModifier); + valueModifier = Math.Clamp(valueModifier, 0, priorityValueMaxModifier); } } if (door != null) { - if (door.Item.Submarine == null) { continue; } - bool isOutdoor = door.LinkedGap?.FlowTargetHull != null && !door.LinkedGap.IsRoomToRoom; - // Ignore inner doors when outside - if (Character.CurrentHull == null && !isOutdoor) { continue; } - bool isOpen = door.CanBeTraversed; - if (!isOpen) - { - if (!canAttackDoors) { continue; } - } - else if (Character.AnimController.CanEnterSubmarine != CanEnterSubmarine.True) - { - // Ignore broken and open doors, if cannot enter submarine - // Also ignore them if the monster can only partially enter the sub: - // these monsters tend to be too large to get through doors anyway. - continue; - } if (IsAggressiveBoarder) { if (Character.CurrentHull == null) @@ -2929,82 +3240,40 @@ namespace Barotrauma // Increase the priority if the character is outside and the door is from outside to inside if (door.CanBeTraversed) { - valueModifier = maxModifier; + valueModifier = priorityValueMaxModifier; } else if (door.LinkedGap != null) { - valueModifier = 1 + door.LinkedGap.Open * (maxModifier - 1); + valueModifier = 1 + door.LinkedGap.Open * (priorityValueMaxModifier - 1); } } else { // Inside -> ignore open doors and outer doors + bool isOpen = door.CanBeTraversed; + bool isOutdoor = door.LinkedGap is { FlowTargetHull: not null, IsRoomToRoom: false }; valueModifier = isOpen || isOutdoor ? 0 : 1; } } } - else if (aiTarget.Entity is IDamageable targetDamageable && targetDamageable.Health <= 0.0f) + else if (aiTarget.Entity is IDamageable { Health: <= 0.0f }) { continue; } } - - if (targetingTag == null) { continue; } - var targetParams = GetTargetParams(targetingTag); - if (targetParams == null) { continue; } - if (targetParams.IgnoreInside && Character.CurrentHull != null) { continue; } - if (targetParams.IgnoreOutside && Character.CurrentHull == null) { continue; } - if (targetParams.IgnoreIncapacitated && targetCharacter != null && targetCharacter.IsIncapacitated) { continue; } - if (targetParams.IgnoreTargetInside && aiTarget.Entity.Submarine != null) { continue; } - if (targetParams.IgnoreTargetOutside && aiTarget.Entity.Submarine == null) { continue; } - if (aiTarget.Entity is ISerializableEntity se) - { - if (targetParams.Conditionals.Any(c => !c.TargetSelf && !c.Matches(se))) { continue; } - } - if (targetParams.Conditionals.Any(c => c.TargetSelf && !c.Matches(Character))) { continue; } - if (targetParams.IgnoreIfNotInSameSub) - { - if (aiTarget.Entity.Submarine != Character.Submarine) { continue; } - var targetHull = targetCharacter != null ? targetCharacter.CurrentHull : aiTarget.Entity is Item it ? it.CurrentHull : null; - if ((targetHull == null) != (Character.CurrentHull == null)) { continue; } - } - if (targetParams.State == AIState.Observe || targetParams.State == AIState.Eat) - { - if (targetCharacter != null && targetCharacter.Submarine != Character.Submarine) - { - // Never allow observing or eating characters that are inside a different submarine / outside when we are inside. - continue; - } - } - if (aiTarget.Entity is Item targetItem) - { - if (targetParams.IgnoreContained && targetItem.ParentInventory != null) { continue; } - if (targetParams.State == AIState.FleeTo) - { - float target = targetParams.Threshold; - if (targetParams.ThresholdMin > 0 && targetParams.ThresholdMax > 0) - { - target = selectedTargetingParams == targetParams && State == AIState.FleeTo ? targetParams.ThresholdMax : targetParams.ThresholdMin; - } - if (Character.HealthPercentage > target) - { - continue; - } - } - } //no need to eat if the character is already in full health (except if it's a pet - pets actually need to eat to stay alive, not just to regain health) - if (targetParams.State == AIState.Eat && Character.Params.Health.HealthRegenerationWhenEating > 0 && !Character.IsPet) + if (matchingTargetParams.State == AIState.Eat && Character.Params.Health.HealthRegenerationWhenEating > 0 && !Character.IsPet) { valueModifier *= MathHelper.Lerp(1f, 0.1f, Character.HealthPercentage / 100f); } - valueModifier *= targetParams.Priority; + valueModifier *= matchingTargetParams.Priority; if (valueModifier == 0.0f) { continue; } - if (targetingTag != "decoy") + if (matchingTargetParams.Tag != Tags.Decoy) { if (SwarmBehavior != null && SwarmBehavior.Members.Any()) { // Halve the priority for each swarm mate targeting the same target -> reduces stacking - foreach (Character otherCharacter in SwarmBehavior.Members) + foreach (AICharacter otherCharacter in SwarmBehavior.Members) { if (otherCharacter == Character) { continue; } if (otherCharacter.AIController?.SelectedAiTarget != aiTarget) { continue; } @@ -3023,6 +3292,8 @@ namespace Barotrauma } } } + #endregion + if (!aiTarget.IsWithinSector(WorldPosition)) { continue; } Vector2 toTarget = aiTarget.WorldPosition - Character.WorldPosition; float dist = toTarget.Length(); @@ -3032,32 +3303,37 @@ namespace Barotrauma { dist *= 0.9f; } - if (targetParams.PerceptionDistanceMultiplier > 0.0f) + if (matchingTargetParams.PerceptionDistanceMultiplier > 0.0f) { - dist /= targetParams.PerceptionDistanceMultiplier; + dist /= matchingTargetParams.PerceptionDistanceMultiplier; } - if (targetParams.MaxPerceptionDistance > 0.0f && - dist * dist > targetParams.MaxPerceptionDistance * targetParams.MaxPerceptionDistance) + if (matchingTargetParams.MaxPerceptionDistance > 0.0f && + dist * dist > matchingTargetParams.MaxPerceptionDistance * matchingTargetParams.MaxPerceptionDistance) { continue; } - - if (!CanPerceive(aiTarget, dist, checkVisibility: SelectedAiTarget != aiTarget)) + + if (State is AIState.PlayDead && targetCharacter == null) + { + // Only react to characters, when playing dead. + continue; + } + else if (!CanPerceive(aiTarget, dist, checkVisibility: SelectedAiTarget != aiTarget || State is AIState.PlayDead or AIState.Hiding)) { continue; } if (SelectedAiTarget == aiTarget) { - if (Character.Submarine == null && aiTarget.Entity is ISpatialEntity spatialEntity && spatialEntity.Submarine != null) + if (Character.Submarine == null && aiTarget.Entity is ISpatialEntity { Submarine: not null } spatialEntity) { - if (targetingTag == "door" || targetingTag == "wall") + if (matchingTargetParams.Tag == Tags.Door || matchingTargetParams.Tag == Tags.Wall) { Vector2 rayStart = Character.SimPosition; Vector2 rayEnd = aiTarget.SimPosition + spatialEntity.Submarine.SimPosition; Body closestBody = Submarine.PickBody(rayStart, rayEnd, collisionCategory: Physics.CollisionWall | Physics.CollisionLevel, allowInsideFixture: true); - if (closestBody != null && closestBody.UserData is ISpatialEntity hit) + if (closestBody is { UserData: ISpatialEntity hit }) { Vector2 hitPos = hit.SimPosition; if (closestBody.UserData is Submarine) @@ -3068,8 +3344,8 @@ namespace Barotrauma { hitPos += hit.Submarine.SimPosition; } - float subHalfWidth = spatialEntity.Submarine.Borders.Width / 2; - float subHalfHeight = spatialEntity.Submarine.Borders.Height / 2; + float subHalfWidth = spatialEntity.Submarine.Borders.Width / 2f; + float subHalfHeight = spatialEntity.Submarine.Borders.Height / 2f; Vector2 diff = ConvertUnits.ToDisplayUnits(rayEnd - hitPos); bool isOtherSideOfTheSub = Math.Abs(diff.X) > subHalfWidth || Math.Abs(diff.Y) > subHalfHeight; if (isOtherSideOfTheSub) @@ -3086,9 +3362,9 @@ namespace Barotrauma } if (!isBeingChased) { - if (targetParams.State == AIState.Avoid || targetParams.State == AIState.PassiveAggressive || targetParams.State == AIState.Aggressive) + if (matchingTargetParams.State is AIState.Avoid or AIState.PassiveAggressive or AIState.Aggressive) { - float reactDistance = targetParams.ReactDistance; + float reactDistance = matchingTargetParams.ReactDistance; if (reactDistance > 0 && reactDistance < dist) { // The target is too far and should be ignored. @@ -3100,7 +3376,7 @@ namespace Barotrauma //if the target is very close, the distance doesn't make much difference // -> just ignore the distance and target whatever has the highest priority dist = Math.Max(dist, 100.0f); - AITargetMemory targetMemory = GetTargetMemory(aiTarget, addIfNotFound: true, keepAlive: SelectedAiTarget != aiTarget); + targetMemory = GetTargetMemory(aiTarget, addIfNotFound: true, keepAlive: SelectedAiTarget != aiTarget); if (Character.Submarine != null && !Character.Submarine.Info.IsRuin && Character.CurrentHull != null) { float diff = Math.Abs(toTarget.Y) - Character.CurrentHull.Size.Y; @@ -3113,7 +3389,7 @@ namespace Barotrauma if (Character.Submarine == null && aiTarget.Entity?.Submarine != null && targetCharacter == null) { - if (targetParams.PrioritizeSubCenter || targetParams.AttackPattern == AttackPattern.Circle || targetParams.AttackPattern == AttackPattern.Sweep) + if (matchingTargetParams.PrioritizeSubCenter || matchingTargetParams.AttackPattern is AttackPattern.Circle or AttackPattern.Sweep) { if (!isAnyTargetClose) { @@ -3123,7 +3399,7 @@ namespace Barotrauma float horizontalDistanceToSubCenter = Math.Abs(aiTarget.WorldPosition.X - aiTarget.Entity.Submarine.WorldPosition.X); dist *= MathHelper.Lerp(1f, 5f, MathUtils.InverseLerp(0, 10000, horizontalDistanceToSubCenter)); } - else if (targetParams.AttackPattern == AttackPattern.Circle) + else if (matchingTargetParams.AttackPattern == AttackPattern.Circle) { dist *= 5; } @@ -3138,16 +3414,16 @@ namespace Barotrauma } // Don't target characters that are outside of the allowed zone, unless chasing or escaping. - switch (targetParams.State) + switch (matchingTargetParams.State) { case AIState.Escape: case AIState.Avoid: break; default: - if (targetParams.State == AIState.Attack) + if (matchingTargetParams.State == AIState.Attack) { // In the attack state allow going into non-allowed zone only when chasing a target. - if (State == targetParams.State && SelectedAiTarget == aiTarget) { break; } + if (State == matchingTargetParams.State && SelectedAiTarget == aiTarget) { break; } } bool insideSameSub = aiTarget?.Entity?.Submarine != null && aiTarget.Entity.Submarine == Character.Submarine; if (!insideSameSub && !IsPositionInsideAllowedZone(aiTarget.WorldPosition, out _)) @@ -3186,24 +3462,20 @@ namespace Barotrauma // ignore if owner is tagged to be explicitly ignored (Feign Death) continue; } - var characterTargetingTag = GetTargetingTag(owner.AiTarget); - if (!characterTargetingTag.IsEmpty) - { - // if the enemy is configured to ignore the target character, ignore the provocative item they're holding/wearing too - var characterTargetingParams = GetTargetParams(characterTargetingTag); - if (characterTargetingParams?.State == AIState.Idle) { continue; } - } + // if the enemy is configured to ignore the target character, ignore the provocative item they're holding/wearing too + if (GetTargetParams(GetTargetingTags(owner.AiTarget)).Any(t => t.State == AIState.Idle)) { continue; } } } if (targetCharacter != null) { if (Character.CurrentHull != null && targetCharacter.CurrentHull != Character.CurrentHull) { - if (targetParams.State == AIState.Follow || targetParams.State == AIState.Protect || targetParams.State == AIState.Observe || targetParams.State == AIState.Eat) + if (matchingTargetParams.State is AIState.Observe or AIState.Eat || + (matchingTargetParams.State is AIState.Follow or AIState.Protect && (!Character.CanClimb || !Character.CanInteract || !AIParams.CanOpenDoors || !Character.Params.UsePathFinding))) { - // Ignore targets that cannot be seen if (!VisibleHulls.Contains(targetCharacter.CurrentHull)) { + // Probably can't get to the target -> ignore. continue; } } @@ -3242,49 +3514,24 @@ namespace Barotrauma } } newTarget = aiTarget; - selectedTargetMemory = targetMemory; + selectedTargetParams = matchingTargetParams; targetValue = valueModifier; - targetingParams = targetParams; if (!isAnyTargetClose) { isAnyTargetClose = ConvertUnits.ToDisplayUnits(colliderLength) > nonModifiedDist; } } } - + + currentTargetingParams = selectedTargetParams; + currentTargetMemory = targetMemory; + State = currentTargetingParams?.State ?? AIState.Idle; SelectedAiTarget = newTarget; - if (SelectedAiTarget != _previousAiTarget) + if ((LatchOntoAI is not { IsAttached: true } || wallTarget != null) && State is AIState.Attack or AIState.Aggressive or AIState.PassiveAggressive) { - if ((SelectedAiTarget != null || wallTarget != null) && IsLatchedOnSub) - { - if (SelectedAiTarget?.Entity is not Structure wall) - { - wall = wallTarget?.Structure; - } - // The target is not a wall or it's not the same as we are attached to -> release - bool releaseTarget = wall?.Bodies == null || (!wall.Bodies.Contains(LatchOntoAI.AttachJoints[0].BodyB) && wall.Submarine?.PhysicsBody?.FarseerBody != LatchOntoAI.AttachJoints[0].BodyB); - if (!releaseTarget) - { - for (int i = 0; i < wall.Sections.Length; i++) - { - if (CanPassThroughHole(wall, i)) - { - releaseTarget = true; - } - } - } - if (releaseTarget) - { - wallTarget = null; - LatchOntoAI.DeattachFromBody(reset: true, cooldown: 1); - } - } - else - { - wallTarget = null; - } + UpdateWallTarget(requiredHoleCount); } - return SelectedAiTarget; + updateTargetsTimer = updateTargetsInterval * Rand.Range(0.75f, 1.25f); } class WallTarget @@ -3624,16 +3871,16 @@ namespace Barotrauma { if (trigger.IsTriggered) { return; } if (activeTriggers.ContainsKey(trigger)) { return; } - if (activeTriggers.ContainsValue(selectedTargetingParams)) + if (activeTriggers.ContainsValue(currentTargetingParams)) { if (!trigger.AllowToOverride) { return; } - var existingTrigger = activeTriggers.FirstOrDefault(kvp => kvp.Value == selectedTargetingParams && kvp.Key.AllowToBeOverridden); + var existingTrigger = activeTriggers.FirstOrDefault(kvp => kvp.Value == currentTargetingParams && kvp.Key.AllowToBeOverridden); if (existingTrigger.Key == null) { return; } activeTriggers.Remove(existingTrigger.Key); } trigger.Launch(); - activeTriggers.Add(trigger, selectedTargetingParams); - ChangeParams(selectedTargetingParams, trigger.State); + activeTriggers.Add(trigger, currentTargetingParams); + ChangeParams(currentTargetingParams, trigger.State); } private void UpdateTriggers(float deltaTime) @@ -3657,34 +3904,38 @@ namespace Barotrauma inactiveTriggers.Clear(); } - private bool TryResetOriginalState(string tag) => - TryResetOriginalState(tag.ToIdentifier()); - /// /// Resets the target's state to the original value defined in the xml. /// private bool TryResetOriginalState(Identifier tag) { if (!modifiedParams.ContainsKey(tag)) { return false; } - if (AIParams.TryGetTarget(tag, out CharacterParams.TargetParams targetParams)) + if (AIParams.TryGetTargets(tag, out IEnumerable matchingParams)) { - modifiedParams.Remove(tag); - if (tempParams.ContainsKey(tag)) + foreach (var targetParams in matchingParams) { - tempParams.Values.ForEach(t => AIParams.RemoveTarget(t)); - tempParams.Remove(tag); + modifiedParams.Remove(tag); + if (tempParams.ContainsKey(tag)) + { + tempParams.Values.ForEach(t => AIParams.RemoveTarget(t)); + tempParams.Remove(tag); + } + ResetParams(targetParams); + return true; } - ResetParams(targetParams); - return true; - } - else - { - return false; } + return false; } - private readonly Dictionary modifiedParams = new Dictionary(); + /// + /// Parameters originally defined in the AI params and modified temporarily. + /// + private readonly Dictionary> modifiedParams = new Dictionary>(); + /// + /// Parameters created temporarily. Not originally defined in the AI params at all. + /// private readonly Dictionary tempParams = new Dictionary(); + private readonly List tempParamsList = new List(); private void ChangeParams(CharacterParams.TargetParams targetParams, AIState state, float? priority = null) { @@ -3699,49 +3950,51 @@ namespace Barotrauma private void ResetParams(CharacterParams.TargetParams targetParams) { targetParams?.Reset(); - if (selectedTargetingParams == targetParams || State == AIState.Idle || State == AIState.Patrol) + if (currentTargetingParams == targetParams || State is AIState.Idle or AIState.Patrol) { ResetAITarget(); State = AIState.Idle; PreviousState = AIState.Idle; } } - - private void ChangeParams(string tag, AIState state, float? priority = null, bool onlyExisting = false) - => ChangeParams(tag.ToIdentifier(), state, priority, onlyExisting); private void ChangeParams(Identifier tag, AIState state, float? priority = null, bool onlyExisting = false, bool ignoreAttacksIfNotInSameSub = false) { - if (!AIParams.TryGetTarget(tag, out CharacterParams.TargetParams targetParams)) + var existingTargetParams = GetTargetParams(tag); + if (existingTargetParams.None()) { if (!onlyExisting && !tempParams.ContainsKey(tag)) { - if (AIParams.TryAddNewTarget(tag, state, priority ?? minPriority, out targetParams)) + if (AIParams.TryAddNewTarget(tag, state, priority ?? minPriority, out CharacterParams.TargetParams targetParams)) { - if (state == AIState.Attack) - { - // Only applies to new temp target params. Shouldn't affect any existing definitions (handled below). - targetParams.IgnoreIfNotInSameSub = ignoreAttacksIfNotInSameSub; - } tempParams.Add(tag, targetParams); } } } - if (targetParams != null) + else { - if (priority.HasValue) + foreach (var targetParams in existingTargetParams) { - targetParams.Priority = Math.Max(targetParams.Priority, priority.Value); - } - targetParams.State = state; - if (!modifiedParams.ContainsKey(tag)) - { - modifiedParams.Add(tag, targetParams); + if (priority.HasValue) + { + targetParams.Priority = Math.Max(targetParams.Priority, priority.Value); + } + targetParams.State = state; + if (state == AIState.Attack) + { + targetParams.IgnoreIfNotInSameSub = ignoreAttacksIfNotInSameSub; + targetParams.IgnoreInside = false; + targetParams.IgnoreOutside = false; + targetParams.IgnoreTargetInside = false; + targetParams.IgnoreTargetOutside = false; + targetParams.IgnoreIncapacitated = false; + } } + modifiedParams.TryAdd(tag, existingTargetParams); } } - private void ChangeTargetState(string tag, AIState state, float? priority = null) + private void ChangeTargetState(Identifier tag, AIState state, float? priority = null) { isStateChanged = true; SetStateResetTimer(); @@ -3763,12 +4016,15 @@ namespace Barotrauma } if (target.IsHuman) { - priority = GetTargetParams("human")?.Priority; - // Target also items, because if we are blind and the target doesn't move, we can only perceive the target when it uses items - if (state == AIState.Attack || state == AIState.Escape) + if (AIParams.TryGetHighestPriorityTarget(Tags.Human, out CharacterParams.TargetParams targetParams)) { - ChangeParams("weapon", state, priority); - ChangeParams("tool", state, priority); + priority = targetParams.Priority; + } + // Target also items, because if we are blind and the target doesn't move, we can only perceive the target when it uses items + if (state is AIState.Attack or AIState.Escape) + { + ChangeParams(Tags.Weapon, state, priority); + ChangeParams(Tags.ToolItem, state, priority); } if (state == AIState.Attack) { @@ -3776,17 +4032,17 @@ namespace Barotrauma // --> Target the submarine too. if (target.Submarine != null && Character.Submarine == null && (canAttackDoors || canAttackWalls)) { - ChangeParams("room", state, priority / 2); + ChangeParams(Tags.Room, state, priority / 2); if (canAttackWalls) { - ChangeParams("wall", state, priority / 2); + ChangeParams(Tags.Wall, state, priority / 2); } if (canAttackDoors && IsAggressiveBoarder) { - ChangeParams("door", state, priority / 2); + ChangeParams(Tags.Door, state, priority / 2); } } - ChangeParams("provocative", state, priority, onlyExisting: true); + ChangeParams(Tags.Provocative, state, priority, onlyExisting: true); } } } @@ -3801,11 +4057,38 @@ namespace Barotrauma protected override void OnTargetChanged(AITarget previousTarget, AITarget newTarget) { base.OnTargetChanged(previousTarget, newTarget); - if (newTarget == null) { return; } - var targetParams = GetTargetParams(newTarget); - if (targetParams != null) + if ((newTarget != null || wallTarget != null) && IsLatchedOnSub) { - observeTimer = targetParams.Timer * Rand.Range(0.75f, 1.25f); + if (newTarget?.Entity is not Structure wall) + { + wall = wallTarget?.Structure; + } + // The target is not a wall or it's not the same as we are attached to -> release + bool releaseTarget = wall?.Bodies == null || (!wall.Bodies.Contains(LatchOntoAI.AttachJoints[0].BodyB) && wall.Submarine?.PhysicsBody?.FarseerBody != LatchOntoAI.AttachJoints[0].BodyB); + if (!releaseTarget) + { + for (int i = 0; i < wall.Sections.Length; i++) + { + if (CanPassThroughHole(wall, i)) + { + releaseTarget = true; + } + } + } + if (releaseTarget) + { + wallTarget = null; + LatchOntoAI.DeattachFromBody(reset: true, cooldown: 1); + } + } + else + { + wallTarget = null; + } + if (newTarget == null) { return; } + if (currentTargetingParams != null) + { + observeTimer = currentTargetingParams.Timer * Rand.Range(0.75f, 1.25f); } reachTimer = 0; sinTime = 0; @@ -3820,9 +4103,17 @@ namespace Barotrauma LatchOntoAI?.DeattachFromBody(reset: true); if (disableTailCoroutine != null) { - CoroutineManager.StopCoroutines(disableTailCoroutine); - Character.AnimController.RestoreTemporarilyDisabled(); - disableTailCoroutine = null; + bool isInTransition = from is AIState.HideTo or AIState.Hiding && to is AIState.HideTo or AIState.Hiding; + if (!isInTransition) + { + CoroutineManager.StopCoroutines(disableTailCoroutine); + Character.AnimController.RestoreTemporarilyDisabled(); + disableTailCoroutine = null; + } + } + if (to is AIState.Hiding) + { + ReleaseDragTargets(); } Character.AnimController.ReleaseStuckLimbs(); AttackLimb = null; @@ -3839,6 +4130,16 @@ namespace Barotrauma { CirclePhase = CirclePhase.Start; } + if (to != AIState.Idle) + { + playDeadTimer = PlayDeadCoolDown; + } +#if CLIENT + if (to == AIState.Attack) + { + Character.PlaySound(CharacterSound.SoundType.Attack, maxInterval: 3); + } +#endif } private void SetStateResetTimer() => stateResetTimer = stateResetCooldown * Rand.Range(0.75f, 1.25f); @@ -3857,12 +4158,14 @@ namespace Barotrauma bool insideSoundRange; if (checkVisibility) { + Submarine mySub = Character.Submarine; + Submarine targetSub = target.Entity.Submarine; // We only want to check the visibility when the target is in ruins/wreck/similiar place where sneaking should be possible. // When the monsters attack the player sub, they wall hack so that they can be more aggressive. // Pets should always check the visibility, unless the pet and the target are both outside the submarine -> shouldn't target when they can't perceive (= no wall hack) - checkVisibility = - Character.IsPet && (Character.Submarine != null || target.Entity.Submarine != null) || - target.Entity.Submarine != null && target.Entity.Submarine == Character.Submarine && target.Entity.Submarine.TeamID == CharacterTeamType.None; + checkVisibility = + (Character.IsPet && (mySub != null || targetSub != null)) || + (mySub != null && (targetSub == null || (targetSub == mySub && !targetSub.Info.IsPlayer))); } if (dist > 0) { @@ -3918,9 +4221,10 @@ namespace Barotrauma public void ReevaluateAttacks() { - canAttackWalls = LatchOntoAI != null && LatchOntoAI.AttachToSub; + canAttackWalls = LatchOntoAI is { AttachToSub: true }; canAttackDoors = false; canAttackCharacters = false; + canAttackItems = false; foreach (var limb in Character.AnimController.Limbs) { if (limb.IsSevered) { continue; } @@ -3928,11 +4232,17 @@ namespace Barotrauma if (limb.attack == null) { continue; } if (!canAttackWalls) { - canAttackWalls = limb.attack.IsValidTarget(AttackTarget.Structure) && (limb.attack.StructureDamage > 0 || limb.attack.Ranged); + canAttackWalls = (limb.attack.StructureDamage > 0 || limb.attack.Ranged && limb.attack.IsValidTarget(AttackTarget.Structure)); } if (!canAttackDoors) { - canAttackDoors = limb.attack.IsValidTarget(AttackTarget.Structure) && (limb.attack.ItemDamage > 0 || limb.attack.Ranged); + // Doors are technically items, but intentionally treated as structures here. + canAttackDoors = (limb.attack.ItemDamage > 0 || limb.attack.Ranged) && limb.attack.IsValidTarget(AttackTarget.Structure); + } + if (!canAttackItems) + { + // AttackTarget.Structure is also accepted for backwards support. + canAttackItems = canAttackDoors || (limb.attack.ItemDamage > 0 || limb.attack.Ranged) && limb.attack.IsValidTarget(AttackTarget.Structure | AttackTarget.Item); } if (!canAttackCharacters) { @@ -4047,7 +4357,7 @@ namespace Barotrauma State = AIState.Idle; return false; } - else if (SelectedTargetMemory is AITargetMemory targetMemory && SelectedAiTarget?.Entity is Character) + else if (CurrentTargetMemory is AITargetMemory targetMemory && SelectedAiTarget?.Entity is Character) { targetMemory.Priority += deltaTime * PriorityFearIncrement; } @@ -4061,11 +4371,11 @@ namespace Barotrauma else if (canAttackDoors && HasValidPath()) { var door = PathSteering.CurrentPath.CurrentNode?.ConnectedDoor ?? PathSteering.CurrentPath.NextNode?.ConnectedDoor; - if (door != null && !door.CanBeTraversed && !door.HasAccess(Character)) + if (door is { CanBeTraversed: false } && !door.HasAccess(Character)) { if (SelectedAiTarget != door.Item.AiTarget || State != AIState.Attack) { - SelectTarget(door.Item.AiTarget, SelectedTargetMemory.Priority); + SelectTarget(door.Item.AiTarget, CurrentTargetMemory.Priority); State = AIState.Attack; return false; } diff --git a/Barotrauma/BarotraumaShared/SharedSource/Characters/AI/HumanAIController.cs b/Barotrauma/BarotraumaShared/SharedSource/Characters/AI/HumanAIController.cs index 8dd5113ae..c4df6028a 100644 --- a/Barotrauma/BarotraumaShared/SharedSource/Characters/AI/HumanAIController.cs +++ b/Barotrauma/BarotraumaShared/SharedSource/Characters/AI/HumanAIController.cs @@ -317,18 +317,21 @@ namespace Barotrauma { obstacleRaycastTimer = obstacleRaycastIntervalShort; // Swimming outside and using the path finder -> check that the path is not blocked with anything (the path finder doesn't know about other subs). - foreach (var connectedSub in Submarine.MainSub.GetConnectedSubs()) + if (Submarine.MainSub != null) { - if (connectedSub == Submarine.MainSub) { continue; } - Vector2 rayStart = SimPosition - connectedSub.SimPosition; - Vector2 dir = PathSteering.CurrentPath.CurrentNode.WorldPosition - WorldPosition; - Vector2 rayEnd = rayStart + dir.ClampLength(Character.AnimController.Collider.GetLocalFront().Length() * 5); - if (Submarine.CheckVisibility(rayStart, rayEnd, ignoreSubs: true) != null) + foreach (var connectedSub in Submarine.MainSub.GetConnectedSubs()) { - PathSteering.CurrentPath.Unreachable = true; - break; + if (connectedSub == Submarine.MainSub) { continue; } + Vector2 rayStart = SimPosition - connectedSub.SimPosition; + Vector2 dir = PathSteering.CurrentPath.CurrentNode.WorldPosition - WorldPosition; + Vector2 rayEnd = rayStart + dir.ClampLength(Character.AnimController.Collider.GetLocalFront().Length() * 5); + if (Submarine.CheckVisibility(rayStart, rayEnd, ignoreSubs: true) != null) + { + PathSteering.CurrentPath.Unreachable = true; + break; + } } - } + } } } } @@ -345,7 +348,7 @@ namespace Barotrauma enemyCheckTimer -= deltaTime; if (enemyCheckTimer < 0) { - CheckEnemies(); + SpotEnemies(); enemyCheckTimer = enemyCheckInterval * Rand.Range(0.75f, 1.25f); } } @@ -440,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 { @@ -464,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 @@ -561,17 +539,19 @@ 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; } - if (c.Removed || c.IsDead || c.IsIncapacitated) { continue; } + if (c.Removed || c.IsDead || c.IsIncapacitated || c.InDetectable) { continue; } if (IsFriendly(c)) { continue; } Vector2 toTarget = c.WorldPosition - WorldPosition; float dist = toTarget.LengthSquared(); @@ -593,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() @@ -603,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) { @@ -618,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. @@ -725,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 @@ -761,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)) @@ -770,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 @@ -801,36 +781,41 @@ namespace Barotrauma if (isCarrying) { return; } if (!ObjectiveManager.CurrentObjective.AllowAutomaticItemUnequipping || !ObjectiveManager.GetActiveObjective().AllowAutomaticItemUnequipping) { return; } - if (findItemState == FindItemState.None || findItemState == FindItemState.OtherItem) + if (Character.Submarine?.TeamID == Character.TeamID && findItemState is FindItemState.None or FindItemState.OtherItem) { + // Only unequip other items inside a friendly sub. foreach (Item item in Character.HeldItems) { if (item == null || !item.IsInteractable(Character)) { continue; } - - if (!item.AllowedSlots.Contains(InvSlotType.Any) || !Character.Inventory.TryPutItem(item, Character, CharacterInventory.AnySlot) && Character.Submarine?.TeamID == Character.TeamID) + if (Character.TryPutItemInAnySlot(item)) { continue; } + if (Character.TryPutItemInBag(item)) { continue; } + if (item.HasTag(Tags.Weapon)) { - if (item.AllowedSlots.Contains(InvSlotType.Bag) && Character.Inventory.TryPutItem(item, Character, new List() { InvSlotType.Bag })) { continue; } - findItemState = FindItemState.OtherItem; - if (FindSuitableContainer(item, out Item targetContainer)) + // 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 move those weapons in containers either. + continue; + } + findItemState = FindItemState.OtherItem; + if (FindSuitableContainer(item, out Item targetContainer)) + { + findItemState = FindItemState.None; + itemIndex = 0; + if (targetContainer != null) { - findItemState = FindItemState.None; - itemIndex = 0; - if (targetContainer != null) + var moveItemObjective = new AIObjectiveMoveItem(Character, item, ObjectiveManager, targetContainer: targetContainer.GetComponent()); + moveItemObjective.Abandoned += () => { - var decontainObjective = new AIObjectiveDecontainItem(Character, item, ObjectiveManager, targetContainer: targetContainer.GetComponent()); - decontainObjective.Abandoned += () => - { - ReequipUnequipped(); - IgnoredItems.Add(targetContainer); - }; - ObjectiveManager.CurrentObjective.AddSubObjective(decontainObjective, addFirst: true); - return; - } - else - { - item.Drop(Character); - HandleRelocation(item); - } + ReequipUnequipped(); + IgnoredItems.Add(targetContainer); + }; + ObjectiveManager.CurrentObjective.AddSubObjective(moveItemObjective, addFirst: true); + return; + } + else + { + item.Drop(Character); + HandleRelocation(item); } } } @@ -842,7 +827,7 @@ namespace Barotrauma public void HandleRelocation(Item item) { if (item.SpawnedInCurrentOutpost) { return; } - if (item.Submarine == null) { return; } + if (item.Submarine == null || Submarine.MainSub == null) { return; } // Only affects bots in the player team if (!Character.IsOnPlayerTeam) { return; } // Don't relocate if the item is on a sub of the same team @@ -869,6 +854,7 @@ namespace Barotrauma if (item == null || item.Removed) { return; } if (!itemsToRelocate.Contains(item)) { return; } var mainSub = Submarine.MainSub; + if (mainSub == null) { return; } Entity owner = item.GetRootInventoryOwner(); if (owner != null) { @@ -1036,13 +1022,13 @@ namespace Barotrauma { foreach (Character target in Character.CharacterList) { - if (target.CurrentHull != hull || !target.Enabled) { continue; } - if (AIObjectiveFightIntruders.IsValidTarget(target, Character, false)) + if (target.CurrentHull != hull || !target.Enabled || target.InDetectable) { continue; } + 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) { @@ -1060,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)) @@ -1067,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) @@ -1086,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; } } @@ -1101,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; } } @@ -1126,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 } } @@ -1163,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); @@ -1295,7 +1297,13 @@ namespace Barotrauma //if (Character.LastDamageSource == null) { return; } //AddCombatObjective(AIObjectiveCombat.CombatMode.Retreat, Rand.Range(0.5f, 1f, Rand.RandSync.Unsynced)); } - if (realDamage <= 0 && (attacker.IsBot || attacker.TeamID == Character.TeamID)) + + bool sameTeam = + attacker.TeamID == Character.TeamID || + // consider escorted characters to be in the same team (otherwise accidental damage or side-effects from healing trigger them too easily) + (attacker.TeamID == CharacterTeamType.Team1 && Character.IsEscorted); + + if (realDamage <= 0 && (attacker.IsBot || sameTeam)) { // Don't react to damage that is entirely based on karma penalties (medics, poisons etc), unless applier is player return; @@ -1307,9 +1315,9 @@ namespace Barotrauma } bool isAttackerInfected = false; bool isAttackerFightingEnemy = false; - float minorDamageThreshold = 1; + float minorDamageThreshold = 5; float majorDamageThreshold = 20; - if (attacker.TeamID == Character.TeamID && !attacker.IsInstigator) + if (sameTeam && !attacker.IsInstigator) { minorDamageThreshold = 10; majorDamageThreshold = 40; @@ -1338,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); } @@ -1406,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; @@ -1417,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); } } @@ -1461,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)) { @@ -1511,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; @@ -1532,6 +1561,7 @@ namespace Barotrauma } else if (cumulativeDamage > minorDamageThreshold) { + attacker.IsActingOffensively = true; return c.IsSecurity ? AIObjectiveCombat.CombatMode.Arrest : AIObjectiveCombat.CombatMode.Retreat; } else @@ -1596,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 @@ -1681,21 +1710,31 @@ namespace Barotrauma public bool AllowCampaignInteraction() { - if (Character == null || Character.Removed || Character.IsIncapacitated) { return false; } + if (Character == null || Character.Removed) { return false; } - switch (ObjectiveManager.CurrentObjective) + //some events might want to allow talking/examining characters that are incapacitated or in some "emergency" ai state, + //so let's ignore those here + var type = Character.CampaignInteractionType; + if (type != CampaignMode.InteractionType.None && + type != CampaignMode.InteractionType.Talk && + type != CampaignMode.InteractionType.Examine) { - case AIObjectiveCombat _: - case AIObjectiveFindSafety _: - case AIObjectiveExtinguishFires _: - case AIObjectiveFightIntruders _: - case AIObjectiveFixLeaks _: - return false; + if (Character.IsIncapacitated) { return false; } + switch (ObjectiveManager.CurrentObjective) + { + case AIObjectiveCombat _: + case AIObjectiveFindSafety _: + case AIObjectiveExtinguishFires _: + case AIObjectiveFightIntruders _: + case AIObjectiveFixLeaks _: + return false; + } } 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; @@ -1704,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) @@ -1839,6 +1882,7 @@ namespace Barotrauma if (combatMode == AIObjectiveCombat.CombatMode.Offensive) { character.IsCriminal = true; + character.IsActingOffensively = true; } if (!TriggerSecurity(otherHumanAI, combatMode)) { @@ -1879,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) { @@ -1992,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) { @@ -2013,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()) { @@ -2044,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); } @@ -2123,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); @@ -2133,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)); @@ -2157,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 == null ? calculateFire(hull) : visibleHulls.Sum(h => calculateFire(h)); + 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; @@ -2212,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)) { @@ -2230,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) @@ -2255,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) + if (onlySameTeam) + { + ignoreHuskDisguising = true; + } + if (other.IsHusk && !ignoreHuskDisguising) { - // Disguised as husk return me.IsDisguisedAsHusk; } else @@ -2290,16 +2370,15 @@ namespace Barotrauma { if (!me.IsSameSpeciesOrGroup(other)) { return false; } } - if (GameMain.GameSession?.GameMode is CampaignMode) + if (GameMain.GameSession?.GameMode is CampaignMode && + //ignore hostile faction if offering services that don't get disabled by faction hostility + (me.CampaignInteractionType == CampaignMode.InteractionType.None || CampaignMode.HostileFactionDisablesInteraction(me.CampaignInteractionType))) { if ((me.TeamID == CharacterTeamType.FriendlyNPC && other.TeamID == CharacterTeamType.Team1) || (me.TeamID == CharacterTeamType.Team1 && other.TeamID == CharacterTeamType.FriendlyNPC)) { Character npc = me.TeamID == CharacterTeamType.FriendlyNPC ? me : other; - //NPCs that allow some campaign interaction are not turned hostile by low reputation - if (npc.CampaignInteractionType != CampaignMode.InteractionType.None) { return true; } - if (npc.AIController is HumanAIController npcAI) { return !npcAI.IsInHostileFaction(); @@ -2332,7 +2411,7 @@ namespace Barotrauma return false; } - public static bool IsActive(Character c) => c != null && c.Enabled && !c.IsUnconscious; + public static bool IsActive(Character c) => c is { Enabled: true, IsUnconscious: false }; public static bool IsTrueForAllBotsInTheCrew(Character character, Func predicate) { @@ -2344,7 +2423,7 @@ namespace Barotrauma { return false; } - } + } return true; } @@ -2412,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)) { @@ -2453,11 +2532,11 @@ namespace Barotrauma operatingCharacter = c; return true; } - if (c.AIController is HumanAIController humanAI && humanAI.ObjectiveManager is AIObjectiveManager objectiveManager) + if (c.AIController is HumanAIController { ObjectiveManager: AIObjectiveManager objectiveManager }) { foreach (var objective in objectiveManager.Objectives) { - if (!(objective is AIObjectiveOperateItem operateObjective)) { continue; } + if (objective is not AIObjectiveOperateItem operateObjective) { continue; } if (operateObjective.Component?.Item != target.Item) { continue; } if (operateObjective.Priority < highestPriority) { continue; } if (operateObjective.PriorityModifier < highestPriorityModifier) { continue; } @@ -2470,135 +2549,6 @@ namespace Barotrauma return operatingCharacter != null; } - // There's some duplicate logic in the two methods below, but making them use the same code would require some changes in the target classes so that we could use exactly the same checks. - // And even then there would be some differences that could end up being confusing (like the exception for steering). - public bool IsItemOperatedByAnother(ItemComponent target, out Character other) - { - other = null; - if (target?.Item == null) { return false; } - bool isOrder = IsOrderedToOperateThis(Character.AIController); - foreach (Character c in Character.CharacterList) - { - if (!IsActive(c)) { continue; } - if (c == Character) { continue; } - if (c.TeamID != Character.TeamID) { continue; } - if (c.IsPlayer) - { - if (c.SelectedItem == target.Item) - { - // If the other character is player, don't try to operate - other = c; - break; - } - } - else if (c.AIController is HumanAIController operatingAI) - { - if (operatingAI.ObjectiveManager.Objectives.None(o => o is AIObjectiveOperateItem operateObjective && operateObjective.Component.Item == target.Item)) - { - // Not targeting the same item. - continue; - } - bool isTargetOrdered = IsOrderedToOperateThis(c.AIController); - if (!isOrder && isTargetOrdered) - { - // If the other bot is ordered to operate the item, let him do it, unless we are ordered too - other = c; - break; - } - else - { - if (isOrder && !isTargetOrdered) - { - // We are ordered and the target is not -> allow to operate - continue; - } - else - { - if (!isTargetOrdered && operatingAI.ObjectiveManager.CurrentOrder != operatingAI.ObjectiveManager.CurrentObjective) - { - // The other bot is ordered to do something else - continue; - } - if (target is Steering) - { - // Steering is hard-coded -> cannot use the required skills collection defined in the xml - if (Character.GetSkillLevel("helm") <= c.GetSkillLevel("helm")) - { - other = c; - break; - } - } - else if (target.DegreeOfSuccess(Character) <= target.DegreeOfSuccess(c)) - { - other = c; - break; - } - } - } - } - } - return other != null; - bool IsOrderedToOperateThis(AIController ai) => ai is HumanAIController humanAI && humanAI.ObjectiveManager.CurrentOrder is AIObjectiveOperateItem operateOrder && operateOrder.Component.Item == target.Item; - } - - public bool IsItemRepairedByAnother(Item target, out Character other) - { - other = null; - if (Character == null) { return false; } - if (target == null) { return false; } - bool isOrder = IsOrderedToRepairThis(Character.AIController as HumanAIController); - foreach (var c in Character.CharacterList) - { - if (!IsActive(c)) { continue; } - if (c == Character) { continue; } - if (c.TeamID != Character.TeamID) { continue; } - other = c; - if (c.IsPlayer) - { - if (target.Repairables.Any(r => r.CurrentFixer == c)) - { - // If the other character is player, don't try to repair - return true; - } - } - else if (c.AIController is HumanAIController operatingAI) - { - var repairItemsObjective = operatingAI.ObjectiveManager.GetObjective(); - if (repairItemsObjective == null) { continue; } - if (repairItemsObjective.SubObjectives.FirstOrDefault(o => o is AIObjectiveRepairItem) is not AIObjectiveRepairItem activeObjective || activeObjective.Item != target) - { - // Not targeting the same item. - continue; - } - bool isTargetOrdered = IsOrderedToRepairThis(operatingAI); - if (!isOrder && isTargetOrdered) - { - // If the other bot is ordered to repair the item, let him do it, unless we are ordered too - return true; - } - else - { - if (isOrder && !isTargetOrdered) - { - // We are ordered and the target is not -> allow to repair - continue; - } - else - { - if (!isTargetOrdered && operatingAI.ObjectiveManager.CurrentOrder != operatingAI.ObjectiveManager.CurrentObjective) - { - // The other bot is ordered to do something else - continue; - } - return target.Repairables.Max(r => r.DegreeOfSuccess(Character)) <= target.Repairables.Max(r => r.DegreeOfSuccess(c)); - } - } - } - } - return false; - bool IsOrderedToRepairThis(HumanAIController ai) => ai.ObjectiveManager.CurrentOrder is AIObjectiveRepairItems repairOrder && repairOrder.PrioritizedItem == target; - } - #region Wrappers public bool IsFriendly(Character other, bool onlySameTeam = false) => IsFriendly(Character, other, onlySameTeam); public bool IsTrueForAnyBotInTheCrew(Func predicate) => IsTrueForAnyBotInTheCrew(Character, predicate); diff --git a/Barotrauma/BarotraumaShared/SharedSource/Characters/AI/IndoorsSteeringManager.cs b/Barotrauma/BarotraumaShared/SharedSource/Characters/AI/IndoorsSteeringManager.cs index 5ab7b6829..b9c0d75f3 100644 --- a/Barotrauma/BarotraumaShared/SharedSource/Characters/AI/IndoorsSteeringManager.cs +++ b/Barotrauma/BarotraumaShared/SharedSource/Characters/AI/IndoorsSteeringManager.cs @@ -320,8 +320,7 @@ namespace Barotrauma Vector2 pos = host.WorldPosition; Vector2 diff = currentPath.CurrentNode.WorldPosition - pos; bool isDiving = character.AnimController.InWater && character.AnimController.HeadInWater; - // Only humanoids can climb ladders - bool canClimb = character.AnimController is HumanoidAnimController && !character.LockHands; + bool canClimb = character.CanClimb; Ladder currentLadder = GetCurrentLadder(); Ladder nextLadder = GetNextLadder(); var ladders = currentLadder ?? nextLadder; @@ -559,26 +558,42 @@ namespace Barotrauma } else { - // We'll want this to run each time, because the delegate is used to find a valid button component. bool canAccessButtons = false; - foreach (var button in door.Item.GetConnectedComponents(true, connectionFilter: c => c.Name == "toggle" || c.Name == "set_state")) + bool buttonsFound = false; + // Check wired controllers (e.g. buttons) + // Always run the buttonFilter delegate (inside CanAccessButton method), if defined, because it's used for find a valid controller component that can be used for closing the door, when needed. + // TODO: connectionFilter is ignored in the recursive searches, so it does nothing here. + foreach (Controller button in door.Item.GetConnectedComponents(recursive: true, connectionFilter: c => c.Name is "toggle" or "set_state")) { - if (button.HasAccess(character) && (buttonFilter == null || buttonFilter(button))) + buttonsFound = true; + if (CanAccessButton(button)) { canAccessButtons = true; } } - foreach (var linked in door.Item.linkedTo) + if (!canAccessButtons) { - if (linked is not Item linkedItem) { continue; } - var button = linkedItem.GetComponent(); - if (button == null) { continue; } - if (button.HasAccess(character) && (buttonFilter == null || buttonFilter(button))) + // Check linked controllers (more complex circuits) + foreach (MapEntity linked in door.Item.linkedTo) { - canAccessButtons = true; - } - } - return canAccessButtons || door.IsOpen || ShouldBreakDoor(door); + if (linked is not Item linkedItem) { continue; } + var button = linkedItem.GetComponent(); + if (button == null) { continue; } + buttonsFound = true; + if (CanAccessButton(button)) + { + canAccessButtons = true; + } + } + } + if (door.IsOpen || ShouldBreakDoor(door)) + { + return true; + } + // If no buttons were found, just trust it if we should have the access to the door. Could be there's some other mechanism controlling the door. + return buttonsFound ? canAccessButtons : door.HasAccess(character); + + bool CanAccessButton(Controller button) => button.HasAccess(character) && (buttonFilter == null || buttonFilter(button)); } } @@ -713,12 +728,15 @@ namespace Barotrauma float distance = Vector2.DistanceSquared(button.Item.WorldPosition, character.WorldPosition); //heavily prefer buttons linked to the door, so sub builders can help the bots figure out which button to use by linking them if (door.Item.linkedTo.Contains(button.Item)) { distance *= 0.1f; } - if (closestButton == null || distance < closestDist && character.CanSeeTarget(button.Item)) + if (closestButton == null || distance < closestDist) { - closestButton = button; - closestDist = distance; + if (distance < MathUtils.Pow2(button.Item.InteractDistance + GetColliderLength()) && character.CanSeeTarget(button.Item)) + { + closestButton = button; + closestDist = distance; + } } - return true; + return closestButton != null; }); if (canAccess) { @@ -741,41 +759,19 @@ namespace Barotrauma } else if (closestButton != null) { - if (closestDist < MathUtils.Pow2(closestButton.Item.InteractDistance + GetColliderLength())) + if (pressButton) { - if (pressButton) + if (closestButton.Item.TryInteract(character, forceSelectKey: true)) { - if (closestButton.Item.TryInteract(character, forceSelectKey: true)) - { - lastDoor = (door, shouldBeOpen); - buttonPressTimer = shouldBeOpen ? ButtonPressCooldown : 0; - } - else - { - buttonPressTimer = 0; - } + lastDoor = (door, shouldBeOpen); + buttonPressTimer = shouldBeOpen ? ButtonPressCooldown : 0; } - break; - } - else - { - // Can't reach the button closest to the character. - // It's possible that we could reach another buttons. - // If this becomes an issue, we could go through them here and check if any of them are reachable - // (would have to cache a collection of buttons instead of a single reference in the CanAccess filter method above) - var body = Submarine.PickBody(character.SimPosition, character.GetRelativeSimPosition(closestButton.Item), collisionCategory: Physics.CollisionWall | Physics.CollisionLevel); - if (body != null) + else { - if (body.UserData is Item item) - { - var d = item.GetComponent(); - if (d == null || d.IsOpen) { return; } - } - // The button is on the wrong side of the door or a wall - currentPath.Unreachable = true; + buttonPressTimer = 0; } - return; } + break; } } else if (shouldBeOpen) @@ -796,10 +792,9 @@ 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; - //non-humanoids can't climb up ladders - if (!(character.AnimController is HumanoidAnimController)) + if (!character.CanClimb) { - if (node.Waypoint.Ladders != null && nextNode.Waypoint.Ladders != null && (!nextNode.Waypoint.Ladders.Item.IsInteractable(character) || character.LockHands)|| + 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 nextNodeAboveWaterLevel)) //upper node not underwater { @@ -847,7 +842,7 @@ namespace Barotrauma if (!node.Waypoint.IsTraversable) { return null; } if (node.IsBlocked()) { return null; } float penalty = 0.0f; - if (node.Waypoint.ConnectedGap != null && node.Waypoint.ConnectedGap.Open < 0.9f) + if (node.Waypoint.ConnectedGap is { Open: < 0.9f }) { var door = node.Waypoint.ConnectedDoor; if (door == null) @@ -858,19 +853,29 @@ namespace Barotrauma { if (!CanAccessDoor(door, button => { - // Ignore buttons that are on the wrong side of the door + if (Vector2.DistanceSquared(door.Item.WorldPosition, button.Item.WorldPosition) > MathUtils.Pow2(button.Item.InteractDistance + GetColliderLength())) + { + // Too far from the door. + return false; + } + if (!ISpatialEntity.IsTargetVisible(button.Item, door.Item)) + { + // Obstructed. + return false; + } + // Ignore buttons that are on the wrong side of the door, unless there's a motion sensor connected to the door, which can be triggered by the character. if (door.IsHorizontal) { if (Math.Sign(button.Item.WorldPosition.Y - door.Item.WorldPosition.Y) != Math.Sign(character.WorldPosition.Y - door.Item.WorldPosition.Y)) { - return false; + return door.Item.GetDirectlyConnectedComponent() is MotionSensor ms && ms.TriggersOn(character); } } else { if (Math.Sign(button.Item.WorldPosition.X - door.Item.WorldPosition.X) != Math.Sign(character.WorldPosition.X - door.Item.WorldPosition.X)) { - return false; + return door.Item.GetDirectlyConnectedComponent() is MotionSensor ms && ms.TriggersOn(character); } } return true; diff --git a/Barotrauma/BarotraumaShared/SharedSource/Characters/AI/LatchOntoAI.cs b/Barotrauma/BarotraumaShared/SharedSource/Characters/AI/LatchOntoAI.cs index a7cd0d8cd..7b6ec2cc6 100644 --- a/Barotrauma/BarotraumaShared/SharedSource/Characters/AI/LatchOntoAI.cs +++ b/Barotrauma/BarotraumaShared/SharedSource/Characters/AI/LatchOntoAI.cs @@ -308,7 +308,7 @@ namespace Barotrauma if (enemyAI.AttackLimb == null) { break; } if (targetBody == null) { break; } if (IsAttached && AttachJoints[0].BodyB == targetBody) { break; } - Vector2 referencePos = TargetCharacter != null ? TargetCharacter.WorldPosition : ConvertUnits.ToDisplayUnits(transformedAttachPos); + Vector2 referencePos = TargetCharacter?.WorldPosition ?? ConvertUnits.ToDisplayUnits(transformedAttachPos); if (Vector2.DistanceSquared(referencePos, enemyAI.AttackLimb.WorldPosition) < enemyAI.AttackLimb.attack.DamageRange * enemyAI.AttackLimb.attack.DamageRange) { AttachToBody(transformedAttachPos); diff --git a/Barotrauma/BarotraumaShared/SharedSource/Characters/AI/Objectives/AIObjective.cs b/Barotrauma/BarotraumaShared/SharedSource/Characters/AI/Objectives/AIObjective.cs index c2d9bf373..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; @@ -513,18 +513,19 @@ namespace Barotrauma ///
private bool Check() { + if (isCompleted) { return true; } if (AbortCondition != null && AbortCondition(this)) { Abandon = true; return false; } - return CheckObjectiveSpecific(); + return CheckObjectiveState(); } /// /// Should return whether the objective is completed or not. /// - protected abstract bool CheckObjectiveSpecific(); + protected abstract bool CheckObjectiveState(); private bool CheckState() { @@ -574,8 +575,6 @@ namespace Barotrauma } } - public virtual void SpeakAfterOrderReceived() { } - protected static bool CanPutInInventory(Character character, Item item, bool allowWearing) { if (item == null) { return false; } diff --git a/Barotrauma/BarotraumaShared/SharedSource/Characters/AI/Objectives/AIObjectiveCheckStolenItems.cs b/Barotrauma/BarotraumaShared/SharedSource/Characters/AI/Objectives/AIObjectiveCheckStolenItems.cs index 931215416..eb95ccebc 100644 --- a/Barotrauma/BarotraumaShared/SharedSource/Characters/AI/Objectives/AIObjectiveCheckStolenItems.cs +++ b/Barotrauma/BarotraumaShared/SharedSource/Characters/AI/Objectives/AIObjectiveCheckStolenItems.cs @@ -45,22 +45,25 @@ namespace Barotrauma InitTimers(); } - protected override bool CheckObjectiveSpecific() => false; + 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 e1f1e3ae9..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); } @@ -117,7 +117,7 @@ namespace Barotrauma objectiveManager.GetObjective().Wander(deltaTime); } - protected override bool CheckObjectiveSpecific() + protected override bool CheckObjectiveState() { if (item.IgnoreByAI(character) || Item.DeconstructItems.Contains(item)) { @@ -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/AIObjectiveCleanupItems.cs b/Barotrauma/BarotraumaShared/SharedSource/Characters/AI/Objectives/AIObjectiveCleanupItems.cs index 78c98414a..a8eeb0daf 100644 --- a/Barotrauma/BarotraumaShared/SharedSource/Characters/AI/Objectives/AIObjectiveCleanupItems.cs +++ b/Barotrauma/BarotraumaShared/SharedSource/Characters/AI/Objectives/AIObjectiveCleanupItems.cs @@ -81,6 +81,8 @@ namespace Barotrauma public static bool IsItemInsideValidSubmarine(Item item, Character character) { + if (item == null || item.Removed) { return false; } + if (character == null || character.Removed) { return false; } if (item.CurrentHull == null) { return false; } if (item.Submarine == null) { return false; } if (item.Submarine.TeamID != character.TeamID) { return false; } diff --git a/Barotrauma/BarotraumaShared/SharedSource/Characters/AI/Objectives/AIObjectiveCombat.cs b/Barotrauma/BarotraumaShared/SharedSource/Characters/AI/Objectives/AIObjectiveCombat.cs index 313448642..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 @@ -74,7 +77,6 @@ namespace Barotrauma private float pathBackTimer; private const float DefaultCoolDown = 10.0f; private const float PathBackCheckTime = 1.0f; - private IEnumerable myBodies; private float aimTimer; private float reloadTimer; private float spreadTimer; @@ -88,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; @@ -169,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(); @@ -230,6 +228,7 @@ namespace Barotrauma public override void Update(float deltaTime) { base.Update(deltaTime); + isAimBlocked = false; ignoreWeaponTimer -= deltaTime; checkWeaponsTimer -= deltaTime; if (reloadTimer > 0) @@ -257,7 +256,7 @@ namespace Barotrauma } } - protected override bool CheckObjectiveSpecific() + protected override bool CheckObjectiveState() { if (character.Submarine is { TeamID: CharacterTeamType.FriendlyNPC } && character.Submarine == Enemy.Submarine) { @@ -332,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; + } } } @@ -408,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; @@ -434,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. @@ -480,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); @@ -489,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 => { @@ -582,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: @@ -790,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; @@ -898,23 +1001,13 @@ namespace Barotrauma } } } - - private void Unequip() + + private void UnequipWeapon() { - if (!character.LockHands && character.HeldItems.Contains(Weapon)) - { - if (!Weapon.AllowedSlots.Contains(InvSlotType.Any) || !character.Inventory.TryPutItem(Weapon, character, new List() { InvSlotType.Any })) - { - if (Weapon.AllowedSlots.Contains(InvSlotType.Bag)) - { - if (character.Inventory.TryPutItem(Weapon, character, new List() { InvSlotType.Bag })) - { - return; - } - } - Weapon.Drop(character); - } - } + if (Weapon == null) { return; } + if (character.LockHands) { return; } + if (character.HeldItems.Contains(Weapon)) { return; } + character.Unequip(Weapon); } private bool Equip() @@ -929,7 +1022,15 @@ namespace Barotrauma ClearInputs(); Weapon.TryInteract(character, forceSelectKey: true); var slots = Weapon.AllowedSlots.Where(CharacterInventory.IsHandSlotType); - if (character.Inventory.TryPutItem(Weapon, character, slots)) + bool successfullyEquipped = character.TryPutItem(Weapon, slots); + if (!successfullyEquipped && character.HasHandsFull(out (Item leftHandItem, Item rightHandItem) items)) + { + // Unequip and try again. + character.Unequip(items.leftHandItem); + character.Unequip(items.rightHandItem); + successfullyEquipped = character.TryPutItem(Weapon, slots); + } + if (successfullyEquipped) { SetAimTimer(Rand.Range(0.2f, 0.4f) / AimSpeed); SetReloadTime(WeaponComponent); @@ -950,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. @@ -1047,6 +1149,7 @@ namespace Barotrauma { if (sqrDistance > MathUtils.Pow2(meleeWeapon.Range)) { + isMoving = true; character.ReleaseSecondaryItem(); // Swim towards the target SteeringManager.Reset(); @@ -1097,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) @@ -1122,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() @@ -1145,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); @@ -1172,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)) @@ -1205,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, @@ -1227,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)) { @@ -1270,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 @@ -1280,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; @@ -1322,8 +1451,6 @@ namespace Barotrauma aimTimer -= deltaTime; return; } - if (reloadTimer > 0) { return; } - if (holdFireCondition != null && holdFireCondition()) { return; } sqrDistance = Vector2.DistanceSquared(character.WorldPosition, Enemy.WorldPosition); distanceTimer = DistanceCheckInterval; if (WeaponComponent is MeleeWeapon meleeWeapon) @@ -1353,9 +1480,11 @@ namespace Barotrauma if (closeEnough && Enemy.WorldPosition.Y < character.WorldPosition.Y && yDiff > 25) { // The target is probably knocked down? -> try to reach it by crouching. - HumanAIController.AnimController.Crouching = true; + HumanAIController.AnimController.Crouch(); } } + if (reloadTimer > 0) { return; } + if (holdFireCondition != null && holdFireCondition()) { return; } if (closeEnough) { UseWeapon(deltaTime); @@ -1371,36 +1500,59 @@ namespace Barotrauma { if (WeaponComponent is RepairTool repairTool) { - if (sqrDistance > repairTool.Range * repairTool.Range) { return; } + float reach = AIObjectiveFixLeak.CalculateReach(repairTool, character); + if (sqrDistance > reach * reach) { return; } } float aimFactor = MathHelper.PiOver2 * (1 - AimAccuracy); if (VectorExtensions.Angle(VectorExtensions.Forward(Weapon.body.TransformedRotation), Enemy.WorldPosition - Weapon.WorldPosition) < MathHelper.PiOver4 + aimFactor) { - myBodies ??= character.AnimController.Limbs.Select(l => l.body.FarseerBody); // Check that we don't hit friendlies. No need to check the walls, because there's a separate check for that at 1096 (which intentionally has a small delay) - var pickedBodies = Submarine.PickBodies(Weapon.SimPosition, Submarine.GetRelativeSimPosition(from: Weapon, to: Enemy), myBodies, Physics.CollisionCharacter); + 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); @@ -1420,11 +1572,8 @@ namespace Barotrauma break; } case MeleeWeapon mw: - { - if (character.AnimController is HumanoidAnimController { Crouching: false }) - { - reloadTime = mw.Reload; - } + { + reloadTime = mw.Reload; break; } } @@ -1485,7 +1634,7 @@ namespace Barotrauma } if (ShouldUnequipWeapon) { - Unequip(); + UnequipWeapon(); } SteeringManager?.Reset(); } @@ -1495,7 +1644,7 @@ namespace Barotrauma base.OnAbandon(); if (ShouldUnequipWeapon) { - Unequip(); + UnequipWeapon(); } SteeringManager?.Reset(); } @@ -1516,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 f6d2702e2..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 @@ -77,9 +79,8 @@ namespace Barotrauma this.container = container; } - protected override bool CheckObjectiveSpecific() + protected override bool CheckObjectiveState() { - if (IsCompleted) { return true; } if (container?.Item == null || !container.Item.HasAccess(character)) { Abandon = true; @@ -148,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); @@ -196,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) || @@ -232,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 5818d9a10..9fc63b0dd 100644 --- a/Barotrauma/BarotraumaShared/SharedSource/Characters/AI/Objectives/AIObjectiveDeconstructItem.cs +++ b/Barotrauma/BarotraumaShared/SharedSource/Characters/AI/Objectives/AIObjectiveDeconstructItem.cs @@ -14,7 +14,8 @@ 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) : base(character, objectiveManager, priorityModifier) @@ -36,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, @@ -45,15 +46,25 @@ namespace Barotrauma }, onCompleted: () => { - StartDeconstructor(); - //make sure the item gets moved to the main sub if the crew leaves while a bot is deconstructing something in the outpost - if (deconstructor.Item.Submarine is { Info.IsOutpost: true }) + if (character.CanInteractWith(deconstructor.Item)) { - HumanAIController.HandleRelocation(Item); - deconstructor.RelocateOutputToMainSub = true; + StartDeconstruction(); } - IsCompleted = true; - RemoveSubObjective(ref decontainObjective); + else + { + TryAddSubObjective(ref gotoObjective, + constructor: () => new AIObjectiveGoTo(Item, character, objectiveManager, priorityModifier: PriorityModifier), + onCompleted: () => + { + StartDeconstruction(); + RemoveSubObjective(ref gotoObjective); + }, + onAbandon: () => + { + Abandon = true; + }); + } + RemoveSubObjective(ref moveItemObjective); }, onAbandon: () => { @@ -61,6 +72,18 @@ namespace Barotrauma }); } + private void StartDeconstruction() + { + StartDeconstructor(); + //make sure the item gets moved to the main sub if the crew leaves while a bot is deconstructing something in the outpost + if (deconstructor.Item.Submarine is { Info.IsOutpost: true }) + { + HumanAIController.HandleRelocation(Item); + deconstructor.RelocateOutputToMainSub = true; + } + IsCompleted = true; + } + private Deconstructor FindDeconstructor() { Deconstructor closestDeconstructor = null; @@ -86,7 +109,7 @@ namespace Barotrauma deconstructor.SetActive(active: true, user: character, createNetworkEvent: true); } - protected override bool CheckObjectiveSpecific() + protected override bool CheckObjectiveState() { if (Item.IgnoreByAI(character)) { @@ -102,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/AIObjectiveDeconstructItems.cs b/Barotrauma/BarotraumaShared/SharedSource/Characters/AI/Objectives/AIObjectiveDeconstructItems.cs index 3b25c239e..4f7b6d28b 100644 --- a/Barotrauma/BarotraumaShared/SharedSource/Characters/AI/Objectives/AIObjectiveDeconstructItems.cs +++ b/Barotrauma/BarotraumaShared/SharedSource/Characters/AI/Objectives/AIObjectiveDeconstructItems.cs @@ -59,13 +59,15 @@ namespace Barotrauma protected override bool IsValidTarget(Item target) { + if (target == null || target.Removed) { return false; } // If the target was selected as a valid target, we'll have to accept it so that the objective can be completed. // The validity changes when a character picks the item up. if (!IsValidTarget(target, character, checkInventory: true)) { return Objectives.ContainsKey(target) && AIObjectiveCleanupItems.IsItemInsideValidSubmarine(target, character); } - if (target.CurrentHull.FireSources.Count > 0) { return false; } + //note that the item can be outside hulls and still be a valid target - it can be in the character's inventory + if (target.CurrentHull != null && target.CurrentHull.FireSources.Count > 0) { return false; } foreach (Character c in Character.CharacterList) { @@ -96,7 +98,7 @@ namespace Barotrauma private static bool IsValidTarget(Item item, Character character, bool checkInventory) { - if (item == null) { return false; } + if (item == null || item.Removed) { return false; } if (item.GetRootInventoryOwner() == character) { return true; } return AIObjectiveCleanupItems.IsValidTarget( item, diff --git a/Barotrauma/BarotraumaShared/SharedSource/Characters/AI/Objectives/AIObjectiveEscapeHandcuffs.cs b/Barotrauma/BarotraumaShared/SharedSource/Characters/AI/Objectives/AIObjectiveEscapeHandcuffs.cs index 995f8bb8b..47588af40 100644 --- a/Barotrauma/BarotraumaShared/SharedSource/Characters/AI/Objectives/AIObjectiveEscapeHandcuffs.cs +++ b/Barotrauma/BarotraumaShared/SharedSource/Characters/AI/Objectives/AIObjectiveEscapeHandcuffs.cs @@ -28,7 +28,7 @@ namespace Barotrauma } public override bool CanBeCompleted => true; - protected override bool CheckObjectiveSpecific() => false; + protected override bool CheckObjectiveState() => false; // escape timer is set to 60 by default to allow players to locate prisoners in time private float escapeTimer = 60f; diff --git a/Barotrauma/BarotraumaShared/SharedSource/Characters/AI/Objectives/AIObjectiveExtinguishFire.cs b/Barotrauma/BarotraumaShared/SharedSource/Characters/AI/Objectives/AIObjectiveExtinguishFire.cs index 302f7a462..53ec38880 100644 --- a/Barotrauma/BarotraumaShared/SharedSource/Characters/AI/Objectives/AIObjectiveExtinguishFire.cs +++ b/Barotrauma/BarotraumaShared/SharedSource/Characters/AI/Objectives/AIObjectiveExtinguishFire.cs @@ -39,58 +39,57 @@ 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; } - protected override bool CheckObjectiveSpecific() => targetHull.FireSources.None(); + protected override bool CheckObjectiveState() => targetHull.FireSources.None(); 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 43a0197ba..cd3996752 100644 --- a/Barotrauma/BarotraumaShared/SharedSource/Characters/AI/Objectives/AIObjectiveFightIntruders.cs +++ b/Barotrauma/BarotraumaShared/SharedSource/Characters/AI/Objectives/AIObjectiveFightIntruders.cs @@ -59,7 +59,7 @@ namespace Barotrauma public static bool IsValidTarget(Character target, Character character, bool targetCharactersInOtherSubs) { if (target == null || target.Removed) { return false; } - if (target.IsDead) { return false; } + if (target.IsDead || target.InDetectable) { return false; } if (target.IsUnconscious && target.Params.Health.ConstantHealthRegeneration <= 0.0f) { return false; } if (target == character) { return false; } if (target.Submarine == null) { return false; } @@ -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 a395f72d6..743f4506c 100644 --- a/Barotrauma/BarotraumaShared/SharedSource/Characters/AI/Objectives/AIObjectiveFindDivingGear.cs +++ b/Barotrauma/BarotraumaShared/SharedSource/Characters/AI/Objectives/AIObjectiveFindDivingGear.cs @@ -21,9 +21,9 @@ namespace Barotrauma private Item targetItem; private int? oxygenSourceSlotIndex; - public const float MIN_OXYGEN = 10; + private const float MinOxygen = 10; - protected override bool CheckObjectiveSpecific() => + protected override bool CheckObjectiveState() => targetItem != null && character.HasEquippedItem(targetItem, slotType: InvSlotType.OuterClothes | InvSlotType.InnerClothes | InvSlotType.Head); public AIObjectiveFindDivingGear(Character character, bool needsDivingSuit, AIObjectiveManager objectiveManager, float priorityModifier = 1) : base(character, objectiveManager, priorityModifier) @@ -39,83 +39,98 @@ namespace Barotrauma TrySetTargetItem(character.Inventory.FindItem( it => it.HasTag(Tags.HeavyDivingGear) && IsSuitablePressureProtection(it, Tags.HeavyDivingGear, character), recursive: true)); } - if (targetItem == null || - !character.HasEquippedItem(targetItem, slotType: InvSlotType.OuterClothes | InvSlotType.InnerClothes | InvSlotType.Head) && - targetItem.ContainedItems.Any(it => IsSuitableContainedOxygenSource(it))) + + bool findDivingGear = targetItem == null || + (!character.HasEquippedItem(targetItem, slotType: InvSlotType.OuterClothes | InvSlotType.InnerClothes | InvSlotType.Head) && targetItem.ContainedItems.Any(IsSuitableContainedOxygenSource)); + + if (findDivingGear) { - bool mustFindMorePressureProtection = - !objectiveManager.FailedToFindDivingGearForDepth && - character.Inventory.FindItem( - it => it.HasTag(Tags.HeavyDivingGear) && !IsSuitablePressureProtection(it, Tags.HeavyDivingGear, character), recursive: true) != null; - TryAddSubObjective(ref getDivingGear, () => + bool mustFindMorePressureProtection = !objectiveManager.FailedToFindDivingGearForDepth && + character.Inventory.FindItem(it => it.HasTag(Tags.HeavyDivingGear) && !IsSuitablePressureProtection(it, Tags.HeavyDivingGear, character), recursive: true) != null; + + if (gearTag == Tags.LightDivingGear) { - if (targetItem == null && character.IsOnPlayerTeam) + if (character.GetEquippedItem(Tags.HeavyDivingGear, slotType: InvSlotType.OuterClothes | InvSlotType.InnerClothes) is Item divingSuit && divingSuit.ContainedItems.None(IsSuitableContainedOxygenSource)) { - character.Speak(TextManager.Get("DialogGetDivingGear").Value, null, 0.0f, "getdivinggear".ToIdentifier(), 30.0f); + // A special case: we are already wearing a suit without enough oxygen, but seeking for a mask, because a suit is not really needed. + // This would result into wearing boh the mask and the suit (because the suit shouldn't be unequipped in this situation), which is a bit weird and also suboptimal, because the mask uses the oxygen 2x faster. + // So, let's target the diving suit and try to find oxygen instead. + targetItem = divingSuit; + findDivingGear = false; } - var getItemObjective = new AIObjectiveGetItem(character, gearTag, objectiveManager, equip: true) + } + if (findDivingGear) + { + TryAddSubObjective(ref getDivingGear, () => { - AllowStealing = HumanAIController.NeedsDivingGear(character.CurrentHull, out _), - AllowToFindDivingGear = false, - AllowDangerousPressure = true, - EquipSlotType = InvSlotType.OuterClothes | InvSlotType.InnerClothes | InvSlotType.Head, - Wear = true - }; - if (gearTag == Tags.HeavyDivingGear) - { - if (mustFindMorePressureProtection) + if (targetItem == null && character.IsOnPlayerTeam) { - //if we're looking for a suit specifically because the current suit isn't enough, - //let's ignore unsuitable suits altogether... - getItemObjective.ItemFilter = it => IsSuitablePressureProtection(it, gearTag, character); + character.Speak(TextManager.Get("DialogGetDivingGear").Value, null, 0.0f, "getdivinggear".ToIdentifier(), 30.0f); } - else + var getItemObjective = new AIObjectiveGetItem(character, gearTag, objectiveManager, equip: true) { - //...Otherwise it's fine to give a very small priority - //to inadequate suits (a suit not adequate for the depth is better than no suit) - getItemObjective.GetItemPriority = it => IsSuitablePressureProtection(it, gearTag, character) ? 1000.0f : 1.0f; - } - getItemObjective.GetItemPriority = it => + AllowStealing = HumanAIController.NeedsDivingGear(character.CurrentHull, out _), + AllowToFindDivingGear = false, + AllowDangerousPressure = true, + EquipSlotType = InvSlotType.OuterClothes | InvSlotType.InnerClothes | InvSlotType.Head, + Wear = true + }; + if (gearTag == Tags.HeavyDivingGear) { - if (IsSuitablePressureProtection(it, gearTag, character)) + if (mustFindMorePressureProtection) { - return 1000.0f; + //if we're looking for a suit specifically because the current suit isn't enough, + //let's ignore unsuitable suits altogether... + getItemObjective.ItemFilter = it => IsSuitablePressureProtection(it, gearTag, character); } else { - //if we're looking for a suit specifically because the current suit isn't enough, - //let's ignore unsuitable suits altogether. Otherwise it's fine to give a very small priority + //...Otherwise it's fine to give a very small priority //to inadequate suits (a suit not adequate for the depth is better than no suit) - return mustFindMorePressureProtection ? 0.0f : 1.0f; + getItemObjective.GetItemPriority = it => IsSuitablePressureProtection(it, gearTag, character) ? 1000.0f : 1.0f; } - }; - } - return getItemObjective; - }, - onAbandon: () => - { - if (mustFindMorePressureProtection) { objectiveManager.FailedToFindDivingGearForDepth = true; } - Abandon = true; - }, - onCompleted: () => - { - RemoveSubObjective(ref getDivingGear); - if (gearTag == Tags.HeavyDivingGear && HumanAIController.HasItem(character, Tags.LightDivingGear, out IEnumerable masks, requireEquipped: true)) - { - foreach (Item mask in masks) - { - if (mask != targetItem) + getItemObjective.GetItemPriority = it => { - character.Inventory.TryPutItem(mask, character, CharacterInventory.AnySlot); + if (IsSuitablePressureProtection(it, gearTag, character)) + { + return 1000.0f; + } + else + { + //if we're looking for a suit specifically because the current suit isn't enough, + //let's ignore unsuitable suits altogether. Otherwise it's fine to give a very small priority + //to inadequate suits (a suit not adequate for the depth is better than no suit) + return mustFindMorePressureProtection ? 0.0f : 1.0f; + } + }; + } + return getItemObjective; + }, + onAbandon: () => + { + if (mustFindMorePressureProtection) { objectiveManager.FailedToFindDivingGearForDepth = true; } + Abandon = true; + }, + onCompleted: () => + { + RemoveSubObjective(ref getDivingGear); + if (gearTag == Tags.HeavyDivingGear && HumanAIController.HasItem(character, Tags.LightDivingGear, out IEnumerable masks, requireEquipped: true)) + { + foreach (Item mask in masks) + { + if (mask != targetItem) + { + character.Inventory.TryPutItem(mask, character, CharacterInventory.AnySlot); + } } } - } - }); + }); + } } - else + if (!findDivingGear) { float min = GetMinOxygen(character); - if (targetItem.OwnInventory != null && targetItem.OwnInventory.AllItems.None(it => IsSuitableContainedOxygenSource(it))) + if (targetItem.OwnInventory != null && targetItem.OwnInventory.AllItems.None(IsSuitableContainedOxygenSource)) { TryAddSubObjective(ref getOxygen, () => { @@ -139,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) { @@ -184,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); @@ -212,7 +228,6 @@ namespace Barotrauma return true; } - private bool IsSuitableContainedOxygenSource(Item item) { return @@ -226,14 +241,7 @@ namespace Barotrauma { if (targetItem == item) { return; } targetItem = item; - if (targetItem != null) - { - oxygenSourceSlotIndex = targetItem.GetComponent()?.FindSuitableSubContainerIndex(Tags.OxygenSource); - } - else - { - oxygenSourceSlotIndex = null; - } + oxygenSourceSlotIndex = targetItem?.GetComponent()?.FindSuitableSubContainerIndex(Tags.OxygenSource); } public override void Reset() @@ -251,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 41e4add54..400e60c76 100644 --- a/Barotrauma/BarotraumaShared/SharedSource/Characters/AI/Objectives/AIObjectiveFindSafety.cs +++ b/Barotrauma/BarotraumaShared/SharedSource/Characters/AI/Objectives/AIObjectiveFindSafety.cs @@ -3,6 +3,7 @@ using FarseerPhysics; using Microsoft.Xna.Framework; using System; using System.Collections.Generic; +using System.Diagnostics; using System.Linq; namespace Barotrauma @@ -31,7 +32,7 @@ namespace Barotrauma public AIObjectiveFindSafety(Character character, AIObjectiveManager objectiveManager, float priorityModifier = 1) : base(character, objectiveManager, priorityModifier) { } - protected override bool CheckObjectiveSpecific() => false; + protected override bool CheckObjectiveState() => false; public override bool CanBeCompleted => true; private bool resetPriority; @@ -41,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 @@ -70,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)) { @@ -82,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)); @@ -148,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) { @@ -306,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) { @@ -339,6 +351,10 @@ namespace Barotrauma float bestHullValue = 0; bool bestHullIsAirlock = false; Hull potentialBestHull; + +#if DEBUG + private readonly Stopwatch stopWatch = new Stopwatch(); +#endif /// /// Tries to find the best (safe, nearby) hull the character can find a path to. @@ -353,6 +369,9 @@ namespace Barotrauma bestHullIsAirlock = false; hulls.Clear(); var connectedSubs = character.Submarine?.GetConnectedSubs(); +#if DEBUG + stopWatch.Restart(); +#endif foreach (Hull hull in Hull.HullList) { if (hull.Submarine == null) { continue; } @@ -363,25 +382,66 @@ namespace Barotrauma if (ignoredHulls != null && ignoredHulls.Contains(hull)) { continue; } if (HumanAIController.UnreachableHulls.Contains(hull)) { continue; } if (connectedSubs != null && !connectedSubs.Contains(hull.Submarine)) { continue; } - //sort the hulls based on distance and which sub they're in - //tends to make the method much faster, because we find a potential hull earlier and can discard further-away hulls more easily - //(for instance, an NPC in an outpost might otherwise go through all the hulls in the main sub first and do tons of expensive - //path calculations, only to discard all of them when going through the hulls in the outpost) - float hullSuitability = EstimateHullSuitability(character, hull); if (hulls.None()) { hulls.Add(hull); } else { + //sort the hulls first based on distance and a rough suitability estimation + //tends to make the method much faster, because we find a potential hull earlier and can discard further-away hulls more easily + //(for instance, an NPC in an outpost might otherwise go through all the hulls in the main sub first and do tons of expensive + //path calculations, only to discard all of them when going through the hulls in the outpost) + bool addLast = true; + float hullSuitability = EstimateHullSuitability(hull); for (int i = 0; i < hulls.Count; i++) { - if (hullSuitability > EstimateHullSuitability(character, hulls[i])) + Hull otherHull = hulls[i]; + float otherHullSuitability = EstimateHullSuitability(otherHull); + if (hullSuitability > otherHullSuitability) { hulls.Insert(i, hull); + addLast = false; break; } } + if (addLast) + { + hulls.Add(hull); + } + } + + float EstimateHullSuitability(Hull h) + { + float distX = Math.Abs(h.WorldPosition.X - character.WorldPosition.X); + float distY = Math.Abs(h.WorldPosition.Y - character.WorldPosition.Y); + if (character.CurrentHull != null) + { + distY *= 3; + } + float dist = distX + distY; + float suitability = -dist; + const float suitabilityReduction = 10000.0f; + if (h.Submarine != character.Submarine) + { + suitability -= suitabilityReduction; + } + if (character.CurrentHull != null) + { + if (h.AvoidStaying) + { + suitability -= suitabilityReduction; + } + if (HumanAIController.UnsafeHulls.Contains(h)) + { + suitability -= suitabilityReduction; + } + if (HumanAIController.NeedsDivingGear(h, out _)) + { + suitability -= suitabilityReduction; + } + } + return suitability; } } if (hulls.None()) @@ -390,19 +450,10 @@ namespace Barotrauma return HullSearchStatus.Finished; } hullSearchIndex = 0; - } - - static float EstimateHullSuitability(Character character, Hull hull) - { - float dist = - Math.Abs(hull.WorldPosition.X - character.WorldPosition.X) + - Math.Abs(hull.WorldPosition.Y - character.WorldPosition.Y) * 3; - float suitability = -dist; - if (hull.Submarine != character.Submarine) - { - suitability -= 10000.0f; - } - return suitability; +#if DEBUG + stopWatch.Stop(); + DebugConsole.Log($"({character.DisplayName}) Sorted hulls by suitability in {stopWatch.ElapsedMilliseconds} ms"); +#endif } Hull potentialHull = hulls[hullSearchIndex]; @@ -420,7 +471,7 @@ namespace Barotrauma if (hullSafety > bestHullValue) { //avoid airlock modules if not allowed to change the sub - if (allowChangingSubmarine || !potentialHull.OutpostModuleTags.Any(t => t == "airlock")) + if (allowChangingSubmarine || potentialHull.OutpostModuleTags.All(t => t != "airlock")) { // Don't allow to go outside if not already outside. var path = PathSteering.PathFinder.FindPath(character.SimPosition, character.GetRelativeSimPosition(potentialHull), character.Submarine, nodeFilter: node => node.Waypoint.CurrentHull != null); @@ -431,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 bcec87ea1..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; } @@ -176,6 +181,8 @@ namespace Barotrauma //only player's crew can steal, ignore other teams if (!target.IsOnPlayerTeam) { return false; } if (target.IsHandcuffed) { return false; } + //ignore thieves in the same team + if (character.OriginalTeamID == target.TeamID || character.TeamID == target.TeamID) { return false; } // Ignore targets that are climbing, because might need to use ladders to get to them. if (target.IsClimbing) { return false; } if (HumanAIController.IsTrueForAnyBotInTheCrew(bot => @@ -190,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 8709d29a6..280ab7b59 100644 --- a/Barotrauma/BarotraumaShared/SharedSource/Characters/AI/Objectives/AIObjectiveFixLeak.cs +++ b/Barotrauma/BarotraumaShared/SharedSource/Characters/AI/Objectives/AIObjectiveFixLeak.cs @@ -31,7 +31,7 @@ namespace Barotrauma this.isPriority = isPriority; } - protected override bool CheckObjectiveSpecific() => Leak.Open <= 0 || Leak.Removed; + protected override bool CheckObjectiveState() => Leak.Open <= 0 || Leak.Removed; protected override float GetPriority() { @@ -166,7 +166,7 @@ namespace Barotrauma // TODO: use the collider size/reach? if (!character.AnimController.InWater && Math.Abs(toLeak.X) < 100 && toLeak.Y < 0.0f && toLeak.Y > -150) { - HumanAIController.AnimController.Crouching = true; + HumanAIController.AnimController.Crouch(); } float reach = CalculateReach(repairTool, character); bool canOperate = toLeak.LengthSquared() < reach * reach; @@ -180,7 +180,7 @@ namespace Barotrauma onAbandon: () => Abandon = true, onCompleted: () => { - if (CheckObjectiveSpecific()) { IsCompleted = true; } + if (CheckObjectiveState()) { IsCompleted = true; } else { // Failed to operate. Probably too far. @@ -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 && @@ -202,11 +202,11 @@ namespace Barotrauma endNodeFilter = IsSuitableEndNode, // The Go To objective can be abandoned if the leak is fixed (in which case we don't want to use the dialogue) // Only report about contextual targets. - SpeakCannotReachCondition = () => isPriority && !CheckObjectiveSpecific() + SpeakCannotReachCondition = () => isPriority && !CheckObjectiveState() }, onAbandon: () => { - if (CheckObjectiveSpecific()) { IsCompleted = true; } + if (CheckObjectiveState()) { IsCompleted = true; } else if ((Leak.WorldPosition - character.AnimController.AimSourceWorldPos).LengthSquared() > MathUtils.Pow(reach * 2, 2)) { // Too far diff --git a/Barotrauma/BarotraumaShared/SharedSource/Characters/AI/Objectives/AIObjectiveGetItem.cs b/Barotrauma/BarotraumaShared/SharedSource/Characters/AI/Objectives/AIObjectiveGetItem.cs index 0aad3d587..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; @@ -658,9 +683,8 @@ namespace Barotrauma return bestItem; } - protected override bool CheckObjectiveSpecific() + protected override bool CheckObjectiveState() { - if (IsCompleted) { return true; } if (targetItem == null) { // Not yet ready diff --git a/Barotrauma/BarotraumaShared/SharedSource/Characters/AI/Objectives/AIObjectiveGetItems.cs b/Barotrauma/BarotraumaShared/SharedSource/Characters/AI/Objectives/AIObjectiveGetItems.cs index f35f6f96b..8569b0776 100644 --- a/Barotrauma/BarotraumaShared/SharedSource/Characters/AI/Objectives/AIObjectiveGetItems.cs +++ b/Barotrauma/BarotraumaShared/SharedSource/Characters/AI/Objectives/AIObjectiveGetItems.cs @@ -44,7 +44,7 @@ namespace Barotrauma ignoredTags = AIObjectiveGetItem.ParseIgnoredTags(identifiersOrTags).ToImmutableHashSet(); } - protected override bool CheckObjectiveSpecific() => subObjectivesCreated && subObjectives.None(); + protected override bool CheckObjectiveState() => subObjectivesCreated && subObjectives.None(); protected override void Act(float deltaTime) { @@ -56,7 +56,7 @@ namespace Barotrauma AIObjectiveGetItem? getItem = null; TryAddSubObjective(ref getItem, () => { - var getItem = new AIObjectiveGetItem(character, tag, objectiveManager, Equip, CheckInventory && count <= 1) + getItem = new AIObjectiveGetItem(character, tag, objectiveManager, Equip, CheckInventory && count <= 1) { AllowVariants = AllowVariants, Wear = Wear, diff --git a/Barotrauma/BarotraumaShared/SharedSource/Characters/AI/Objectives/AIObjectiveGoTo.cs b/Barotrauma/BarotraumaShared/SharedSource/Characters/AI/Objectives/AIObjectiveGoTo.cs index 1fab5c816..26cae185b 100644 --- a/Barotrauma/BarotraumaShared/SharedSource/Characters/AI/Objectives/AIObjectiveGoTo.cs +++ b/Barotrauma/BarotraumaShared/SharedSource/Characters/AI/Objectives/AIObjectiveGoTo.cs @@ -3,6 +3,7 @@ using System; using System.Collections.Generic; using System.Linq; using Barotrauma.Extensions; +using Barotrauma.Items.Components; namespace Barotrauma { @@ -94,7 +95,35 @@ 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(); + public LocalizedString TargetName { get; set; } public ISpatialEntity Target { get; private set; } @@ -112,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; @@ -178,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); @@ -194,6 +234,43 @@ namespace Barotrauma Abandon = true; return; } + if (checkExoSuitTimer <= 0) + { + checkExoSuitTimer = CheckExoSuitTime * Rand.Range(0.9f, 1.1f); + if (character.GetEquippedItem(Tags.PoweredDivingSuit, InvSlotType.OuterClothes) is { OwnInventory: Inventory exoSuitInventory } exoSuit && + exoSuit.GetComponent() is not { HasPower: true }) + { + if (HumanAIController.HasItem(character, Tags.DivingSuitFuel, out IEnumerable fuelRods, conditionPercentage: 1, recursive: true)) + { + // Try to switch the fuel sources + if (character.IsOnPlayerTeam) + { + character.Speak(TextManager.Get(ExoSuitRefuel).Value, minDurationBetweenSimilar: 10f, identifier: ExoSuitRefuel); + } + // Have to copy the list, because it's modified when we unequip the item. + foreach (Item containedItem in exoSuit.ContainedItems.ToList()) + { + if (containedItem.HasTag(Tags.DivingSuitFuel) && containedItem.Condition <= 0) + { + character.Unequip(containedItem); + } + } + // Refuel + // The information about the target slot is defined in a status effect. We could parse it, but let's keep it simple and just presume that the target slot is the second slot, as it the case with the vanilla exosuits. + const int targetSlot = 1; + Item fuelRod = fuelRods.MaxBy(b => b.Condition); + exoSuitInventory.TryPutItem(fuelRod, targetSlot, allowSwapping: true, allowCombine: true, user: character); + } + else if (character.IsOnPlayerTeam) + { + character.Speak(TextManager.Get(ExoSuitOutOfFuel).Value, minDurationBetweenSimilar: 30.0f, identifier: ExoSuitOutOfFuel); + } + } + } + else + { + checkExoSuitTimer -= deltaTime; + } if (Target == character || character.SelectedBy != null && HumanAIController.IsFriendly(character.SelectedBy)) { // Wait @@ -301,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; @@ -353,9 +439,9 @@ namespace Barotrauma } else { - // Try again without requiring the diving suit + // Try again without requiring the diving suit (or mask) RemoveSubObjective(ref findDivingGear); - TryAddSubObjective(ref findDivingGear, () => new AIObjectiveFindDivingGear(character, needsDivingSuit: false, objectiveManager), + TryAddSubObjective(ref findDivingGear, () => new AIObjectiveFindDivingGear(character, needsDivingSuit: !tryToGetDivingSuit, objectiveManager), onAbandon: () => { Abandon = character.CurrentHull != null && (objectiveManager.CurrentOrder != this || Target.Submarine == null); @@ -442,7 +528,7 @@ namespace Barotrauma if (checkScooterTimer <= 0) { useScooter = false; - checkScooterTimer = checkScooterTime * Rand.Range(0.75f, 1.25f); + checkScooterTimer = CheckScooterTime * Rand.Range(0.9f, 1.1f); Item scooter = null; bool shouldUseScooter = Mimic && targetCharacter != null && targetCharacter.HasEquippedItem(Tags.Scooter, allowBroken: false); if (!shouldUseScooter) @@ -465,24 +551,25 @@ namespace Barotrauma } else if (shouldUseScooter) { - var leftHandItem = character.GetEquippedItem(slotType: InvSlotType.LeftHand); - var rightHandItem = character.GetEquippedItem(slotType: InvSlotType.RightHand); - bool handsFull = - (leftHandItem != null && !character.Inventory.IsAnySlotAvailable(leftHandItem) && !character.Inventory.TryPutItem(leftHandItem, character, InvSlotType.Bag.ToEnumerable())) || - (rightHandItem != null && !character.Inventory.IsAnySlotAvailable(rightHandItem) && !character.Inventory.TryPutItem(rightHandItem, character, InvSlotType.Bag.ToEnumerable())); - if (!handsFull) + bool hasHandsFull = character.HasHandsFull(out (Item leftHandItem, Item rightHandItem) items); + if (hasHandsFull) + { + hasHandsFull = !character.TryPutItemInAnySlot(items.leftHandItem) && + !character.TryPutItemInAnySlot(items.rightHandItem) && + !character.TryPutItemInBag(items.leftHandItem) && + !character.TryPutItemInBag(items.rightHandItem); + } + if (!hasHandsFull) { bool hasBattery = false; - if (HumanAIController.HasItem(character, Tags.Scooter, out IEnumerable nonEquippedScooters, containedTag: Tags.MobileBattery, conditionPercentage: 1, requireEquipped: false)) + if (HumanAIController.HasItem(character, Tags.Scooter, out IEnumerable nonEquippedScootersWithBattery, containedTag: Tags.MobileBattery, conditionPercentage: 1, requireEquipped: false)) { - // Non-equipped scooter with a battery - scooter = nonEquippedScooters.FirstOrDefault(); + scooter = nonEquippedScootersWithBattery.FirstOrDefault(); hasBattery = true; } - else if (HumanAIController.HasItem(character, Tags.Scooter, out IEnumerable _nonEquippedScooters, requireEquipped: false)) + else if (HumanAIController.HasItem(character, Tags.Scooter, out IEnumerable nonEquippedScootersWithoutBattery, requireEquipped: false)) { - // Non-equipped scooter without a battery - scooter = _nonEquippedScooters.FirstOrDefault(); + scooter = nonEquippedScootersWithoutBattery.FirstOrDefault(); // Non-recursive so that the bots won't take batteries from other items. Also means that they can't find batteries inside containers. Not sure how to solve this. hasBattery = HumanAIController.HasItem(character, Tags.MobileBattery, out _, requireEquipped: false, conditionPercentage: 1, recursive: false); } @@ -518,8 +605,7 @@ namespace Barotrauma } if (!useScooter) { - // Unequip - character.Inventory.TryPutItem(scooter, character, CharacterInventory.AnySlot); + character.TryPutItemInAnySlot(scooter); } } } @@ -663,7 +749,10 @@ namespace Barotrauma private bool useScooter; private float checkScooterTimer; - private readonly float checkScooterTime = 0.5f; + private const float CheckScooterTime = 0.5f; + + private float checkExoSuitTimer; + private const float CheckExoSuitTime = 2.0f; public Hull GetTargetHull() => GetTargetHull(Target); @@ -750,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) @@ -764,9 +858,8 @@ namespace Barotrauma } } - protected override bool CheckObjectiveSpecific() + protected override bool CheckObjectiveState() { - if (IsCompleted) { return true; } // First check the distance and then if can interact (heaviest) if (Target == null) { @@ -850,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 07343eb5c..efa495d6c 100644 --- a/Barotrauma/BarotraumaShared/SharedSource/Characters/AI/Objectives/AIObjectiveIdle.cs +++ b/Barotrauma/BarotraumaShared/SharedSource/Characters/AI/Objectives/AIObjectiveIdle.cs @@ -88,7 +88,7 @@ namespace Barotrauma CalculatePriority(); } - protected override bool CheckObjectiveSpecific() => false; + protected override bool CheckObjectiveState() => false; public override bool CanBeCompleted => true; public readonly HashSet PreferredOutpostModuleTypes = new HashSet(); @@ -158,8 +158,17 @@ namespace Barotrauma { character.DeselectCharacter(); } - - character.SelectedItem = null; + if (character.SelectedItem != null) + { + if (character.SelectedItem.Prefab.AllowDeselectWhenIdling) + { + character.SelectedItem = null; + } + else + { + return; + } + } if (!character.IsClimbing) { @@ -176,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) @@ -378,14 +414,14 @@ namespace Barotrauma chairCheckTimer -= deltaTime; if (chairCheckTimer <= 0.0f && character.SelectedSecondaryItem == null) { - foreach (Item item in Item.ItemList) + foreach (Item chair in Item.ChairItems) { - if (item.CurrentHull != currentHull || !item.HasTag(Tags.ChairItem)) { continue; } + if (chair.CurrentHull != currentHull) { continue; } //not possible in vanilla game, but a mod might have holdable/attachable chairs - if (item.ParentInventory != null || item.body is { Enabled: true }) { continue; } - var controller = item.GetComponent(); + if (chair.ParentInventory != null || chair.body is { Enabled: true }) { continue; } + var controller = chair.GetComponent(); if (controller == null || controller.User != null) { continue; } - item.TryInteract(character, forceSelectKey: true); + chair.TryInteract(character, forceSelectKey: true); } chairCheckTimer = chairCheckInterval; } @@ -489,27 +525,26 @@ namespace Barotrauma if (checkItemsTimer <= 0) { checkItemsTimer = checkItemsInterval * Rand.Range(0.9f, 1.1f); - var hull = character.CurrentHull; - if (hull != null) + if (character.Submarine is not Submarine sub) { return; } + if (sub.TeamID != character.TeamID) { return; } + if (character.CurrentHull is not Hull currentHull) { return; } + itemsToClean.Clear(); + foreach (Item item in Item.CleanableItems) { - itemsToClean.Clear(); - foreach (Item item in Item.CleanableItems) + if (item.CurrentHull != currentHull) { continue; } + if (AIObjectiveCleanupItems.IsValidTarget(item, character, checkInventory: true, allowUnloading: false) && !ignoredItems.Contains(item)) { - if (item.CurrentHull != hull) { continue; } - if (AIObjectiveCleanupItems.IsValidTarget(item, character, checkInventory: true, allowUnloading: false) && !ignoredItems.Contains(item)) - { - itemsToClean.Add(item); - } + itemsToClean.Add(item); } - if (itemsToClean.Any()) + } + if (itemsToClean.Any()) + { + var targetItem = itemsToClean.MinBy(i => Math.Abs(character.WorldPosition.X - i.WorldPosition.X)); + if (targetItem != null) { - var targetItem = itemsToClean.OrderBy(i => Math.Abs(character.WorldPosition.X - i.WorldPosition.X)).FirstOrDefault(); - if (targetItem != null) - { - var cleanupObjective = new AIObjectiveCleanupItem(targetItem, character, objectiveManager, PriorityModifier); - cleanupObjective.Abandoned += () => ignoredItems.Add(targetItem); - subObjectives.Add(cleanupObjective); - } + var cleanupObjective = new AIObjectiveCleanupItem(targetItem, character, objectiveManager, PriorityModifier); + cleanupObjective.Abandoned += () => ignoredItems.Add(targetItem); + subObjectives.Add(cleanupObjective); } } } @@ -534,6 +569,8 @@ namespace Barotrauma itemsToClean.Clear(); ignoredItems.Clear(); autonomousObjectiveRetryTimer = 10; + timerMargin = 0; + newTargetTimer = 0; } public override void OnDeselected() diff --git a/Barotrauma/BarotraumaShared/SharedSource/Characters/AI/Objectives/AIObjectiveInspectNoises.cs b/Barotrauma/BarotraumaShared/SharedSource/Characters/AI/Objectives/AIObjectiveInspectNoises.cs index 7295391b4..b8639dd08 100644 --- a/Barotrauma/BarotraumaShared/SharedSource/Characters/AI/Objectives/AIObjectiveInspectNoises.cs +++ b/Barotrauma/BarotraumaShared/SharedSource/Characters/AI/Objectives/AIObjectiveInspectNoises.cs @@ -120,7 +120,6 @@ namespace Barotrauma { } - protected override bool CheckObjectiveSpecific() => false; - + protected override bool CheckObjectiveState() => false; } } diff --git a/Barotrauma/BarotraumaShared/SharedSource/Characters/AI/Objectives/AIObjectiveLoadItem.cs b/Barotrauma/BarotraumaShared/SharedSource/Characters/AI/Objectives/AIObjectiveLoadItem.cs index 3649eb11c..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: () => { @@ -318,13 +319,13 @@ namespace Barotrauma return true; } - protected override bool CheckObjectiveSpecific() => IsCompleted; + protected override bool CheckObjectiveState() => IsCompleted; public override void Reset() { 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/AIObjectiveLoadItems.cs b/Barotrauma/BarotraumaShared/SharedSource/Characters/AI/Objectives/AIObjectiveLoadItems.cs index 185d057b1..894f27e60 100644 --- a/Barotrauma/BarotraumaShared/SharedSource/Characters/AI/Objectives/AIObjectiveLoadItems.cs +++ b/Barotrauma/BarotraumaShared/SharedSource/Characters/AI/Objectives/AIObjectiveLoadItems.cs @@ -71,7 +71,7 @@ namespace Barotrauma if (item.IsClaimedByBallastFlora) { return false; } if (!item.HasAccess(character)) { return false; } // Ignore items that require power but don't have it - if (item.GetComponent() is Powered powered && powered.PowerConsumption > 0 && powered.Voltage < powered.MinVoltage) { return false; } + if (item.GetComponent() is { PowerConsumption: > 0, HasPower: false }) { return false; } return true; } diff --git a/Barotrauma/BarotraumaShared/SharedSource/Characters/AI/Objectives/AIObjectiveLoop.cs b/Barotrauma/BarotraumaShared/SharedSource/Characters/AI/Objectives/AIObjectiveLoop.cs index 04cc059e3..3935b931a 100644 --- a/Barotrauma/BarotraumaShared/SharedSource/Characters/AI/Objectives/AIObjectiveLoop.cs +++ b/Barotrauma/BarotraumaShared/SharedSource/Characters/AI/Objectives/AIObjectiveLoop.cs @@ -53,7 +53,7 @@ namespace Barotrauma : base(character, objectiveManager, priorityModifier, option) { } protected override void Act(float deltaTime) { } - protected override bool CheckObjectiveSpecific() => false; + protected override bool CheckObjectiveState() => false; public override bool CanBeCompleted => true; public override bool AbandonWhenCannotCompleteSubObjectives => false; public override bool AllowSubObjectiveSorting => true; diff --git a/Barotrauma/BarotraumaShared/SharedSource/Characters/AI/Objectives/AIObjectiveManager.cs b/Barotrauma/BarotraumaShared/SharedSource/Characters/AI/Objectives/AIObjectiveManager.cs index e59e3e0fb..7cc00e50a 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; /// @@ -228,11 +228,7 @@ namespace Barotrauma coroutine = CoroutineManager.Invoke(() => { //round ended before the coroutine finished -#if CLIENT - if (GameMain.GameSession == null || Level.Loaded == null && !(GameMain.GameSession.GameMode is TestGameMode)) { return; } -#else - if (GameMain.GameSession == null || Level.Loaded == null) { return; } -#endif + if (GameMain.GameSession == null || Level.Loaded == null && GameMain.GameSession.GameMode is not TestGameMode) { return; } DelayedObjectives.Remove(objective); AddObjective(objective); callback?.Invoke(); @@ -480,7 +476,7 @@ namespace Barotrauma IgnoreIfTargetDead = true, IsFollowOrder = true, Mimic = character.IsOnPlayerTeam, - DialogueIdentifier = "dialogcannotreachplace".ToIdentifier() + DialogueIdentifier = AIObjectiveGoTo.DialogCannotReachPlace }; break; case "wait": @@ -719,6 +715,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(); /// @@ -735,7 +736,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; @@ -758,7 +760,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 73% rename from Barotrauma/BarotraumaShared/SharedSource/Characters/AI/Objectives/AIObjectiveDecontainItem.cs rename to Barotrauma/BarotraumaShared/SharedSource/Characters/AI/Objectives/AIObjectiveMoveItem.cs index 4293dc327..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; @@ -71,20 +78,20 @@ namespace Barotrauma this.targetContainer = targetContainer; } - protected override bool CheckObjectiveSpecific() => IsCompleted; + protected override bool CheckObjectiveState() => IsCompleted; 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 be6674906..3c024b878 100644 --- a/Barotrauma/BarotraumaShared/SharedSource/Characters/AI/Objectives/AIObjectiveOperateItem.cs +++ b/Barotrauma/BarotraumaShared/SharedSource/Characters/AI/Objectives/AIObjectiveOperateItem.cs @@ -1,4 +1,5 @@ using Barotrauma.Items.Components; +using Barotrauma.Extensions; using Microsoft.Xna.Framework; using System; using System.Collections.Generic; @@ -15,7 +16,7 @@ namespace Barotrauma public override bool AllowMultipleInstances => true; protected override bool AllowInAnySub => true; protected override bool AllowWhileHandcuffed => false; - public override bool PrioritizeIfSubObjectivesActive => component != null && (component is Reactor || component is Turret); + public override bool PrioritizeIfSubObjectivesActive => component is Reactor or Turret; private readonly ItemComponent component, controller; private readonly Entity operateTarget; @@ -88,12 +89,23 @@ namespace Barotrauma Priority = 0; return Priority; } - var reactor = component?.Item.GetComponent(); + 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) { if (!isOrder) { - if (reactor.LastUserWasPlayer && character.TeamID != CharacterTeamType.FriendlyNPC) + if (reactor.LastUserWasPlayer && character.IsOnPlayerTeam) { // The reactor was previously operated by a player -> ignore. Priority = 0; @@ -126,7 +138,7 @@ namespace Barotrauma } else if (!isOrder) { - var steering = component?.Item.GetComponent(); + var steering = component.Item.GetComponent(); if (steering != null && (steering.AutoPilot || HumanAIController.IsTrueForAnyCrewMember(c => c != character && c.IsCaptain, onlyActive: true, onlyConnectedSubs: true))) { // Ignore if already set to autopilot or if there's a captain onboard @@ -136,10 +148,8 @@ namespace Barotrauma } if (targetItem.CurrentHull == null || targetItem.Submarine != character.Submarine && !isOrder || - targetItem.CurrentHull.FireSources.Any() || - HumanAIController.IsItemOperatedByAnother(target, out _) || - Character.CharacterList.Any(c => c.CurrentHull == targetItem.CurrentHull && !HumanAIController.IsFriendly(c) && HumanAIController.IsActive(c)) - || component.Item.IgnoreByAI(character) || useController && controller.Item.IgnoreByAI(character)) + IsItemOperatedByAnother(target) || + component.Item.IgnoreByAI(character) || useController && controller.Item.IgnoreByAI(character)) { Priority = 0; } @@ -154,8 +164,8 @@ namespace Barotrauma else if (!OverridePriority.HasValue) { float value = CumulatedDevotion + (AIObjectiveManager.LowestOrderPriority * PriorityModifier); - float max = AIObjectiveManager.LowestOrderPriority - 1; - if (reactor != null && reactor.PowerOn && reactor.FissionRate > 1 && reactor.AutoTemp && Option == "powerup") + const float max = AIObjectiveManager.LowestOrderPriority - 1; + if (reactor is { PowerOn: true, FissionRate: > 1, AutoTemp: true } && Option == "powerup") { // Already on, no need to operate. value = 0; @@ -171,12 +181,12 @@ namespace Barotrauma Entity operateTarget = null, bool useController = false, ItemComponent controller = null, float priorityModifier = 1) : base(character, objectiveManager, priorityModifier, option) { - component = item ?? throw new ArgumentNullException("item", "Attempted to create an AIObjectiveOperateItem with a null target."); + component = item ?? throw new ArgumentNullException(nameof(item), "Attempted to create an AIObjectiveOperateItem with a null target."); this.requireEquip = requireEquip; this.operateTarget = operateTarget; this.useController = useController; - if (useController) { this.controller = controller ?? component?.Item?.FindController(); } - var target = GetTarget(); + if (useController) { this.controller = controller ?? component.Item?.FindController(); } + ItemComponent target = GetTarget(); if (target == null) { Abandon = true; @@ -245,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) }, @@ -312,7 +323,7 @@ namespace Barotrauma } } - protected override bool CheckObjectiveSpecific() => isDoneOperating && !Repeat; + protected override bool CheckObjectiveState() => isDoneOperating && !Repeat; public override void Reset() { @@ -320,5 +331,68 @@ namespace Barotrauma goToObjective = null; getItemObjective = null; } + + private bool IsItemOperatedByAnother(ItemComponent target) + { + if (target?.Item == null) { return false; } + bool isOrdered = IsOrderedToOperateTarget(HumanAIController); + foreach (Character c in Character.CharacterList) + { + if (!HumanAIController.IsActive(c)) { continue; } + if (c == character) { continue; } + if (c.TeamID != character.TeamID) { continue; } + if (c.IsPlayer) + { + if (c.SelectedItem == target.Item) + { + // If the other character is player, don't try to operate + return true; + } + } + else if (c.AIController is HumanAIController otherAI) + { + if (otherAI.ObjectiveManager.Objectives.None(o => o is AIObjectiveOperateItem operateObjective && operateObjective.Component.Item == target.Item)) + { + // Not targeting the same item. + continue; + } + bool isOtherCharacterOrdered = IsOrderedToOperateTarget(otherAI); + switch (isOrdered) + { + case false when isOtherCharacterOrdered: + // We are not ordered and the target is ordered -> let the other character operate the target item. + return true; + case true when !isOtherCharacterOrdered: + // We are ordered and the other character is not -> allow to us to operate the target item. + continue; + default: + { + // Neither or both are ordered to operate this item. + if (!IsOperatingTarget(otherAI)) + { + // The other bot is doing something else -> stick to the target. + continue; + } + if (target is Steering) + { + // Steering is hard-coded -> cannot use the required skills collection defined in the xml + if (character.GetSkillLevel(Tags.HelmSkill) <= c.GetSkillLevel(Tags.HelmSkill)) + { + return true; + } + } + else if (target.DegreeOfSuccess(character) <= target.DegreeOfSuccess(c)) + { + return true; + } + break; + } + } + } + } + return false; + bool IsOrderedToOperateTarget(HumanAIController ai) => ai.ObjectiveManager.CurrentOrder is AIObjectiveOperateItem operateOrder && operateOrder.Component.Item == target.Item; + bool IsOperatingTarget(HumanAIController ai) => ai.ObjectiveManager.CurrentObjective is AIObjectiveOperateItem operateObjective && operateObjective.Component.Item == target.Item; + } } } diff --git a/Barotrauma/BarotraumaShared/SharedSource/Characters/AI/Objectives/AIObjectivePrepare.cs b/Barotrauma/BarotraumaShared/SharedSource/Characters/AI/Objectives/AIObjectivePrepare.cs index 95ea194d8..bd1a9e375 100644 --- a/Barotrauma/BarotraumaShared/SharedSource/Characters/AI/Objectives/AIObjectivePrepare.cs +++ b/Barotrauma/BarotraumaShared/SharedSource/Characters/AI/Objectives/AIObjectivePrepare.cs @@ -54,7 +54,7 @@ namespace Barotrauma } } - protected override bool CheckObjectiveSpecific() => IsCompleted; + protected override bool CheckObjectiveState() => IsCompleted; protected override float GetPriority() { diff --git a/Barotrauma/BarotraumaShared/SharedSource/Characters/AI/Objectives/AIObjectiveRepairItem.cs b/Barotrauma/BarotraumaShared/SharedSource/Characters/AI/Objectives/AIObjectiveRepairItem.cs index cb4becf62..b5b6efdd2 100644 --- a/Barotrauma/BarotraumaShared/SharedSource/Characters/AI/Objectives/AIObjectiveRepairItem.cs +++ b/Barotrauma/BarotraumaShared/SharedSource/Characters/AI/Objectives/AIObjectiveRepairItem.cs @@ -50,7 +50,7 @@ namespace Barotrauma } return Priority; } - if (HumanAIController.IsItemRepairedByAnother(Item, out _)) + if (AIObjectiveRepairItems.IsItemRepairedByAnother(character, Item)) { Priority = 0; IsCompleted = true; @@ -91,7 +91,7 @@ namespace Barotrauma return Priority; } - protected override bool CheckObjectiveSpecific() + protected override bool CheckObjectiveState() { IsCompleted = Item.IsFullCondition; if (character.IsOnPlayerTeam && IsCompleted && IsRepairing()) @@ -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/AIObjectiveRepairItems.cs b/Barotrauma/BarotraumaShared/SharedSource/Characters/AI/Objectives/AIObjectiveRepairItems.cs index 3cd5544b2..f96df16ed 100644 --- a/Barotrauma/BarotraumaShared/SharedSource/Characters/AI/Objectives/AIObjectiveRepairItems.cs +++ b/Barotrauma/BarotraumaShared/SharedSource/Characters/AI/Objectives/AIObjectiveRepairItems.cs @@ -76,7 +76,7 @@ namespace Barotrauma { if (item.Repairables.None(r => r.RequiredSkills.Any(s => s.Identifier == RelevantSkill))) { return false; } } - return !HumanAIController.IsItemRepairedByAnother(item, out _); + return !IsItemRepairedByAnother(character, item); } public static bool ViableForRepair(Item item, Character character, HumanAIController humanAIController) @@ -161,5 +161,57 @@ namespace Barotrauma return true; } + + public static bool IsItemRepairedByAnother(Character character, Item target) + { + if (target == null) { return false; } + bool isOrder = IsOrderedToPrioritizeTarget(character.AIController as HumanAIController); + foreach (Character c in Character.CharacterList) + { + if (!HumanAIController.IsActive(c)) { continue; } + if (c == character) { continue; } + if (c.TeamID != character.TeamID) { continue; } + if (c.IsPlayer) + { + if (target.Repairables.Any(r => r.CurrentFixer == c)) + { + // If the other character is player, don't try to repair + return true; + } + } + else if (c.AIController is HumanAIController otherAI) + { + var repairItemsObjective = otherAI.ObjectiveManager.GetObjective(); + if (repairItemsObjective == null) { continue; } + if (repairItemsObjective.SubObjectives.FirstOrDefault(o => o is AIObjectiveRepairItem) is not AIObjectiveRepairItem activeObjective || activeObjective.Item != target) + { + // Not targeting the same item. + continue; + } + bool isTargetOrdered = IsOrderedToPrioritizeTarget(otherAI); + switch (isOrder) + { + case false when isTargetOrdered: + // We are not ordered and the target is ordered -> let the other character repair the target. + return true; + case true when !isTargetOrdered: + // We are ordered and the target is not -> allow us to repair the target. + continue; + default: + { + // Neither or both are ordered to repair this item. + if (otherAI.ObjectiveManager.CurrentObjective is not AIObjectiveRepairItems) + { + // The other bot is doing something else -> stick to the target. + continue; + } + return target.Repairables.Max(r => r.DegreeOfSuccess(character)) <= target.Repairables.Max(r => r.DegreeOfSuccess(c)); + } + } + } + } + return false; + bool IsOrderedToPrioritizeTarget(HumanAIController ai) => ai.ObjectiveManager.CurrentOrder is AIObjectiveRepairItems repairOrder && repairOrder.PrioritizedItem == target; + } } } diff --git a/Barotrauma/BarotraumaShared/SharedSource/Characters/AI/Objectives/AIObjectiveRescue.cs b/Barotrauma/BarotraumaShared/SharedSource/Characters/AI/Objectives/AIObjectiveRescue.cs index 2d6c385d1..e5242161d 100644 --- a/Barotrauma/BarotraumaShared/SharedSource/Characters/AI/Objectives/AIObjectiveRescue.cs +++ b/Barotrauma/BarotraumaShared/SharedSource/Characters/AI/Objectives/AIObjectiveRescue.cs @@ -70,7 +70,7 @@ namespace Barotrauma if (otherRescuer != null && otherRescuer != character) { // Someone else is rescuing/holding the target. - Abandon = otherRescuer.IsPlayer || character.GetSkillLevel("medical") < otherRescuer.GetSkillLevel("medical"); + Abandon = otherRescuer.IsPlayer || character.GetSkillLevel(Tags.MedicalSkill) < otherRescuer.GetSkillLevel(Tags.MedicalSkill); return; } if (Target != character) @@ -149,7 +149,7 @@ namespace Barotrauma if (HumanAIController.VisibleHulls.Contains(Target.CurrentHull) && Target.CurrentHull.DisplayName != null) { character.Speak(TextManager.GetWithVariables("DialogFoundUnconsciousTarget", - ("[targetname]", Target.Name, FormatCapitals.No), + ("[targetname]", Target.DisplayName, FormatCapitals.No), ("[roomname]", Target.CurrentHull.DisplayName, FormatCapitals.Yes)).Value, null, 1.0f, $"foundunconscioustarget{Target.Name}".ToIdentifier(), 60.0f); } @@ -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), @@ -239,7 +242,7 @@ namespace Barotrauma if (Target.CurrentHull?.DisplayName != null) { character.Speak(TextManager.GetWithVariables("DialogFoundWoundedTarget", - ("[targetname]", Target.Name, FormatCapitals.No), + ("[targetname]", Target.DisplayName, FormatCapitals.No), ("[roomname]", Target.CurrentHull.DisplayName, FormatCapitals.Yes)).Value, null, 1.0f, $"foundwoundedtarget{Target.Name}".ToIdentifier(), 60.0f); } @@ -287,6 +290,8 @@ namespace Barotrauma currentTreatmentSuitabilities, limb: Target.CharacterHealth.GetAfflictionLimb(affliction), user: character, + checkTreatmentThreshold: true, + checkTreatmentSuggestionThreshold: false, predictFutureDuration: 10.0f); foreach (KeyValuePair treatmentSuitability in currentTreatmentSuitabilities) @@ -330,7 +335,10 @@ namespace Barotrauma { //get "overall" suitability for no specific limb at this point Target.CharacterHealth.GetSuitableTreatments( - currentTreatmentSuitabilities, user: character, predictFutureDuration: 10.0f); + currentTreatmentSuitabilities, user: character, + checkTreatmentThreshold: true, + checkTreatmentSuggestionThreshold: false, + predictFutureDuration: 10.0f); //didn't have any suitable treatments available, try to find some medical items if (currentTreatmentSuitabilities.Any(s => s.Value > cprSuitability)) { @@ -387,13 +395,22 @@ namespace Barotrauma if (Target != character && character.IsOnPlayerTeam) { character.Speak(TextManager.GetWithVariables("DialogListRequiredTreatments", - ("[targetname]", Target.Name, FormatCapitals.No), + ("[targetname]", Target.DisplayName, FormatCapitals.No), ("[treatmentlist]", itemListStr, FormatCapitals.Yes)).Value, null, 2.0f, $"listrequiredtreatments{Target.Name}".ToIdentifier(), 60.0f); } + + var itemsToFind = currentTreatmentSuitabilities + //items that have a positive effect and that the bot doesn't yet have + .Where(kvp => kvp.Value > 0.0f && character.Inventory.AllItems.None(it => it.Prefab.Identifier == kvp.Key)) + .Select(kvp => kvp.Key); + RemoveSubObjective(ref getItemObjective); TryAddSubObjective(ref getItemObjective, - constructor: () => new AIObjectiveGetItem(character, suitableItemIdentifiers.ToArray(), objectiveManager, equip: true, spawnItemIfNotFound: character.TeamID == CharacterTeamType.FriendlyNPC), + constructor: () => new AIObjectiveGetItem(character, itemsToFind, objectiveManager, equip: true, spawnItemIfNotFound: character.TeamID == CharacterTeamType.FriendlyNPC) + { + GetItemPriority = it => currentTreatmentSuitabilities.GetValueOrDefault(it.Prefab.Identifier) + }, onCompleted: () => RemoveSubObjective(ref getItemObjective), onAbandon: () => { @@ -468,16 +485,16 @@ namespace Barotrauma item.ApplyTreatment(character, Target, Target.CharacterHealth.GetAfflictionLimb(affliction)); } - protected override bool CheckObjectiveSpecific() + protected override bool CheckObjectiveState() { - bool isCompleted = AIObjectiveRescueAll.GetVitalityFactor(Target) >= AIObjectiveRescueAll.GetVitalityThreshold(objectiveManager, character, Target); - if (isCompleted && Target != character && character.IsOnPlayerTeam) + IsCompleted = AIObjectiveRescueAll.GetVitalityFactor(Target) >= AIObjectiveRescueAll.GetVitalityThreshold(objectiveManager, character, Target); + if (IsCompleted && Target != character && character.IsOnPlayerTeam) { string textTag = performedCpr ? "DialogTargetResuscitated" : "DialogTargetHealed"; - string message = TextManager.GetWithVariable(textTag, "[targetname]", Target.Name)?.Value; + string message = TextManager.GetWithVariable(textTag, "[targetname]", Target.DisplayName)?.Value; character.Speak(message, delay: 1.0f, identifier: $"targethealed{Target.Name}".ToIdentifier(), minDurationBetweenSimilar: 60.0f); } - return isCompleted; + return IsCompleted; } protected override float GetPriority() diff --git a/Barotrauma/BarotraumaShared/SharedSource/Characters/AI/Objectives/AIObjectiveRescueAll.cs b/Barotrauma/BarotraumaShared/SharedSource/Characters/AI/Objectives/AIObjectiveRescueAll.cs index e27ce1933..306fb786e 100644 --- a/Barotrauma/BarotraumaShared/SharedSource/Characters/AI/Objectives/AIObjectiveRescueAll.cs +++ b/Barotrauma/BarotraumaShared/SharedSource/Characters/AI/Objectives/AIObjectiveRescueAll.cs @@ -47,9 +47,9 @@ namespace Barotrauma if (objectiveManager.GetFirstActiveObjective() == null) { charactersWithMinorInjuries.Add(target); - character.Speak(TextManager.GetWithVariable("dialogignoreminorinjuries", "[targetname]", target.Name).Value, + character.Speak(TextManager.GetWithVariable("dialogignoreminorinjuries", "[targetname]", target.DisplayName).Value, delay: 1.0f, - identifier: $"notreatableafflictions{target.Name}".ToIdentifier(), + identifier: $"notreatableafflictions{target.DisplayName}".ToIdentifier(), minDurationBetweenSimilar: 10.0f); } } @@ -96,13 +96,20 @@ namespace Barotrauma { float strength = character.CharacterHealth.GetPredictedStrength(affliction, predictFutureDuration: 10.0f); vitality -= affliction.GetVitalityDecrease(character.CharacterHealth, strength) / character.MaxVitality * 100; - if (affliction.Prefab.AfflictionType == AfflictionPrefab.ParalysisType) + if (affliction.Strength > affliction.Prefab.TreatmentThreshold) { - vitality -= affliction.Strength; - } - else if (affliction.Prefab.AfflictionType == AfflictionPrefab.PoisonType) - { - vitality -= affliction.Strength; + if (affliction.Prefab.AfflictionType == AfflictionPrefab.ParalysisType) + { + vitality -= affliction.Strength; + } + else if (affliction.Prefab.AfflictionType == AfflictionPrefab.PoisonType) + { + vitality -= affliction.Strength; + } + else if (affliction.Prefab == AfflictionPrefab.HuskInfection) + { + vitality -= affliction.Strength; + } } } return Math.Clamp(vitality, 0, 100); diff --git a/Barotrauma/BarotraumaShared/SharedSource/Characters/AI/Objectives/AIObjectiveReturn.cs b/Barotrauma/BarotraumaShared/SharedSource/Characters/AI/Objectives/AIObjectiveReturn.cs index c580a2d0c..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; @@ -7,7 +7,7 @@ namespace Barotrauma class AIObjectiveReturn : AIObjective { public override Identifier Identifier { get; set; } = "return".ToIdentifier(); - public Submarine ReturnTarget { get; } + public Submarine Target { get; } private AIObjectiveGoTo moveInsideObjective, moveOutsideObjective; private bool usingEscapeBehavior, isSteeringThroughGap; @@ -17,10 +17,13 @@ namespace Barotrauma public AIObjectiveReturn(Character character, Character orderGiver, AIObjectiveManager objectiveManager, float priorityModifier = 1.0f) : base(character, objectiveManager, priorityModifier) { - ReturnTarget = GetReturnTarget(Submarine.MainSubs) ?? GetReturnTarget(Submarine.Loaded); - if (ReturnTarget == null) + Target = GetReturnTarget(Submarine.MainSubs) ?? GetReturnTarget(Submarine.Loaded); + if (Target == null) { - DebugConsole.AddSafeError("Error with a Return objective: no suitable return target found"); + if (GameMain.GameSession?.GameMode is not TestGameMode) + { + DebugConsole.AddWarning($"({character.DisplayName}) No suitable return target found. Cannot return back to the main sub."); + } Abandon = true; } @@ -54,7 +57,7 @@ namespace Barotrauma protected override void Act(float deltaTime) { - if (ReturnTarget == null) + if (Target == null) { Abandon = true; return; @@ -62,7 +65,7 @@ namespace Barotrauma bool shouldUseEscapeBehavior = false; if (character.CurrentHull != null || isSteeringThroughGap) { - if (character.Submarine == null || !character.Submarine.IsConnectedTo(ReturnTarget)) + if (character.Submarine == null || !character.Submarine.IsConnectedTo(Target)) { // Character is on another sub that is not connected to the target sub, use the escape behavior to get them out shouldUseEscapeBehavior = true; @@ -76,13 +79,13 @@ namespace Barotrauma Abandon = true; } } - else if (character.Submarine != ReturnTarget) + else if (character.Submarine != Target) { // Character is on another sub that is connected to the target sub, create a Go To objective to reach the target sub if (moveInsideObjective == null) { Hull targetHull = null; - foreach (var d in ReturnTarget.ConnectedDockingPorts.Values) + foreach (var d in Target.ConnectedDockingPorts.Values) { if (!d.Docked) { continue; } if (d.DockingTarget == null) { continue; } @@ -143,7 +146,7 @@ namespace Barotrauma Hull targetHull = null; float targetDistanceSquared = float.MaxValue; bool targetIsAirlock = false; - foreach (var hull in ReturnTarget.GetHulls(false)) + foreach (var hull in Target.GetHulls(false)) { bool hullIsAirlock = hull.IsAirlock; if(hullIsAirlock || (!targetIsAirlock && hull.LeadsOutside(character))) @@ -178,18 +181,14 @@ namespace Barotrauma usingEscapeBehavior = shouldUseEscapeBehavior; } - protected override bool CheckObjectiveSpecific() + protected override bool CheckObjectiveState() { - if (IsCompleted) - { - return true; - } - if (ReturnTarget == null) + if (Target == null) { Abandon = true; return false; } - if (character.Submarine == ReturnTarget) + if (character.Submarine == Target) { IsCompleted = true; } diff --git a/Barotrauma/BarotraumaShared/SharedSource/Characters/AI/Order.cs b/Barotrauma/BarotraumaShared/SharedSource/Characters/AI/Order.cs index 9533252c1..95a6cab00 100644 --- a/Barotrauma/BarotraumaShared/SharedSource/Characters/AI/Order.cs +++ b/Barotrauma/BarotraumaShared/SharedSource/Characters/AI/Order.cs @@ -204,12 +204,8 @@ namespace Barotrauma var allTargetItems = new List(); for (int i = 0; i < AllOptions.Length; i++) { - Identifier[] optionTargetItemsSplit = i < splitTargetItems.Length ? splitTargetItems[i].Split(',', ',').ToIdentifiers() : Array.Empty(); - for (int j = 0; j < optionTargetItemsSplit.Length; j++) - { - optionTargetItemsSplit[j] = optionTargetItemsSplit[j].Value.Trim().ToIdentifier(); - allTargetItems.Add(optionTargetItemsSplit[j]); - } + Identifier[] optionTargetItemsSplit = i < splitTargetItems.Length ? splitTargetItems[i].ToIdentifiers().ToArray() : Array.Empty(); + allTargetItems.AddRange(optionTargetItemsSplit); optionTargetItems.Add(AllOptions[i], optionTargetItemsSplit.ToImmutableArray()); } TargetItems = allTargetItems.ToImmutableArray(); diff --git a/Barotrauma/BarotraumaShared/SharedSource/Characters/AI/PetBehavior.cs b/Barotrauma/BarotraumaShared/SharedSource/Characters/AI/PetBehavior.cs index a859d0c50..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; @@ -45,7 +45,8 @@ namespace Barotrauma public float HappyThreshold { get; set; } public float MaxHappiness { get; set; } - + + public bool HideStatusIndicators { get; set; } /// /// At which point is the pet considered "hungry" (playing unhappy sounds and showing the icon) @@ -59,6 +60,14 @@ namespace Barotrauma public float PlayForce { get; set; } public float PlayTimer { get; set; } + + public float PlayCooldown { get; set; } + + /// + /// Should the pet lose ownership (and stop following) when the same character interacts with it twice? Unlike with other pets, if another character interacts with the pet, they will become the owner. + /// + public bool ToggleOwner { get; set; } + private float? UnstunY { get; set; } public EnemyAIController AIController { get; private set; } = null; @@ -151,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; } @@ -162,7 +172,7 @@ namespace Barotrauma private class Food { - public string Tag; + public Identifier Tag; public Vector2 HungerRange; public float Hunger; public float Happiness; @@ -182,6 +192,7 @@ namespace Barotrauma MaxHappiness = element.GetAttributeFloat(nameof(MaxHappiness), 100.0f); UnhappyThreshold = element.GetAttributeFloat(nameof(UnhappyThreshold), MaxHappiness * 0.25f); HappyThreshold = element.GetAttributeFloat(nameof(HappyThreshold), MaxHappiness * 0.8f); + HideStatusIndicators = element.GetAttributeBool(nameof(HideStatusIndicators), false); MaxHunger = element.GetAttributeFloat(nameof(MaxHunger), 100.0f); HungryThreshold = element.GetAttributeFloat(nameof(HungryThreshold), MaxHunger * 0.5f); @@ -192,7 +203,9 @@ namespace Barotrauma HappinessDecreaseRate = element.GetAttributeFloat(nameof(HappinessDecreaseRate), 0.1f); HungerIncreaseRate = element.GetAttributeFloat(nameof(HungerIncreaseRate), 0.25f); - PlayForce = element.GetAttributeFloat("playforce", 15.0f); + PlayForce = element.GetAttributeFloat(nameof(PlayForce), 15.0f); + PlayCooldown = element.GetAttributeFloat(nameof(PlayCooldown), 5.0f); + ToggleOwner = element.GetAttributeBool(nameof(ToggleOwner), false); foreach (var subElement in element.Elements()) { @@ -204,7 +217,7 @@ namespace Barotrauma case "eat": Food food = new Food { - Tag = subElement.GetAttributeString("tag", ""), + Tag = subElement.GetAttributeIdentifier("tag", Identifier.Empty), Hunger = subElement.GetAttributeFloat("hunger", -1), Happiness = subElement.GetAttributeFloat("happiness", 1), Priority = subElement.GetAttributeFloat("priority", 100), @@ -227,6 +240,7 @@ namespace Barotrauma public StatusIndicatorType GetCurrentStatusIndicatorType() { + if (HideStatusIndicators) { return StatusIndicatorType.None; } if (Hunger > HungryThreshold) { return StatusIndicatorType.Hungry; } if (Happiness > HappyThreshold) { return StatusIndicatorType.Happy; } if (Happiness < UnhappyThreshold) { return StatusIndicatorType.Sad; } @@ -280,17 +294,30 @@ namespace Barotrauma return false; } + public bool CanPlayWith(Character player) + { + return AIController.Character.IsOnFriendlyTeam(player); + } + public void Play(Character player) { if (PlayTimer > 0.0f) { return; } - Owner ??= player; - PlayTimer = 5.0f; + if (!CanPlayWith(player)) { return; } + if (ToggleOwner) + { + Owner = Owner == player ? null : player; + } + else + { + Owner ??= player; + } + PlayTimer = PlayCooldown; AIController.Character.IsRagdolled = true; Happiness += 10.0f; AIController.Character.AnimController.MainLimb.body.LinearVelocity += new Vector2(0, PlayForce); UnstunY = AIController.Character.SimPosition.Y; #if CLIENT - AIController.Character.PlaySound(CharacterSound.SoundType.Happy, 0.9f); + AIController.Character.PlaySound(Owner == null ? CharacterSound.SoundType.Unhappy : CharacterSound.SoundType.Happy); #endif } @@ -318,7 +345,7 @@ namespace Barotrauma if (UnstunY.HasValue) { - if (PlayTimer > 4.0f) + if (PlayTimer > PlayCooldown - 1.0f) { float extent = character.AnimController.MainLimb.body.GetMaxExtent(); if (character.SimPosition.Y < (UnstunY.Value + extent * 3.0f) && @@ -354,9 +381,12 @@ namespace Barotrauma { if (food.TargetParams == null) { - if (AIController.AIParams.TryGetTarget(food.Tag, out TargetParams target)) + if (AIController.AIParams.TryGetTargets(food.Tag, out IEnumerable existingTargetParams)) { - food.TargetParams = target; + foreach (var targetParams in existingTargetParams) + { + food.TargetParams = targetParams; + } } else if (AIController.AIParams.TryAddNewTarget(food.Tag, AIState.Eat, food.Priority, out TargetParams targetParams)) { @@ -444,11 +474,15 @@ namespace Barotrauma } else { + WayPoint spawnPoint = null; //try to find a spawnpoint in the main sub - var spawnPoint = WayPoint.WayPointList.Where(wp => wp.SpawnType == SpawnType.Human && wp.Submarine == Submarine.MainSub).GetRandomUnsynced(); + if (Submarine.MainSub != null) + { + spawnPoint = WayPoint.WayPointList.Where(wp => wp.SpawnType == SpawnType.Human && wp.Submarine == Submarine.MainSub).GetRandomUnsynced(); + } //if not found, try any player sub (shuttle/drone etc) spawnPoint ??= WayPoint.WayPointList.Where(wp => wp.SpawnType == SpawnType.Human && wp.Submarine?.Info.Type == SubmarineType.Player).GetRandomUnsynced(); - spawnPos = spawnPoint?.WorldPosition ?? Submarine.MainSub.WorldPosition; + spawnPos = spawnPoint?.WorldPosition ?? Submarine.MainSub?.WorldPosition ?? Vector2.Zero; } var characterPrefab = CharacterPrefab.FindBySpeciesName(speciesName.ToIdentifier()); diff --git a/Barotrauma/BarotraumaShared/SharedSource/Characters/AI/ShipCommand/ShipIssueWorker.cs b/Barotrauma/BarotraumaShared/SharedSource/Characters/AI/ShipCommand/ShipIssueWorker.cs index bf6db4177..0ad1c19ae 100644 --- a/Barotrauma/BarotraumaShared/SharedSource/Characters/AI/ShipCommand/ShipIssueWorker.cs +++ b/Barotrauma/BarotraumaShared/SharedSource/Characters/AI/ShipCommand/ShipIssueWorker.cs @@ -50,9 +50,12 @@ namespace Barotrauma if (OrderedCharacter.AIController is HumanAIController humanAI && humanAI.ObjectiveManager.CurrentOrders.None(o => o.MatchesOrder(SuggestedOrder.Identifier, Option) && o.TargetEntity == TargetItem)) { - if (orderedCharacter != CommandingCharacter) + bool orderGivenByDifferentCharacter = orderedCharacter != CommandingCharacter; + if (orderGivenByDifferentCharacter) { - CommandingCharacter.Speak(SuggestedOrder.GetChatMessage(OrderedCharacter.Name, "", givingOrderToSelf: false), minDurationBetweenSimilar: 5); + CommandingCharacter.Speak(SuggestedOrder.GetChatMessage(OrderedCharacter.Name, "", givingOrderToSelf: false), + minDurationBetweenSimilar: 5, + identifier: ("GiveOrder." + SuggestedOrder.Prefab.Identifier).ToIdentifier()); } CurrentOrder = SuggestedOrder .WithOption(Option) @@ -60,7 +63,12 @@ namespace Barotrauma .WithOrderGiver(CommandingCharacter) .WithManualPriority(CharacterInfo.HighestManualOrderPriority); OrderedCharacter.SetOrder(CurrentOrder, CommandingCharacter != OrderedCharacter); - OrderedCharacter.Speak(TextManager.Get("DialogAffirmative").Value, delay: 1.0f, minDurationBetweenSimilar: 5); + if (orderGivenByDifferentCharacter) + { + OrderedCharacter.Speak(TextManager.Get("DialogAffirmative").Value, delay: 1.0f, + minDurationBetweenSimilar: 5, + identifier: ("ReceiveOrder." + SuggestedOrder.Prefab.Identifier).ToIdentifier()); + } } TimeSinceLastAttempt = 0f; } diff --git a/Barotrauma/BarotraumaShared/SharedSource/Characters/AI/ShipCommand/ShipIssueWorkerSteer.cs b/Barotrauma/BarotraumaShared/SharedSource/Characters/AI/ShipCommand/ShipIssueWorkerSteer.cs index aa6328e96..648b5f224 100644 --- a/Barotrauma/BarotraumaShared/SharedSource/Characters/AI/ShipCommand/ShipIssueWorkerSteer.cs +++ b/Barotrauma/BarotraumaShared/SharedSource/Characters/AI/ShipCommand/ShipIssueWorkerSteer.cs @@ -11,7 +11,7 @@ namespace Barotrauma public override void CalculateImportanceSpecific() { if (shipCommandManager.NavigationState == ShipCommandManager.NavigationStates.Inactive) { return; } - if (TargetItemComponent is Powered powered && powered.Voltage <= powered.MinVoltage) { return; } + if (TargetItemComponent is Powered { HasPower: false }) { return; } if (TargetItem.Condition <= 0f) { return; } Importance = 70f; diff --git a/Barotrauma/BarotraumaShared/SharedSource/Characters/AI/Wreck/WreckAI.cs b/Barotrauma/BarotraumaShared/SharedSource/Characters/AI/Wreck/WreckAI.cs index c2299f4cd..b3255db1a 100644 --- a/Barotrauma/BarotraumaShared/SharedSource/Characters/AI/Wreck/WreckAI.cs +++ b/Barotrauma/BarotraumaShared/SharedSource/Characters/AI/Wreck/WreckAI.cs @@ -5,6 +5,7 @@ using System.Collections.Generic; using Barotrauma.Networking; using System.Linq; using System; +using System.Diagnostics; namespace Barotrauma { @@ -89,12 +90,12 @@ namespace Barotrauma { public bool IsAlive { get; private set; } - private readonly List allItems; private readonly List thalamusItems; private readonly List thalamusStructures; private readonly List wayPoints = new List(); private readonly List hulls = new List(); private readonly List spawnOrgans = new List(); + private readonly List jammedDoors = new List(); private readonly Item brain; private bool initialCellsSpawned; @@ -105,7 +106,7 @@ namespace Barotrauma private bool IsThalamus(MapEntityPrefab entityPrefab) => IsThalamus(entityPrefab, Config.Entity); - private static IEnumerable GetThalamusEntities(Submarine wreck, Identifier tag) where T : MapEntity => GetThalamusEntities(wreck, tag).Where(e => e is T).Select(e => e as T); + private static IEnumerable GetThalamusEntities(Submarine wreck, Identifier tag) where T : MapEntity => GetThalamusEntities(wreck, tag).OfType(); private static IEnumerable GetThalamusEntities(Submarine wreck, Identifier tag) => MapEntity.MapEntityList.Where(e => e.Submarine == wreck && e.Prefab != null && IsThalamus(e.Prefab, tag)); @@ -122,93 +123,52 @@ namespace Barotrauma { GetConfig(); if (Config == null) { return; } - var thalamusPrefabs = ItemPrefab.Prefabs.Where(p => IsThalamus(p)); + var thalamusPrefabs = ItemPrefab.Prefabs.Where(IsThalamus); var brainPrefab = thalamusPrefabs.GetRandom(i => i.Tags.Contains(Config.Brain), Rand.RandSync.ServerAndClient); if (brainPrefab == null) { - DebugConsole.ThrowError($"WreckAI: Could not find any brain prefab with the tag {Config.Brain}! Cannot continue. Failed to create wreck AI."); + DebugConsole.ThrowError($"WreckAI {wreck.Info.Name}: Could not find any brain prefab with the tag {Config.Brain}! Cannot continue. Failed to create wreck AI.", contentPackage: Config.ContentPackage); return; } - allItems = wreck.GetItems(false); - thalamusItems = allItems.FindAll(i => IsThalamus(((MapEntity)i).Prefab)); - hulls.AddRange(wreck.GetHulls(false)); - var potentialBrainHulls = new List<(Hull hull, float weight)>(); + thalamusItems = GetThalamusEntities(wreck, Config.Entity).ToList(); + hulls.AddRange(wreck.GetHulls(alsoFromConnectedSubs: false)); brain = new Item(brainPrefab, Vector2.Zero, wreck); thalamusItems.Add(brain); Point minSize = brain.Rect.Size.Multiply(brain.Scale); - // Bigger hulls are allowed, but not preferred more than what's sufficent. - Vector2 sufficentSize = new Vector2(minSize.X * 2, minSize.Y * 1.1f); - // Shrink the horizontal axis so that the brain is not placed in the left or right side, where we often have curved walls. - Rectangle shrinkedBounds = ToolBox.GetWorldBounds(wreck.WorldPosition.ToPoint(), new Point(wreck.Borders.Width - 500, wreck.Borders.Height)); - foreach (Hull hull in hulls) - { - float distanceFromCenter = Vector2.Distance(wreck.WorldPosition, hull.WorldPosition); - float distanceFactor = MathHelper.Lerp(1.0f, 0.5f, MathUtils.InverseLerp(0, Math.Max(shrinkedBounds.Width, shrinkedBounds.Height) / 2, distanceFromCenter)); - float horizontalSizeFactor = MathHelper.Lerp(0.5f, 1.0f, MathUtils.InverseLerp(minSize.X, sufficentSize.X, hull.Rect.Width)); - float verticalSizeFactor = MathHelper.Lerp(0.5f, 1.0f, MathUtils.InverseLerp(minSize.Y, sufficentSize.Y, hull.Rect.Height)); - float weight = verticalSizeFactor * horizontalSizeFactor * distanceFactor; - if (hull.GetLinkedEntities().Any()) - { - // Ignore hulls that have any linked hulls to keep the calculations simple. - continue; - } - else if (hull.ConnectedGaps.Any(g => g.Open > 0 && (!g.IsRoomToRoom || g.Position.Y < hull.Position.Y))) - { - // Ignore hulls that have open gaps to outside or below the center point, because we'll want the room to be full of water and not be accessible without breaking the wall. - continue; - } - else if (thalamusItems.Any(i => i.CurrentHull == hull)) - { - // Don't create the brain in a room that already has thalamus items inside it. - continue; - } - else if (hull.Rect.Width < minSize.X || hull.Rect.Height < minSize.Y) - { - // Don't select too small rooms. - continue; - } - if (weight > 0) - { - potentialBrainHulls.Add((hull, weight)); - } - } + var potentialBrainHulls = GetPotentialBrainRooms(wreck, Config, minSize, thalamusItems); Hull brainHull = ToolBox.SelectWeightedRandom(potentialBrainHulls.Select(pbh => pbh.hull).ToList(), potentialBrainHulls.Select(pbh => pbh.weight).ToList(), Rand.RandSync.ServerAndClient); var thalamusStructurePrefabs = StructurePrefab.Prefabs.Where(IsThalamus); if (brainHull == null) { - DebugConsole.AddWarning("Wreck AI: Cannot find a proper room for the brain. Using a random room."); + DebugConsole.ThrowError($"Wreck AI {wreck.Info.Name}: Cannot find a suitable room for the Thalamus brain. Using a random room. " + + $"The wreck should be fixed so that there's at least one room where the following conditions are met: No linked hulls, no open gaps in the floor or to outside the sub, and no other Thalamus items present in the hull.", + contentPackage: Config.ContentPackage); + brainHull = hulls.GetRandom(Rand.RandSync.ServerAndClient); } if (brainHull == null) { - DebugConsole.ThrowError("Wreck AI: Cannot find any room for the brain! Failed to create the Thalamus."); + DebugConsole.ThrowError($"Wreck AI {wreck.Info.Name}: Cannot find any room for the brain! Failed to create the Thalamus.", contentPackage: Config.ContentPackage); return; } + Debug.WriteLine($"Wreck AI {wreck.Info.Name}: Selected brain room: {brainHull.DisplayName}"); brainHull.WaterVolume = brainHull.Volume; brain.SetTransform(brainHull.SimPosition, rotation: 0, findNewHull: false); brain.CurrentHull = brainHull; + + // Jam the doors, mainly to prevent any mechanisms from opening them. Also makes it a little bit more difficult for the player to breach into the brain room, because they now have to break the door. + foreach (Door door in brainHull.ConnectedGaps.Select(g => g.ConnectedDoor)) + { + if (door == null) { continue; } + door.IsJammed = true; + jammedDoors.Add(door); + } + var backgroundPrefab = thalamusStructurePrefabs.GetRandom(i => i.Tags.Contains(Config.BrainRoomBackground), Rand.RandSync.ServerAndClient); if (backgroundPrefab != null) { - new Structure(brainHull.Rect, backgroundPrefab, wreck); - } - var horizontalWallPrefab = thalamusStructurePrefabs.GetRandom(p => p.Tags.Contains(Config.BrainRoomHorizontalWall), Rand.RandSync.ServerAndClient); - if (horizontalWallPrefab != null) - { - int height = (int)horizontalWallPrefab.Size.Y; - int halfHeight = height / 2; - int quarterHeight = halfHeight / 2; - new Structure(new Rectangle(brainHull.Rect.Left, brainHull.Rect.Top + quarterHeight, brainHull.Rect.Width, height), horizontalWallPrefab, wreck); - new Structure(new Rectangle(brainHull.Rect.Left, brainHull.Rect.Top - brainHull.Rect.Height + halfHeight + quarterHeight, brainHull.Rect.Width, height), horizontalWallPrefab, wreck); - } - var verticalWallPrefab = thalamusStructurePrefabs.GetRandom(p => p.Tags.Contains(Config.BrainRoomVerticalWall), Rand.RandSync.ServerAndClient); - if (verticalWallPrefab != null) - { - int width = (int)verticalWallPrefab.Size.X; - int halfWidth = width / 2; - int quarterWidth = halfWidth / 2; - new Structure(new Rectangle(brainHull.Rect.Left - quarterWidth, brainHull.Rect.Top, width, brainHull.Rect.Height), verticalWallPrefab, wreck); - new Structure(new Rectangle(brainHull.Rect.Right - halfWidth - quarterWidth, brainHull.Rect.Top, width, brainHull.Rect.Height), verticalWallPrefab, wreck); + var background = new Structure(brainHull.Rect, backgroundPrefab, wreck); + background.SpriteDepth -= 0.01f; } foreach (Item item in thalamusItems) { @@ -360,6 +320,7 @@ namespace Barotrauma public void Kill() { + jammedDoors.ForEach(d => d.IsJammed = false); thalamusItems.ForEach(i => i.Condition = 0); foreach (var turret in turrets) { @@ -376,27 +337,24 @@ namespace Barotrauma protectiveCells.ForEach(c => c.OnDeath -= OnCellDeath); if (!IsClient) { - if (Config != null) + if (Config is { KillAgentsWhenEntityDies: true }) { - if (Config.KillAgentsWhenEntityDies) + protectiveCells.ForEach(c => c.Kill(CauseOfDeathType.Unknown, null)); + if (!string.IsNullOrWhiteSpace(Config.OffensiveAgent)) { - protectiveCells.ForEach(c => c.Kill(CauseOfDeathType.Unknown, null)); - if (!string.IsNullOrWhiteSpace(Config.OffensiveAgent)) + foreach (var character in Character.CharacterList) { - foreach (var character in Character.CharacterList) + // Kills ALL offensive agents that are near the thalamus. Not the ideal solution, + // but as long as spawning is handled via status effects, I don't know if there is any better way. + // In practice there shouldn't be terminal cells from different thalamus organisms at the same time. + // And if there was, the distance check should prevent killing the agents of a different organism. + if (character.SpeciesName == Config.OffensiveAgent) { - // Kills ALL offensive agents that are near the thalamus. Not the ideal solution, - // but as long as spawning is handled via status effects, I don't know if there is any better way. - // In practice there shouldn't be terminal cells from different thalamus organisms at the same time. - // And if there was, the distance check should prevent killing the agents of a different organism. - if (character.SpeciesName == Config.OffensiveAgent) + // Sonar distance is used also for wreck positioning. No wreck should be closer to each other than this. + float maxDistance = Sonar.DefaultSonarRange; + if (Vector2.DistanceSquared(character.WorldPosition, Submarine.WorldPosition) < maxDistance * maxDistance) { - // Sonar distance is used also for wreck positioning. No wreck should be closer to each other than this. - float maxDistance = Sonar.DefaultSonarRange; - if (Vector2.DistanceSquared(character.WorldPosition, Submarine.WorldPosition) < maxDistance * maxDistance) - { - character.Kill(CauseOfDeathType.Unknown, null); - } + character.Kill(CauseOfDeathType.Unknown, null); } } } @@ -515,5 +473,62 @@ namespace Barotrauma msg.WriteBoolean(IsAlive); } #endif + + public static List<(Hull hull, float weight)> GetPotentialBrainRooms(Submarine wreck, WreckAIConfig wreckAI, Point minSize, IEnumerable thalamusItems = null) + { + var potentialBrainHulls = new List<(Hull hull, float weight)>(); + // Bigger hulls are allowed, but not preferred more than what's sufficient. + Vector2 sufficientSize = new Vector2(minSize.X * 2, minSize.Y * 1.1f); + Rectangle worldBounds = ToolBox.GetWorldBounds(wreck.WorldPosition.ToPoint(), new Point(wreck.Borders.Width, wreck.Borders.Height)); + thalamusItems ??= GetThalamusEntities(wreck, wreckAI.Entity); + foreach (Hull hull in wreck.GetHulls(alsoFromConnectedSubs: false)) + { + if (hull.GetLinkedEntities().Any()) + { + // Ignore hulls that have any linked hulls to keep the calculations simple. + continue; + } + else if (hull.ConnectedGaps.Any(g => (g.Open > 0 || g.ConnectedDoor?.Item.Condition <= 0) && (!g.IsRoomToRoom || g.Position.Y < hull.Position.Y))) + { + // Ignore hulls that have open gaps to outside or below the center point, because we'll want the room to be full of water and not be accessible without breaking the wall. + // Gaps in the broken doors are not yet open at this stage. Also Door.IsBroken is not yet up-to-date, so we'll have to check the item condition. + continue; + } + else if (thalamusItems.Any(i => i.CurrentHull == hull && !i.HasTag(Tags.WireItem))) + { + // Don't create the brain in a room that already has thalamus items inside it. + continue; + } + else if (hull.Rect.Width < minSize.X || hull.Rect.Height < minSize.Y) + { + // Don't select too small rooms. + continue; + } + float weight = 0; + if (hull.IsAirlock) + { + // Prefer something else than airlocks + weight = 0; + } + else + { + float distanceFromCenter = Vector2.Distance(wreck.WorldPosition, hull.WorldPosition); + float distanceFactor = MathHelper.Lerp(1.0f, 0.5f, MathUtils.InverseLerp(0, Math.Max(worldBounds.Width, worldBounds.Height) / 2f, distanceFromCenter)); + float horizontalSizeFactor = MathHelper.Lerp(0.5f, 1.0f, MathUtils.InverseLerp(minSize.X, sufficientSize.X, hull.Rect.Width)); + float verticalSizeFactor = MathHelper.Lerp(0.5f, 1.0f, MathUtils.InverseLerp(minSize.Y, sufficientSize.Y, hull.Rect.Height)); + weight = verticalSizeFactor * horizontalSizeFactor * distanceFactor; + } + if (weight > 0 || potentialBrainHulls.None()) + { + potentialBrainHulls.Add((hull, weight)); + } + } + Debug.WriteLine($"Wreck AI {wreck.Info.Name}: Potential brain rooms: {potentialBrainHulls.Count}"); + foreach ((Hull hull, float weight) in potentialBrainHulls) + { + Debug.WriteLine($"Wreck AI: Potential brain room: {hull.DisplayName}, {weight.FormatSingleDecimal()}"); + } + return potentialBrainHulls; + } } } \ No newline at end of file diff --git a/Barotrauma/BarotraumaShared/SharedSource/Characters/Animation/AnimController.cs b/Barotrauma/BarotraumaShared/SharedSource/Characters/Animation/AnimController.cs index 958368329..9cfaaf315 100644 --- a/Barotrauma/BarotraumaShared/SharedSource/Characters/Animation/AnimController.cs +++ b/Barotrauma/BarotraumaShared/SharedSource/Characters/Animation/AnimController.cs @@ -38,21 +38,7 @@ namespace Barotrauma public readonly AnimationType AnimationType; public readonly AnimationParams TemporaryAnimation; public readonly float Priority; - public bool IsActive - { - get { return _isActive; } - set - { - if (value) - { - expirationTimer = expirationTime; - } - _isActive = value; - } - } - private bool _isActive; - private float expirationTimer; - private const float expirationTime = 0.1f; + public bool IsActive; public AnimSwap(AnimationParams temporaryAnimation, float priority) { @@ -61,15 +47,6 @@ namespace Barotrauma Priority = priority; IsActive = true; } - - public void Update(float deltaTime) - { - expirationTimer -= deltaTime; - if (expirationTimer <= 0) - { - IsActive = false; - } - } } protected readonly Dictionary tempAnimations = new Dictionary(); @@ -151,7 +128,8 @@ namespace Barotrauma } else { - return Math.Abs(TargetMovement.X) > (WalkParams.MovementSpeed + RunParams.MovementSpeed) / 2.0f; + float movementSpeed = IsClimbing ? TargetMovement.Y : TargetMovement.X; + return Math.Abs(movementSpeed) > (WalkParams.MovementSpeed + RunParams.MovementSpeed) / 2.0f; } } } @@ -226,7 +204,7 @@ namespace Barotrauma public void UpdateAnimations(float deltaTime) { - UpdateTemporaryAnimations(deltaTime); + UpdateTemporaryAnimations(); UpdateAnim(deltaTime); } @@ -338,6 +316,31 @@ namespace Barotrauma { FlipLockTime = (float)Timing.TotalTime + time; } + + protected void UpdateConstantTorque(float deltaTime) + { + foreach (var limb in Limbs) + { + if (limb.IsSevered) { continue; } + if (Math.Abs(limb.Params.ConstantTorque) > 0) + { + // TODO: not sure if this works on ground + float movementFactor = Math.Max(character.AnimController.Collider.LinearVelocity.Length() * 0.5f, 1); + limb.body.SmoothRotate(MainLimb.Rotation + MathHelper.ToRadians(limb.Params.ConstantAngle) * Dir, limb.Mass * limb.Params.ConstantTorque * movementFactor, wrapAngle: true); + } + } + } + + protected void UpdateBlink(float deltaTime) + { + foreach (var limb in Limbs) + { + if (limb.IsSevered) { continue; } + if (limb.Params.BlinkFrequency <= 0) { continue; } + if (!limb.InWater && limb.Params.OnlyBlinkInWater) { continue; } + limb.UpdateBlink(deltaTime, MainLimb.Rotation); + } + } public void UpdateUseItem(bool allowMovement, Vector2 handWorldPos) { @@ -408,9 +411,9 @@ namespace Barotrauma character.WorldPosition.Y - handWorldPos.Y > ConvertUnits.ToDisplayUnits(CurrentGroundedParams.TorsoPosition) / 4 && this is HumanoidAnimController humanoidAnimController) { - humanoidAnimController.Crouching = true; + humanoidAnimController.Crouch(); + // TODO: is this redundant/required? humanoidAnimController.ForceSelectAnimationType = AnimationType.Crouch; - character.SetInput(InputType.Crouch, hit: false, held: true); } } @@ -605,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); @@ -696,6 +699,290 @@ namespace Barotrauma hand.body.SmoothRotate(handAngle, 10.0f * handTorque * hand.Mass, wrapAngle: false); } } + + private float prevFootPos; + protected void UpdateClimbing() + { + var ladder = character.SelectedSecondaryItem?.GetComponent(); + if (character.IsIncapacitated) + { + Anim = Animation.None; + return; + } + else if (ladder == null) + { + StopClimbing(); + return; + } + + onGround = false; + IgnorePlatforms = true; + + bool climbFast = !character.Params.ForceSlowClimbing && character.AnimController.IsMovingFast; + var animParams = climbFast ? RunParams : WalkParams; + // Don't slide if we can climb faster than slide. + bool slide = animParams.SlideSpeed > animParams.ClimbSpeed && targetMovement.Y < -0.1f && climbFast; + float maxClimbingSpeed = climbFast && !character.Params.ForceSlowClimbing ? RunParams.ClimbSpeed : WalkParams.ClimbSpeed; + Vector2 tempTargetMovement = TargetMovement; + tempTargetMovement.Y = Math.Clamp(tempTargetMovement.Y, slide ? -animParams.SlideSpeed : -maxClimbingSpeed, maxClimbingSpeed); + + movement = MathUtils.SmoothStep(movement, tempTargetMovement, 0.3f); + + Limb leftFoot = GetClimbingLimb(LimbType.LeftFoot); + Limb rightFoot = GetClimbingLimb(LimbType.RightFoot); + Limb head = GetClimbingLimb(LimbType.Head); + Limb torso = GetClimbingLimb(LimbType.Torso); + + Limb leftHand = GetClimbingLimb(LimbType.LeftHand); + Limb rightHand = GetClimbingLimb(LimbType.RightHand); + + Vector2 ladderSimPos = ConvertUnits.ToSimUnits( + ladder.Item.Rect.X + ladder.Item.Rect.Width / 2.0f, + ladder.Item.Rect.Y); + + Vector2 ladderSimSize = ConvertUnits.ToSimUnits(ladder.Item.Rect.Size.ToVector2()); + + var lowestNearbyLadder = GetLowestNearbyLadder(ladder); + if (lowestNearbyLadder != null && lowestNearbyLadder != ladder) + { + ladderSimSize.Y = ConvertUnits.ToSimUnits(ladder.Item.WorldRect.Y - (lowestNearbyLadder.Item.WorldRect.Y - lowestNearbyLadder.Item.Rect.Size.Y)); + } + + float stepHeight = ConvertUnits.ToSimUnits(animParams.ClimbStepHeight); + + if (currentHull == null && ladder.Item.Submarine != null) + { + ladderSimPos += ladder.Item.Submarine.SimPosition; + } + else if (currentHull?.Submarine != null && currentHull.Submarine != ladder.Item.Submarine && ladder.Item.Submarine != null) + { + ladderSimPos += ladder.Item.Submarine.SimPosition - currentHull.Submarine.SimPosition; + } + else if (currentHull?.Submarine != null && ladder.Item.Submarine == null) + { + ladderSimPos -= currentHull.Submarine.SimPosition; + } + + float bottomPos = Collider.SimPosition.Y - ColliderHeightFromFloor - Collider.Radius - Collider.Height / 2.0f; + float torsoPos = TorsoPosition ?? 0; + float bodyMoveForce = animParams.ClimbBodyMoveForce; + if (torso != null) + { + MoveLimb(torso, new Vector2(ladderSimPos.X - 0.35f * Dir, bottomPos + torsoPos), bodyMoveForce); + } + if (head != null) + { + float headPos = HeadPosition ?? 0; + MoveLimb(head, new Vector2(ladderSimPos.X - 0.2f * Dir, bottomPos + headPos), bodyMoveForce); + } + + Collider.MoveToPos(new Vector2(ladderSimPos.X - 0.1f * Dir, Collider.SimPosition.Y), bodyMoveForce); + + Vector2 handPos = new Vector2( + ladderSimPos.X, + bottomPos + torsoPos + movement.Y * 0.1f - ladderSimPos.Y); + if (climbFast) { handPos.Y -= stepHeight; } + + float handMoveForce = animParams.ClimbHandMoveForce; + + //prevent the hands from going above the top of the ladders + handPos.Y = Math.Min(-0.5f, handPos.Y); + if (!Aiming || !(character.Inventory?.GetItemInLimbSlot(InvSlotType.RightHand)?.GetComponent()?.ControlPose ?? false) || Math.Abs(movement.Y) > 0.01f) + { + if (rightHand != null) + { + MoveLimb(rightHand, + new Vector2(slide ? handPos.X + ladderSimSize.X * 0.75f : handPos.X, + (slide ? handPos.Y + stepHeight : MathUtils.Round(handPos.Y, stepHeight * 2.0f)) + ladderSimPos.Y), + handMoveForce); + rightHand.body.ApplyTorque(Dir * 2.0f); + } + } + if (!Aiming || !(character.Inventory?.GetItemInLimbSlot(InvSlotType.LeftHand)?.GetComponent()?.ControlPose ?? false) || Math.Abs(movement.Y) > 0.01f) + { + if (leftHand != null) + { + MoveLimb(leftHand, + new Vector2(handPos.X - ladderSimSize.X * (slide ? 1.0f : 0.5f), + (slide ? handPos.Y + stepHeight : MathUtils.Round(handPos.Y - stepHeight, stepHeight * 2.0f) + stepHeight) + ladderSimPos.Y), + handMoveForce); ; + leftHand.body.ApplyTorque(Dir * 2.0f); + } + } + + float stepHeightAdjustment = stepHeight * 2.7f; + Vector2 footPos = new Vector2( + handPos.X - Dir * 0.05f, + bottomPos + ColliderHeightFromFloor - stepHeightAdjustment - ladderSimPos.Y); + if (climbFast) { footPos.Y += stepHeight; } + + //apply torque to the legs to make the knees bend + Limb leftLeg = GetClimbingLimb(LimbType.LeftLeg); + Limb rightLeg = GetClimbingLimb(LimbType.RightLeg); + + //only move the feet if they're above the bottom of the ladders + //(if not, they'll just dangle in air, and the character holds itself up with its arms) + if (footPos.Y > -ladderSimSize.Y - 0.2f && leftFoot != null && rightFoot != null && leftLeg != null && rightLeg != null) + { + Limb refLimb = GetClimbingLimb(LimbType.Waist) ?? GetClimbingLimb(LimbType.Torso) ?? MainLimb; + bool leftLegBackwards = Math.Abs(leftLeg.body.Rotation - refLimb.body.Rotation) > MathHelper.Pi; + bool rightLegBackwards = Math.Abs(rightLeg.body.Rotation - refLimb.body.Rotation) > MathHelper.Pi; + float footMoveForce = animParams.ClimbFootMoveForce; + if (slide) + { + if (!leftLegBackwards) { MoveLimb(leftFoot, new Vector2(footPos.X - ladderSimSize.X * 0.5f, footPos.Y + ladderSimPos.Y), footMoveForce, pullFromCenter: true); } + if (!rightLegBackwards) { MoveLimb(rightFoot, new Vector2(footPos.X, footPos.Y + ladderSimPos.Y), footMoveForce, pullFromCenter: true); } + } + else + { + float leftFootPos = MathUtils.Round(footPos.Y + stepHeight, stepHeight * 2.0f) - stepHeight; + float prevLeftFootPos = MathUtils.Round(prevFootPos + stepHeight, stepHeight * 2.0f) - stepHeight; + if (!leftLegBackwards) { MoveLimb(leftFoot, new Vector2(footPos.X, leftFootPos + ladderSimPos.Y), footMoveForce, pullFromCenter: true); } + + float rightFootPos = MathUtils.Round(footPos.Y, stepHeight * 2.0f); + float prevRightFootPos = MathUtils.Round(prevFootPos, stepHeight * 2.0f); + if (!rightLegBackwards) { MoveLimb(rightFoot, new Vector2(footPos.X, rightFootPos + ladderSimPos.Y), footMoveForce, pullFromCenter: true); } +#if CLIENT + if (Math.Abs(leftFootPos - prevLeftFootPos) > stepHeight && leftFoot.LastImpactSoundTime < Timing.TotalTime - Limb.SoundInterval) + { + SoundPlayer.PlaySound("footstep_armor_heavy", leftFoot.WorldPosition, hullGuess: currentHull); + leftFoot.LastImpactSoundTime = (float)Timing.TotalTime; + } + if (Math.Abs(rightFootPos - prevRightFootPos) > stepHeight && rightFoot.LastImpactSoundTime < Timing.TotalTime - Limb.SoundInterval) + { + SoundPlayer.PlaySound("footstep_armor_heavy", rightFoot.WorldPosition, hullGuess: currentHull); + rightFoot.LastImpactSoundTime = (float)Timing.TotalTime; + } +#endif + prevFootPos = footPos.Y; + } + + if (!leftLegBackwards) { leftLeg.body.ApplyTorque(Dir * -8.0f); } // TODO: expose? + if (!rightLegBackwards) { rightLeg.body.ApplyTorque(Dir * -8.0f); } + } + + float movementFactor = (handPos.Y / stepHeight) * (float)Math.PI; + movementFactor = 0.8f + (float)Math.Abs(Math.Sin(movementFactor)); + + Vector2 subSpeed = currentHull != null || ladder.Item.Submarine == null + ? Vector2.Zero : ladder.Item.Submarine.Velocity; + + //reached the top of the ladders -> can't go further up + Vector2 climbForce = new Vector2(0.0f, movement.Y) * movementFactor; + + if (!InWater) { climbForce.Y += 0.3f * movementFactor; } + + if (character.SimPosition.Y > ladderSimPos.Y) { climbForce.Y = Math.Min(0.0f, climbForce.Y); } + //reached the bottom -> can't go further down + float minHeightFromFloor = ColliderHeightFromFloor / 2 + Collider.Height; + if (floorFixture != null && + !floorFixture.CollisionCategories.HasFlag(Physics.CollisionStairs) && + !floorFixture.CollisionCategories.HasFlag(Physics.CollisionPlatform) && + character.SimPosition.Y < standOnFloorY + minHeightFromFloor) + { + climbForce.Y = MathHelper.Clamp((standOnFloorY + minHeightFromFloor - character.SimPosition.Y) * 5.0f, climbForce.Y, 1.0f); + } + + //apply forces to the collider to move the Character up/down + Collider.ApplyForce((climbForce * 20.0f + subSpeed * 50.0f) * Collider.Mass); + // Don't rotate the head on non-humanoids, because it can cause issues with some ragdolls. + // E.g. the head might not actually be head, or it's not where we expect it to be. + if (head != null && character.IsHumanoid) + { + if (Aiming) + { + RotateHead(head); + } + else if (Anim == Animation.UsingItemWhileClimbing && character.SelectedItem is { } selectedItem) + { + Vector2 diff = (selectedItem.WorldPosition - head.WorldPosition) * Dir; + float targetRotation = MathHelper.WrapAngle(MathUtils.VectorToAngle(diff) - MathHelper.PiOver4 * Dir); + head.body.SmoothRotate(targetRotation, force: animParams.HeadTorque); + } + else + { + float movementMultiplier = targetMovement.Y < 0 ? 0 : 1; + head.body.SmoothRotate(MathHelper.PiOver4 * movementMultiplier * Dir, force: animParams.HeadTorque); + } + } + + if (ladder.Item.Prefab.Triggers.None()) + { + character.ReleaseSecondaryItem(); + return; + } + + Rectangle trigger = ladder.Item.Prefab.Triggers.FirstOrDefault(); + trigger = ladder.Item.TransformTrigger(trigger); + + bool isRemote = false; + bool isClimbing = true; + if (GameMain.NetworkMember != null && GameMain.NetworkMember.IsClient) + { + isRemote = character.IsRemotelyControlled; + } + //if the character is remotely controlled, + //let the server decide when to deselect the ladder and stop climbing + if (!isRemote) + { + if ((character.IsKeyDown(InputType.Left) || character.IsKeyDown(InputType.Right)) && + (!character.IsKeyDown(InputType.Up) && !character.IsKeyDown(InputType.Down))) + { + isClimbing = false; + } + } + + if (!isClimbing) + { + character.StopClimbing(); + IgnorePlatforms = false; + } + + Ladder GetLowestNearbyLadder(Ladder currentLadder, float threshold = 16.0f) + { + foreach (Ladder ladder in Ladder.List) + { + if (ladder == currentLadder || !ladder.Item.IsInteractable(character)) { continue; } + if (Math.Abs(ladder.Item.WorldPosition.X - currentLadder.Item.WorldPosition.X) > threshold) { continue; } + if (ladder.Item.WorldPosition.Y > currentLadder.Item.WorldPosition.Y) { continue; } + if ((currentLadder.Item.WorldRect.Y - currentLadder.Item.Rect.Height) - ladder.Item.WorldRect.Y > threshold) { continue; } + return ladder; + } + return null; + } + + Limb GetClimbingLimb(LimbType limbType) + { + if (HasMultipleLimbsOfSameType) + { + // First try to find a match using the secondary type, if that fails, use the primary type and exclude all the limbs with the secondary type. + // Secondary limbs are first excluded and then targeted, because some feet are meant to be used as hands in this context, which means we don't want to get them when seeking the feet. + return GetLimb(limbType, useSecondaryType: true) ?? GetLimb(limbType, excludeLimbsWithSecondaryType: true); + } + else + { + return GetLimb(limbType); + } + } + } + + protected void RotateHead(Limb head) + { + Vector2 mousePos = ConvertUnits.ToSimUnits(character.CursorPosition); + Vector2 dir = (mousePos - head.SimPosition) * Dir; + float rot = MathUtils.VectorToAngle(dir); + var neckJoint = GetJointBetweenLimbs(LimbType.Head, LimbType.Torso); + if (neckJoint != null) + { + float offset = MathUtils.WrapAnglePi(GetLimb(LimbType.Torso).body.Rotation); + float lowerLimit = neckJoint.LowerLimit + offset; + float upperLimit = neckJoint.UpperLimit + offset; + float min = Math.Min(lowerLimit, upperLimit); + float max = Math.Max(lowerLimit, upperLimit); + rot = Math.Clamp(rot, min, max); + } + head.body.SmoothRotate(rot, CurrentAnimationParams.HeadTorque); + } public void ApplyPose(Vector2 leftHandPos, Vector2 rightHandPos, Vector2 leftFootPos, Vector2 rightFootPos, float footMoveForce = 10) { @@ -754,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; @@ -818,6 +1113,13 @@ namespace Barotrauma CalculateArmLengths(); } } + + public void RecreateAndRespawn(RagdollParams ragdollParams = null) + { + Vector2 pos = character.WorldPosition; + Recreate(ragdollParams); + character.TeleportTo(pos); + } private void StartAnimation(Animation animation) { @@ -906,7 +1208,7 @@ namespace Barotrauma return true; } - private void UpdateTemporaryAnimations(float deltaTime) + private void UpdateTemporaryAnimations() { if (tempAnimations.None()) { return; } foreach ((AnimationType animationType, AnimSwap animSwap) in tempAnimations) @@ -932,7 +1234,8 @@ namespace Barotrauma expiredAnimations.Clear(); foreach (AnimSwap animSwap in tempAnimations.Values) { - animSwap.Update(deltaTime); + // Will be removed on the next frame, unless something keeps it alive. + animSwap.IsActive = false; } } diff --git a/Barotrauma/BarotraumaShared/SharedSource/Characters/Animation/FishAnimController.cs b/Barotrauma/BarotraumaShared/SharedSource/Characters/Animation/FishAnimController.cs index fa20dd94c..316d3db5d 100644 --- a/Barotrauma/BarotraumaShared/SharedSource/Characters/Animation/FishAnimController.cs +++ b/Barotrauma/BarotraumaShared/SharedSource/Characters/Animation/FishAnimController.cs @@ -139,26 +139,15 @@ namespace Barotrauma ResetState(); return; } + UpdateConstantTorque(deltaTime); + UpdateBlink(deltaTime); var mainLimb = MainLimb; - levitatingCollider = !IsHangingWithRope; + levitatingCollider = !IsHangingWithRope && !IsClimbing; if (!character.CanMove) { - levitatingCollider = false; - Collider.FarseerBody.FixedRotation = false; - if (GameMain.NetworkMember == null || !GameMain.NetworkMember.IsClient) - { - Collider.Enabled = false; - Collider.LinearVelocity = mainLimb.LinearVelocity; - Collider.SetTransformIgnoreContacts(mainLimb.SimPosition, mainLimb.Rotation); - //reset pull joints to prevent the character from "hanging" mid-air if pull joints had been active when the character was still moving - //(except when dragging, then we need the pull joints) - if (!Draggable || character.SelectedBy == null) - { - ResetPullJoints(); - } - } + UpdateRagdollControlsMovement(); if (character.IsDead && deathAnimTimer < deathAnimDuration) { deathAnimTimer += deltaTime; @@ -184,11 +173,11 @@ namespace Barotrauma if (InWater) { - Collider.SetTransform(new Vector2(Collider.SimPosition.X, MainLimb.SimPosition.Y), 0.0f); + Collider.SetTransformIgnoreContacts(new Vector2(Collider.SimPosition.X, MainLimb.SimPosition.Y), 0.0f); } else { - Collider.SetTransform(new Vector2( + Collider.SetTransformIgnoreContacts(new Vector2( Collider.SimPosition.X, Math.Max(lowestLimb.SimPosition.Y + (Collider.Radius + Collider.Height / 2), Collider.SimPosition.Y)), 0.0f); @@ -208,6 +197,11 @@ namespace Barotrauma { TargetMovement = TargetMovement.ClampLength(2); } + + if (IsClimbing) + { + UpdateClimbing(); + } if (inWater && !forceStanding) { @@ -336,7 +330,6 @@ namespace Barotrauma if (target == null) { return; } Limb mouthLimb = GetLimb(LimbType.Head); if (mouthLimb == null) { return; } - if (GameMain.NetworkMember == null || !GameMain.NetworkMember.IsClient) { //stop dragging if there's something between the pull limb and the target @@ -357,23 +350,23 @@ namespace Barotrauma return; } } - - float dmg = character.Params.EatingSpeed; - float eatSpeed = dmg / ((float)Math.Sqrt(Math.Max(target.Mass, 1)) * 10); - eatTimer += deltaTime * eatSpeed; - - Vector2 mouthPos = SimplePhysicsEnabled ? character.SimPosition : GetMouthPosition().Value; - Vector2 attackSimPosition = character.Submarine == null ? ConvertUnits.ToSimUnits(target.WorldPosition) : target.SimPosition; - - Vector2 limbDiff = attackSimPosition - mouthPos; - float extent = Math.Max(mouthLimb.body.GetMaxExtent(), 1); - bool tooFar = character.InWater ? limbDiff.LengthSquared() > extent * extent : limbDiff.X > extent; - if (tooFar) - { - character.SelectedCharacter = null; - } - else + if (Character.CanEat) { + Vector2 mouthPos = SimplePhysicsEnabled ? character.SimPosition : GetMouthPosition() ?? Vector2.Zero; + Vector2 attackSimPosition = character.Submarine == null ? ConvertUnits.ToSimUnits(target.WorldPosition) : target.SimPosition; + Vector2 limbDiff = attackSimPosition - mouthPos; + float extent = Math.Max(mouthLimb.body.GetMaxExtent(), 1); + bool tooFar = character.InWater ? limbDiff.LengthSquared() > extent * extent : limbDiff.X > extent; + if (tooFar) + { + character.DeselectCharacter(); + return; + } + + float dmg = character.Params.EatingSpeed; + float eatSpeed = dmg / ((float)Math.Sqrt(Math.Max(target.Mass, 1)) * 10); + eatTimer += deltaTime * eatSpeed; + //pull the target character to the position of the mouth //(+ make the force fluctuate to waggle the character a bit) float dragForce = MathHelper.Clamp(eatSpeed * 10, 0, 40); @@ -405,20 +398,19 @@ namespace Barotrauma else { float force = (float)Math.Sin(eatTimer * 100) * mouthLimb.Mass; - mouthLimb.body.ApplyLinearImpulse(Vector2.UnitY * force * 2, maxVelocity: NetConfig.MaxPhysicsBodyVelocity); - mouthLimb.body.ApplyTorque(-force * 50); + mouthLimb.body.ApplyLinearImpulse(Vector2.UnitY * force * mouthLimb.Params.EatImpulse, maxVelocity: NetConfig.MaxPhysicsBodyVelocity); + mouthLimb.body.ApplyTorque(-force * mouthLimb.Params.EatTorque); } - - if (Character.CanEat && target.IsDead) + + var jaw = GetLimb(LimbType.Jaw); + if (jaw != null) + { + jaw.body.ApplyTorque(-(float)Math.Sin(eatTimer * 150) * jaw.Mass * 25); + } + character.ApplyStatusEffects(ActionType.OnEating, deltaTime); + + if (target.IsDead) { - var jaw = GetLimb(LimbType.Jaw); - if (jaw != null) - { - jaw.body.ApplyTorque(-(float)Math.Sin(eatTimer * 150) * jaw.Mass * 25); - } - - character.ApplyStatusEffects(ActionType.OnEating, deltaTime); - float particleFrequency = MathHelper.Clamp(eatSpeed / 2, 0.02f, 0.5f); if (Rand.Value() < particleFrequency / 6) { @@ -430,7 +422,7 @@ namespace Barotrauma } if (eatTimer % 1.0f < 0.5f && (eatTimer - deltaTime * eatSpeed) % 1.0f > 0.5f) { - static bool CanBeSevered(LimbJoint j) => !j.IsSevered && j.CanBeSevered && j.LimbA != null && !j.LimbA.IsSevered && j.LimbB != null && !j.LimbB.IsSevered; + static bool CanBeSevered(LimbJoint j) => !j.IsSevered && j.CanBeSevered && j.LimbA is { IsSevered: false } && j.LimbB is { IsSevered: false }; //keep severing joints until there is only one limb left var nonSeveredJoints = target.AnimController.LimbJoints.Where(CanBeSevered); if (nonSeveredJoints.None()) @@ -440,16 +432,13 @@ namespace Barotrauma { target.Inventory?.AllItemsMod.ForEach(it => it?.Drop(dropper: null)); } - //only one limb left, the character is now full eaten Entity.Spawner?.AddEntityToRemoveQueue(target); - if (Character.AIController is EnemyAIController enemyAi) { enemyAi.PetBehavior?.OnEat(target); } - - character.SelectedCharacter = null; + character.DeselectCharacter(); } else //sever a random joint { @@ -460,7 +449,7 @@ namespace Barotrauma } } - public bool reverse; + public bool Reverse; void UpdateSineAnim(float deltaTime) { @@ -510,7 +499,7 @@ namespace Barotrauma } else { - Vector2 transformedMovement = reverse ? -movement : movement; + Vector2 transformedMovement = Reverse ? -movement : movement; float movementAngle = MathUtils.VectorToAngle(transformedMovement) - MathHelper.PiOver2; float mainLimbAngle = 0; if (mainLimb.type == LimbType.Torso && TorsoAngle.HasValue) @@ -555,7 +544,6 @@ namespace Barotrauma foreach (var limb in Limbs) { if (limb.IsSevered) { continue; } - if (limb.type != LimbType.Tail) { continue; } if (!limb.Params.ApplyTailAngle) { continue; } RotateTail(limb); isAngleApplied = true; @@ -592,7 +580,7 @@ namespace Barotrauma else { movementAngle = Dir > 0 ? -MathHelper.PiOver2 : MathHelper.PiOver2; - if (reverse) + if (Reverse) { movementAngle = MathUtils.WrapAngleTwoPi(movementAngle - MathHelper.Pi); } @@ -651,29 +639,21 @@ namespace Barotrauma foreach (var limb in Limbs) { if (limb.IsSevered) { continue; } - switch (limb.type) + if (limb.type is LimbType.LeftFoot or LimbType.RightFoot) { - case LimbType.LeftFoot: - case LimbType.RightFoot: - if (CurrentSwimParams.FootAnglesInRadians.ContainsKey(limb.Params.ID)) - { - SmoothRotateWithoutWrapping(limb, movementAngle + CurrentSwimParams.FootAnglesInRadians[limb.Params.ID] * Dir, mainLimb, FootTorque); - } - break; - case LimbType.Tail: - if (waveLength > 0 && waveAmplitude > 0) - { - float waveRotation = (float)Math.Sin(WalkPos * limb.Params.SineFrequencyMultiplier); - limb.body.ApplyTorque(waveRotation * limb.Mass * waveAmplitude * limb.Params.SineAmplitudeMultiplier); - } - break; + if (CurrentSwimParams.FootAnglesInRadians.ContainsKey(limb.Params.ID)) + { + SmoothRotateWithoutWrapping(limb, movementAngle + CurrentSwimParams.FootAnglesInRadians[limb.Params.ID] * Dir, mainLimb, FootTorque); + } + } + if (limb.type == LimbType.Tail || limb.Params.ApplySineMovement) + { + if (waveLength > 0 && waveAmplitude > 0) + { + float waveRotation = (float)Math.Sin(WalkPos * limb.Params.SineFrequencyMultiplier); + limb.body.ApplyTorque(waveRotation * limb.Mass * waveAmplitude * limb.Params.SineAmplitudeMultiplier); + } } - } - - for (int i = 0; i < Limbs.Length; i++) - { - var limb = Limbs[i]; - if (limb.IsSevered) { continue; } if (limb.SteerForce <= 0.0f) { continue; } if (!Collider.PhysEnabled) { continue; } Vector2 pullPos = limb.PullJointWorldAnchorA; @@ -698,20 +678,6 @@ namespace Barotrauma } } - foreach (var limb in Limbs) - { - if (limb.IsSevered) { continue; } - if (Math.Abs(limb.Params.ConstantTorque) > 0) - { - float movementFactor = Math.Max(character.AnimController.Collider.LinearVelocity.Length() * 0.5f, 1); - limb.body.SmoothRotate(MainLimb.Rotation + MathHelper.ToRadians(limb.Params.ConstantAngle) * Dir, limb.Mass * limb.Params.ConstantTorque * movementFactor, wrapAngle: true); - } - if (limb.Params.BlinkFrequency > 0) - { - limb.UpdateBlink(deltaTime, MainLimb.Rotation); - } - } - floorY = Limbs[0].SimPosition.Y; } @@ -744,9 +710,9 @@ namespace Barotrauma } float offset = MathHelper.Pi * CurrentGroundedParams.StepLiftOffset; - if (CurrentGroundedParams.MultiplyByDir) + if (character.AnimController.Dir < 0) { - offset *= Dir; + offset += MathHelper.Pi * CurrentGroundedParams.StepLiftFrequency; } float stepLift = TargetMovement.X == 0.0f ? 0 : (float)Math.Sin(WalkPos * Dir * CurrentGroundedParams.StepLiftFrequency + offset) * (CurrentGroundedParams.StepLiftAmount / 100); @@ -847,14 +813,6 @@ namespace Barotrauma foreach (Limb limb in Limbs) { if (limb.IsSevered) { continue; } - if (Math.Abs(limb.Params.ConstantTorque) > 0) - { - limb.body.SmoothRotate(MainLimb.Rotation + MathHelper.ToRadians(limb.Params.ConstantAngle) * Dir, limb.Mass * limb.Params.ConstantTorque, wrapAngle: true); - } - if (limb.Params.BlinkFrequency > 0 && !limb.Params.OnlyBlinkInWater) - { - limb.UpdateBlink(deltaTime, MainLimb.Rotation); - } switch (limb.type) { case LimbType.LeftFoot: @@ -1024,7 +982,7 @@ namespace Barotrauma if (RagdollParams.IsSpritesheetOrientationHorizontal) { //horizontally aligned limbs need to be flipped 180 degrees - l.body.SetTransform(l.SimPosition, l.body.Rotation + MathHelper.Pi * Dir); + l.body.SetTransformIgnoreContacts(l.SimPosition, l.body.Rotation + MathHelper.Pi * Dir); } //no need to do anything when flipping vertically oriented limbs //the sprite gets flipped horizontally, which does the job diff --git a/Barotrauma/BarotraumaShared/SharedSource/Characters/Animation/HumanoidAnimController.cs b/Barotrauma/BarotraumaShared/SharedSource/Characters/Animation/HumanoidAnimController.cs index 14c49042d..09a1170f4 100644 --- a/Barotrauma/BarotraumaShared/SharedSource/Characters/Animation/HumanoidAnimController.cs +++ b/Barotrauma/BarotraumaShared/SharedSource/Characters/Animation/HumanoidAnimController.cs @@ -13,10 +13,8 @@ namespace Barotrauma private const float SteepestWalkableSlopeAngleDegrees = 55f; private const float SlowlyWalkableSlopeAngleDegrees = 30f; - private static readonly float SteepestWalkableSlopeNormalX = - MathF.Sin(MathHelper.ToRadians(SteepestWalkableSlopeAngleDegrees)); - private static readonly float SlowlyWalkableSlopeNormalX = - MathF.Sin(MathHelper.ToRadians(SlowlyWalkableSlopeAngleDegrees)); + private static readonly float SteepestWalkableSlopeNormalX = MathF.Sin(MathHelper.ToRadians(SteepestWalkableSlopeAngleDegrees)); + private static readonly float SlowlyWalkableSlopeNormalX = MathF.Sin(MathHelper.ToRadians(SlowlyWalkableSlopeAngleDegrees)); private const float MaxSpeedOnStairs = 1.7f; private const float SteepSlopePushMagnitude = MaxSpeedOnStairs; @@ -254,7 +252,8 @@ namespace Barotrauma { if (Frozen) { return; } if (MainLimb == null) { return; } - + UpdateConstantTorque(deltaTime); + UpdateBlink(deltaTime); levitatingCollider = !IsHangingWithRope; if (onGround && character.CanMove) { @@ -297,25 +296,7 @@ namespace Barotrauma fallingProneAnimTimer += deltaTime; UpdateFallingProne(1.0f); } - levitatingCollider = false; - Collider.FarseerBody.FixedRotation = false; - if (GameMain.NetworkMember == null || !GameMain.NetworkMember.IsClient) - { - if (Collider.Enabled) - { - //deactivating the collider -> make the main limb inherit the collider's velocity because it'll control the movement now - MainLimb.body.LinearVelocity = Collider.LinearVelocity; - Collider.Enabled = false; - } - Collider.LinearVelocity = MainLimb.LinearVelocity; - Collider.SetTransformIgnoreContacts(MainLimb.SimPosition, MainLimb.Rotation); - //reset pull joints to prevent the character from "hanging" mid-air if pull joints had been active when the character was still moving - //(except when dragging, then we need the pull joints) - if (!Draggable || character.SelectedBy == null) - { - ResetPullJoints(); - } - } + UpdateRagdollControlsMovement(); return; } fallingProneAnimTimer = 0.0f; @@ -325,7 +306,7 @@ namespace Barotrauma { var lowestLimb = FindLowestLimb(); - Collider.SetTransform(new Vector2( + Collider.SetTransformIgnoreContacts(new Vector2( Collider.SimPosition.X, Math.Max(lowestLimb.SimPosition.Y + (Collider.Radius + Collider.Height / 2), Collider.SimPosition.Y)), Collider.Rotation); @@ -357,7 +338,7 @@ namespace Barotrauma float angleDiff = MathUtils.GetShortestAngle(Collider.Rotation, 0.0f); if (Math.Abs(angleDiff) > 0.001f) { - Collider.SetTransform(Collider.SimPosition, Collider.Rotation + angleDiff); + Collider.SetTransformIgnoreContacts(Collider.SimPosition, Collider.Rotation + angleDiff); } } @@ -582,9 +563,7 @@ namespace Barotrauma footMid += (Math.Max(Math.Abs(walkPosX) * limpAmount, 0.0f) * Math.Min(Math.Abs(TargetMovement.X), 0.3f)) * Dir; } - movement = overrideTargetMovement == Vector2.Zero ? - MathUtils.SmoothStep(movement, TargetMovement, movementLerp) : - overrideTargetMovement; + movement = overrideTargetMovement ?? MathUtils.SmoothStep(movement, TargetMovement, movementLerp); if (Math.Abs(movement.X) < 0.005f) { @@ -652,9 +631,14 @@ namespace Barotrauma { movement = Vector2.Zero; } - + + float offset = MathHelper.Pi * currentGroundedParams.StepLiftOffset; + if (character.AnimController.Dir < 0) + { + offset += MathHelper.Pi * currentGroundedParams.StepLiftFrequency; + } float stepLift = TargetMovement.X == 0.0f ? 0 : - (float)Math.Sin(WalkPos * currentGroundedParams.StepLiftFrequency + MathHelper.Pi * currentGroundedParams.StepLiftOffset) * (currentGroundedParams.StepLiftAmount / 100); + (float)Math.Sin(WalkPos * Dir * currentGroundedParams.StepLiftFrequency + offset) * (currentGroundedParams.StepLiftAmount / 100); float y = colliderPos.Y + stepLift; @@ -987,7 +971,7 @@ namespace Barotrauma { head.body.SmoothRotate(Collider.Rotation + HeadAngle.Value * Dir, CurrentSwimParams.HeadTorque); } - else + else if (character.FollowCursor) { RotateHead(head); } @@ -1145,245 +1129,6 @@ namespace Barotrauma } } - private float prevFootPos; - - void UpdateClimbing() - { - var ladder = character.SelectedSecondaryItem?.GetComponent(); - if (character.IsIncapacitated) - { - Anim = Animation.None; - return; - } - else if (ladder == null) - { - StopClimbing(); - return; - } - - onGround = false; - IgnorePlatforms = true; - - bool climbFast = targetMovement.Y > 3.0f; - bool slide = targetMovement.Y < -1.1f; - Vector2 tempTargetMovement = TargetMovement; - tempTargetMovement.Y = climbFast ? - Math.Min(tempTargetMovement.Y, 2.0f) : - Math.Min(tempTargetMovement.Y, 1.0f); - - movement = MathUtils.SmoothStep(movement, tempTargetMovement, 0.3f); - - Limb leftFoot = GetLimb(LimbType.LeftFoot); - Limb rightFoot = GetLimb(LimbType.RightFoot); - Limb head = GetLimb(LimbType.Head); - Limb torso = GetLimb(LimbType.Torso); - - Limb leftHand = GetLimb(LimbType.LeftHand); - Limb rightHand = GetLimb(LimbType.RightHand); - - if (leftHand == null || rightHand == null || head == null || torso == null) { return; } - - Vector2 ladderSimPos = ConvertUnits.ToSimUnits( - ladder.Item.Rect.X + ladder.Item.Rect.Width / 2.0f, - ladder.Item.Rect.Y); - - Vector2 ladderSimSize = ConvertUnits.ToSimUnits(ladder.Item.Rect.Size.ToVector2()); - - float lowestLadderSimPos = ladderSimPos.Y - ladderSimPos.Y; - var lowestNearbyLadder = GetLowestNearbyLadder(ladder); - if (lowestNearbyLadder != null && lowestNearbyLadder != ladder) - { - ladderSimSize.Y = ConvertUnits.ToSimUnits(ladder.Item.WorldRect.Y - (lowestNearbyLadder.Item.WorldRect.Y - lowestNearbyLadder.Item.Rect.Size.Y)); - } - - float stepHeight = ConvertUnits.ToSimUnits(30.0f); - if (climbFast) { stepHeight *= 2; } - - if (currentHull == null && ladder.Item.Submarine != null) - { - ladderSimPos += ladder.Item.Submarine.SimPosition; - } - else if (currentHull?.Submarine != null && currentHull.Submarine != ladder.Item.Submarine && ladder.Item.Submarine != null) - { - ladderSimPos += ladder.Item.Submarine.SimPosition - currentHull.Submarine.SimPosition; - } - else if (currentHull?.Submarine != null && ladder.Item.Submarine == null) - { - ladderSimPos -= currentHull.Submarine.SimPosition; - } - - float bottomPos = Collider.SimPosition.Y - ColliderHeightFromFloor - Collider.Radius - Collider.Height / 2.0f; - float torsoPos = TorsoPosition ?? 0; - MoveLimb(torso, new Vector2(ladderSimPos.X - 0.35f * Dir, bottomPos + torsoPos), 10.5f); - float headPos = HeadPosition ?? 0; - MoveLimb(head, new Vector2(ladderSimPos.X - 0.2f * Dir, bottomPos + headPos), 10.5f); - - Collider.MoveToPos(new Vector2(ladderSimPos.X - 0.1f * Dir, Collider.SimPosition.Y), 10.5f); - - Vector2 handPos = new Vector2( - ladderSimPos.X, - bottomPos + torsoPos + movement.Y * 0.1f - ladderSimPos.Y); - if (climbFast) { handPos.Y -= stepHeight; } - - //prevent the hands from going above the top of the ladders - handPos.Y = Math.Min(-0.5f, handPos.Y); - if (!Aiming || !(character.Inventory?.GetItemInLimbSlot(InvSlotType.RightHand)?.GetComponent()?.ControlPose ?? false) || Math.Abs(movement.Y) > 0.01f) - { - MoveLimb(rightHand, - new Vector2(slide ? handPos.X + ladderSimSize.X * 0.5f : handPos.X, - (slide ? handPos.Y : MathUtils.Round(handPos.Y, stepHeight * 2.0f)) + ladderSimPos.Y), - 5.2f); - rightHand.body.ApplyTorque(Dir * 2.0f); - } - if (!Aiming || !(character.Inventory?.GetItemInLimbSlot(InvSlotType.LeftHand)?.GetComponent()?.ControlPose ?? false) || Math.Abs(movement.Y) > 0.01f) - { - MoveLimb(leftHand, - new Vector2(handPos.X - ladderSimSize.X * 0.5f, - (slide ? handPos.Y : MathUtils.Round(handPos.Y - stepHeight, stepHeight * 2.0f) + stepHeight) + ladderSimPos.Y), - 5.2f); ; - leftHand.body.ApplyTorque(Dir * 2.0f); - } - - Vector2 footPos = new Vector2( - handPos.X - Dir * 0.05f, - bottomPos + ColliderHeightFromFloor - stepHeight * 2.7f - ladderSimPos.Y); - if (climbFast) { footPos.Y += stepHeight; } - - //apply torque to the legs to make the knees bend - Limb leftLeg = GetLimb(LimbType.LeftLeg); - Limb rightLeg = GetLimb(LimbType.RightLeg); - - //only move the feet if they're above the bottom of the ladders - //(if not, they'll just dangle in air, and the character holds itself up with it's arms) - if (footPos.Y > -ladderSimSize.Y - 0.2f && leftFoot != null && rightFoot != null) - { - Limb refLimb = GetLimb(LimbType.Waist) ?? GetLimb(LimbType.Torso); - bool leftLegBackwards = Math.Abs(leftLeg.body.Rotation - refLimb.body.Rotation) > MathHelper.Pi; - bool rightLegBackwards = Math.Abs(rightLeg.body.Rotation - refLimb.body.Rotation) > MathHelper.Pi; - - if (slide) - { - if (!leftLegBackwards) { MoveLimb(leftFoot, new Vector2(footPos.X - ladderSimSize.X * 0.5f, footPos.Y + ladderSimPos.Y), 15.5f, true); } - if (!rightLegBackwards) { MoveLimb(rightFoot, new Vector2(footPos.X, footPos.Y + ladderSimPos.Y), 15.5f, true); } - } - else - { - float leftFootPos = MathUtils.Round(footPos.Y + stepHeight, stepHeight * 2.0f) - stepHeight; - float prevLeftFootPos = MathUtils.Round(prevFootPos + stepHeight, stepHeight * 2.0f) - stepHeight; - if (!leftLegBackwards) { MoveLimb(leftFoot, new Vector2(footPos.X, leftFootPos + ladderSimPos.Y), 15.5f, true); } - - float rightFootPos = MathUtils.Round(footPos.Y, stepHeight * 2.0f); - float prevRightFootPos = MathUtils.Round(prevFootPos, stepHeight * 2.0f); - if (!rightLegBackwards) { MoveLimb(rightFoot, new Vector2(footPos.X, rightFootPos + ladderSimPos.Y), 15.5f, true); } -#if CLIENT - if (Math.Abs(leftFootPos - prevLeftFootPos) > stepHeight && leftFoot.LastImpactSoundTime < Timing.TotalTime - Limb.SoundInterval) - { - SoundPlayer.PlaySound("footstep_armor_heavy", leftFoot.WorldPosition, hullGuess: currentHull); - leftFoot.LastImpactSoundTime = (float)Timing.TotalTime; - } - if (Math.Abs(rightFootPos - prevRightFootPos) > stepHeight && rightFoot.LastImpactSoundTime < Timing.TotalTime - Limb.SoundInterval) - { - SoundPlayer.PlaySound("footstep_armor_heavy", rightFoot.WorldPosition, hullGuess: currentHull); - rightFoot.LastImpactSoundTime = (float)Timing.TotalTime; - } -#endif - prevFootPos = footPos.Y; - } - - if (!leftLegBackwards) { leftLeg.body.ApplyTorque(Dir * -8.0f); } - if (!rightLegBackwards) { rightLeg.body.ApplyTorque(Dir * -8.0f); } - } - - float movementFactor = (handPos.Y / stepHeight) * (float)Math.PI; - movementFactor = 0.8f + (float)Math.Abs(Math.Sin(movementFactor)); - - Vector2 subSpeed = currentHull != null || ladder.Item.Submarine == null - ? Vector2.Zero : ladder.Item.Submarine.Velocity; - - //reached the top of the ladders -> can't go further up - Vector2 climbForce = new Vector2(0.0f, movement.Y) * movementFactor; - - if (!InWater) { climbForce.Y += 0.3f * movementFactor; } - - if (character.SimPosition.Y > ladderSimPos.Y) { climbForce.Y = Math.Min(0.0f, climbForce.Y); } - //reached the bottom -> can't go further down - float minHeightFromFloor = ColliderHeightFromFloor / 2 + Collider.Height; - if (floorFixture != null && - !floorFixture.CollisionCategories.HasFlag(Physics.CollisionStairs) && - !floorFixture.CollisionCategories.HasFlag(Physics.CollisionPlatform) && - character.SimPosition.Y < standOnFloorY + minHeightFromFloor) - { - climbForce.Y = MathHelper.Clamp((standOnFloorY + minHeightFromFloor - character.SimPosition.Y) * 5.0f, climbForce.Y, 1.0f); - } - - //apply forces to the collider to move the Character up/down - Collider.ApplyForce((climbForce * 20.0f + subSpeed * 50.0f) * Collider.Mass); - if (Aiming) - { - RotateHead(head); - } - else if (Anim == Animation.UsingItemWhileClimbing && character.SelectedItem is { } selectedItem) - { - Vector2 diff = (selectedItem.WorldPosition - head.WorldPosition) * Dir; - float targetRotation = MathHelper.WrapAngle(MathUtils.VectorToAngle(diff) - MathHelper.PiOver4 * Dir); - head.body.SmoothRotate(targetRotation, force: WalkParams.HeadTorque); - } - else - { - float movementMultiplier = targetMovement.Y < 0 ? 0 : 1; - head.body.SmoothRotate(MathHelper.PiOver4 * movementMultiplier * Dir, force: WalkParams.HeadTorque); - } - - if (ladder.Item.Prefab.Triggers.None()) - { - character.ReleaseSecondaryItem(); - return; - } - - Rectangle trigger = ladder.Item.Prefab.Triggers.FirstOrDefault(); - trigger = ladder.Item.TransformTrigger(trigger); - - bool isRemote = false; - bool isClimbing = true; - if (GameMain.NetworkMember != null && GameMain.NetworkMember.IsClient) - { - isRemote = character.IsRemotelyControlled; - } - if (isRemote) - { - if (Math.Abs(targetMovement.X) > 0.05f || - (TargetMovement.Y < 0.0f && ConvertUnits.ToSimUnits(trigger.Height) + handPos.Y < HeadPosition) || - (TargetMovement.Y > 0.0f && handPos.Y > 0.1f)) - { - isClimbing = false; - } - } - else if ((character.IsKeyDown(InputType.Left) || character.IsKeyDown(InputType.Right)) && - (!character.IsKeyDown(InputType.Up) && !character.IsKeyDown(InputType.Down))) - { - isClimbing = false; - } - - if (!isClimbing) - { - character.StopClimbing(); - IgnorePlatforms = false; - } - - Ladder GetLowestNearbyLadder(Ladder currentLadder, float threshold = 16.0f) - { - foreach (Ladder ladder in Ladder.List) - { - if (ladder == currentLadder || !ladder.Item.IsInteractable(character)) { continue; } - if (Math.Abs(ladder.Item.WorldPosition.X - currentLadder.Item.WorldPosition.X) > threshold) { continue; } - if (ladder.Item.WorldPosition.Y > currentLadder.Item.WorldPosition.Y) { continue; } - if ((currentLadder.Item.WorldRect.Y - currentLadder.Item.Rect.Height) - ladder.Item.WorldRect.Y > threshold) { continue; } - return ladder; - } - return null; - } - } - void UpdateFallingProne(float strength, bool moveHands = true, bool moveTorso = true, bool moveLegs = true) { if (strength <= 0.0f) { return; } @@ -1518,7 +1263,7 @@ namespace Barotrauma float cprBoost = character.GetStatValue(StatTypes.CPRBoost); - int skill = (int)character.GetSkillLevel("medical"); + int skill = (int)character.GetSkillLevel(Tags.MedicalSkill); if (GameMain.NetworkMember is not { IsClient: true }) { @@ -1595,7 +1340,7 @@ namespace Barotrauma //otherwise it's easy to abuse the system by repeatedly reviving in a low-oxygen room if (!target.IsDead) { - target.CharacterHealth.CalculateVitality(); + target.CharacterHealth.RecalculateVitality(); if (wasCritical && target.Vitality > 0.0f && Timing.TotalTime > lastReviveTime + 10.0f) { character.Info?.ApplySkillGain(Tags.MedicalSkill, SkillSettings.Current.SkillIncreasePerCprRevive); @@ -1811,7 +1556,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 @@ -1876,23 +1621,11 @@ namespace Barotrauma } } } - - private void RotateHead(Limb head) + + public void Crouch() { - Vector2 mousePos = ConvertUnits.ToSimUnits(character.CursorPosition); - Vector2 dir = (mousePos - head.SimPosition) * Dir; - float rot = MathUtils.VectorToAngle(dir); - var neckJoint = GetJointBetweenLimbs(LimbType.Head, LimbType.Torso); - if (neckJoint != null) - { - float offset = MathUtils.WrapAnglePi(GetLimb(LimbType.Torso).body.Rotation); - float lowerLimit = neckJoint.LowerLimit + offset; - float upperLimit = neckJoint.UpperLimit + offset; - float min = Math.Min(lowerLimit, upperLimit); - float max = Math.Max(lowerLimit, upperLimit); - rot = Math.Clamp(rot, min, max); - } - head.body.SmoothRotate(rot, CurrentAnimationParams.HeadTorque); + Crouching = true; + character.SetInput(InputType.Crouch, hit: false, held: true); } private void FootIK(Limb foot, Vector2 pos, float legTorque, float footTorque, float footAngle) diff --git a/Barotrauma/BarotraumaShared/SharedSource/Characters/Animation/Ragdoll.cs b/Barotrauma/BarotraumaShared/SharedSource/Characters/Animation/Ragdoll.cs index 9ce0e88a9..1fcda70d0 100644 --- a/Barotrauma/BarotraumaShared/SharedSource/Characters/Animation/Ragdoll.cs +++ b/Barotrauma/BarotraumaShared/SharedSource/Characters/Animation/Ragdoll.cs @@ -64,6 +64,9 @@ namespace Barotrauma } } + private readonly List limbBodies = new List(); + public IEnumerable LimbBodies => limbBodies; + public bool HasMultipleLimbsOfSameType => limbs != null && limbs.Length > limbDictionary.Count; private bool frozen; @@ -91,7 +94,7 @@ namespace Barotrauma private bool simplePhysicsEnabled; public Character Character => character; - protected Character character; + protected readonly Character character; protected float strongestImpact; @@ -108,7 +111,7 @@ namespace Barotrauma //a movement vector that overrides targetmovement if trying to steer //a Character to the position sent by server in multiplayer mode - protected Vector2 overrideTargetMovement; + protected Vector2? overrideTargetMovement; protected float floorY, standOnFloorY; protected Fixture floorFixture; @@ -138,6 +141,12 @@ namespace Barotrauma private Category prevCollisionCategory = Category.None; + /// + /// When the character is alive/conscious, the collider drives the character's movement and is used to sync the character's position in MP. + /// When unconscious, the ragdoll controls the movement and the collider just sticks to the main limb. + /// + public bool ColliderControlsMovement => character.CanMove; + public bool IsStuck => Limbs.Any(l => l.IsStuck); public PhysicsBody Collider @@ -185,7 +194,7 @@ namespace Barotrauma Vector2 pos = collider[colliderIndex].SimPosition; pos.Y -= collider[colliderIndex].Height * 0.5f; pos.Y += collider[value].Height * 0.5f; - collider[value].SetTransform(pos, collider[colliderIndex].Rotation); + collider[value].SetTransformIgnoreContacts(pos, collider[colliderIndex].Rotation); collider[value].LinearVelocity = collider[colliderIndex].LinearVelocity; collider[value].AngularVelocity = collider[colliderIndex].AngularVelocity; @@ -282,7 +291,7 @@ namespace Barotrauma foreach (Limb limb in Limbs) { if (limb.IsSevered || !limb.body.PhysEnabled) { continue; } - limb.body.SetTransform(Collider.SimPosition, Collider.Rotation); + limb.body.SetTransformIgnoreContacts(Collider.SimPosition, Collider.Rotation); //reset pull joints (they may be somewhere far away if the character has moved from the position where animations were last updated) limb.PullJointEnabled = false; limb.PullJointWorldAnchorB = limb.SimPosition; @@ -297,11 +306,11 @@ namespace Barotrauma { get { - return (overrideTargetMovement == Vector2.Zero) ? targetMovement : overrideTargetMovement; + return overrideTargetMovement ?? targetMovement; } set { - if (!MathUtils.IsValid(value)) return; + if (!MathUtils.IsValid(value)) { return; } targetMovement.X = MathHelper.Clamp(value.X, -MAX_SPEED, MAX_SPEED); targetMovement.Y = MathHelper.Clamp(value.Y, -MAX_SPEED, MAX_SPEED); } @@ -385,15 +394,16 @@ namespace Barotrauma if (ragdollParams != null) { RagdollParams = ragdollParams; - if (!character.VariantOf.IsEmpty) - { - RagdollParams.TryApplyVariantScale(character.Params.VariantFile); - } } else { + // Only re-equip items if the ragdoll doesn't change, because re-equiping items might throw exceptions if the limbs have changed. items = limbs?.ToDictionary(l => l.Params, l => l.WearingItems); } + if (character.Params.VariantFile is XDocument variantFile) + { + RagdollParams.TryApplyVariantScale(variantFile); + } foreach (var limbParams in RagdollParams.Limbs) { if (!PhysicsBody.IsValidShape(limbParams.Radius, limbParams.Height, limbParams.Width)) @@ -430,18 +440,13 @@ namespace Barotrauma if (character.IsHusk && character.Params.UseHuskAppendage) { - bool inEditor = false; -#if CLIENT - inEditor = Screen.Selected == GameMain.CharacterEditorScreen; -#endif - var characterPrefab = CharacterPrefab.FindByFilePath(character.ConfigPath); if (characterPrefab?.ConfigElement != null) { var mainElement = characterPrefab.ConfigElement; foreach (var huskAppendage in mainElement.GetChildElements("huskappendage")) { - if (!inEditor && huskAppendage.GetAttributeBool("onlyfromafflictions", false)) { continue; } + if (huskAppendage.GetAttributeBool("onlyfromafflictions", false)) { continue; } Identifier afflictionIdentifier = huskAppendage.GetAttributeIdentifier("affliction", Identifier.Empty); if (!AfflictionPrefab.Prefabs.TryGet(afflictionIdentifier, out AfflictionPrefab affliction) || @@ -452,7 +457,7 @@ namespace Barotrauma } else { - AfflictionHusk.AttachHuskAppendage(character, matchingAffliction, huskAppendage, ragdoll: this); + AfflictionHusk.AttachHuskAppendage(character, matchingAffliction, huskedSpeciesName: character.SpeciesName, huskAppendage, ragdoll: this); } } } @@ -478,7 +483,7 @@ namespace Barotrauma DebugConsole.ThrowError("Invalid collider dimensions: " + cParams.Name); break; ; } - var body = new PhysicsBody(cParams); + var body = new PhysicsBody(cParams, findNewContacts: false); collider.Add(body); body.UserData = character; body.FarseerBody.OnCollision += OnLimbCollision; @@ -520,7 +525,7 @@ namespace Barotrauma { if (joint == null) { continue; } float angle = (joint.LowerLimit + joint.UpperLimit) / 2.0f; - joint.LimbB?.body?.SetTransform( + joint.LimbB?.body?.SetTransformIgnoreContacts( (joint.WorldAnchorA - MathUtils.RotatePointAroundTarget(joint.LocalAnchorB, Vector2.Zero, joint.BodyA.Rotation + angle, true)), joint.BodyA.Rotation + angle); } @@ -528,11 +533,13 @@ namespace Barotrauma protected void CreateLimbs() { + limbBodies.Clear(); limbs?.ForEach(l => l.Remove()); + Mass = 0; DebugConsole.Log($"Creating limbs from {RagdollParams.Name}."); limbDictionary = new Dictionary(); limbs = new Limb[RagdollParams.Limbs.Count]; - RagdollParams.Limbs.ForEach(l => AddLimb(l)); + RagdollParams.Limbs.ForEach(AddLimb); if (limbs.Contains(null)) { return; } SetupDrawOrder(); } @@ -549,11 +556,11 @@ namespace Barotrauma /// /// Resets the serializable data to the currently selected ragdoll params. - /// Force reloading always loads the xml stored on the disk. + /// Always loads the xml stored on the disk. /// - public void ResetRagdoll(bool forceReload = false) + public void ResetRagdoll() { - RagdollParams.Reset(forceReload); + RagdollParams.Reset(forceReload: true); ResetJoints(); ResetLimbs(); } @@ -577,7 +584,7 @@ namespace Barotrauma public void AddJoint(JointParams jointParams) { - if (!checkLimbIndex(jointParams.Limb2, "Limb1") || !checkLimbIndex(jointParams.Limb2, "Limb2")) + if (!checkLimbIndex(jointParams.Limb1, "Limb1") || !checkLimbIndex(jointParams.Limb2, "Limb2")) { return; } @@ -621,6 +628,7 @@ namespace Barotrauma { throw new Exception($"Failed to add a limb to the character \"{Character?.ConfigPath ?? "null"}\" (limb index {ID} out of bounds). The ragdoll file may be configured incorrectly."); } + limbBodies.Add(limb.body.FarseerBody); Limbs[ID] = limb; Mass += limb.Mass; if (!limbDictionary.ContainsKey(limb.type)) { limbDictionary.Add(limb.type, limb); } @@ -632,6 +640,7 @@ namespace Barotrauma limb.body.FarseerBody.OnCollision += OnLimbCollision; Array.Resize(ref limbs, Limbs.Length + 1); Limbs[Limbs.Length - 1] = limb; + limbBodies.Add(limb.body.FarseerBody); Mass += limb.Mass; if (!limbDictionary.ContainsKey(limb.type)) { limbDictionary.Add(limb.type, limb); } SetupDrawOrder(); @@ -639,14 +648,14 @@ namespace Barotrauma public void RemoveLimb(Limb limb) { - if (!Limbs.Contains(limb)) return; + if (!Limbs.Contains(limb)) { return; } Limb[] newLimbs = new Limb[Limbs.Length - 1]; int i = 0; foreach (Limb existingLimb in Limbs) { - if (existingLimb == limb) continue; + if (existingLimb == limb) { continue; } newLimbs[i] = existingLimb; i++; } @@ -684,8 +693,10 @@ namespace Barotrauma LimbJoints = newJoints; } - SubtractMass(limb); + limbBodies.Remove(limb.body.FarseerBody); limb.Remove(); + System.Diagnostics.Debug.Assert(!limbs.Contains(limb)); + System.Diagnostics.Debug.Assert(limbs.None(l => l.Removed)); foreach (LimbJoint limbJoint in attachedJoints) { GameMain.World.Remove(limbJoint.Joint); @@ -819,9 +830,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; } @@ -1073,7 +1085,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), @@ -1294,6 +1306,11 @@ namespace Barotrauma } } + float MaxVel = NetConfig.MaxPhysicsBodyVelocity; + Collider.LinearVelocity = new Vector2( + NetConfig.Quantize(Collider.LinearVelocity.X, -MaxVel, MaxVel, 12), + NetConfig.Quantize(Collider.LinearVelocity.Y, -MaxVel, MaxVel, 12)); + if (forceStanding) { inWater = false; @@ -1400,7 +1417,12 @@ namespace Barotrauma limb.Update(deltaTime); } - if (!inWater && character.AllowInput && levitatingCollider) + bool isAttachedToController = + character.SelectedItem?.GetComponent() is { } controller && + controller.User == character && + controller.IsAttachedUser(controller.User); + + if (!inWater && character.AllowInput && levitatingCollider && !isAttachedToController) { if (onGround && Collider.LinearVelocity.Y > -ImpactTolerance) { @@ -1433,7 +1455,7 @@ namespace Barotrauma else { // Falling -> ragdoll briefly if we are not moving at all, because we are probably stuck. - if (Collider.LinearVelocity == Vector2.Zero) + if (Collider.LinearVelocity == Vector2.Zero && !character.IsRemotePlayer) { character.IsRagdolled = true; if (character.IsBot) @@ -1448,6 +1470,30 @@ namespace Barotrauma forceNotStanding = false; } + /// + /// Update the logic that needs to run when the ragdoll is what controls the character's movement instead of the collider + /// (making the collider stick to the ragdoll's main limb). + /// + protected void UpdateRagdollControlsMovement() + { + levitatingCollider = false; + Collider.FarseerBody.FixedRotation = false; + if (Collider.Enabled) + { + //deactivating the collider -> make the main limb inherit the collider's velocity because it'll control the movement now + MainLimb.body.LinearVelocity = Collider.LinearVelocity; + Collider.Enabled = false; + } + Collider.LinearVelocity = MainLimb.LinearVelocity; + Collider.SetTransformIgnoreContacts(MainLimb.SimPosition, MainLimb.Rotation); + //reset pull joints to prevent the character from "hanging" mid-air if pull joints had been active when the character was still moving + //(except when dragging, then we need the pull joints) + if (!Draggable || character.SelectedBy == null) + { + ResetPullJoints(); + } + } + private void CheckBodyInRest(float deltaTime) { if (SimplePhysicsEnabled) { return; } @@ -1910,7 +1956,7 @@ namespace Barotrauma } else { - Collider.SetTransform(simPosition, Collider.Rotation); + Collider.SetTransformIgnoreContacts(simPosition, Collider.Rotation); } if (!MathUtils.NearlyEqual(limbMoveAmount, Vector2.Zero)) @@ -2009,7 +2055,7 @@ namespace Barotrauma } else { - limb.body.SetTransform(movePos, rotation); + limb.body.SetTransformIgnoreContacts(movePos, rotation); limb.PullJointWorldAnchorB = limb.PullJointWorldAnchorA; limb.PullJointEnabled = false; } @@ -2084,7 +2130,7 @@ namespace Barotrauma partial void UpdateNetPlayerPositionProjSpecific(float deltaTime, float lowestSubPos); private void UpdateNetPlayerPosition(float deltaTime) { - if (GameMain.NetworkMember == null) return; + if (GameMain.NetworkMember == null) { return; } float lowestSubPos = float.MaxValue; if (Submarine.Loaded.Any()) @@ -2113,26 +2159,48 @@ namespace Barotrauma /// /// Note that if there are multiple limbs of the same type, only the first (valid) limb is returned. /// - public Limb GetLimb(LimbType limbType, bool excludeSevered = true) + /// + /// Should we filter out severed limbs? + /// Should we target limbs with secondary type instead of (primary) type? + /// Should we filter out all limbs with a secondary type something else than "None"? + /// + public Limb GetLimb(LimbType limbType, bool excludeSevered = true, bool excludeLimbsWithSecondaryType = false, bool useSecondaryType = false) { - if (limbDictionary.TryGetValue(limbType, out Limb limb)) + Limb limb = null; + if (!HasMultipleLimbsOfSameType && !useSecondaryType && !excludeLimbsWithSecondaryType) { - if (excludeSevered && limb.IsSevered) + // Faster method, but doesn't work when there's multiple limbs of the same type or if we want to seek/exclude limbs with different conditions. + if (limbDictionary.TryGetValue(limbType, out limb)) { - limb = null; - } + if (limb.Removed) + { + limb = null; + } + if (excludeSevered && limb is { IsSevered: true } ) + { + limb = null; + } + } } - if (limb == null && HasMultipleLimbsOfSameType) + if (limb == null) { - // Didn't find a (valid) limb of the matching type. If there's multiple limbs of the same type, check the other limbs. + // Didn't seek or find a (valid) limb of the matching type. If there's multiple limbs of the same type, check the other limbs. foreach (var l in limbs) { - if (l.type != limbType) { continue; } - if (!excludeSevered || !l.IsSevered) + if (l.Removed) { continue; } + if (useSecondaryType) { - limb = l; - break; + if (l.Params.SecondaryType != limbType) { continue; } } + else if (l.type != limbType) + { + continue; + } + if (excludeSevered && l.IsSevered) { continue; } + if (excludeLimbsWithSecondaryType && l.Params.SecondaryType != LimbType.None) { continue; } + // Found a valid and match + limb = l; + break; } } return limb; @@ -2220,6 +2288,7 @@ namespace Barotrauma } limbs = null; } + limbBodies.Clear(); if (collider != null) { diff --git a/Barotrauma/BarotraumaShared/SharedSource/Characters/Attack.cs b/Barotrauma/BarotraumaShared/SharedSource/Characters/Attack.cs index 92f4d9897..899984f8c 100644 --- a/Barotrauma/BarotraumaShared/SharedSource/Characters/Attack.cs +++ b/Barotrauma/BarotraumaShared/SharedSource/Characters/Attack.cs @@ -4,6 +4,7 @@ using System; using System.Collections.Generic; using System.Linq; using System.Xml.Linq; +using Barotrauma.Extensions; namespace Barotrauma { @@ -24,11 +25,22 @@ namespace Barotrauma NotDefined } + [Flags] public enum AttackTarget { - Any, - Character, - Structure // Including hulls etc. Evaluated as anything but a character. + Any = 0, + /// + /// Characters only + /// + Character = 1, + /// + /// Structures and hulls, but also items (for backwards support)! + /// + Structure = 2, + /// + /// Items only + /// + Item = 4 } public enum AIBehaviorAfterAttack @@ -185,7 +197,22 @@ namespace Barotrauma /// /// Used for multiplying all the damage. /// - public float DamageMultiplier { get; set; } = 1; + public float DamageMultiplier + { + get => _damageMultiplier ?? initialDamageMultiplier; + set + { + if (!_damageMultiplier.HasValue) + { + SetInitialDamageMultiplier(value); + } + _damageMultiplier = value; + } + } + private float? _damageMultiplier; + private float initialDamageMultiplier = 1.0f; + public void ResetDamageMultiplier() => _damageMultiplier = initialDamageMultiplier; + public void SetInitialDamageMultiplier(float value) => initialDamageMultiplier = value; /// /// Used for multiplying all the ranges. @@ -198,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; } @@ -263,6 +295,8 @@ namespace Barotrauma [Serialize("0.0, 0.0", IsPropertySaveable.Yes, description: "Applied to the main limb. In world space coordinates(i.e. 0, 1 pushes the character upwards a bit). The attacker's facing direction is taken into account."), Editable] public Vector2 RootForceWorldEnd { get; private set; } + public bool HasRootForce => RootForceWorldStart != Vector2.Zero || RootForceWorldMiddle != Vector2.Zero || RootForceWorldEnd != Vector2.Zero; + [Serialize(TransitionMode.Linear, IsPropertySaveable.Yes, description:"Applied to the main limb. The transition smoothing of the applied force."), Editable] public TransitionMode RootTransitionEasing { get; private set; } @@ -410,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); @@ -816,15 +849,28 @@ namespace Barotrauma return true; } - public bool IsValidTarget(AttackTarget targetType) => TargetType == AttackTarget.Any || TargetType == targetType; + public bool IsValidTarget(AttackTarget targetType) => TargetType == AttackTarget.Any || TargetType.HasAnyFlag(targetType); public bool IsValidTarget(Entity target) { return TargetType switch { AttackTarget.Character => target is Character, - AttackTarget.Structure => !(target is Character), - _ => true, + AttackTarget.Structure => target is Structure or Hull or Item, // Items are intentionally included for backwards-support. + AttackTarget.Item => target is Item, + _ => IsValidTarget(GetAttackTargetTypeFromEntity(target)) + }; + } + + private static AttackTarget GetAttackTargetTypeFromEntity(Entity entity) + { + return entity switch + { + Character => AttackTarget.Character, + Item => AttackTarget.Item, + Structure => AttackTarget.Structure, + Hull => AttackTarget.Structure, + _ => AttackTarget.Any }; } diff --git a/Barotrauma/BarotraumaShared/SharedSource/Characters/Character.cs b/Barotrauma/BarotraumaShared/SharedSource/Characters/Character.cs index 6e2a69bc8..3b9638cd5 100644 --- a/Barotrauma/BarotraumaShared/SharedSource/Characters/Character.cs +++ b/Barotrauma/BarotraumaShared/SharedSource/Characters/Character.cs @@ -35,6 +35,8 @@ namespace Barotrauma public const float MaxHighlightDistance = 150.0f; public const float MaxDragDistance = 200.0f; + public override ContentPackage ContentPackage => Prefab?.ContentPackage; + partial void UpdateLimbLightSource(Limb limb); private bool enabled = true; @@ -142,13 +144,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; @@ -213,7 +225,6 @@ namespace Barotrauma } } - private CharacterTeamType? originalTeamID; public CharacterTeamType OriginalTeamID { @@ -242,6 +253,26 @@ namespace Barotrauma protected readonly Dictionary activeTeamChanges = new Dictionary(); protected ActiveTeamChange currentTeamChange; private const string OriginalChangeTeamIdentifier = "original"; + + public bool AllowPlayDead { get; set; } + + public void EvaluatePlayDeadProbability(float? probability = null) + { + if (Params.AI is CharacterParams.AIParams aiParams) + { + if (probability.HasValue) + { + // Override so that can't revert back to the old value. + aiParams.PlayDeadProbability = probability.Value; + } + AllowPlayDead = Rand.Value() <= aiParams.PlayDeadProbability; + } + else if (probability.HasValue) + { + AllowPlayDead = Rand.Value() <= probability.Value; + } + // Do nothing, if no value is defined and no AI Params were found. + } private void ThrowIfAccessingWalletsInSingleplayer() { @@ -254,26 +285,37 @@ namespace Barotrauma } } - public void SetOriginalTeam(CharacterTeamType newTeam) + /// + /// Saves the character's original team (which affects e.g. whether the character considers the sub/outpost they're in to be their own or a "foreign" one), + /// and adds a new team change to be processed on the next update. + /// + /// Should the team change be processed right now, or along with any other pending team changes in the next Update? + public void SetOriginalTeamAndChangeTeam(CharacterTeamType newTeam, bool processImmediately = false) { TryRemoveTeamChange(OriginalChangeTeamIdentifier); currentTeamChange = new ActiveTeamChange(newTeam, ActiveTeamChange.TeamChangePriorities.Base); TryAddNewTeamChange(OriginalChangeTeamIdentifier, currentTeamChange); + if (processImmediately) + { + UpdateTeam(); + } } private void ChangeTeam(CharacterTeamType newTeam) { if (newTeam == teamID) { return; } - if (originalTeamID == null) { originalTeamID = teamID; } + originalTeamID ??= teamID; TeamID = newTeam; if (GameMain.NetworkMember != null && GameMain.NetworkMember.IsClient) { return; } - // clear up any duties the character might have had from its old team (autonomous objectives are automatically recreated) - var order = OrderPrefab.Dismissal.CreateInstance(OrderPrefab.OrderTargetType.Entity, orderGiver: this).WithManualPriority(CharacterInfo.HighestManualOrderPriority); - SetOrder(order, isNewOrder: true, speak: false); - + if (AIController is HumanAIController) + { + // clear up any duties the character might have had from its old team (autonomous objectives are automatically recreated) + var order = OrderPrefab.Dismissal.CreateInstance(OrderPrefab.OrderTargetType.Entity, orderGiver: this).WithManualPriority(CharacterInfo.HighestManualOrderPriority); + SetOrder(order, isNewOrder: true, speak: false); + } #if SERVER GameMain.NetworkMember.CreateEntityEvent(this, new TeamChangeEventData()); #endif @@ -292,7 +334,7 @@ namespace Barotrauma if (currentTeamChange == null) { // set team logic to use active team changes as soon as the first team change is added - SetOriginalTeam(TeamID); + SetOriginalTeamAndChangeTeam(TeamID); } } else @@ -330,12 +372,13 @@ namespace Barotrauma bestTeamChange = desiredTeamChange.Value; } } - if (TeamID != bestTeamChange.DesiredTeamId) + if (TeamID != bestTeamChange.DesiredTeamId) { ChangeTeam(bestTeamChange.DesiredTeamId); currentTeamChange = bestTeamChange; - if (bestTeamChange.AggressiveBehavior) // this seemed like the least disruptive way to induce aggressive behavior + // this seemed like the least disruptive way to induce aggressive behavior on human characters + if (bestTeamChange.AggressiveBehavior && AIController is HumanAIController) { var order = OrderPrefab.Prefabs["fightintruders"].CreateInstance(OrderPrefab.OrderTargetType.Entity, orderGiver: this).WithManualPriority(CharacterInfo.HighestManualOrderPriority); SetOrder(order, isNewOrder: true, speak: false); @@ -343,11 +386,13 @@ namespace Barotrauma } } - public bool IsOnPlayerTeam => teamID == CharacterTeamType.Team1 || teamID == CharacterTeamType.Team2; + public bool IsOnPlayerTeam => + teamID == CharacterTeamType.Team1 || + (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; + public bool IsFriendlyNPCTurnedHostile => originalTeamID == CharacterTeamType.FriendlyNPC && teamID is CharacterTeamType.Team2 or CharacterTeamType.None; public bool IsInstigator => CombatAction is { IsInstigator: true }; @@ -360,6 +405,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 ). /// @@ -413,15 +464,15 @@ namespace Barotrauma public Identifier GetBaseCharacterSpeciesName() => Prefab.GetBaseCharacterSpeciesName(SpeciesName); - public Identifier Group => HumanPrefab is HumanPrefab humanPrefab && !humanPrefab.Group.IsEmpty ? humanPrefab.Group : Params.Group; + public Identifier Group => HumanPrefab is { Group.IsEmpty: false } prefab ? prefab.Group : Params.Group; public bool IsHumanoid => Params.Humanoid; public bool IsMachine => Params.IsMachine; public bool IsHusk => Params.Husk; - public bool IsDisguisedAsHusk => CharacterHealth.GetAfflictionStrengthByType("disguiseashusk".ToIdentifier()) > 0; - public bool IsHuskInfected => CharacterHealth.GetActiveAfflictionTags().Contains("huskinfected".ToIdentifier()); + public bool IsDisguisedAsHusk => CharacterHealth.GetAfflictionStrengthByType(AfflictionPrefab.DisguisedAsHuskType) > 0; + public bool IsHuskInfected => CharacterHealth.GetActiveAfflictionTags().Contains(Tags.HuskInfected); public bool IsMale => info?.IsMale ?? false; @@ -630,9 +681,23 @@ namespace Barotrauma public bool RequireConsciousnessForCustomInteract = true; public bool AllowCustomInteract { - get { return (!RequireConsciousnessForCustomInteract || (!IsIncapacitated && Stun <= 0.0f)) && !Removed; } + get + { + if (CampaignMode.HostileFactionDisablesInteraction(CampaignInteractionType) && + AIController is HumanAIController humanAi && humanAi.IsInHostileFaction()) + { + return false; + } + + return (!RequireConsciousnessForCustomInteract || (!IsIncapacitated && Stun <= 0.0f)) && !Removed; + } } + public bool ShouldShowCustomInteractText => + !CustomInteractHUDText.IsNullOrEmpty() && + AllowCustomInteract && + (AIController is not HumanAIController humanAi || humanAi.AllowCampaignInteraction()); + private float lockHandsTimer; public bool LockHands { @@ -668,6 +733,8 @@ namespace Barotrauma // Eating is not implemented for humanoids. If we implement that at some point, we could remove this restriction. public bool CanEat => !IsHumanoid && Params.CanEat && AllowInput && AnimController.GetLimb(LimbType.Head) != null; + + public bool CanClimb => Params.CanClimb && CanInteract; public Vector2 CursorPosition { @@ -698,11 +765,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)); @@ -862,7 +942,7 @@ namespace Barotrauma private float ragdollingLockTimer; public bool IsRagdolled; public bool IsForceRagdolled; - public bool dontFollowCursor; + public bool FollowCursor = true; public bool IsIncapacitated { @@ -883,10 +963,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 { @@ -1031,18 +1108,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(); } @@ -1145,7 +1222,7 @@ namespace Barotrauma { get { - return (SelectedItem == null || SelectedItem.GetComponent() is { AllowAiming: true }) && !IsIncapacitated && (!IsRagdolled || AnimController.IsHoldingToRope); + return (SelectedItem == null || SelectedItem.GetComponent() is { AllowAiming: true }) && !IsKnockedDownOrRagdolled && (!IsRagdolled || AnimController.IsHoldingToRope); } } @@ -1174,6 +1251,7 @@ namespace Barotrauma } public CampaignMode.InteractionType CampaignInteractionType; + public Identifier MerchantIdentifier; private bool accessRemovedCharacterErrorShown; @@ -1227,6 +1305,10 @@ namespace Barotrauma public bool IsInFriendlySub => Submarine != null && Submarine.TeamID == TeamID; public bool IsInPlayerSub => Submarine != null && Submarine.Info.IsPlayer; + /// + /// Alias for , so the same property name works on both items and characters. + /// + public bool InPlayerSubmarine => IsInPlayerSub; public float AITurretPriority { @@ -1368,6 +1450,11 @@ 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 { NpcSetIdentifier.IsEmpty: false, NpcIdentifier.IsEmpty: false }) + { + HumanPrefab = characterInfo.HumanPrefab; + } + keys = new Key[Enum.GetNames(typeof(InputType)).Length]; for (int i = 0; i < Enum.GetNames(typeof(InputType)).Length; i++) { @@ -1460,32 +1547,38 @@ namespace Barotrauma CharacterHealth = new CharacterHealth(selectedHealthElement, this, limbHealthElement); } - if (Params.Husk && speciesName != "husk" && Prefab.VariantOf != "husk") + if (Params.Husk) { - Identifier nonHuskedSpeciesName = Identifier.Empty; - AfflictionPrefabHusk matchingAffliction = null; - foreach (var huskPrefab in AfflictionPrefab.Prefabs.OfType()) + Identifier nonHuskedSpeciesName = Params.NonHuskedSpecies; + if (!nonHuskedSpeciesName.IsEmpty || Params.UseHuskAppendage) { - var nonHuskedName = AfflictionHusk.GetNonHuskedSpeciesName(speciesName, huskPrefab); - if (huskPrefab.TargetSpecies.Contains(nonHuskedName)) + // Check that there's a matching species and affliction for the non-husked species definition. + AfflictionPrefab matchingAffliction = null; + foreach (var huskPrefab in AfflictionPrefab.Prefabs.OfType()) { - var huskedSpeciesName = AfflictionHusk.GetHuskedSpeciesName(nonHuskedName, huskPrefab); - if (huskedSpeciesName.Equals(speciesName)) + if (huskPrefab.HuskedSpeciesName.IsEmpty) { continue; } + Identifier nonHuskedSpecies = nonHuskedSpeciesName; + if (nonHuskedSpeciesName.IsEmpty) { - nonHuskedSpeciesName = nonHuskedName; + nonHuskedSpecies = AfflictionHusk.GetNonHuskedSpeciesName(Params, huskPrefab); + } + if (huskPrefab.TargetSpecies.Contains(nonHuskedSpecies)) + { + nonHuskedSpeciesName = nonHuskedSpecies; matchingAffliction = huskPrefab; break; } - } - } - if (matchingAffliction == null || nonHuskedSpeciesName.IsEmpty) - { - DebugConsole.ThrowError($"Cannot find a husk infection that matches {speciesName}! Please make sure that the speciesname is added as 'targets' in the husk affliction prefab definition!\n" - + "Note that all the infected speciesnames and files must stick the following pattern: [nonhuskedspeciesname][huskedspeciesname]. E.g. Humanhusk, Crawlerhusk, or Humancustomhusk, or Crawlerzombie. Not \"Customhumanhusk!\" or \"Zombiecrawler\"", - contentPackage: Prefab.ContentPackage); - // Crashes if we fail to create a ragdoll -> Let's just use some ragdoll so that the user sees the error msg. - nonHuskedSpeciesName = IsHumanoid ? CharacterPrefab.HumanSpeciesName : "crawler".ToIdentifier(); - speciesName = nonHuskedSpeciesName; + } + if (matchingAffliction == null) + { + DebugConsole.ThrowError($"Cannot find a husk infection that matches {speciesName}! Please make sure that the speciesname is added as 'targets' in the husk affliction prefab definition! " + + $"If the name of the character doesn't match the default pattern ('Crawlerhusk', 'Humanhusk', etc), you'll also need to define the non-husked species with {nameof(Params.NonHuskedSpecies)} attribute in the character config file.", + contentPackage: Prefab.ContentPackage); + + // Crashes if we fail to create a ragdoll -> Let's just use some ragdoll so that the user sees the error msg. + nonHuskedSpeciesName = IsHumanoid ? CharacterPrefab.HumanSpeciesName : "crawler".ToIdentifier(); + speciesName = nonHuskedSpeciesName; + } } if (ragdollParams == null && prefab.VariantOf == null) { @@ -1710,6 +1803,11 @@ namespace Barotrauma } } + if (this == Controlled && inputType == InputType.Run && ToggleRun) + { + return true; + } + return keys[(int)inputType].Held; } @@ -1745,22 +1843,22 @@ namespace Barotrauma #endif } - public void GiveJobItems(WayPoint spawnPoint = null) + public void GiveJobItems(bool isPvPMode, WayPoint spawnPoint = null) { 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; } } - info.Job?.GiveJobItems(this, spawnPoint); + info.Job?.GiveJobItems(this, isPvPMode, spawnPoint); } public void GiveIdCardTags(WayPoint spawnPoint, bool createNetworkEvent = false) @@ -1778,6 +1876,10 @@ namespace Barotrauma { item.AddTag(s); } + if (GameMain.GameSession?.GameMode is PvPMode) + { + item.AddTag($"id_{TeamID}".ToIdentifier()); + } if (createNetworkEvent && GameMain.NetworkMember is { IsServer: true }) { GameMain.NetworkMember.CreateEntityEvent(item, new Item.ChangePropertyEventData(item.SerializableProperties[nameof(item.Tags).ToIdentifier()], item)); @@ -1785,9 +1887,6 @@ namespace Barotrauma } } - public float GetSkillLevel(string skillIdentifier) => - GetSkillLevel(skillIdentifier.ToIdentifier()); - private static readonly ImmutableDictionary overrideStatTypes = new Dictionary { { new("helm"), StatTypes.HelmSkillOverride }, @@ -1797,6 +1896,9 @@ namespace Barotrauma { new("mechanical"), StatTypes.MechanicalSkillOverride } }.ToImmutableDictionary(); + /// + /// Get the character's current skill level, taking into account any temporary boosts from wearables and afflictions + /// public float GetSkillLevel(Identifier skillIdentifier) { if (Info?.Job == null) { return 0.0f; } @@ -1819,27 +1921,17 @@ 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; } } - skillLevel += GetStatValue(GetSkillStatType(skillIdentifier)); + var skillStatType = GetSkillStatType(skillIdentifier); + if (skillStatType != StatTypes.None) + { + skillLevel += GetStatValue(skillStatType); + } return Math.Max(skillLevel, 0); } @@ -1876,11 +1968,32 @@ namespace Barotrauma // - dragging someone // - crouching // - moving backwards - public bool CanRun => CanRunWhileDragging() && + public bool CanRun => + !DisableRunning && + CanRunWhileDragging() && AnimController is not HumanoidAnimController { Crouching: true } && !AnimController.IsMovingBackwards && !HasAbilityFlag(AbilityFlags.MustWalk) && !AnimController.IsHoldingToRope; + private double disableRunningLastSet; + + /// + /// Can be used to temporarily disable running using StatusEffects. Resets in 0.1 seconds if not set. + /// + public bool DisableRunning + { + get => disableRunningLastSet > Timing.TotalTime - 0.1; + set + { + if (value) + { + disableRunningLastSet = Timing.TotalTime; + } + } + } + + public bool ToggleRun; + public bool CanRunWhileDragging() { if (selectedCharacter is not { IsDraggable: true }) { return true; } @@ -1919,8 +2032,7 @@ namespace Barotrauma /// Can be used to modify the character's speed via StatusEffects /// public float SpeedMultiplier { get; private set; } = 1; - - + private double propulsionSpeedMultiplierLastSet; private float propulsionSpeedMultiplier; /// @@ -1984,6 +2096,7 @@ namespace Barotrauma /// public float GetTemporarySpeedReduction() { + if (!Params.Health.ApplyMovementPenalties) { return 0; } float reduction = 0; reduction = CalculateMovementPenalty(AnimController.GetLimb(LimbType.RightFoot, excludeSevered: false), reduction); reduction = CalculateMovementPenalty(AnimController.GetLimb(LimbType.LeftFoot, excludeSevered: false), reduction); @@ -2021,6 +2134,7 @@ namespace Barotrauma private float CalculateMovementPenalty(Limb limb, float sum, float max = 0.8f) { + if (!Params.Health.ApplyMovementPenalties) { return 0; } if (limb != null) { sum += MathHelper.Lerp(0, max, CharacterHealth.GetLimbDamage(limb, afflictionType: AfflictionPrefab.DamageType)); @@ -2085,8 +2199,9 @@ namespace Barotrauma SmoothedCursorPosition = cursorPosition - smoothedCursorDiff; } - bool aiControlled = this is AICharacter && Controlled != this && !IsRemotelyControlled; - if (!aiControlled) + bool aiControlled = this is AICharacter && Controlled != this && !IsRemotePlayer; + bool controlledByServer = GameMain.NetworkMember is { IsClient: true } && IsRemotelyControlled; + if (!aiControlled && !controlledByServer) { Vector2 targetMovement = GetTargetMovement(); AnimController.TargetMovement = targetMovement; @@ -2111,11 +2226,12 @@ namespace Barotrauma ((!IsClimbing && AnimController.OnGround) || (IsClimbing && IsKeyDown(InputType.Aim))) && !AnimController.InWater) { - if (dontFollowCursor) + if (!FollowCursor) { AnimController.TargetDir = Direction.Right; } - else + //only humanoids' flipping is controlled by the cursor, monster flipping is driven by their movement in FishAnimController + else if (AnimController is HumanoidAnimController) { if (CursorPosition.X < AnimController.Collider.Position.X - cursorFollowMargin) { @@ -2178,15 +2294,9 @@ namespace Barotrauma } else if (IsKeyDown(InputType.Attack)) { - if (GameMain.NetworkMember != null && GameMain.NetworkMember.IsClient && Controlled != this) - { - if ((currentAttackTarget.DamageTarget as Entity)?.Removed ?? false) - { - currentAttackTarget = default; - } - currentAttackTarget.AttackLimb?.UpdateAttack(deltaTime, currentAttackTarget.AttackPos, currentAttackTarget.DamageTarget, out _); - } - else if (IsPlayer) + //normally the attack target, where to aim the attack and such is handled by EnemyAIController, + //but in the case of player-controlled monsters, we handle it here + if (IsPlayer) { float dist = -1; Vector2 attackPos = SimPosition + ConvertUnits.ToSimUnits(cursorPosition - Position); @@ -2231,18 +2341,21 @@ namespace Barotrauma } } var currentContexts = GetAttackContexts(); - var validLimbs = AnimController.Limbs.Where(l => + var attackLimbs = AnimController.Limbs.Where(static l => l.attack != null); + bool hasAttacksWithoutRootForce = attackLimbs.Any(static l=> !l.attack.HasRootForce); + var validLimbs = attackLimbs.Where(l => { if (l.IsSevered || l.IsStuck) { return false; } if (l.Disabled) { return false; } var attack = l.attack; - if (attack == null) { return false; } if (attack.CoolDownTimer > 0) { return false; } + //disallow attacks with root force if there's any other attacks available + if (hasAttacksWithoutRootForce && attack.HasRootForce) { return false; } if (!attack.IsValidContext(currentContexts)) { return false; } if (attackTarget != null) { if (!attack.IsValidTarget(attackTarget as Entity)) { return false; } - if (attackTarget is ISerializableEntity se && attackTarget is Character) + if (attackTarget is ISerializableEntity se and Character) { if (attack.Conditionals.Any(c => !c.TargetSelf && !c.Matches(se))) { return false; } } @@ -2275,11 +2388,19 @@ namespace Barotrauma } } } + else if (GameMain.NetworkMember is { IsClient: true } && Controlled != this) + { + if (currentAttackTarget.DamageTarget is Entity { Removed: true }) + { + currentAttackTarget = default; + } + currentAttackTarget.AttackLimb?.UpdateAttack(deltaTime, currentAttackTarget.AttackPos, currentAttackTarget.DamageTarget, out _); + } } if (Inventory != null) { - if (IsKeyHit(InputType.DropItem)) + if (IsKeyHit(InputType.DropItem) && Screen.Selected is { IsEditor: false }) { foreach (Item item in HeldItems) { @@ -2395,118 +2516,11 @@ namespace Barotrauma seeingEntity ??= AnimController.SimplePhysicsEnabled ? this : GetSeeingLimb(); if (target is Character targetCharacter) { - return IsCharacterVisible(targetCharacter, seeingEntity, seeThroughWindows, checkFacing); + return ISpatialEntity.IsCharacterVisible(targetCharacter, seeingEntity, seeThroughWindows, checkFacing); } else { - return CheckVisibility(target, seeingEntity, seeThroughWindows, checkFacing); - } - } - - public static bool IsTargetVisible(ISpatialEntity target, ISpatialEntity seeingEntity, bool seeThroughWindows = false, bool checkFacing = false) - { - if (seeingEntity is Character seeingCharacter) - { - return seeingCharacter.CanSeeTarget(target, seeThroughWindows: seeThroughWindows, checkFacing: checkFacing); - } - if (target is Character targetCharacter) - { - return IsCharacterVisible(targetCharacter, seeingEntity, seeThroughWindows, checkFacing); - } - else - { - return CheckVisibility(target, seeingEntity, seeThroughWindows, checkFacing); - } - } - - private static bool IsCharacterVisible(Character target, ISpatialEntity seeingEntity, bool seeThroughWindows = false, bool checkFacing = false) - { - System.Diagnostics.Debug.Assert(target != null); - if (target == null || target.Removed) { return false; } - if (CheckVisibility(target, seeingEntity, seeThroughWindows, checkFacing)) { return true; } - if (!target.AnimController.SimplePhysicsEnabled) - { - //find the limbs that are furthest from the target's position (from the viewer's point of view) - Limb leftExtremity = null, rightExtremity = null; - float leftMostDot = 0.0f, rightMostDot = 0.0f; - Vector2 dir = target.WorldPosition - seeingEntity.WorldPosition; - Vector2 leftDir = new Vector2(dir.Y, -dir.X); - Vector2 rightDir = new Vector2(-dir.Y, dir.X); - foreach (Limb limb in target.AnimController.Limbs) - { - if (limb.IsSevered || limb == target.AnimController.MainLimb) { continue; } - if (limb.Hidden) { continue; } - Vector2 limbDir = limb.WorldPosition - seeingEntity.WorldPosition; - float leftDot = Vector2.Dot(limbDir, leftDir); - if (leftDot > leftMostDot) - { - leftMostDot = leftDot; - leftExtremity = limb; - continue; - } - float rightDot = Vector2.Dot(limbDir, rightDir); - if (rightDot > rightMostDot) - { - rightMostDot = rightDot; - rightExtremity = limb; - continue; - } - } - if (leftExtremity != null && CheckVisibility(leftExtremity, seeingEntity, seeThroughWindows, checkFacing)) { return true; } - if (rightExtremity != null && CheckVisibility(rightExtremity, seeingEntity, seeThroughWindows, checkFacing)) { return true; } - } - return false; - } - - private static bool CheckVisibility(ISpatialEntity target, ISpatialEntity seeingEntity, bool seeThroughWindows = true, bool checkFacing = false) - { - System.Diagnostics.Debug.Assert(target != null); - if (target == null) { return false; } - if (seeingEntity == null) { return false; } - // TODO: Could we just use the method below? If not, let's refactor it so that we can. - Vector2 diff = ConvertUnits.ToSimUnits(target.WorldPosition - seeingEntity.WorldPosition); - if (checkFacing && seeingEntity is Character seeingCharacter) - { - if (Math.Sign(diff.X) != seeingCharacter.AnimController.Dir) { return false; } - } - //both inside the same sub (or both outside) - //OR the we're inside, the other character outside - if (target.Submarine == seeingEntity.Submarine || target.Submarine == null) - { - return Submarine.CheckVisibility(seeingEntity.SimPosition, seeingEntity.SimPosition + diff, blocksVisibilityPredicate: IsBlocking) == null; - } - //we're outside, the other character inside - else if (seeingEntity.Submarine == null) - { - return Submarine.CheckVisibility(target.SimPosition, target.SimPosition - diff, blocksVisibilityPredicate: IsBlocking) == null; - } - //both inside different subs - else - { - return - Submarine.CheckVisibility(seeingEntity.SimPosition, seeingEntity.SimPosition + diff, blocksVisibilityPredicate: IsBlocking) == null && - Submarine.CheckVisibility(target.SimPosition, target.SimPosition - diff, blocksVisibilityPredicate: IsBlocking) == null; - } - - bool IsBlocking(Fixture f) - { - var body = f.Body; - if (body == null) { return false; } - if (body.UserData is Structure wall) - { - if (!wall.CastShadow && seeThroughWindows) { return false; } - return wall != target; - } - else if (body.UserData is Item item) - { - if (item.GetComponent() is { HasWindow: true } door && seeThroughWindows) - { - if (door.IsPositionOnWindow(ConvertUnits.ToDisplayUnits(Submarine.LastPickedPosition))) { return false; } - } - - return item != target; - } - return true; + return ISpatialEntity.CheckVisibility(target, seeingEntity, seeThroughWindows, checkFacing); } } @@ -2583,6 +2597,39 @@ namespace Barotrauma } return null; } + + public bool HasHandsFull(out (Item leftHandItem, Item rightHandItem) items) + { + var leftHandItem = GetEquippedItem(slotType: InvSlotType.LeftHand); + var rightHandItem = GetEquippedItem(slotType: InvSlotType.RightHand); + items = (leftHandItem, rightHandItem); + bool handsFull = leftHandItem != null && rightHandItem != null; + return handsFull; + } + + public bool TryPutItem(Item item, IEnumerable allowedSlots) => Inventory.TryPutItem(item, user: this, allowedSlots); + public bool TryPutItemInBag(Item item) => item != null && item.AllowedSlots.Contains(InvSlotType.Bag) && TryPutItem(item, CharacterInventory.BagSlot); + public bool TryPutItemInAnySlot(Item item) => item != null && item.AllowedSlots.Contains(InvSlotType.Any) && TryPutItem(item, CharacterInventory.AnySlot); + + /// + /// Attempts to unequip an item. + /// First tries to put the item in any slot. + /// If that fails, tries to put in the bag slot. + /// If that too fails, drops the item. + /// + /// false only if the item is not equipped. + public bool Unequip(Item item) + { + if (!HasEquippedItem(item)) { return false; } + if (!TryPutItemInAnySlot(item)) + { + if (!TryPutItemInBag(item)) + { + item.Drop(this); + } + } + return true; + } public bool CanAccessInventory(Inventory inventory, CharacterInventory.AccessLevel accessLevel = CharacterInventory.AccessLevel.Limited) { @@ -2608,7 +2655,6 @@ namespace Barotrauma if (container != null) { if (!container.HasRequiredItems(this, addMessage: false)) { return false; } - if (!container.AllowAccess) { return false; } } } return true; @@ -2623,7 +2669,7 @@ namespace Barotrauma public bool CanBeDraggedBy(Character character) { if (!IsDraggable) { return false; } - return IsKnockedDown || LockHands || IsPet || (IsBot && character.TeamID == TeamID); + return IsKnockedDownOrRagdolled || LockHands || (IsPet && character.IsOnFriendlyTeam(this)) || (IsBot && character.TeamID == TeamID); } /// @@ -2771,6 +2817,12 @@ namespace Barotrauma #endif if (!CanInteract || hidden || !item.IsInteractable(this)) { return false; } + Controller controller = item.GetComponent(); + if (controller != null && IsAnySelectedItem(item) && controller.IsAttachedUser(this)) + { + return true; + } + if (item.ParentInventory != null) { return CanAccessInventory(item.ParentInventory); @@ -2880,7 +2932,7 @@ namespace Barotrauma { //don't allow selecting another Controller if it'd try to turn the character in the opposite direction //(e.g. periscope that's facing the wrong way while sitting in a chair) - if (item.GetComponent() is { } controller && controller.Direction != 0 && controller.Direction != AnimController.Direction) { return false; } + if (controller != null && controller.Direction != 0 && controller.Direction != AnimController.Direction) { return false; } //if a Controller that controls the character's pose is selected, //don't allow selecting items that are behind the character's back @@ -3001,12 +3053,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) { @@ -3027,7 +3074,7 @@ namespace Barotrauma } #if CLIENT - if (isLocalPlayer) + if (IsLocalPlayer) { if (!IsMouseOnUI && (ViewTarget == null || ViewTarget == this) && !DisableFocusingOnEntities) { @@ -3358,7 +3405,9 @@ namespace Barotrauma if (Inventory != null) { - foreach (Item item in Inventory.AllItems) + //do not check for duplicates: this is code is called very frequently, and duplicates don't matter here, + //so it's better just to avoid the relatively expensive duplicate check + foreach (Item item in Inventory.GetAllItems(checkForDuplicates: false)) { if (item.body == null || item.body.Enabled) { continue; } item.SetTransform(SimPosition, 0.0f); @@ -3532,19 +3581,26 @@ namespace Barotrauma { humanAnimController.Crouching = false; } - if (IsRagdolled) { AnimController.IgnorePlatforms = true; } + //ragdolling manually makes the character go through platforms + //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); @@ -3751,41 +3807,51 @@ namespace Barotrauma if (!IsDead || (CauseOfDeath?.Type == CauseOfDeathType.Disconnected && GameMain.GameSession?.Campaign != null)) { return; } - int subCorpseCount = 0; - - if (Submarine != null) - { - subCorpseCount = CharacterList.Count(c => c.IsDead && c.Submarine == Submarine); - if (subCorpseCount < GameSettings.CurrentConfig.CorpsesPerSubDespawnThreshold) { return; } - } - if (SelectedBy != null) { despawnTimer = 0.0f; return; } - float distToClosestPlayer = GetDistanceToClosestPlayer(); - if (distToClosestPlayer > Params.DisableDistance) - { - //despawn in 1 minute if very far from all human players - despawnTimer = Math.Max(despawnTimer, GameSettings.CurrentConfig.CorpseDespawnDelay - 60.0f); - } - + float despawnDelay = GameSettings.CurrentConfig.CorpseDespawnDelay; float despawnPriority = 1.0f; - if (subCorpseCount > GameSettings.CurrentConfig.CorpsesPerSubDespawnThreshold) + if (GameMain.GameSession?.GameMode is PvPMode && + GameMain.NetworkMember?.RespawnManager != null) { - //despawn faster if there are lots of corpses in the sub (twice as many as the threshold -> despawn twice as fast) - despawnPriority += (subCorpseCount - GameSettings.CurrentConfig.CorpsesPerSubDespawnThreshold) / (float)GameSettings.CurrentConfig.CorpsesPerSubDespawnThreshold; + //simpler despawning logic in PvP modes with respawning: just a short timer + despawnDelay = GameSettings.CurrentConfig.CorpseDespawnDelayPvP; } - if (AIController is EnemyAIController) + else { - //enemies despawn faster - despawnPriority *= 2.0f; + int subCorpseCount = 0; + if (Submarine != null) + { + subCorpseCount = CharacterList.Count(c => c.IsDead && c.Submarine == Submarine); + if (subCorpseCount < GameSettings.CurrentConfig.CorpsesPerSubDespawnThreshold) { return; } + } + + if (subCorpseCount > GameSettings.CurrentConfig.CorpsesPerSubDespawnThreshold) + { + //despawn faster if there are lots of corpses in the sub (twice as many as the threshold -> despawn twice as fast) + despawnPriority += (subCorpseCount - GameSettings.CurrentConfig.CorpsesPerSubDespawnThreshold) / (float)GameSettings.CurrentConfig.CorpsesPerSubDespawnThreshold; + } + + float distToClosestPlayer = GetDistanceToClosestPlayer(); + if (distToClosestPlayer > Params.DisableDistance) + { + //despawn in 1 minute if very far from all human players + despawnTimer = Math.Max(despawnTimer, despawnDelay - 60.0f); + } + + if (AIController is EnemyAIController) + { + //enemies despawn faster + despawnPriority *= 2.0f; + } } despawnTimer += deltaTime * despawnPriority; - if (despawnTimer < GameSettings.CurrentConfig.CorpseDespawnDelay) { return; } + if (despawnTimer < despawnDelay) { return; } Despawn(); } @@ -3798,7 +3864,12 @@ namespace Barotrauma IsHuman ? Tags.DespawnContainer : Params.DespawnContainer; - if (!despawnContainerId.IsEmpty) + + //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 && + //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 ?? @@ -3846,6 +3917,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); } } @@ -3933,8 +4014,6 @@ namespace Barotrauma targetRange = Math.Min(targetRange, maxAIRange); float newRange = MathHelper.SmoothStep(aiTarget.SoundRange, targetRange, deltaTime * aiTargetChangeSpeed); - - newRange *= 1.0f + GetStatValue(StatTypes.SoundRangeMultiplier); if (!float.IsNaN(newRange)) { aiTarget.SoundRange = newRange; @@ -3973,6 +4052,7 @@ namespace Barotrauma if (character.TeamID != TeamID) { continue; } if (character.AIController is not HumanAIController) { continue; } if (!HumanAIController.IsActive(character)) { continue; } + if (character.Info == null) { continue; } foreach (var currentOrder in character.CurrentOrders) { if (currentOrder == null) { continue; } @@ -3988,12 +4068,15 @@ namespace Barotrauma case OrderCategory.Movement: // If there character has another movement order, dismiss that order Order orderToReplace = null; - foreach (var currentOrder in CurrentOrders) + if (CurrentOrders != null) { - if (currentOrder == null) { continue; } - if (currentOrder.Category != OrderCategory.Movement) { continue; } - orderToReplace = currentOrder; - break; + foreach (var currentOrder in CurrentOrders) + { + if (currentOrder == null) { continue; } + if (currentOrder.Category != OrderCategory.Movement) { continue; } + orderToReplace = currentOrder; + break; + } } if (orderToReplace is { AutoDismiss: true }) { @@ -4029,6 +4112,7 @@ namespace Barotrauma private void AddCurrentOrder(Order newOrder) { + if (CurrentOrders == null) { return; } if (newOrder == null || newOrder.Identifier == "dismissed") { if (newOrder.Option != Identifier.Empty) @@ -4070,9 +4154,9 @@ namespace Barotrauma } } - private bool RemoveDuplicateOrders(Order order) + private void RemoveDuplicateOrders(Order order) { - bool removed = false; + if (CurrentOrders == null) { return; } int? priorityOfRemoved = null; for (int i = CurrentOrders.Count - 1; i >= 0; i--) { @@ -4081,12 +4165,11 @@ namespace Barotrauma { priorityOfRemoved = orderInfo.ManualPriority; CurrentOrders.RemoveAt(i); - removed = true; break; } } - if (!priorityOfRemoved.HasValue) { return removed; } + if (!priorityOfRemoved.HasValue) { return; } for (int i = 0; i < CurrentOrders.Count; i++) { @@ -4097,11 +4180,9 @@ namespace Barotrauma } } - CurrentOrders.RemoveAll(order => order.ManualPriority <= 0); + CurrentOrders.RemoveAll(o => o.ManualPriority <= 0); // Sort the current orders so the one with the highest priority comes first CurrentOrders.Sort((x, y) => y.ManualPriority.CompareTo(x.ManualPriority)); - - return removed; } public Order GetCurrentOrderWithTopPriority() @@ -4165,16 +4246,51 @@ namespace Barotrauma { prevAiChatMessages.Remove(identifier); } - - //already sent a similar message a moment ago - if (identifier != Identifier.Empty && minDurationBetweenSimilar > 0.0f && - (aiChatMessageQueue.Any(m => m.Identifier == identifier) || prevAiChatMessages.ContainsKey(identifier))) + + if (minDurationBetweenSimilar > 0) { - return; + if (identifier == Identifier.Empty) + { +#if DEBUG + // TODO: This is stupid. We shouldn't allow passing minDurationBetweenSimilar without an identifier in the first place, but need to think how to refactor this. + DebugConsole.AddWarning($"Called Character.Speak() with minDurationBetweenSimilar but didn't define the identifier! Cannot compare with the old messages. The message will be sent each time the function is called."); + Debugger.Break(); +#endif + } + else if (aiChatMessageQueue.Any(m => m.Identifier == identifier) || prevAiChatMessages.ContainsKey(identifier)) + { + //already sent a similar message a moment ago + return; + } } + aiChatMessageQueue.Add(new AIChatMessage(message, messageType, identifier, delay)); } +#if CLIENT + public void SendSinglePlayerMessage(AIChatMessage message, bool canUseRadio, WifiComponent radio) + { + if (message.MessageType == null) + { + message.MessageType = canUseRadio ? ChatMessageType.Radio : ChatMessageType.Default; + } + if (GameMain.GameSession?.CrewManager is { IsSinglePlayer: true } crewManager) + { + string modifiedMessage = ChatMessage.ApplyDistanceEffect(message.Message, message.MessageType.Value, this, Controlled); + if (!string.IsNullOrEmpty(modifiedMessage)) + { + crewManager.AddSinglePlayerChatMessage(Name, modifiedMessage, message.MessageType.Value, this); + } + if (canUseRadio) + { + Signal s = new Signal(modifiedMessage, sender: this, source: radio.Item); + radio.TransmitSignal(s, sentFromChat: true); + } + } + ShowSpeechBubble(ChatMessage.MessageColor[(int)message.MessageType.Value], message.Message); + } +#endif + private void UpdateAIChatMessages(float deltaTime) { if (GameMain.NetworkMember != null && GameMain.NetworkMember.IsClient) { return; } @@ -4191,28 +4307,13 @@ namespace Barotrauma message.MessageType = canUseRadio ? ChatMessageType.Radio : ChatMessageType.Default; } #if CLIENT - if (GameMain.GameSession?.CrewManager != null && GameMain.GameSession.CrewManager.IsSinglePlayer) - { - string modifiedMessage = ChatMessage.ApplyDistanceEffect(message.Message, message.MessageType.Value, this, Controlled); - if (!string.IsNullOrEmpty(modifiedMessage)) - { - GameMain.GameSession.CrewManager.AddSinglePlayerChatMessage(Name, modifiedMessage, message.MessageType.Value, this); - } - if (canUseRadio) - { - Signal s = new Signal(modifiedMessage, sender: this, source: radio.Item); - radio.TransmitSignal(s, sentFromChat: true); - } - } + SendSinglePlayerMessage(message, canUseRadio, radio); #endif #if SERVER if (GameMain.Server != null && message.MessageType != ChatMessageType.Order) { GameMain.Server.SendChatMessage(message.Message, message.MessageType.Value, null, this); } -#endif -#if CLIENT - ShowSpeechBubble(ChatMessage.MessageColor[(int)message.MessageType.Value], message.Message); #endif sentMessages.Add(message); } @@ -4269,7 +4370,9 @@ namespace Barotrauma Limb limbHit = targetLimb; - float impulseMagnitude = (attack.TargetImpulse + attack.TargetForce * attack.ImpactMultiplier) * deltaTime; + // TODO: should we apply deltatime only on TargetForce, not TargetImpulse? Changing this would have implications on many existing monster attacks, so all the monsters would have to be tested and possibly readjusted. + // Should be (attack.TargetImpulse + attack.TargetForce * deltaTime) * attack.ImpactMultiplier? + float impulseMagnitude = (attack.TargetImpulse + attack.TargetForce) * attack.ImpactMultiplier * deltaTime; Vector2 attackImpulse = Vector2.Zero; if (Math.Abs(impulseMagnitude) > 0.0f) @@ -4290,10 +4393,12 @@ namespace Barotrauma { attackAfflictions = attack.Afflictions.Keys; } - + + float damageMultiplier = attack.DamageMultiplier * attackData.DamageMultiplier; + var attackResult = targetLimb == null ? - AddDamage(worldPosition, attackAfflictions, attack.Stun, playSound, attackImpulse, out limbHit, attacker, attack.DamageMultiplier * attackData.DamageMultiplier) : - DamageLimb(worldPosition, targetLimb, attackAfflictions, attack.Stun, playSound, attackImpulse, attacker, attack.DamageMultiplier * attackData.DamageMultiplier, penetration: penetration + attackData.AddedPenetration, shouldImplode: attackData.ShouldImplode); + AddDamage(worldPosition, attackAfflictions, attack.Stun, playSound, attackImpulse, out limbHit, attacker, damageMultiplier) : + DamageLimb(worldPosition, targetLimb, attackAfflictions, attack.Stun, playSound, attackImpulse, attacker, damageMultiplier, penetration: penetration + attackData.AddedPenetration, shouldImplode: attackData.ShouldImplode); if (attacker != null) { @@ -4303,7 +4408,7 @@ namespace Barotrauma } if (limbHit == null) { return new AttackResult(); } - Vector2 forceWorld = attack.TargetImpulseWorld + attack.TargetForceWorld * attack.ImpactMultiplier; + Vector2 forceWorld = (attack.TargetImpulseWorld + attack.TargetForceWorld) * attack.ImpactMultiplier; if (attacker != null) { forceWorld.X *= attacker.AnimController.Dir; @@ -4445,35 +4550,21 @@ namespace Barotrauma CreatureMetrics.RecordKill(target.SpeciesName); } - public AttackResult DamageLimb(Vector2 worldPosition, Limb hitLimb, IEnumerable afflictions, float stun, bool playSound, Vector2 attackImpulse, Character attacker = null, float damageMultiplier = 1, bool allowStacking = true, float penetration = 0f, bool shouldImplode = false) + /// + /// + /// + /// Set false as an optimization only when you manually call . Only applies to limb specific afflictions. + public AttackResult DamageLimb(Vector2 worldPosition, Limb hitLimb, IEnumerable afflictions, float stun, bool playSound, Vector2 attackImpulse, Character attacker = null, float damageMultiplier = 1, bool allowStacking = true, float penetration = 0f, bool shouldImplode = false, bool ignoreDamageOverlay = false, bool recalculateVitality = true) { if (Removed) { return new AttackResult(); } - //character inside the sub received damage from a monster outside the sub - //can happen during normal gameplay if someone for example fires a ranged weapon from outside, - //the intention of this error message is to diagnose an issue with monsters being able to damage characters from outside - - // Disabled, because this happens every now and then when the monsters can get in and out of the sub. - -// if (attacker?.AIController is EnemyAIController && Submarine != null && attacker.Submarine == null) -// { -// string errorMsg = $"Character {Name} received damage from outside the sub while inside (attacker: {attacker.Name})"; -// GameAnalyticsManager.AddErrorEventOnce("Character.DamageLimb:DamageFromOutside" + Name + attacker.Name, -// GameAnalyticsManager.ErrorSeverity.Warning, -// errorMsg + "\n" + Environment.StackTrace.CleanupStackTrace()); -//#if DEBUG -// DebugConsole.ThrowError(errorMsg); -//#endif -// } - SetStun(stun); if (attacker != null && attacker != this && GameMain.NetworkMember != null && !GameMain.NetworkMember.ServerSettings.AllowFriendlyFire) { if (attacker.TeamID == TeamID) { - afflictions = afflictions.Where(a => a.Prefab.IsBuff); - if (!afflictions.Any()) { return new AttackResult(); } + if (afflictions.None(a => a.Prefab.IsBuff)) { return new AttackResult(); } } } @@ -4493,9 +4584,8 @@ namespace Barotrauma } bool wasDead = IsDead; Vector2 simPos = hitLimb.SimPosition + ConvertUnits.ToSimUnits(dir); - float prevVitality = CharacterHealth.Vitality; AttackResult attackResult = hitLimb.AddDamage(simPos, afflictions, playSound, damageMultiplier: damageMultiplier, penetration: penetration, attacker: attacker); - CharacterHealth.ApplyDamage(hitLimb, attackResult, allowStacking); + CharacterHealth.ApplyDamage(hitLimb, attackResult, allowStacking, recalculateVitality); if (shouldImplode) { // Only used by assistant's True Potential talent. Has to run here in order to properly give kill credit when it activates. @@ -4504,8 +4594,16 @@ namespace Barotrauma if (attacker != this) { + bool wasDamageOverlayVisible = CharacterHealth.ShowDamageOverlay; + if (ignoreDamageOverlay) + { + // Temporarily ignore damage overlay (husk transition damage) + CharacterHealth.ShowDamageOverlay = false; + } OnAttacked?.Invoke(attacker, attackResult); OnAttackedProjSpecific(attacker, attackResult, stun); + // Reset damage overlay + CharacterHealth.ShowDamageOverlay = wasDamageOverlayVisible; if (!wasDead) { TryAdjustAttackerSkill(attacker, attackResult); @@ -4615,17 +4713,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) @@ -4633,6 +4738,13 @@ namespace Barotrauma return; } } + + // apply pvp stun resistance to humans (reduce stun amount via resist multiplier) + if (newStun > 0 && GameMain.NetworkMember is { } networkMember && GameMain.GameSession?.GameMode is PvPMode && IsHuman) + { + newStun = Math.Max(0, newStun - (newStun * networkMember.ServerSettings.PvPStunResist)); + } + if ((newStun <= Stun && !allowStunDecrease) || !MathUtils.IsValid(newStun)) { return; } if (Math.Sign(newStun) != Math.Sign(Stun)) { @@ -4811,6 +4923,24 @@ namespace Barotrauma } partial void ImplodeFX(); + + public void TurnIntoHusk(AfflictionPrefabHusk huskInfection = null, bool? playDead = null) + { + huskInfection ??= AfflictionPrefab.HuskInfection as AfflictionPrefabHusk; + if (huskInfection == null) + { + DebugConsole.ThrowError($"Cannot turn {Name} into husk, because husk infection was not found!", contentPackage: AfflictionPrefab.Prefabs.First().ContentPackage); + return; + } + // Randomize the start strength a bit, so that the husks don't turn at the same time, which can cause performance issues when turning multiple characters to husk at the same time. + float startStrength = Rand.Range(Math.Max(huskInfection.MaxStrength - 2, huskInfection.ActiveThreshold), huskInfection.MaxStrength); + startStrength *= MaxVitality / 100f; + CharacterHealth.ApplyAffliction(AnimController.MainLimb, huskInfection.Instantiate(startStrength)); + if (playDead.HasValue) + { + AllowPlayDead = playDead.Value; + } + } public void Kill(CauseOfDeathType causeOfDeath, Affliction causeOfDeathAffliction, bool isNetworkMessage = false, bool log = true) { @@ -4878,6 +5008,8 @@ namespace Barotrauma { characterInfo.PermanentlyDead = true; } + + GameMain.GameSession.RefreshAnyOpenPlayerInfo(); #endif #if SERVER @@ -4886,8 +5018,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(" ", ""); @@ -5151,7 +5285,7 @@ namespace Barotrauma { SpawnInventoryItemsRecursive(inventory, itemData, new List()); } - + private void SpawnInventoryItemsRecursive(Inventory inventory, ContentXElement element, List extraDuffelBags) { foreach (var itemElement in element.Elements()) @@ -5168,6 +5302,7 @@ namespace Barotrauma newItem.GetComponent()?.SyncHistory(); if (newItem.GetComponent() is WifiComponent wifiComponent) { newItem.CreateServerEvent(wifiComponent); } if (newItem.GetComponent() is GeneticMaterial geneticMaterial) { newItem.CreateServerEvent(geneticMaterial); } + SyncInGameEditables(newItem); #endif int[] slotIndices = itemElement.GetAttributeIntArray("i", new int[] { 0 }); if (!slotIndices.Any()) @@ -5380,13 +5515,38 @@ 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(); public IReadOnlyCollection CharacterTalents => characterTalents; + /// + /// Removes the talents the character has unlocked in their talent tree. + /// + public void ResetTalents(int talentPointReduction) + { + characterTalents.Clear(); + abilityResistances.Clear(); + abilityFlags = AbilityFlags.None; + CharacterHealth.RemoveAfflictions(affliction => affliction.Prefab.AfflictionType == Tags.AfflictionTypeTalentBuff); + statValues.Clear(); + + for (int i = 0; i < talentPointReduction; i++) + { + int currentLevel = info.GetCurrentLevel(); + if (currentLevel > 0) + { + info.SetExperience(info.ExperiencePoints - CharacterInfo.ExperienceRequiredPerLevel(currentLevel)); + } + else + { + break; + } + } + } + public void LoadTalents() { List toBeRemoved = null; @@ -5528,13 +5688,17 @@ namespace Barotrauma return sameRoomHulls.Contains(character.CurrentHull); } + /// + /// Returns all friendly crew members that are alive. + /// Filters out pets and characters that don't have . + /// public static IEnumerable GetFriendlyCrew(Character character) { if (character is null) { return Enumerable.Empty(); } - return CharacterList.Where(c => HumanAIController.IsFriendly(character, c, onlySameTeam: true) && !c.IsDead); + return CharacterList.Where(c => c.Info != null && !c.IsDead && !c.IsPet && HumanAIController.IsFriendly(character, c, onlySameTeam: true)); } public bool HasRecipeForItem(Identifier recipeIdentifier) @@ -5590,12 +5754,12 @@ namespace Barotrauma #if CLIENT public void SetMoney(int amount) { - if (!(GameMain.GameSession?.Campaign is { } campaign)) { return; } - if (amount == campaign.Wallet.Balance) { return; } + if (Wallet == null) { return; } + if (amount == Wallet.Balance) { return; } - int prevAmount = campaign.Wallet.Balance; - campaign.Wallet.Balance = amount; - OnMoneyChanged(prevAmount, campaign.Wallet.Balance); + int prevAmount = Wallet.Balance; + Wallet.Balance = amount; + OnMoneyChanged(prevAmount, Wallet.Balance); } #endif @@ -5612,9 +5776,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)) @@ -5625,7 +5794,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); @@ -5647,12 +5816,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)) @@ -5664,6 +5838,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); + } + } } } } @@ -5734,7 +5919,7 @@ namespace Barotrauma } // NOTE: Resistance is handled as a multiplier here, so 1.0 == 0% resistance - return hadResistance ? resistance : 1f; + return hadResistance ? Math.Max(0, resistance) : 1f; } public float GetAbilityResistance(AfflictionPrefab affliction) @@ -5753,7 +5938,7 @@ namespace Barotrauma } // NOTE: Resistance is handled as a multiplier here, so 1.0 == 0% resistance - return hadResistance ? resistance : 1f; + return hadResistance ? Math.Max(0, resistance) : 1f; } public void ChangeAbilityResistance(TalentResistanceIdentifier identifier, float value) @@ -5788,9 +5973,12 @@ namespace Barotrauma return myTeam switch { // NPCs are friendly to the same team and the friendly NPCs - CharacterTeamType.None or CharacterTeamType.Team1 or CharacterTeamType.Team2 => otherTeam == CharacterTeamType.FriendlyNPC, + CharacterTeamType.Team1 or CharacterTeamType.Team2 => otherTeam == CharacterTeamType.FriendlyNPC, // Friendly NPCs are friendly to both player teams - CharacterTeamType.FriendlyNPC => otherTeam == CharacterTeamType.Team1 || otherTeam == CharacterTeamType.Team2, + CharacterTeamType.FriendlyNPC => otherTeam is CharacterTeamType.Team1 or CharacterTeamType.Team2, + // None (bandits and such) consider friendly NPCs friendly, not attacking them unless they attack first + // Otherwise bandits would for example attach the hostages. + CharacterTeamType.None => otherTeam == CharacterTeamType.FriendlyNPC, _ => true }; } @@ -5802,6 +5990,8 @@ namespace Barotrauma public bool IsSameSpeciesOrGroup(Character other) => IsSameSpeciesOrGroup(this, other); public static bool IsSameSpeciesOrGroup(Character me, Character other) => other.SpeciesName == me.SpeciesName || CharacterParams.CompareGroup(me.Group, other.Group); + + public bool MatchesSpeciesNameOrGroup(Identifier speciesNameOrGroup) => Prefab.MatchesSpeciesNameOrGroup(speciesNameOrGroup); public void StopClimbing() { diff --git a/Barotrauma/BarotraumaShared/SharedSource/Characters/CharacterEventData.cs b/Barotrauma/BarotraumaShared/SharedSource/Characters/CharacterEventData.cs index 5392608ed..e05e5b992 100644 --- a/Barotrauma/BarotraumaShared/SharedSource/Characters/CharacterEventData.cs +++ b/Barotrauma/BarotraumaShared/SharedSource/Characters/CharacterEventData.cs @@ -29,11 +29,13 @@ namespace Barotrauma UpdatePermanentStats = 14, RemoveFromCrew = 15, LatchOntoTarget = 16, - + UpdateTalentRefundPoints = 17, + ConfirmTalentRefund = 18, + MinValue = 0, - MaxValue = 16 + MaxValue = 18 } - + private interface IEventData : NetEntityEvent.IData { public EventType EventType { get; } @@ -230,9 +232,18 @@ namespace Barotrauma public struct UpdateSkillsEventData : IEventData { - public EventType EventType => EventType.UpdateSkills; + public readonly EventType EventType => EventType.UpdateSkills; + + public readonly bool ForceNotification; + public readonly Identifier SkillIdentifier; + + public UpdateSkillsEventData(Identifier skillIdentifier, bool forceNotification) + { + SkillIdentifier = skillIdentifier; + ForceNotification = forceNotification; + } } - + private struct UpdateMoneyEventData : IEventData { public EventType EventType => EventType.UpdateMoney; @@ -242,11 +253,21 @@ namespace Barotrauma { public EventType EventType => EventType.UpdatePermanentStats; public readonly StatTypes StatType; - + public UpdatePermanentStatsEventData(StatTypes statType) { StatType = statType; } } + + public struct UpdateRefundPointsEventData : IEventData + { + public EventType EventType => EventType.UpdateTalentRefundPoints; + } + + public struct ConfirmRefundEventData : IEventData + { + public EventType EventType => EventType.ConfirmTalentRefund; + } } } diff --git a/Barotrauma/BarotraumaShared/SharedSource/Characters/CharacterInfo.cs b/Barotrauma/BarotraumaShared/SharedSource/Characters/CharacterInfo.cs index 3706c4360..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,19 +370,55 @@ 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; - + public int Salary; public int ExperiencePoints { get; private set; } + private int talentRefundPoints; + + /// + /// How many times the player is eligible to refund talents + /// + public int TalentRefundPoints + { + get => talentRefundPoints; + set => talentRefundPoints = MathHelper.Max(value, 0); + } + public HashSet UnlockedTalents { get; private set; } = new HashSet(); + private int talentResetCount; + + /// + /// How many times have the characters' talents been reset? + /// + public int TalentResetCount + { + get => talentResetCount; + set => talentResetCount = MathHelper.Max(value, 0); + } + public (Identifier factionId, float reputation) MinReputationToHire; /// @@ -708,7 +774,9 @@ namespace Barotrauma SetAttachments(randSync); SetColors(randSync); - Job = job ?? ((jobPrefab == null) ? Job.Random(Rand.RandSync.Unsynced) : new Job(jobPrefab, randSync, variant)); + Job = job ?? ((jobPrefab == null) ? + Job.Random(isPvP: false, Rand.RandSync.Unsynced) : + new Job(jobPrefab, isPvP: false, randSync, variant)); if (!string.IsNullOrEmpty(name)) { @@ -725,6 +793,8 @@ namespace Barotrauma } OriginalName = !string.IsNullOrEmpty(originalName) ? originalName : Name; + TalentRefundPoints = CharacterConfigElement.GetAttributeInt("refundpoints", 0); + int loadedLastRewardDistribution = CharacterConfigElement.GetAttributeInt("lastrewarddistribution", -1); if (loadedLastRewardDistribution >= 0) { @@ -742,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; @@ -799,10 +876,12 @@ namespace Barotrauma Salary = infoElement.GetAttributeInt("salary", 1000); ExperiencePoints = infoElement.GetAttributeInt("experiencepoints", 0); AdditionalTalentPoints = infoElement.GetAttributeInt("additionaltalentpoints", 0); + TalentResetCount = infoElement.GetAttributeInt(nameof(talentResetCount), 0); HashSet tags = infoElement.GetAttributeIdentifierArray("tags", Array.Empty()).ToHashSet(); 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) @@ -1047,6 +1126,7 @@ namespace Barotrauma public string ReplaceVars(string str) { + if (Head == null) { return str; } return Prefab.ReplaceVars(str, Head.Preset); } @@ -1254,7 +1334,7 @@ namespace Barotrauma partial void LoadAttachmentSprites(); - public int CalculateSalary() + public int CalculateSalary(int baseSalary = 0, float salaryMultiplier = 1.0f) { if (Name == null || Job == null) { return 0; } @@ -1264,25 +1344,25 @@ namespace Barotrauma salary += (int)(skill.Level * skill.PriceMultiplier); } - return (int)(salary * Job.Prefab.PriceMultiplier); + return (int)(baseSalary + (salary * Job.Prefab.PriceMultiplier * salaryMultiplier)); } /// /// Increases the characters skill at a rate proportional to their current skill. /// If you want to increase the skill level by a specific amount instead, use /// - public void ApplySkillGain(Identifier skillIdentifier, float baseGain, bool gainedFromAbility = false, float maxGain = 2f) + public void ApplySkillGain(Identifier skillIdentifier, float baseGain, bool gainedFromAbility = false, float maxGain = 2f, bool forceNotification = false) { float skillLevel = Job.GetSkillLevel(skillIdentifier); // The formula is too generous on low skill levels, hence the minimum divider. float skillDivider = MathF.Pow(Math.Max(skillLevel, 15f), SkillSettings.Current.SkillIncreaseExponent); - IncreaseSkillLevel(skillIdentifier, Math.Min(baseGain / skillDivider, maxGain), gainedFromAbility); + IncreaseSkillLevel(skillIdentifier, Math.Min(baseGain / skillDivider, maxGain), gainedFromAbility, forceNotification); } /// /// Increase the skill by a specific amount. Talents may affect the actual, final skill increase. /// - public void IncreaseSkillLevel(Identifier skillIdentifier, float increase, bool gainedFromAbility = false) + public void IncreaseSkillLevel(Identifier skillIdentifier, float increase, bool gainedFromAbility = false, bool forceNotification = false) { if (Job == null || (GameMain.NetworkMember != null && GameMain.NetworkMember.IsClient) || Character == null) { return; } @@ -1312,14 +1392,14 @@ namespace Barotrauma } } - OnSkillChanged(skillIdentifier, prevLevel, newLevel); + OnSkillChanged(skillIdentifier, prevLevel, newLevel, forceNotification); } private static readonly ImmutableDictionary skillGainStatValues = new Dictionary { { new("helm"), StatTypes.HelmSkillGainSpeed }, - { new("medical"), StatTypes.WeaponsSkillGainSpeed }, - { new("weapons"), StatTypes.MedicalSkillGainSpeed }, + { new("weapons"), StatTypes.WeaponsSkillGainSpeed }, + { new("medical"), StatTypes.MedicalSkillGainSpeed }, { new("electrical"), StatTypes.ElectricalSkillGainSpeed }, { new("mechanical"), StatTypes.MechanicalSkillGainSpeed } }.ToImmutableDictionary(); @@ -1334,7 +1414,7 @@ namespace Barotrauma return increase; } - public void SetSkillLevel(Identifier skillIdentifier, float level) + public void SetSkillLevel(Identifier skillIdentifier, float level, bool forceNotification = false) { if (Job == null) { return; } @@ -1342,17 +1422,17 @@ namespace Barotrauma if (skill == null) { Job.IncreaseSkillLevel(skillIdentifier, level, increasePastMax: false); - OnSkillChanged(skillIdentifier, 0.0f, level); + OnSkillChanged(skillIdentifier, 0.0f, level, forceNotification); } else { float prevLevel = skill.Level; skill.Level = level; - OnSkillChanged(skillIdentifier, prevLevel, skill.Level); + OnSkillChanged(skillIdentifier, prevLevel, skill.Level, forceNotification); } } - partial void OnSkillChanged(Identifier skillIdentifier, float prevLevel, float newLevel); + partial void OnSkillChanged(Identifier skillIdentifier, float prevLevel, float newLevel, bool forceNotification); public void GiveExperience(int amount) { @@ -1437,10 +1517,11 @@ namespace Barotrauma experienceRequired += ExperienceRequiredPerLevel(level); level++; } - return level; + + return Math.Max(level, 0); } - private static int ExperienceRequiredPerLevel(int level) + public static int ExperienceRequiredPerLevel(int level) { return BaseExperienceRequired + AddedExperienceRequiredPerLevel * level; } @@ -1449,6 +1530,43 @@ namespace Barotrauma partial void OnPermanentStatChanged(StatTypes statType); + public void RefundTalents() + { + if (TalentRefundPoints <= 0) { return; } + + //e.g. talents from endocrine booster or extra talents some special NPC has + var talentsFromOutsideTree = GetUnlockedTalentsOutsideTree().ToList(); + + UnlockedTalents.Clear(); + SavedStatValues.Clear(); + Character?.ResetTalents(talentPointReduction: talentResetCount); + TalentRefundPoints--; + talentResetCount++; + + //it's simpler to just remove everything first and then reapply the "extra" talents than to + //try determining which talent the resistances, ability flags etc came from and only remove specific ones + if (Character == null) + { + talentsFromOutsideTree.ForEach(talentId => UnlockedTalents.Add(talentId)); + } + else + { + talentsFromOutsideTree.ForEach(talentId => Character.GiveTalent(talentId, addingFirstTime: true)); + } + + GameMain.NetworkMember?.CreateEntityEvent(Character, new Character.ConfirmRefundEventData()); + } + + public void AddRefundPoints(int newRefundPoints) + { + TalentRefundPoints += newRefundPoints; +#if SERVER + GameMain.NetworkMember?.CreateEntityEvent(Character, new Character.UpdateRefundPointsEventData()); +#elif CLIENT + ShowTalentResetPopupOnOpen = true; +#endif + } + public void Rename(string newName) { if (string.IsNullOrEmpty(newName)) { return; } @@ -1491,6 +1609,7 @@ namespace Barotrauma new XAttribute("salary", Salary), new XAttribute("experiencepoints", ExperiencePoints), new XAttribute("additionaltalentpoints", AdditionalTalentPoints), + new XAttribute(nameof(talentResetCount), TalentResetCount), new XAttribute("hairindex", Head.HairIndex), new XAttribute("beardindex", Head.BeardIndex), new XAttribute("moustacheindex", Head.MoustacheIndex), @@ -1500,8 +1619,10 @@ namespace Barotrauma new XAttribute("facialhaircolor", XMLExtensions.ColorToString(Head.FacialHairColor)), new XAttribute("startitemsgiven", StartItemsGiven), new XAttribute("personality", PersonalityTrait?.Identifier ?? Identifier.Empty), + 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) ); @@ -1601,7 +1722,7 @@ namespace Barotrauma targetAvailableInNextLevel = !isOutside && GameMain.GameSession?.Campaign is not { SwitchedSubsThisRound: true } && - (isOnConnectedLinkedSub || entitySub == Submarine.MainSub); + (isOnConnectedLinkedSub || (Submarine.MainSub != null && entitySub == Submarine.MainSub)); if (!targetAvailableInNextLevel) { if (!order.Prefab.CanBeGeneralized) @@ -1636,7 +1757,7 @@ namespace Barotrauma } if (order.TargetSpatialEntity?.Submarine is Submarine targetSub) { - if (targetSub == Submarine.MainSub) + if (Submarine.MainSub != null && targetSub == Submarine.MainSub) { orderElement.Add(new XAttribute("onmainsub", true)); } diff --git a/Barotrauma/BarotraumaShared/SharedSource/Characters/CharacterNetworking.cs b/Barotrauma/BarotraumaShared/SharedSource/Characters/CharacterNetworking.cs index 5d8e294ec..85aa05d69 100644 --- a/Barotrauma/BarotraumaShared/SharedSource/Characters/CharacterNetworking.cs +++ b/Barotrauma/BarotraumaShared/SharedSource/Characters/CharacterNetworking.cs @@ -14,24 +14,29 @@ namespace Barotrauma public readonly AnimController.Animation Animation; - public CharacterStateInfo(Vector2 pos, float? rotation, Vector2 velocity, float? angularVelocity, float time, Direction dir, Character selectedCharacter, Item selectedItem, Item selectedSecondaryItem, AnimController.Animation animation = AnimController.Animation.None) - : this(pos, rotation, velocity, angularVelocity, 0, time, dir, selectedCharacter, selectedItem, selectedSecondaryItem, animation) + public bool IgnorePlatforms; + + public readonly Vector2 TargetMovement; + + public CharacterStateInfo(Vector2 pos, float? rotation, Vector2 velocity, float? angularVelocity, float time, Direction dir, Character selectedCharacter, Item selectedItem, Item selectedSecondaryItem, Vector2 targetMovement, AnimController.Animation animation = AnimController.Animation.None, bool ignorePlatforms = false) + : this(pos, rotation, velocity, angularVelocity, 0, time, dir, selectedCharacter, selectedItem, selectedSecondaryItem, targetMovement, animation, ignorePlatforms) { } - public CharacterStateInfo(Vector2 pos, float? rotation, UInt16 ID, Direction dir, Character selectedCharacter, Item selectedItem, Item selectedSecondaryItem, AnimController.Animation animation = AnimController.Animation.None) - : this(pos, rotation, Vector2.Zero, 0.0f, ID, 0.0f, dir, selectedCharacter, selectedItem, selectedSecondaryItem, animation) + public CharacterStateInfo(Vector2 pos, float? rotation, UInt16 ID, Direction dir, Character selectedCharacter, Item selectedItem, Item selectedSecondaryItem, Vector2 targetMovement, AnimController.Animation animation = AnimController.Animation.None, bool ignorePlatforms = false) + : this(pos, rotation, Vector2.Zero, 0.0f, ID, 0.0f, dir, selectedCharacter, selectedItem, selectedSecondaryItem, targetMovement, animation, ignorePlatforms) { } - protected CharacterStateInfo(Vector2 pos, float? rotation, Vector2 velocity, float? angularVelocity, UInt16 ID, float time, Direction dir, Character selectedCharacter, Item selectedItem, Item selectedSecondaryItem, AnimController.Animation animation = AnimController.Animation.None) + protected CharacterStateInfo(Vector2 pos, float? rotation, Vector2 velocity, float? angularVelocity, UInt16 ID, float time, Direction dir, Character selectedCharacter, Item selectedItem, Item selectedSecondaryItem, Vector2 targetMovement, AnimController.Animation animation = AnimController.Animation.None, bool ignorePlatforms = false) : base(pos, rotation, velocity, angularVelocity, ID, time) { Direction = dir; SelectedCharacter = selectedCharacter; SelectedItem = selectedItem; SelectedSecondaryItem = selectedSecondaryItem; - + IgnorePlatforms = ignorePlatforms; + TargetMovement = targetMovement; Animation = animation; } } diff --git a/Barotrauma/BarotraumaShared/SharedSource/Characters/CharacterPrefab.cs b/Barotrauma/BarotraumaShared/SharedSource/Characters/CharacterPrefab.cs index 22f22f6c6..93e4b1bf6 100644 --- a/Barotrauma/BarotraumaShared/SharedSource/Characters/CharacterPrefab.cs +++ b/Barotrauma/BarotraumaShared/SharedSource/Characters/CharacterPrefab.cs @@ -33,6 +33,10 @@ namespace Barotrauma } public bool HasCharacterInfo { get; private set; } + + public Identifier Group { get; private set; } + + public bool MatchesSpeciesNameOrGroup(Identifier speciesNameOrGroup) => Identifier == speciesNameOrGroup || Group == speciesNameOrGroup; public void InheritFrom(CharacterPrefab parent) { @@ -52,9 +56,10 @@ namespace Barotrauma { CharacterInfoPrefab = new CharacterInfoPrefab(this, headsElement, varsElement, menuCategoryElement, pronounsElement); } + Group = ConfigElement.GetAttributeIdentifier(nameof(Group), Identifier.Empty); } - private readonly XElement originalElement; + private readonly ContentXElement originalElement; public ContentXElement ConfigElement { get; private set; } public CharacterInfoPrefab CharacterInfoPrefab { get; private set; } diff --git a/Barotrauma/BarotraumaShared/SharedSource/Characters/Health/Afflictions/Affliction.cs b/Barotrauma/BarotraumaShared/SharedSource/Characters/Health/Afflictions/Affliction.cs index baa030c31..ef7ce4f6e 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) @@ -337,18 +343,24 @@ namespace Barotrauma } } - public float GetResistance(Identifier afflictionId) + /// + /// How much resistance to the specified affliction does this affliction currently give? + /// + public float GetResistance(Identifier afflictionId, LimbType limbType) { if (Strength < Prefab.ActivationThreshold) { return 0.0f; } var affliction = AfflictionPrefab.Prefabs[afflictionId]; AfflictionPrefab.Effect currentEffect = GetActiveEffect(); if (currentEffect == null) { return 0.0f; } - if (!currentEffect.ResistanceFor.Any(r => - r == affliction.Identifier || - r == affliction.AfflictionType)) - { - return 0.0f; - } + + bool hasResistanceForAffliction = currentEffect.ResistanceFor.Any(identifier => + identifier == affliction.Identifier || + identifier == affliction.AfflictionType); + if (!hasResistanceForAffliction) { return 0.0f; } + + bool hasResistanceForLimb = limbType == LimbType.None || currentEffect.ResistanceLimbs.None() || currentEffect.ResistanceLimbs.Contains(limbType); + if (!hasResistanceForLimb) { return 0.0f; } + return MathHelper.Lerp( currentEffect.MinResistance, currentEffect.MaxResistance, @@ -402,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); @@ -430,7 +444,7 @@ namespace Barotrauma } else if (currentEffect.StrengthChange > 0) // Reduce strengthening of afflictions if resistant { - _strength += currentEffect.StrengthChange * deltaTime * (1f - characterHealth.GetResistance(Prefab)); + _strength += currentEffect.StrengthChange * deltaTime * (1f - characterHealth.GetResistance(Prefab, targetLimb?.type ?? LimbType.None)); } // Don't use the property, because it's virtual and some afflictions like husk overload it for external use. _strength = MathHelper.Clamp(_strength, 0.0f, Prefab.MaxStrength); @@ -441,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/AfflictionBleeding.cs b/Barotrauma/BarotraumaShared/SharedSource/Characters/Health/Afflictions/AfflictionBleeding.cs index bc8ac1329..ea52bef11 100644 --- a/Barotrauma/BarotraumaShared/SharedSource/Characters/Health/Afflictions/AfflictionBleeding.cs +++ b/Barotrauma/BarotraumaShared/SharedSource/Characters/Health/Afflictions/AfflictionBleeding.cs @@ -13,7 +13,7 @@ public override void Update(CharacterHealth characterHealth, Limb targetLimb, float deltaTime) { base.Update(characterHealth, targetLimb, deltaTime); - float bloodlossResistance = GetResistance(characterHealth.BloodlossAffliction.Identifier); + float bloodlossResistance = characterHealth.GetResistance(characterHealth.BloodlossAffliction.Prefab, targetLimb?.type ?? LimbType.None); characterHealth.BloodlossAmount += Strength * (1.0f - bloodlossResistance) / 60.0f * deltaTime; if (Source != null) { diff --git a/Barotrauma/BarotraumaShared/SharedSource/Characters/Health/Afflictions/AfflictionHusk.cs b/Barotrauma/BarotraumaShared/SharedSource/Characters/Health/Afflictions/AfflictionHusk.cs index a4338ac70..9f0b4e23f 100644 --- a/Barotrauma/BarotraumaShared/SharedSource/Characters/Health/Afflictions/AfflictionHusk.cs +++ b/Barotrauma/BarotraumaShared/SharedSource/Characters/Health/Afflictions/AfflictionHusk.cs @@ -28,8 +28,6 @@ namespace Barotrauma private bool stun = false; - private readonly List huskInfection = new List(); - [Serialize(0f, IsPropertySaveable.Yes), Editable] public override float Strength { @@ -62,7 +60,7 @@ namespace Barotrauma } } - private readonly AfflictionPrefabHusk HuskPrefab; + public readonly AfflictionPrefabHusk HuskPrefab; private float DormantThreshold => HuskPrefab.DormantThreshold; private float ActiveThreshold => HuskPrefab.ActiveThreshold; @@ -129,7 +127,7 @@ namespace Barotrauma { State = InfectionState.Final; ActivateHusk(); - ApplyDamage(deltaTime, applyForce: true); + ApplyDamage(deltaTime); character.SetStun(5); } } @@ -192,27 +190,41 @@ namespace Barotrauma prevDisplayedMessage = State; } - private void ApplyDamage(float deltaTime, bool applyForce) + private const float DamageCooldown = 0.1f; + private float damageCooldownTimer; + private void ApplyDamage(float deltaTime) { - int limbCount = character.AnimController.Limbs.Count(l => !l.IgnoreCollisions && !l.IsSevered && !l.Hidden); + if (damageCooldownTimer > 0) + { + damageCooldownTimer -= deltaTime; + return; + } + damageCooldownTimer = DamageCooldown; + int limbCount = character.AnimController.Limbs.Count(IsValidLimb); foreach (Limb limb in character.AnimController.Limbs) { - if (limb.IsSevered) { continue; } - if (limb.Hidden) { continue; } + if (!IsValidLimb(limb)) { continue; } float random = Rand.Value(); - huskInfection.Clear(); - huskInfection.Add(AfflictionPrefab.InternalDamage.Instantiate(random * 10 * deltaTime / limbCount)); + if (random == 0) { continue; } + const float damageRate = 2; + float dmg = random / limbCount * damageRate; character.LastDamageSource = null; - float force = applyForce ? random * 0.5f * limb.Mass : 0; - character.DamageLimb(limb.WorldPosition, limb, huskInfection, 0, false, Rand.Vector(force)); + var afflictions = AfflictionPrefab.InternalDamage.Instantiate(dmg).ToEnumerable(); + const float forceMultiplier = 5; + float force = dmg * limb.Mass * forceMultiplier; + character.DamageLimb(limb.WorldPosition, limb, afflictions, stun: 0, playSound: false, Rand.Vector(force), ignoreDamageOverlay: true, recalculateVitality: false); } + character.CharacterHealth.RecalculateVitality(); + + static bool IsValidLimb(Limb limb) => !limb.IgnoreCollisions && !limb.IsSevered && !limb.Hidden; } public void ActivateHusk() { if (huskAppendage == null && character.Params.UseHuskAppendage) { - huskAppendage = AttachHuskAppendage(character, Prefab as AfflictionPrefabHusk); + var huskAffliction = Prefab as AfflictionPrefabHusk; + huskAppendage = AttachHuskAppendage(character, huskAffliction, GetHuskedSpeciesName(character.Params, huskAffliction)); } if (Prefab is AfflictionPrefabHusk { NeedsAir: false }) @@ -287,7 +299,7 @@ namespace Barotrauma Entity.Spawner.AddEntityToRemoveQueue(character); UnsubscribeFromDeathEvent(); - Identifier huskedSpeciesName = GetHuskedSpeciesName(character.SpeciesName, Prefab as AfflictionPrefabHusk); + Identifier huskedSpeciesName = GetHuskedSpeciesName(character.Params, Prefab as AfflictionPrefabHusk); CharacterPrefab prefab = CharacterPrefab.FindBySpeciesName(huskedSpeciesName); if (prefab == null) @@ -309,11 +321,16 @@ 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; husk.Info.TeamID = CharacterTeamType.None; } + husk.AllowPlayDead = character.AllowPlayDead; if (Prefab is AfflictionPrefabHusk huskPrefab) { @@ -379,17 +396,15 @@ namespace Barotrauma yield return CoroutineStatus.Success; } - public static List AttachHuskAppendage(Character character, AfflictionPrefabHusk matchingAffliction, ContentXElement appendageDefinition = null, Ragdoll ragdoll = null) + public static List AttachHuskAppendage(Character character, AfflictionPrefabHusk matchingAffliction, Identifier huskedSpeciesName, ContentXElement appendageDefinition = null, Ragdoll ragdoll = null) { - var appendage = new List(); - Identifier nonhuskedSpeciesName = GetNonHuskedSpeciesName(character.SpeciesName, matchingAffliction); - Identifier huskedSpeciesName = GetHuskedSpeciesName(nonhuskedSpeciesName, matchingAffliction); + 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; @@ -401,28 +416,25 @@ 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 (ragdoll == null) - { - ragdoll = character.AnimController; - } + if (doc == null) { return appendageLimbs; } + ragdoll ??= character.AnimController; if (ragdoll.Dir < 1.0f) { ragdoll.Flip(); } 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; @@ -444,38 +456,54 @@ 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(Identifier speciesName, AfflictionPrefabHusk prefab) + public static Identifier GetHuskedSpeciesName(CharacterParams character, AfflictionPrefabHusk prefab) { - return new Identifier(speciesName.Value + prefab.HuskedSpeciesName.Value); + Identifier huskedSpecies = character.HuskedSpecies; + if (huskedSpecies.IsEmpty) + { + // Default pattern: Crawler -> Crawlerhusk, Human -> Humanhusk + return new Identifier(character.SpeciesName.Value + prefab.HuskedSpeciesName.Value); + } + return huskedSpecies; } - public static Identifier GetNonHuskedSpeciesName(Identifier huskedSpeciesName, AfflictionPrefabHusk prefab) + public static Identifier GetNonHuskedSpeciesName(CharacterParams character, AfflictionPrefabHusk prefab) { - return huskedSpeciesName.Remove(prefab.HuskedSpeciesName); + Identifier nonHuskedSpecies = character.NonHuskedSpecies; + if (nonHuskedSpecies.IsEmpty) + { + // Default pattern: Crawlerhusk -> Crawler, Humanhusk -> Human + return character.SpeciesName.Remove(prefab.HuskedSpeciesName); + } + return nonHuskedSpecies; } } } diff --git a/Barotrauma/BarotraumaShared/SharedSource/Characters/Health/Afflictions/AfflictionPrefab.cs b/Barotrauma/BarotraumaShared/SharedSource/Characters/Health/Afflictions/AfflictionPrefab.cs index e74665360..725e12478 100644 --- a/Barotrauma/BarotraumaShared/SharedSource/Characters/Health/Afflictions/AfflictionPrefab.cs +++ b/Barotrauma/BarotraumaShared/SharedSource/Characters/Health/Afflictions/AfflictionPrefab.cs @@ -329,6 +329,11 @@ namespace Barotrauma /// public readonly ImmutableArray ResistanceFor; + /// + /// List of limb types that the resistance applies to. If empty, the resistance applies to the whole body. + /// + public readonly ImmutableArray ResistanceLimbs; + [Serialize(0.0f, IsPropertySaveable.No, description: "The amount of resistance to the afflictions specified by ResistanceFor to apply at this effect's lowest strength.")] public float MinResistance { get; private set; } @@ -359,6 +364,18 @@ namespace Barotrauma description: "Color to tint the affected character's entire body with at this effect's highest strength. The alpha channel is used to determine how much to tint the character.")] public Color MaxBodyTint { get; private set; } + [Serialize(0.0f, IsPropertySaveable.No, + description: "Range of the \"thermal goggles overlay\" enabled by the affliction.")] + public float ThermalOverlayRange { get; private set; } + + [Serialize("255,0,0,255", IsPropertySaveable.No, + 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. /// @@ -385,7 +402,7 @@ namespace Barotrauma public readonly float MinValue; /// - /// Minimum value to apply + /// Maximum value to apply /// public readonly float MaxValue; @@ -423,6 +440,8 @@ namespace Barotrauma SerializableProperty.DeserializeProperties(this, element); ResistanceFor = element.GetAttributeIdentifierArray("resistancefor", Array.Empty())!.ToImmutableArray(); + ResistanceLimbs = element.GetAttributeEnumArray("resistancelimbs", Array.Empty()).ToImmutableArray(); + BlockTransformation = element.GetAttributeIdentifierArray("blocktransformation", Array.Empty())!.ToImmutableArray(); var afflictionStatValues = new Dictionary(); @@ -594,7 +613,7 @@ namespace Barotrauma } else { - MinInterval = Math.Max(element.GetAttributeFloat(nameof(MinInterval), 1.0f), 1.0f); + MinInterval = Math.Max(element.GetAttributeFloat(nameof(MinInterval), 1.0f), 0.1f); MaxInterval = Math.Max(element.GetAttributeFloat(nameof(MaxInterval), 1.0f), MinInterval); MinStrength = Math.Max(element.GetAttributeFloat(nameof(MinStrength), 0f), 0f); MaxStrength = Math.Max(element.GetAttributeFloat(nameof(MaxStrength), MinStrength), MinStrength); @@ -612,6 +631,7 @@ namespace Barotrauma public static readonly Identifier SpaceHerpesType = "spaceherpes".ToIdentifier(); public static readonly Identifier AlienInfectedType = "alieninfected".ToIdentifier(); public static readonly Identifier InvertControlsType = "invertcontrols".ToIdentifier(); + public static readonly Identifier DisguisedAsHuskType = "disguiseashusk".ToIdentifier(); public static AfflictionPrefab InternalDamage => Prefabs["internaldamage"]; public static AfflictionPrefab BiteWounds => Prefabs["bitewounds"]; @@ -624,7 +644,8 @@ namespace Barotrauma public static AfflictionPrefab OrganDamage => Prefabs["organdamage"]; 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(); @@ -641,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. /// @@ -748,6 +774,12 @@ namespace Barotrauma /// public readonly float TreatmentThreshold; + /// + /// How strong the affliction needs to be for treatment suggestions to be shown in the health interface. + /// Defaults to . + /// + public readonly float TreatmentSuggestionThreshold; + /// /// Bots will not try to treat the affliction if the character has any of these afflictions /// @@ -880,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); @@ -898,7 +932,10 @@ namespace Barotrauma if (element.GetAttribute("nameidentifier") != null) { - Name = TextManager.Get(element.GetAttributeString("nameidentifier", string.Empty)).Fallback(Name); + string nameIdentifier = element.GetAttributeString("nameidentifier", string.Empty); + Name = TextManager.Get(nameIdentifier) + .Fallback(TextManager.Get($"AfflictionName.{nameIdentifier}")) + .Fallback(Name); } LimbSpecific = element.GetAttributeBool("limbspecific", false); @@ -914,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); @@ -922,6 +965,7 @@ namespace Barotrauma ShowInHealthScannerThreshold = element.GetAttributeFloat(nameof(ShowInHealthScannerThreshold), Math.Max(ActivationThreshold, AfflictionType == "talentbuff" ? float.MaxValue : ShowIconToOthersThreshold)); TreatmentThreshold = element.GetAttributeFloat(nameof(TreatmentThreshold), Math.Max(ActivationThreshold, 10.0f)); + TreatmentSuggestionThreshold = element.GetAttributeFloat(nameof(TreatmentSuggestionThreshold), TreatmentThreshold); DamageOverlayAlpha = element.GetAttributeFloat(nameof(DamageOverlayAlpha), 0.0f); BurnOverlayAlpha = element.GetAttributeFloat(nameof(BurnOverlayAlpha), 0.0f); diff --git a/Barotrauma/BarotraumaShared/SharedSource/Characters/Health/CharacterHealth.cs b/Barotrauma/BarotraumaShared/SharedSource/Characters/Health/CharacterHealth.cs index 03a1f1e26..af18586d3 100644 --- a/Barotrauma/BarotraumaShared/SharedSource/Characters/Health/CharacterHealth.cs +++ b/Barotrauma/BarotraumaShared/SharedSource/Characters/Health/CharacterHealth.cs @@ -248,6 +248,12 @@ namespace Barotrauma /// Was the character in full health at the beginning of the frame? /// public bool WasInFullHealth { get; private set; } + + /// + /// Show the blood overlay screen space effect when the character takes damage. + /// Enabled normally, but can be disabled for some special cases. + /// + public bool ShowDamageOverlay = true; public Affliction PressureAffliction { @@ -442,7 +448,7 @@ namespace Barotrauma return strength; } - public void ApplyAffliction(Limb targetLimb, Affliction affliction, bool allowStacking = true, bool ignoreUnkillability = false) + public void ApplyAffliction(Limb targetLimb, Affliction affliction, bool allowStacking = true, bool ignoreUnkillability = false, bool recalculateVitality = true) { if (Character.GodMode) { return; } if (!ignoreUnkillability) @@ -456,12 +462,12 @@ namespace Barotrauma //if a limb-specific affliction is applied to no specific limb, apply to all limbs foreach (LimbHealth limbHealth in limbHealths) { - AddLimbAffliction(limbHealth, affliction, allowStacking: allowStacking); + AddLimbAffliction(limbHealth, limb: null, affliction, allowStacking: allowStacking, recalculateVitality: recalculateVitality); } } else { - AddLimbAffliction(targetLimb, affliction, allowStacking: allowStacking); + AddLimbAffliction(targetLimb, affliction, allowStacking: allowStacking, recalculateVitality: recalculateVitality); } } else @@ -470,14 +476,17 @@ namespace Barotrauma } } - public float GetResistance(AfflictionPrefab afflictionPrefab) + /// + /// How much resistance all the afflictions the character has give to the specified affliction? + /// + public float GetResistance(AfflictionPrefab afflictionPrefab, LimbType limbType) { // This is a % resistance (0 to 1.0) float resistance = 0.0f; foreach (KeyValuePair kvp in afflictions) { var affliction = kvp.Key; - resistance += affliction.GetResistance(afflictionPrefab.Identifier); + resistance += affliction.GetResistance(afflictionPrefab.Identifier, limbType); } // This is a multiplier, ie. 0.0 = 100% resistance and 1.0 = 0% resistance float abilityResistanceMultiplier = Character.GetAbilityResistance(afflictionPrefab); @@ -610,7 +619,11 @@ namespace Barotrauma CalculateVitality(); } - public void ApplyDamage(Limb hitLimb, AttackResult attackResult, bool allowStacking = true) + /// + /// + /// + /// Set false only as an optimization when you manually call . Only applies to limb specific afflictions. + public void ApplyDamage(Limb hitLimb, AttackResult attackResult, bool allowStacking = true, bool recalculateVitality = true) { if (Unkillable || Character.GodMode) { return; } if (hitLimb.HealthIndex < 0 || hitLimb.HealthIndex >= limbHealths.Count) @@ -619,18 +632,19 @@ namespace Barotrauma "\" only has health configured for" + limbHealths.Count + " limbs but the limb " + hitLimb.type + " is targeting index " + hitLimb.HealthIndex); return; } - + foreach (Affliction newAffliction in attackResult.Afflictions) { if (newAffliction.Prefab.LimbSpecific) { - AddLimbAffliction(hitLimb, newAffliction, allowStacking); + AddLimbAffliction(hitLimb, newAffliction, allowStacking, recalculateVitality: recalculateVitality); } else { + // Always recalculate vitality for non-limb specific afflictions. AddAffliction(newAffliction, allowStacking); } - } + } } private void KillIfOutOfVitality() @@ -664,9 +678,8 @@ namespace Barotrauma if (bleedingDamageAmount > 0.0f && DoesBleed) { afflictions.Add(AfflictionPrefab.Bleeding.Instantiate(bleedingDamageAmount), limbHealth); } if (burnDamageAmount > 0.0f) { afflictions.Add(AfflictionPrefab.Burn.Instantiate(burnDamageAmount), limbHealth); } } - - CalculateVitality(); - KillIfOutOfVitality(); + + RecalculateVitality(); } public float GetLimbDamage(Limb limb, Identifier afflictionType) @@ -697,12 +710,25 @@ namespace Barotrauma } } + public void RemoveAfflictions(Func predicate) + { + afflictionsToRemove.Clear(); + afflictionsToRemove.AddRange(afflictions.Keys.Where(affliction => predicate(affliction))); + foreach (var affliction in afflictionsToRemove) + { + afflictions.Remove(affliction); + } + CalculateVitality(); + } + public void RemoveAllAfflictions() { afflictionsToRemove.Clear(); 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) @@ -731,7 +757,11 @@ namespace Barotrauma CalculateVitality(); } - private void AddLimbAffliction(Limb limb, Affliction newAffliction, bool allowStacking = true) + /// + /// + /// + /// Set false only as an optimization when you manually call + private void AddLimbAffliction(Limb limb, Affliction newAffliction, bool allowStacking = true, bool recalculateVitality = true) { if (!newAffliction.Prefab.LimbSpecific || limb == null) { return; } if (limb.HealthIndex < 0 || limb.HealthIndex >= limbHealths.Count) @@ -740,11 +770,16 @@ namespace Barotrauma "\" only has health configured for" + limbHealths.Count + " limbs but the limb " + limb.type + " is targeting index " + limb.HealthIndex); return; } - AddLimbAffliction(limbHealths[limb.HealthIndex], newAffliction, allowStacking); + AddLimbAffliction(limbHealths[limb.HealthIndex], limb, newAffliction, allowStacking, recalculateVitality); } - private void AddLimbAffliction(LimbHealth limbHealth, Affliction newAffliction, bool allowStacking = true) + /// + /// + /// + /// Set false only as an optimization when you manually call + private void AddLimbAffliction(LimbHealth limbHealth, Limb limb, Affliction newAffliction, bool allowStacking = true, bool recalculateVitality = true) { + LimbType limbType = limb?.type ?? LimbType.None; if (Character.Params.IsMachine && !newAffliction.Prefab.AffectMachines) { return; } if (!DoesBleed && newAffliction is AfflictionBleeding) { return; } if (!Character.NeedsOxygen && newAffliction.Prefab == AfflictionPrefab.OxygenLow) { return; } @@ -778,26 +813,29 @@ namespace Barotrauma if (existingAffliction != null) { - float newStrength = newAffliction.Strength * (100.0f / MaxVitality) * (1f - GetResistance(existingAffliction.Prefab)); + float newStrength = newAffliction.Strength * (100.0f / MaxVitality) * (1f - GetResistance(existingAffliction.Prefab, limbType)); if (allowStacking) { // Add the existing strength newStrength += existingAffliction.Strength; } newStrength = Math.Min(existingAffliction.Prefab.MaxStrength, newStrength); - if (existingAffliction == stunAffliction) { Character.SetStun(newStrength, true, true); } existingAffliction.Strength = newStrength; + //set stun after setting the strength, because stun multipliers might want to set the strength to something else + if (existingAffliction == stunAffliction) { Character.SetStun(newStrength, allowStunDecrease: true, isNetworkMessage: true); } existingAffliction.Duration = existingAffliction.Prefab.Duration; if (newAffliction.Source != null) { existingAffliction.Source = newAffliction.Source; } - CalculateVitality(); - KillIfOutOfVitality(); + if (recalculateVitality) + { + RecalculateVitality(); + } return; } //create a new instance of the affliction to make sure we don't use the same instance for multiple characters //or modify the affliction instance of an Attack or a StatusEffect var copyAffliction = newAffliction.Prefab.Instantiate( - Math.Min(newAffliction.Prefab.MaxStrength, newAffliction.Strength * (100.0f / MaxVitality) * (1f - GetResistance(newAffliction.Prefab))), + Math.Min(newAffliction.Prefab.MaxStrength, newAffliction.Strength * (100.0f / MaxVitality) * (1f - GetResistance(newAffliction.Prefab, limbType))), newAffliction.Source); afflictions.Add(copyAffliction, limbHealth); AchievementManager.OnAfflictionReceived(copyAffliction, Character); @@ -805,8 +843,10 @@ namespace Barotrauma Character.HealthUpdateInterval = 0.0f; - CalculateVitality(); - KillIfOutOfVitality(); + if (recalculateVitality) + { + RecalculateVitality(); + } #if CLIENT if (OpenHealthWindow != this && limbHealth != null) { @@ -817,7 +857,7 @@ namespace Barotrauma private void AddAffliction(Affliction newAffliction, bool allowStacking = true) { - AddLimbAffliction(limbHealth: null, newAffliction, allowStacking); + AddLimbAffliction(limbHealth: null, limb: null, newAffliction, allowStacking); } partial void UpdateSkinTint(); @@ -850,6 +890,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; } @@ -902,14 +944,15 @@ namespace Barotrauma if (!Character.GodMode) { #if CLIENT - if (Character.IsVisible) + updateVisualsTimer -= deltaTime; + if (Character.IsVisible && updateVisualsTimer <= 0.0f) { UpdateLimbAfflictionOverlays(); UpdateSkinTint(); + updateVisualsTimer = UpdateVisualsInterval; } #endif - CalculateVitality(); - KillIfOutOfVitality(); + RecalculateVitality(); } } @@ -941,7 +984,7 @@ namespace Barotrauma /// /// 0-1. /// - public float OxygenLowResistance => !Character.NeedsOxygen ? 1 : GetResistance(oxygenLowAffliction.Prefab); + public float OxygenLowResistance => !Character.NeedsOxygen ? 1 : GetResistance(oxygenLowAffliction.Prefab, LimbType.None); private void UpdateOxygen(float deltaTime) { @@ -951,7 +994,7 @@ namespace Barotrauma return; } - float oxygenlowResistance = GetResistance(oxygenLowAffliction.Prefab); + float oxygenlowResistance = GetResistance(oxygenLowAffliction.Prefab, LimbType.None); float prevOxygen = OxygenAmount; if (IsUnconscious) { @@ -991,13 +1034,13 @@ namespace Barotrauma CalculateVitality(); } - public void CalculateVitality() + private void CalculateVitality() { vitality = MaxVitality; IsParalyzed = false; if (Unkillable || Character.GodMode) { return; } - foreach (var (affliction, limbHealth) in afflictions) + foreach ((Affliction affliction, LimbHealth limbHealth) in afflictions) { float vitalityDecrease = affliction.GetVitalityDecrease(this); if (limbHealth != null) @@ -1020,6 +1063,12 @@ namespace Barotrauma } #endif } + + public void RecalculateVitality() + { + CalculateVitality(); + KillIfOutOfVitality(); + } private static float GetVitalityMultiplier(Affliction affliction, LimbHealth limbHealth) { @@ -1139,7 +1188,11 @@ namespace Barotrauma /// /// A dictionary where the key is the identifier of the item and the value the suitability /// If above 0, the method will take into account how much currently active status effects while affect the afflictions in the next x seconds. - public void GetSuitableTreatments(Dictionary treatmentSuitability, Character user, Limb limb = null, bool ignoreHiddenAfflictions = false, float predictFutureDuration = 0.0f) + /// Should the method check whether the afflictions are above (whether they're severe enough for AI to treat)? + /// Should the method check whether the afflictions are above (whether treatment suggestions are shown in the health interface)? + public void GetSuitableTreatments(Dictionary treatmentSuitability, Character user, Limb limb = null, bool ignoreHiddenAfflictions = false, + bool checkTreatmentThreshold = true, bool checkTreatmentSuggestionThreshold = true, + float predictFutureDuration = 0.0f) { //key = item identifier //float = suitability @@ -1190,7 +1243,14 @@ namespace Barotrauma //if this a suitable treatment, ignore it if the affliction isn't severe enough to treat //if the suitability is negative though, we need to take it into account! //otherwise we may end up e.g. giving too much opiates to someone already close to overdosing - if (totalAfflictionStrength < affliction.Prefab.TreatmentThreshold) { continue; } + if (checkTreatmentThreshold) + { + if (totalAfflictionStrength < affliction.Prefab.TreatmentThreshold) { continue; } + } + if (checkTreatmentSuggestionThreshold) + { + if (totalAfflictionStrength < affliction.Prefab.TreatmentSuggestionThreshold) { continue; } + } } if (treatment.Value > strength) { diff --git a/Barotrauma/BarotraumaShared/SharedSource/Characters/Health/DamageModifier.cs b/Barotrauma/BarotraumaShared/SharedSource/Characters/Health/DamageModifier.cs index dcdd8ddb4..6416787d8 100644 --- a/Barotrauma/BarotraumaShared/SharedSource/Characters/Health/DamageModifier.cs +++ b/Barotrauma/BarotraumaShared/SharedSource/Characters/Health/DamageModifier.cs @@ -53,7 +53,7 @@ namespace Barotrauma private set { rawAfflictionIdentifierString = value; - ParseAfflictionIdentifiers(); + parsedAfflictionIdentifiers = rawAfflictionIdentifierString.ToIdentifiers().ToImmutableArray(); } } @@ -67,7 +67,7 @@ namespace Barotrauma private set { rawAfflictionTypeString = value; - ParseAfflictionTypes(); + parsedAfflictionTypes = rawAfflictionTypeString.ToIdentifiers().ToImmutableArray(); } } @@ -119,30 +119,6 @@ namespace Barotrauma } } - private void ParseAfflictionTypes() - { - if (string.IsNullOrWhiteSpace(rawAfflictionTypeString)) - { - parsedAfflictionTypes = Enumerable.Empty().ToImmutableArray(); - return; - } - - parsedAfflictionTypes = rawAfflictionTypeString.Split(',', ',') - .Select(s => s.Trim()).ToIdentifiers().ToImmutableArray(); - } - - private void ParseAfflictionIdentifiers() - { - if (string.IsNullOrWhiteSpace(rawAfflictionIdentifierString)) - { - parsedAfflictionIdentifiers = Enumerable.Empty().ToImmutableArray(); - return; - } - - parsedAfflictionIdentifiers = rawAfflictionIdentifierString.Split(',', ',') - .Select(s => s.Trim()).ToIdentifiers().ToImmutableArray(); - } - public bool MatchesAfflictionIdentifier(string identifier) => MatchesAfflictionIdentifier(identifier.ToIdentifier()); diff --git a/Barotrauma/BarotraumaShared/SharedSource/Characters/HumanPrefab.cs b/Barotrauma/BarotraumaShared/SharedSource/Characters/HumanPrefab.cs index 925d30006..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; @@ -33,6 +34,12 @@ namespace Barotrauma [Serialize(0, IsPropertySaveable.No)] public int ExperiencePoints { get; private set; } + [Serialize(0, IsPropertySaveable.No)] + public int BaseSalary { get; private set; } + + [Serialize(1f, IsPropertySaveable.No)] + public float SalaryMultiplier { get; private set; } + private readonly HashSet tags = new HashSet(); [Serialize("", IsPropertySaveable.Yes)] @@ -94,6 +101,9 @@ namespace Barotrauma } } + [Serialize(false, IsPropertySaveable.No, description: "If enabled, the NPC will not spawn if the specified spawn point tags can't be found.")] + public bool RequireSpawnPointTag { get; protected set; } + [Serialize(CampaignMode.InteractionType.None, IsPropertySaveable.No)] public CampaignMode.InteractionType CampaignInteractionType { get; protected set; } @@ -242,14 +252,20 @@ 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(); } + characterInfo.Salary = characterInfo.CalculateSalary(BaseSalary, SalaryMultiplier); characterInfo.HumanPrefabIds = (NpcSetIdentifier, Identifier); 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) { @@ -314,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 2dff4a2f4..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; @@ -21,9 +22,9 @@ namespace Barotrauma public Skill PrimarySkill { get; private set; } - public Job(JobPrefab jobPrefab) : this(jobPrefab, randSync: Rand.RandSync.Unsynced, variant: 0) { } + public Job(JobPrefab jobPrefab, bool isPvP) : this(jobPrefab, isPvP, randSync: Rand.RandSync.Unsynced, variant: 0) { } - public Job(JobPrefab jobPrefab, Rand.RandSync randSync, int variant, params Skill[] s) + public Job(JobPrefab jobPrefab, bool isPvP, Rand.RandSync randSync, int variant, params Skill[] s) { prefab = jobPrefab; Variant = variant; @@ -40,7 +41,7 @@ namespace Barotrauma } else { - skill = new Skill(skillPrefab, randSync); + skill = new Skill(skillPrefab, isPvP, randSync); skills.Add(skillPrefab.Identifier, skill); } if (skillPrefab.IsPrimarySkill) { PrimarySkill = skill; } @@ -74,11 +75,11 @@ namespace Barotrauma } } - public static Job Random(Rand.RandSync randSync) + public static Job Random(bool isPvP, Rand.RandSync randSync) { var prefab = JobPrefab.Random(randSync); - var variant = Rand.Range(0, prefab.Variants, randSync); - return new Job(prefab, randSync, variant); + int variant = Rand.Range(0, prefab.Variants, randSync); + return new Job(prefab, isPvP, randSync, variant); } public IEnumerable GetSkills() @@ -127,14 +128,24 @@ 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, WayPoint spawnPoint = null) + public void GiveJobItems(Character character, bool isPvPMode, WayPoint spawnPoint = null) { - if (!prefab.ItemSets.TryGetValue(Variant, out var spawnItems)) { return; } + if (!prefab.JobItems.TryGetValue(Variant, out var spawnItems)) { return; } - foreach (XElement itemElement in spawnItems.GetChildElements("Item")) + foreach (JobPrefab.JobItem jobItem in spawnItems) { - InitializeJobItem(character, itemElement, spawnPoint); + //spawn the "root items" here, InitializeJobItem goes through the children recursively + if (jobItem.ParentItem != null) { continue; } + for (int i = 0; i < jobItem.Amount; i++) + { + InitializeJobItem(character, isPvPMode, jobItem, spawnItems, spawnPoint); + } } if (GameMain.GameSession is { TraitorsEnabled: true } && character.IsSecurity) @@ -144,29 +155,14 @@ namespace Barotrauma } } - private void InitializeJobItem(Character character, XElement itemElement, WayPoint spawnPoint = null, Item parentItem = null) + private void InitializeJobItem(Character character, bool isPvPMode, JobPrefab.JobItem jobItem, IEnumerable allJobItems, WayPoint spawnPoint = null, Item parentItem = null) { - ItemPrefab itemPrefab; - if (itemElement.Attribute("name") != null) + Identifier itemIdentifier = jobItem.GetItemIdentifier(character.TeamID, isPvPMode); + if (itemIdentifier.IsEmpty) { return; } + if ((MapEntityPrefab.FindByIdentifier(itemIdentifier) ?? MapEntityPrefab.FindByName(itemIdentifier.Value)) is not ItemPrefab itemPrefab) { - string itemName = itemElement.Attribute("name").Value; - DebugConsole.ThrowErrorLocalized("Error in Job config (" + Name + ") - use item identifiers instead of names to configure the items."); - itemPrefab = MapEntityPrefab.FindByName(itemName) as ItemPrefab; - if (itemPrefab == null) - { - DebugConsole.ThrowErrorLocalized("Tried to spawn \"" + Name + "\" with the item \"" + itemName + "\". Matching item prefab not found."); - return; - } - } - else - { - string itemIdentifier = itemElement.GetAttributeString("identifier", ""); - itemPrefab = MapEntityPrefab.FindByIdentifier(itemIdentifier.ToIdentifier()) as ItemPrefab; - if (itemPrefab == null) - { - DebugConsole.ThrowErrorLocalized("Tried to spawn \"" + Name + "\" with the item \"" + itemIdentifier + "\". Matching item prefab not found."); - return; - } + DebugConsole.ThrowErrorLocalized($"Tried to spawn \"{Name}\" with the item \"{itemIdentifier}\". Matching item prefab not found."); + return; } Item item = new Item(itemPrefab, character.Position, null); @@ -187,7 +183,7 @@ namespace Barotrauma } #endif - if (itemElement.GetAttributeBool("equip", false)) + if (jobItem.Equip) { //if the item is both pickable and wearable, try to wear it instead of picking it up List allowedSlots = @@ -229,12 +225,18 @@ namespace Barotrauma wifiComponent.TeamID = character.TeamID; } - if (parentItem != null) { parentItem.Combine(item, user: null); } + parentItem?.Combine(item, user: null); - foreach (XElement childItemElement in itemElement.Elements()) + foreach (JobPrefab.JobItem childItem in allJobItems) { - InitializeJobItem(character, childItemElement, spawnPoint, item); - } + if (childItem.ParentItem == jobItem) + { + for (int i = 0; i < childItem.Amount; i++) + { + InitializeJobItem(character, isPvPMode, childItem, allJobItems, spawnPoint, parentItem: item); + } + } + } } public XElement Save(XElement parentElement) diff --git a/Barotrauma/BarotraumaShared/SharedSource/Characters/Jobs/JobPrefab.cs b/Barotrauma/BarotraumaShared/SharedSource/Characters/Jobs/JobPrefab.cs index a01454ba2..eabd43f15 100644 --- a/Barotrauma/BarotraumaShared/SharedSource/Characters/Jobs/JobPrefab.cs +++ b/Barotrauma/BarotraumaShared/SharedSource/Characters/Jobs/JobPrefab.cs @@ -94,21 +94,73 @@ 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 PreviewItem + public class JobItem { - public readonly Identifier ItemIdentifier; - public readonly bool ShowPreview; - - public PreviewItem(Identifier itemIdentifier, bool showPreview) + public enum GameModeType { - ItemIdentifier = itemIdentifier; - ShowPreview = showPreview; + Any, PvP, PvE + } + + public readonly Identifier ItemIdentifier; + public readonly Identifier ItemIdentifierTeam2; + public readonly bool ShowPreview; + public readonly bool Equip; + public readonly bool Outfit; + public readonly int Amount; + public readonly bool Infinite; + + public readonly JobItem ParentItem; + + public readonly GameModeType GameMode; + + public JobItem(ContentXElement element, JobItem parentItem) + { + ItemIdentifier = element.GetAttributeIdentifier("identifier", Identifier.Empty); + ItemIdentifierTeam2 = element.GetAttributeIdentifier("identifierteam2", Identifier.Empty); + 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) + { + case GameModeType.PvP: + if (!isPvPMode) { return Identifier.Empty; } + break; + case GameModeType.PvE: + if (isPvPMode) { return Identifier.Empty; } + break; + } + return + team == CharacterTeamType.Team2 && !ItemIdentifierTeam2.IsEmpty ? + ItemIdentifierTeam2 : + ItemIdentifier; } } - public readonly Dictionary ItemSets = new Dictionary(); - public readonly ImmutableDictionary> PreviewItems; + /// + /// The items the character can get when spawning. The key is the index of the job variant. + /// + public readonly ImmutableDictionary> JobItems; public readonly List Skills = new List(); public readonly List AutonomousObjectives = new List(); public readonly List AppropriateOrders = new List(); @@ -145,6 +197,13 @@ namespace Barotrauma private set; } + [Serialize(10, IsPropertySaveable.No, description: "Determines the order of the characters in the campaign setup ui.")] + public int CampaignSetupUIOrder + { + get; + private set; + } + [Serialize(false, IsPropertySaveable.No, description: "If set to true, a client that has chosen this as their preferred job will get it regardless of the maximum number or the amount of spawnpoints in the sub.")] public bool AllowAlways { @@ -211,7 +270,7 @@ namespace Barotrauma Description = TextManager.Get("JobDescription." + Identifier); Element = element; - var previewItems = new Dictionary>(); + var jobItems = new Dictionary>(); int variant = 0; foreach (var subElement in element.Elements()) @@ -219,9 +278,8 @@ namespace Barotrauma switch (subElement.Name.ToString().ToLowerInvariant()) { case "itemset": - ItemSets.Add(variant, subElement); - previewItems[variant] = new List(); - loadItemIdentifiers(subElement, variant); + jobItems[variant] = new List(); + loadJobItems(subElement, variant, parentItem: null); variant++; break; case "skills": @@ -246,35 +304,39 @@ namespace Barotrauma } } - void loadItemIdentifiers(XElement parentElement, int variant) + void loadJobItems(ContentXElement parentElement, int variant, JobItem parentItem) { - foreach (XElement itemElement in parentElement.GetChildElements("Item")) + foreach (ContentXElement itemElement in parentElement.GetChildElements("Item")) { - if (itemElement.Element("name") != null) + if (itemElement.GetAttribute("name") != null) { - DebugConsole.ThrowErrorLocalized("Error in job config \"" + Name + "\" - use identifiers instead of names to configure the items."); + DebugConsole.ThrowErrorLocalized("Error in job config \"" + Name + "\" - use identifiers instead of names to configure the items.", + contentPackage: parentElement.ContentPackage); continue; } Identifier itemIdentifier = itemElement.GetAttributeIdentifier("identifier", Identifier.Empty); + JobItem jobItem = null; if (itemIdentifier.IsEmpty) { - DebugConsole.ThrowErrorLocalized("Error in job config \"" + Name + "\" - item with no identifier."); + DebugConsole.ThrowErrorLocalized("Error in job config \"" + Name + "\" - item with no identifier.", + contentPackage: parentElement.ContentPackage); } else { - previewItems[variant].Add(new PreviewItem(itemIdentifier, itemElement.GetAttributeBool("showpreview", true))); + jobItem = new JobItem(itemElement, parentItem); + jobItems[variant].Add(jobItem); } - loadItemIdentifiers(itemElement, variant); + loadJobItems(itemElement, variant, parentItem: jobItem); } } - PreviewItems = previewItems.Select(kvp => (kvp.Key, kvp.Value.ToImmutableArray())) + JobItems = jobItems.Select(kvp => (kvp.Key, kvp.Value.ToImmutableArray())) .ToImmutableDictionary(); Variants = variant; - Skills.Sort((x,y) => y.LevelRange.Start.CompareTo(x.LevelRange.Start)); + Skills.Sort((x,y) => y.GetLevelRange(isPvP: false).Start.CompareTo(x.GetLevelRange(isPvP: false).Start)); } public static JobPrefab Random(Rand.RandSync sync, Func predicate = null) => Prefabs.GetRandom(p => !p.HiddenJob && (predicate == null || predicate(p)), sync); diff --git a/Barotrauma/BarotraumaShared/SharedSource/Characters/Jobs/Skill.cs b/Barotrauma/BarotraumaShared/SharedSource/Characters/Jobs/Skill.cs index ff8c4ecaa..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; @@ -40,10 +55,12 @@ namespace Barotrauma public readonly float PriceMultiplier = 1.0f; - public Skill(SkillPrefab prefab, Rand.RandSync randSync) + public Skill(SkillPrefab prefab, bool isPvP, Rand.RandSync randSync) { Identifier = prefab.Identifier; - Level = Rand.Range(prefab.LevelRange.Start, prefab.LevelRange.End, randSync); + + var levelRange = prefab.GetLevelRange(isPvP); + Level = Rand.Range(levelRange.Start, levelRange.End, randSync); iconJobId = GetIconJobId(); PriceMultiplier = prefab.PriceMultiplier; DisplayName = TextManager.Get("SkillName." + Identifier); diff --git a/Barotrauma/BarotraumaShared/SharedSource/Characters/Jobs/SkillPrefab.cs b/Barotrauma/BarotraumaShared/SharedSource/Characters/Jobs/SkillPrefab.cs index 3a8ed2a6f..cf5ba43b3 100644 --- a/Barotrauma/BarotraumaShared/SharedSource/Characters/Jobs/SkillPrefab.cs +++ b/Barotrauma/BarotraumaShared/SharedSource/Characters/Jobs/SkillPrefab.cs @@ -1,5 +1,4 @@ -using Microsoft.Xna.Framework; -using System.Xml.Linq; +using System.Globalization; namespace Barotrauma { @@ -7,7 +6,8 @@ namespace Barotrauma { public readonly Identifier Identifier; - public Range LevelRange { get; private set; } + private readonly Range levelRange; + private readonly Range levelRangePvP; /// /// How much this skill affects characters' hiring cost @@ -19,20 +19,33 @@ namespace Barotrauma public SkillPrefab(ContentXElement element) { Identifier = element.GetAttributeIdentifier("identifier", ""); - PriceMultiplier = element.GetAttributeFloat("pricemultiplier", 25.0f); - var levelString = element.GetAttributeString("level", ""); - if (levelString.Contains(",")) - { - var rangeVector2 = XMLExtensions.ParseVector2(levelString, false); - LevelRange = new Range(rangeVector2.X, rangeVector2.Y); - } - else - { - float skillLevel = float.Parse(levelString, System.Globalization.CultureInfo.InvariantCulture); - LevelRange = new Range(skillLevel, skillLevel); - } - + PriceMultiplier = element.GetAttributeFloat("pricemultiplier", 15.0f); + levelRange = GetSkillRange("level", element, defaultValue: new Range(0, 0)); + levelRangePvP = GetSkillRange("pvplevel", element, defaultValue: levelRange); IsPrimarySkill = element.GetAttributeBool("primary", false); + + static Range GetSkillRange(string attributeName, ContentXElement element, Range defaultValue) + { + string levelString = element.GetAttributeString(attributeName, string.Empty); + if (levelString.Contains(',')) + { + var rangeVector2 = XMLExtensions.ParseVector2(levelString, false); + return new Range(rangeVector2.X, rangeVector2.Y); + } + else if (float.TryParse(levelString, NumberStyles.Any, CultureInfo.InvariantCulture, out float skillLevel)) + { + return new Range(skillLevel, skillLevel); + } + else + { + return defaultValue; + } + } + } + + public Range GetLevelRange(bool isPvP) + { + return isPvP ? levelRangePvP : levelRange; } } } diff --git a/Barotrauma/BarotraumaShared/SharedSource/Characters/Limb.cs b/Barotrauma/BarotraumaShared/SharedSource/Characters/Limb.cs index b6de960f5..1e9755e5a 100644 --- a/Barotrauma/BarotraumaShared/SharedSource/Characters/Limb.cs +++ b/Barotrauma/BarotraumaShared/SharedSource/Characters/Limb.cs @@ -213,7 +213,9 @@ namespace Barotrauma public readonly Ragdoll ragdoll; public readonly LimbParams Params; - //the physics body of the limb + /// + /// The physics body of the limb + /// public PhysicsBody body; public Vector2 StepOffset => ConvertUnits.ToSimUnits(Params.StepOffset) * ragdoll.RagdollParams.JointScale; @@ -258,10 +260,7 @@ namespace Barotrauma { get { - if (!mouthPos.HasValue) - { - mouthPos = Params.MouthPos; - } + mouthPos ??= Params.MouthPos; return mouthPos.Value; } set @@ -366,7 +365,7 @@ namespace Barotrauma if (isSevered) { ragdoll.SubtractMass(this); - if (type == LimbType.Head) + if (type == LimbType.Head && character.Params.Health.DieFromBeheading) { character.Kill(CauseOfDeathType.Unknown, null); } @@ -386,10 +385,18 @@ namespace Barotrauma public Submarine Submarine => character?.Submarine; + private bool _hidden; public bool Hidden { - get => Params.Hide; - set => Params.Hide = value; + get => _hidden || Params.Hide; + set => _hidden = value; + } + + // Just a wrapper for Hidden, but both can be used via status effects, so it's not safe to remove it. + public bool Hide + { + get => Hidden; + set => Hidden = value; } public Vector2 WorldPosition @@ -523,6 +530,9 @@ namespace Barotrauma public readonly List WearingItems = new List(); + /// + /// Other wearables attached to the head. I.e. husk sprite, hair, beard, moustache, and face attachments. + /// public readonly List OtherWearables = new List(); public bool PullJointEnabled @@ -636,7 +646,7 @@ namespace Barotrauma //if (character.Params.CanInteract) { return false; } if (this == character.AnimController.MainLimb) { return false; } bool canBeSevered = Params.CanBeSeveredAlive; - if (character.AnimController.CanWalk) + if (character.AnimController.CanWalk && !character.Params.Health.AllowSeveringLegs) { switch (type) { @@ -671,7 +681,7 @@ namespace Barotrauma this.character = character; this.Params = limbParams; dir = Direction.Right; - body = new PhysicsBody(limbParams); + body = new PhysicsBody(limbParams, findNewContacts: false); type = limbParams.Type; IgnoreCollisions = limbParams.IgnoreCollisions; body.UserData = this; @@ -716,7 +726,7 @@ namespace Barotrauma var attackElement = character.Params.VariantFile.GetRootExcludingOverride().GetChildElement("attack"); if (attackElement != null) { - attack.DamageMultiplier = attackElement.GetAttributeFloat("damagemultiplier", 1f); + attack.SetInitialDamageMultiplier(attackElement.GetAttributeFloat("damagemultiplier", 1f)); attack.RangeMultiplier = attackElement.GetAttributeFloat("rangemultiplier", 1f); attack.ImpactMultiplier = attackElement.GetAttributeFloat("impactmultiplier", 1f); } @@ -937,7 +947,7 @@ namespace Barotrauma severedFadeOutTimer = SeveredFadeOutTime; } } - else if (!IsDead) + else if (!IsDead && (character.IsPlayer || character.AIState is not AIState.PlayDead)) { if (Params.BlinkFrequency > 0) { @@ -989,6 +999,7 @@ namespace Barotrauma public void ReEnable() { if (!temporarilyDisabled) { return; } + temporarilyDisabled = false; Hidden = false; Disabled = false; IgnoreCollisions = originalIgnoreCollisions; @@ -1008,6 +1019,7 @@ namespace Barotrauma float dist = distance > -1 ? distance : ConvertUnits.ToDisplayUnits(Vector2.Distance(simPos, attackSimPos)); bool wasRunning = attack.IsRunning; attack.UpdateAttackTimer(deltaTime, character); + if (attack.Blink) { if (attack.ForceOnLimbIndices != null && attack.ForceOnLimbIndices.Any()) @@ -1156,7 +1168,7 @@ namespace Barotrauma // Set the main collider where the body lands after the attack if (Vector2.DistanceSquared(character.AnimController.Collider.SimPosition, character.AnimController.MainLimb.body.SimPosition) > 0.1f * 0.1f) { - character.AnimController.Collider.SetTransform(character.AnimController.MainLimb.body.SimPosition, rotation: character.AnimController.Collider.Rotation); + character.AnimController.Collider.SetTransformIgnoreContacts(character.AnimController.MainLimb.body.SimPosition, rotation: character.AnimController.Collider.Rotation); } } return wasHit; @@ -1172,9 +1184,11 @@ namespace Barotrauma LastAttackSoundTime = SoundInterval; } #endif - if (damageTarget is Character targetCharacter && targetLimb != null) - { - attackResult = attack.DoDamageToLimb(character, targetLimb, WorldPosition, 1.0f, playSound, body, this); + attack.ResetDamageMultiplier(); + attack.DamageMultiplier *= 1.0f + character.GetStatValue(attack.Ranged ? StatTypes.NaturalRangedAttackMultiplier : StatTypes.NaturalMeleeAttackMultiplier); + if (damageTarget is Character && targetLimb != null) + { + attackResult = attack.DoDamageToLimb(character, targetLimb, WorldPosition, deltaTime: 1.0f, playSound, body, sourceLimb: this); } else { @@ -1184,7 +1198,7 @@ namespace Barotrauma } else { - attackResult = attack.DoDamage(character, damageTarget, WorldPosition, 1.0f, playSound, body, this); + attackResult = attack.DoDamage(character, damageTarget, WorldPosition, deltaTime: 1.0f, playSound, body, sourceLimb: this); } } /*if (structureBody != null && attack.StickChance > Rand.Range(0.0f, 1.0f, Rand.RandSync.ServerAndClient)) @@ -1434,6 +1448,7 @@ namespace Barotrauma public void Remove() { + ragdoll.SubtractMass(this); body?.Remove(); body = null; if (pullJoint != null) diff --git a/Barotrauma/BarotraumaShared/SharedSource/Characters/Params/Animation/AnimationParams.cs b/Barotrauma/BarotraumaShared/SharedSource/Characters/Params/Animation/AnimationParams.cs index 83f685fbd..9954efb09 100644 --- a/Barotrauma/BarotraumaShared/SharedSource/Characters/Params/Animation/AnimationParams.cs +++ b/Barotrauma/BarotraumaShared/SharedSource/Characters/Params/Animation/AnimationParams.cs @@ -44,9 +44,6 @@ namespace Barotrauma [Serialize(0f, IsPropertySaveable.Yes, description: "How much the body raises when taking a step."), Editable(MinValueFloat = 0, MaxValueFloat = 100, ValueStep = 0.1f)] public float StepLiftAmount { get; set; } - [Serialize(true, IsPropertySaveable.Yes), Editable] - public bool MultiplyByDir { get; set; } - [Serialize(0.5f, IsPropertySaveable.Yes, description: "When does the body raise when taking a step. The default (0.5) is in the middle of the step."), Editable(MinValueFloat = -1, MaxValueFloat = 1, DecimalCount = 2, ValueStep = 0.1f)] public float StepLiftOffset { get; set; } @@ -56,6 +53,48 @@ namespace Barotrauma [Header("Movement")] [Serialize(0.75f, IsPropertySaveable.Yes, description: "The character's movement speed is multiplied with this value when moving backwards."), Editable(MinValueFloat = 0.1f, MaxValueFloat = 0.99f, DecimalCount = 2)] public float BackwardsMovementMultiplier { get; set; } + + [Serialize(1.0f, IsPropertySaveable.Yes, description: "Adjusts the maximum speed while climbing. The actual speed is affected by the MovementSpeed."), Editable(MinValueFloat = 0.1f, MaxValueFloat = 10f, DecimalCount = 2)] + public float ClimbSpeed { get; set; } + + [Serialize(2.0f, IsPropertySaveable.Yes, description: "Used instead of ClimbSpeed when descending ladders while moving fast (running). Not used if lower than ClimbSpeed."), Editable(MinValueFloat = 0.1f, MaxValueFloat = 10f, DecimalCount = 2)] + public float SlideSpeed { get; set; } + + [Serialize(10.5f, IsPropertySaveable.Yes, description: "Force applied to the main collider, torso and head, when climbing ladders."), Editable(MinValueFloat = 0.1f, MaxValueFloat = 100f, DecimalCount = 1)] + public float ClimbBodyMoveForce { get; set; } + + [Serialize(5.2f, IsPropertySaveable.Yes, description: "Force applied to the hands when climbing ladders."), Editable(MinValueFloat = 0.1f, MaxValueFloat = 100f, DecimalCount = 1)] + public float ClimbHandMoveForce { get; set; } + + [Serialize(10.0f, IsPropertySaveable.Yes, description: "Force applied to the feet when climbing ladders."), Editable(MinValueFloat = 0.1f, MaxValueFloat = 100f, DecimalCount = 1)] + public float ClimbFootMoveForce { get; set; } + + [Serialize(30.0f, IsPropertySaveable.Yes), Editable(MinValueFloat = 0.1f, MaxValueFloat = 100f, DecimalCount = 1)] + public float ClimbStepHeight { get; set; } + + protected override bool Deserialize(XElement element = null) + { + if (element.GetAttributeEnum(nameof(AnimationType), AnimationType.NotDefined) is AnimationType.Run) + { + // These values were previously hard-coded when running, so we need to set different default values for the run animations, when they are not defined. + const string climbSpeedName = nameof(ClimbSpeed); + if (element.GetAttribute(climbSpeedName) == null) + { + element.SetAttribute(climbSpeedName, 2.0f); + } + const string climbStepName = nameof(ClimbStepHeight); + if (element.GetAttribute(climbStepName) == null) + { + element.SetAttribute(climbStepName, 60.0f); + } + const string slideSpeedName = nameof(SlideSpeed); + if (element.GetAttribute(slideSpeedName) == null) + { + element.SetAttribute(slideSpeedName, 4.0f); + } + } + return base.Deserialize(element); + } } abstract class SwimParams : AnimationParams @@ -92,7 +131,7 @@ namespace Barotrauma /// /// In degrees. /// - [Header("Standing")] + [Header("Orientation")] [Serialize(float.NaN, IsPropertySaveable.Yes), Editable(-360f, 360f)] public float HeadAngle { @@ -143,14 +182,14 @@ 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) { CharacterPrefab prefab = CharacterPrefab.FindBySpeciesName(speciesName); if (prefab?.ConfigElement == null) { - DebugConsole.ThrowError($"Failed to find config file for '{speciesName}'"); + DebugConsole.ThrowError($"Failed to find config file for '{speciesName}'", contentPackage: prefab?.ContentPackage); return string.Empty; } return GetFolder(prefab.ConfigElement, prefab.FilePath.Value); @@ -275,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()) { @@ -375,7 +416,7 @@ namespace Barotrauma { if (animationType == AnimationType.NotDefined) { - throw new Exception("Cannot create an animation file of type " + animationType.ToString()); + throw new Exception("Cannot create an animation file of type " + animationType); } if (!allAnimations.TryGetValue(speciesName, out Dictionary anims)) { @@ -504,7 +545,7 @@ namespace Barotrauma { if (doc == null) { - DebugConsole.ThrowError("[AnimationParams] The source XML Document is null!"); + DebugConsole.ThrowError("[AnimationParams] The source XML Document is null!", contentPackage: Path.ContentPackage); return; } Serialize(); diff --git a/Barotrauma/BarotraumaShared/SharedSource/Characters/Params/CharacterParams.cs b/Barotrauma/BarotraumaShared/SharedSource/Characters/Params/CharacterParams.cs index 7a8544a75..fbef6a4f5 100644 --- a/Barotrauma/BarotraumaShared/SharedSource/Characters/Params/CharacterParams.cs +++ b/Barotrauma/BarotraumaShared/SharedSource/Characters/Params/CharacterParams.cs @@ -19,6 +19,16 @@ namespace Barotrauma { [Serialize("", IsPropertySaveable.Yes), Editable] public Identifier SpeciesName { get; private set; } + + [Serialize("", IsPropertySaveable.Yes), Editable] + public string Tags + { + get => tags.ConvertToString(); + set => tags = value.ToIdentifiers().ToHashSet(); + } + private HashSet tags = new HashSet(); + + public bool HasTag(Identifier tag) => tags.Contains(tag); [Serialize("", IsPropertySaveable.Yes, description: "References to another species. Define only if the creature is a variant that needs to use a pre-existing translation."), Editable] public Identifier SpeciesTranslationOverride { get; private set; } @@ -37,9 +47,21 @@ namespace Barotrauma [Serialize(false, IsPropertySaveable.Yes, description: "Can the creature interact with items?"), Editable] public bool CanInteract { get; private set; } + + [Serialize(true, IsPropertySaveable.Yes, description: "Can the creature use ladders? Doesn't have an effect, if CanInteract is false."), Editable] + public bool CanClimb { get; private set; } + + [Serialize(false, IsPropertySaveable.Yes, description: "If set true, this character only uses the climbing parameters defined in the walk parameters (not run)."), Editable] + public bool ForceSlowClimbing { get; private set; } [Serialize(false, IsPropertySaveable.Yes, description: "Should this character be treated as a husk?"), Editable] public bool Husk { get; private set; } + + [Serialize("", IsPropertySaveable.Yes, description: "If this character can turn into a husk, which character it turns to? If not defined, uses the default pattern (e.g. Crawler -> Crawlerhusk, Human -> Humanhusk)."), Editable] + public Identifier HuskedSpecies { get; private set; } + + [Serialize("", IsPropertySaveable.Yes, description: "If this character is a husk, from what species it can be turned into? If not defined, uses the default pattern (e.g. Crawlerhusk -> Crawler, Humanhusk -> Human)."), Editable] + public Identifier NonHuskedSpecies { get; private set; } [Serialize(false, IsPropertySaveable.Yes, description:"Should this character use a special husk appendage, attached to the ragdoll, when it turns into a husk?"), Editable] public bool UseHuskAppendage { get; private set; } @@ -125,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; } @@ -161,7 +193,7 @@ namespace Barotrauma } } - public static XElement CreateVariantXml(XElement variantXML, XElement baseXML) + public static XElement CreateVariantXml(ContentXElement variantXML, ContentXElement baseXML) { XElement newXml = variantXML.CreateVariantXML(baseXML); XElement variantAi = variantXML.GetChildElement("ai"); @@ -171,25 +203,32 @@ namespace Barotrauma { return newXml; } - // CreateVariantXML seems to merge the ai targets so that in the new xml we have both the old and the new target definitions. + + // CreateVariantXML does not understand anything about targeting tags, it just replaces the elements in the order they're defined in. + // We can do better here by replacing the target with a matching tag, so let's clear the element and do that. var finalAiElement = newXml.GetChildElement("ai"); - var processedTags = new HashSet(); - foreach (var aiTarget in finalAiElement.Elements().ToArray()) + finalAiElement.Elements().Remove(); + + //add all the targets from the base character + baseAi.Elements().ForEach(e => finalAiElement.Add(e)); + + var processedTags = new List(); + foreach (var variantTargetElement in variantAi.Elements()) { - string tag = aiTarget.GetAttributeString("tag", null); - if (tag == null) { continue; } - if (processedTags.Contains(tag)) + Identifier tag = variantTargetElement.GetAttributeIdentifier("tag", Identifier.Empty); + var matchingElements = finalAiElement.Elements().Where(e => e.GetAttributeIdentifier("tag", Identifier.Empty) == tag); + int alreadyProcessed = processedTags.Count(t => t == tag); + if (matchingElements.Count() > alreadyProcessed) { - aiTarget.Remove(); - continue; + //more matching elements found, replace the first one that hasn't been processed yet + matchingElements.Skip(alreadyProcessed).First().ReplaceWith(variantTargetElement); + } + else + { + //no more matching elements in the base XML, this must be a new target + finalAiElement.Add(variantTargetElement); } processedTags.Add(tag); - var matchInSelf = variantAi.Elements().FirstOrDefault(e => e.GetAttributeString("tag", null) == tag); - var matchInParent = baseAi.Elements().FirstOrDefault(e => e.GetAttributeString("tag", null) == tag); - if (matchInSelf != null && matchInParent != null) - { - aiTarget.ReplaceWith(new XElement(matchInSelf)); - } } return newXml; } @@ -433,17 +472,11 @@ namespace Barotrauma [Serialize("", IsPropertySaveable.Yes, description: "Which tags are required for this sound to play?"), Editable()] public string Tags { - get { return string.Join(',', TagSet); } - private set - { - TagSet = value.Split(',') - .ToIdentifiers() - .Where(id => !id.IsEmpty) - .ToImmutableHashSet(); - } + get => TagSet.ConvertToString(); + private set => TagSet = value.ToIdentifiers().ToImmutableHashSet(); } - public ImmutableHashSet TagSet { get; private set; } + public ImmutableHashSet TagSet { get; private set; } = ImmutableHashSet.Empty; public SoundParams(ContentXElement element, CharacterParams character) : base(element, character) { @@ -549,6 +582,15 @@ namespace Barotrauma [Serialize(0f, IsPropertySaveable.Yes), Editable] public float EmpVulnerability { get; set; } + + [Serialize(true, IsPropertySaveable.Yes, description: "Apply movement penalties when legs or tail limbs get damaged. Enabled by default."), Editable] + public bool ApplyMovementPenalties { get; set; } + + [Serialize(true, IsPropertySaveable.Yes, description: "Normally characters die when they don't have a head. But maybe not all of them?"), Editable] + public bool DieFromBeheading { get; set; } + + [Serialize(false, IsPropertySaveable.Yes, description: "Severing legs doesn't work with most characters, because we'd need to take that into account with the walking animations and the standing position of the main collider etc. But there might be cases where you'll want to override this default."), Editable] + public bool AllowSeveringLegs { get; set; } [Serialize(false, IsPropertySaveable.Yes, description: "Can afflictions affect the face/body tint of the character."), Editable] public bool ApplyAfflictionColors { get; private set; } @@ -719,34 +761,50 @@ namespace Barotrauma [Serialize(WallTargetingMethod.Target, IsPropertySaveable.Yes, description: "Defines the method of checking whether there's a blocking (submarine) wall."), Editable] public WallTargetingMethod WallTargetingMethod { get; private set; } + + [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; - protected readonly List targets = new List(); + private readonly List targets = new List(); public AIParams(ContentXElement element, CharacterParams character) : base(element, character) { if (element == null) { return; } - element.GetChildElements("target").ForEach(t => TryAddTarget(t, out _)); - element.GetChildElements("targetpriority").ForEach(t => TryAddTarget(t, out _)); + element.GetChildElements("target").ForEach(t => AddTarget(t)); + element.GetChildElements("targetpriority").ForEach(t => AddTarget(t)); + IsPet = element.GetChildElement("petbehavior") != null; } + /// + /// Adds a target but checks for duplicates first. Doesn't allow adding multiple targets with the same tag (see ). + /// private bool TryAddTarget(ContentXElement targetElement, out TargetParams target) { string tag = targetElement.GetAttributeString("tag", null); if (HasTag(tag)) { target = null; - DebugConsole.AddWarning($"Trying to add multiple targets with the same tag ('{tag}') defined! Only the first will be used!", - targetElement.ContentPackage); - return false; + DebugConsole.AddWarning($"Trying to add multiple targets with the same tag ('{tag}') defined! Only the first will be used!", targetElement.ContentPackage); } else { - target = new TargetParams(targetElement, Character); - targets.Add(target); - SubParams.Add(target); - return true; + target = AddTarget(targetElement); } + return target != null; + } + + /// + /// This method allows adding multiple targets with the same tag. + /// + private TargetParams AddTarget(ContentXElement targetElement) + { + var target = new TargetParams(targetElement, Character); + targets.Add(target); + SubParams.Add(target); + return target; } public bool TryAddEmptyTarget(out TargetParams targetParams) => TryAddNewTarget("newtarget" + targets.Count, AIState.Attack, 0f, out targetParams); @@ -782,26 +840,40 @@ namespace Barotrauma } public bool RemoveTarget(TargetParams target) => RemoveSubParam(target, targets); - - public bool TryGetTarget(string targetTag, out TargetParams target) - => TryGetTarget(targetTag.ToIdentifier(), out target); - public bool TryGetTarget(Identifier targetTag, out TargetParams target) + public IEnumerable GetMatchingTargets(Func predicate) => targets.Where(predicate); + public IEnumerable GetTargets(Identifier target) => GetMatchingTargets(t => t.Tag == target); + public IEnumerable GetTargets(Character target) => GetMatchingTargets(t => t.Tag == target.SpeciesName || t.Tag == target.Params.Group || target.Params.HasTag(t.Tag)); + public TargetParams GetHighestPriorityTarget(Identifier target) => GetHighestPriorityTarget(GetTargets(target)); + public TargetParams GetHighestPriorityTarget(Character target) => GetHighestPriorityTarget(GetTargets(target)); + + private static TargetParams GetHighestPriorityTarget(IEnumerable targetParams) => targetParams.MaxBy(static t => t.Priority); + + public bool TryGetTargets(Identifier target, out IEnumerable targetParams) { - target = targets.FirstOrDefault(t => t.Tag == targetTag); - return target != null; + targetParams = GetTargets(target); + return targetParams.Any(); + } + + public bool TryGetTargets(Character target, out IEnumerable targetParams) + { + targetParams = GetTargets(target); + return targetParams.Any(); + } + + public bool TryGetHighestPriorityTarget(Identifier target, out TargetParams targetParams) + { + targetParams = GetHighestPriorityTarget(target); + return targetParams != null; + } + + public bool TryGetHighestPriorityTarget(Character target, out TargetParams targetParams) + { + targetParams = GetHighestPriorityTarget(target); + return targetParams != null; } - public bool TryGetTarget(Character targetCharacter, out TargetParams target) - { - if (!TryGetTarget(targetCharacter.SpeciesName, out target)) - { - target = targets.FirstOrDefault(t => t.Tag == targetCharacter.Params.Group); - } - return target != null; - } - - public bool TryGetTarget(IEnumerable tags, out TargetParams target) + public bool TryGetHighestPriorityTarget(IEnumerable tags, out TargetParams target) { target = null; if (tags == null || tags.None()) { return false; } @@ -819,22 +891,6 @@ namespace Barotrauma } return target != null; } - - public TargetParams GetTarget(string targetTag, bool throwError = true) - => GetTarget(targetTag.ToIdentifier(), throwError); - - public TargetParams GetTarget(Identifier targetTag, bool throwError = true) - { - if (targetTag.IsEmpty) { return null; } - if (!TryGetTarget(targetTag, out TargetParams target)) - { - if (throwError) - { - DebugConsole.ThrowError($"Cannot find a target with the tag {targetTag}!"); - } - } - return target; - } } public class TargetParams : SubParam @@ -889,7 +945,7 @@ namespace Barotrauma [Serialize(-1f, IsPropertySaveable.Yes, description: "A generic max threshold. Not used if set to negative."), Editable] public float ThresholdMax { get; private set; } - [Serialize(1.0f, IsPropertySaveable.Yes, description: "Can be used to make the monster perceive the target further than it normally can."), Editable] + [Serialize(1.0f, IsPropertySaveable.Yes, description: "Can be used to make the monster perceive the target further or closer than it normally can."), Editable] public float PerceptionDistanceMultiplier { get; private set; } [Serialize(-1.0f, IsPropertySaveable.Yes, description: "Maximum distance at which the monster can perceive the target, regardless of the sight/hearing or how visible or how much noise the target is making. Not used if set to negative."), Editable] diff --git a/Barotrauma/BarotraumaShared/SharedSource/Characters/Params/Ragdoll/RagdollParams.cs b/Barotrauma/BarotraumaShared/SharedSource/Characters/Params/Ragdoll/RagdollParams.cs index e19fb68a5..f6094f8e0 100644 --- a/Barotrauma/BarotraumaShared/SharedSource/Characters/Params/Ragdoll/RagdollParams.cs +++ b/Barotrauma/BarotraumaShared/SharedSource/Characters/Params/Ragdoll/RagdollParams.cs @@ -113,7 +113,7 @@ namespace Barotrauma [Serialize(true, IsPropertySaveable.Yes), Editable] public bool CanWalk { get; set; } - + [Serialize(true, IsPropertySaveable.Yes, description: "Can the character be dragged around by other creatures?"), Editable()] public bool Draggable { get; set; } @@ -137,15 +137,14 @@ namespace Barotrauma .Concat(Joints); public static string GetDefaultFileName(Identifier speciesName) => $"{speciesName.Value.CapitaliseFirstInvariant()}DefaultRagdoll"; - public static string GetDefaultFile(Identifier speciesName, ContentPackage contentPackage = null) - => IO.Path.Combine(GetFolder(speciesName, contentPackage), $"{GetDefaultFileName(speciesName)}.xml"); - - public static string GetFolder(Identifier speciesName, ContentPackage contentPackage = null) + public static string GetDefaultFile(Identifier speciesName) => IO.Path.Combine(GetFolder(speciesName), $"{GetDefaultFileName(speciesName)}.xml"); + + public static string GetFolder(Identifier speciesName) { - CharacterPrefab prefab = CharacterPrefab.Find(p => p.Identifier == speciesName && (contentPackage == null || p.ContentFile.ContentPackage == contentPackage)); + CharacterPrefab prefab = CharacterPrefab.FindBySpeciesName(speciesName); if (prefab?.ConfigElement == null) { - DebugConsole.ThrowError($"Failed to find config file for '{speciesName}'", contentPackage: contentPackage); + DebugConsole.ThrowError($"Failed to find config file for '{speciesName}'"); return string.Empty; } return GetFolder(prefab.ConfigElement, prefab.ContentFile.Path.Value); @@ -199,10 +198,10 @@ namespace Barotrauma } } } - else if (!variantOf.IsEmpty && CharacterPrefab.FindBySpeciesName(variantOf) is CharacterPrefab prefab) + else if (!variantOf.IsEmpty && CharacterPrefab.FindBySpeciesName(variantOf) is CharacterPrefab parentPrefab) { - // Ragdoll element not defined -> use the ragdoll defined in the base definition file. - ragdollSpecies = prefab.GetBaseCharacterSpeciesName(variantOf); + //get the params from the parent prefab if this one doesn't re-define them + return GetDefaultRagdollParams(variantOf, parentPrefab.ConfigElement, parentPrefab.ContentPackage); } // Using a null file definition means we use the default animations found in the Ragdolls folder. return GetRagdollParams(speciesName, ragdollSpecies, file: null, contentPackage); @@ -245,7 +244,7 @@ namespace Barotrauma } else { - DebugConsole.ThrowError($"[AnimationParams] Failed to load an animation {ragdollInstance} from {contentPath.Value} for the character {speciesName}. Using the default ragdoll.", contentPackage: contentPackage); + DebugConsole.ThrowError($"[RagdollParams] Failed to load a ragdoll {ragdollInstance} from {contentPath.Value} for the character {speciesName}. Using the default ragdoll.", contentPackage: contentPackage); } } // Seek the default ragdoll from the character's ragdoll folder. @@ -294,8 +293,30 @@ namespace Barotrauma } else { - // Failing to create a ragdoll causes so many issues that cannot be handled. Dummy ragdoll just seems to make things harder to debug. It's better to fail early. - throw new Exception($"[RagdollParams] Failed to load ragdoll {r.Name} from {selectedFile} for the character {speciesName}."); + string error = $"[RagdollParams] Failed to load ragdoll {r.Name} from {selectedFile} for the character {speciesName}."; + if (contentPackage == GameMain.VanillaContent) + { + // Check if the base character content package is vanilla too. + CharacterPrefab characterPrefab = CharacterPrefab.FindBySpeciesName(speciesName); + if (characterPrefab?.ParentPrefab == null || characterPrefab.ParentPrefab.ContentPackage == GameMain.VanillaContent) + { + // If the error is in the vanilla content, it's just better to crash early. + // If dodging with the solution below fails, we'll also get here. + throw new Exception(error); + } + } + // Try to dodge crashing on modded content. + DebugConsole.ThrowError(error, contentPackage: contentPackage); + if (typeof(T) == typeof(HumanRagdollParams)) + { + Identifier fallbackSpecies = CharacterPrefab.HumanSpeciesName; + r = GetRagdollParams(fallbackSpecies, fallbackSpecies, file: ContentPath.FromRaw(contentPackage, "Content/Characters/Human/Ragdolls/HumanDefaultRagdoll.xml"), contentPackage: GameMain.VanillaContent); + } + else + { + Identifier fallbackSpecies = "crawler".ToIdentifier(); + r = GetRagdollParams(fallbackSpecies, fallbackSpecies, file: ContentPath.FromRaw(contentPackage, "Content/Characters/Crawler/Ragdolls/CrawlerDefaultRagdoll.xml"), contentPackage: GameMain.VanillaContent); + } } return r; } @@ -654,7 +675,7 @@ namespace Barotrauma [Serialize(0.25f, IsPropertySaveable.Yes), Editable] public float Stiffness { get; set; } - [Serialize(1f, IsPropertySaveable.Yes, description: "CAUTION: Not fully implemented. Only use for limb joints that connect non-animated limbs!"), Editable] + [Serialize(1f, IsPropertySaveable.Yes, description: "CAUTION: Not fully implemented. Only use for limb joints that connect non-animated limbs!"), Editable(DecimalCount = 2)] public float Scale { get; set; } [Serialize(false, IsPropertySaveable.No), Editable(ReadOnly = true)] @@ -705,6 +726,9 @@ namespace Barotrauma [Serialize(LimbType.None, IsPropertySaveable.Yes, description: "The limb type affects many things, like the animations. Torso or Head are considered as the main limbs. Every character should have at least one Torso or Head."), Editable()] public LimbType Type { get; set; } + + [Serialize(LimbType.None, IsPropertySaveable.Yes, description: "Secondary limb type to be used for generic purposes. Currently only used in climbing animations."), Editable()] + public LimbType SecondaryType { get; set; } /// /// The orientation of the sprite as drawn on the sprite sheet (in radians). @@ -775,6 +799,12 @@ namespace Barotrauma [Serialize("0, 0", IsPropertySaveable.Yes, description: "Relative offset for the mouth position (starting from the center). Only applicable for LimbType.Head. Used for eating."), Editable(DecimalCount = 2, MinValueFloat = -10f, MaxValueFloat = 10f)] public Vector2 MouthPos { get; set; } + + [Serialize(50f, IsPropertySaveable.Yes, description: "How much torque is applied on the head while updating the eating animations?"), Editable] + public float EatTorque { get; set; } + + [Serialize(2f, IsPropertySaveable.Yes, description: "How strong a linear impulse is applied on the head while updating the eating animations?"), Editable] + public float EatImpulse { get; set; } [Serialize(0f, IsPropertySaveable.Yes), Editable] public float ConstantTorque { get; set; } @@ -795,8 +825,11 @@ namespace Barotrauma [Serialize(10f, IsPropertySaveable.Yes, "How long it takes for the severed limb to fade out"), Editable(MinValueFloat = 0, MaxValueFloat = 100, ValueStep = 1)] public float SeveredFadeOutTime { get; set; } = 10.0f; - [Serialize(false, IsPropertySaveable.Yes, description: "Only applied when the limb is of type Tail. If none of the tails have been defined to use the angle and an angle is defined in the animation parameters, the first tail limb is used."), Editable] + [Serialize(false, IsPropertySaveable.Yes, description: "Should the tail angle be applied on this limb? If none of the limbs have been defined to use the angle and an angle is defined in the animation parameters, the first tail limb is used."), Editable] public bool ApplyTailAngle { get; set; } + + [Serialize(false, IsPropertySaveable.Yes, description: "Should this limb be moved like a tail when swimming? Always true for tail limbs. On tails, disable by setting SineFrequencyMultiplier to 0."), Editable] + public bool ApplySineMovement { get; set; } [Serialize(1f, IsPropertySaveable.Yes), Editable(ValueStep = 0.1f, DecimalCount = 2)] public float SineFrequencyMultiplier { get; set; } @@ -857,6 +890,9 @@ namespace Barotrauma [Serialize(true, IsPropertySaveable.Yes, description: "Can the limb enter submarines? Only valid if the ragdoll's CanEnterSubmarine is set to Partial, otherwise the limb can enter if the ragdoll can."), Editable] public bool CanEnterSubmarine { get; private set; } + [Serialize(LimbType.None, IsPropertySaveable.Yes, description: "When set to something else than None, this limb will be hidden if the limb of the specified type is hidden."), Editable] + public LimbType InheritHiding { get; set; } + public LimbParams(ContentXElement element, RagdollParams ragdoll) : base(element, ragdoll) { var spriteElement = element.GetChildElement("sprite"); 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/AbilityConditionals/AbilityConditionData/AbilityConditionMission.cs b/Barotrauma/BarotraumaShared/SharedSource/Characters/Talents/Abilities/AbilityConditionals/AbilityConditionData/AbilityConditionMission.cs index 71ae4f7dd..c374bfe75 100644 --- a/Barotrauma/BarotraumaShared/SharedSource/Characters/Talents/Abilities/AbilityConditionals/AbilityConditionData/AbilityConditionMission.cs +++ b/Barotrauma/BarotraumaShared/SharedSource/Characters/Talents/Abilities/AbilityConditionals/AbilityConditionData/AbilityConditionMission.cs @@ -1,5 +1,4 @@ using System; -using System.Collections.Generic; using System.Collections.Immutable; using System.Linq; @@ -7,31 +6,13 @@ namespace Barotrauma.Abilities { class AbilityConditionMission : AbilityConditionData { - private readonly ImmutableHashSet missionType; + private readonly ImmutableHashSet missionType; private readonly bool isAffiliated; public AbilityConditionMission(CharacterTalent characterTalent, ContentXElement conditionElement) : base(characterTalent, conditionElement) { - string[] missionTypeStrings = conditionElement.GetAttributeStringArray("missiontype", new []{ "None" })!; - HashSet missionTypes = new HashSet(); + missionType = conditionElement.GetAttributeIdentifierImmutableHashSet("missiontype", ImmutableHashSet.Empty)!; isAffiliated = conditionElement.GetAttributeBool("isaffiliated", false); - - foreach (string missionTypeString in missionTypeStrings) - { - if (!Enum.TryParse(missionTypeString, out MissionType parsedMission) || parsedMission is MissionType.None) - { - if (!isAffiliated) - { - DebugConsole.ThrowError($"Error in AbilityConditionMission \"{characterTalent.DebugIdentifier}\" - \"{missionTypeString}\" is not a valid mission type.", - contentPackage: conditionElement.ContentPackage); - } - continue; - } - - missionTypes.Add(parsedMission); - } - - missionType = missionTypes.ToImmutableHashSet(); } protected override bool MatchesConditionSpecific(AbilityObject abilityObject) diff --git a/Barotrauma/BarotraumaShared/SharedSource/Characters/Talents/Abilities/AbilityConditionals/AbilityConditionDataless/AbilityConditionCrewMemberUnconscious.cs b/Barotrauma/BarotraumaShared/SharedSource/Characters/Talents/Abilities/AbilityConditionals/AbilityConditionDataless/AbilityConditionCrewMemberUnconscious.cs index ede7abe7a..23fe93b3b 100644 --- a/Barotrauma/BarotraumaShared/SharedSource/Characters/Talents/Abilities/AbilityConditionals/AbilityConditionDataless/AbilityConditionCrewMemberUnconscious.cs +++ b/Barotrauma/BarotraumaShared/SharedSource/Characters/Talents/Abilities/AbilityConditionals/AbilityConditionDataless/AbilityConditionCrewMemberUnconscious.cs @@ -8,7 +8,7 @@ namespace Barotrauma.Abilities protected override bool MatchesConditionSpecific() { - foreach (Character c in GameSession.GetSessionCrewCharacters(CharacterType.Both)) + foreach (Character c in Character.GetFriendlyCrew(character)) { if (!c.IsDead && c.IsUnconscious) { diff --git a/Barotrauma/BarotraumaShared/SharedSource/Characters/Talents/Abilities/AbilityConditionals/AbilityConditionDataless/AbilityConditionHasDifferentJobs.cs b/Barotrauma/BarotraumaShared/SharedSource/Characters/Talents/Abilities/AbilityConditionals/AbilityConditionDataless/AbilityConditionHasDifferentJobs.cs index fc3e186c7..c4550dfaa 100644 --- a/Barotrauma/BarotraumaShared/SharedSource/Characters/Talents/Abilities/AbilityConditionals/AbilityConditionDataless/AbilityConditionHasDifferentJobs.cs +++ b/Barotrauma/BarotraumaShared/SharedSource/Characters/Talents/Abilities/AbilityConditionals/AbilityConditionDataless/AbilityConditionHasDifferentJobs.cs @@ -1,6 +1,5 @@ using System.Collections.Generic; using System.Linq; -using System.Xml.Linq; namespace Barotrauma.Abilities { @@ -14,8 +13,8 @@ namespace Barotrauma.Abilities protected override bool MatchesConditionSpecific() { - IEnumerable crewmembers = Character.GetFriendlyCrew(character); - int differentCrewAmount = crewmembers.Select(c => c.Info?.Job?.Prefab.Identifier).Distinct().Count(); + IEnumerable crewMembers = Character.GetFriendlyCrew(character); + int differentCrewAmount = crewMembers.Select(c => c.Info.Job?.Prefab.Identifier).Distinct().Count(); return differentCrewAmount >= amount; } } diff --git a/Barotrauma/BarotraumaShared/SharedSource/Characters/Talents/Abilities/AbilityConditionals/AbilityConditionDataless/AbilityConditionHasSkill.cs b/Barotrauma/BarotraumaShared/SharedSource/Characters/Talents/Abilities/AbilityConditionals/AbilityConditionDataless/AbilityConditionHasSkill.cs index 865384e7b..0bea1e7a5 100644 --- a/Barotrauma/BarotraumaShared/SharedSource/Characters/Talents/Abilities/AbilityConditionals/AbilityConditionDataless/AbilityConditionHasSkill.cs +++ b/Barotrauma/BarotraumaShared/SharedSource/Characters/Talents/Abilities/AbilityConditionals/AbilityConditionDataless/AbilityConditionHasSkill.cs @@ -1,18 +1,13 @@ -using Barotrauma.Items.Components; -using System.Collections.Generic; -using System.Linq; -using System.Xml.Linq; - -namespace Barotrauma.Abilities +namespace Barotrauma.Abilities { class AbilityConditionHasSkill : AbilityConditionDataless { - private readonly string skillIdentifier; + private readonly Identifier skillIdentifier; private readonly float minValue; public AbilityConditionHasSkill(CharacterTalent characterTalent, ContentXElement conditionElement) : base(characterTalent, conditionElement) { - skillIdentifier = conditionElement.GetAttributeString("skillidentifier", string.Empty); + skillIdentifier = conditionElement.GetAttributeIdentifier("skillidentifier", Identifier.Empty); minValue = conditionElement.GetAttributeFloat("minvalue", 0f); } diff --git a/Barotrauma/BarotraumaShared/SharedSource/Characters/Talents/Abilities/AbilityConditionals/AbilityConditionDataless/AbilityConditionLevelsBehindHighest.cs b/Barotrauma/BarotraumaShared/SharedSource/Characters/Talents/Abilities/AbilityConditionals/AbilityConditionDataless/AbilityConditionLevelsBehindHighest.cs index 46e55c575..0685fb074 100644 --- a/Barotrauma/BarotraumaShared/SharedSource/Characters/Talents/Abilities/AbilityConditionals/AbilityConditionDataless/AbilityConditionLevelsBehindHighest.cs +++ b/Barotrauma/BarotraumaShared/SharedSource/Characters/Talents/Abilities/AbilityConditionals/AbilityConditionDataless/AbilityConditionLevelsBehindHighest.cs @@ -1,6 +1,4 @@ -using System.Collections.Generic; -using System.Linq; -using System.Xml.Linq; +using System.Linq; namespace Barotrauma.Abilities { @@ -14,7 +12,7 @@ namespace Barotrauma.Abilities protected override bool MatchesConditionSpecific() { - return Character.GetFriendlyCrew(character).Where(c => c.Info != null && (c.Info.GetCurrentLevel() - character.Info.GetCurrentLevel() >= levelsBehind)).Any(); + return Character.GetFriendlyCrew(character).Any(c => c.Info.GetCurrentLevel() - character.Info.GetCurrentLevel() >= levelsBehind); } } } diff --git a/Barotrauma/BarotraumaShared/SharedSource/Characters/Talents/Abilities/AbilityConditionals/AbilityConditionDataless/AbilityConditionLowestLevel.cs b/Barotrauma/BarotraumaShared/SharedSource/Characters/Talents/Abilities/AbilityConditionals/AbilityConditionDataless/AbilityConditionLowestLevel.cs index fb6749419..adda5b464 100644 --- a/Barotrauma/BarotraumaShared/SharedSource/Characters/Talents/Abilities/AbilityConditionals/AbilityConditionDataless/AbilityConditionLowestLevel.cs +++ b/Barotrauma/BarotraumaShared/SharedSource/Characters/Talents/Abilities/AbilityConditionals/AbilityConditionDataless/AbilityConditionLowestLevel.cs @@ -9,7 +9,7 @@ namespace Barotrauma.Abilities protected override bool MatchesCharacter(Character character) { int ownLevel = character.Info.GetCurrentLevel(); - foreach (Character otherCharacter in GameSession.GetSessionCrewCharacters(CharacterType.Both)) + foreach (Character otherCharacter in Character.GetFriendlyCrew(character)) { if (otherCharacter == character) { continue; } if (otherCharacter.Info.GetCurrentLevel() < ownLevel) { return false; } diff --git a/Barotrauma/BarotraumaShared/SharedSource/Characters/Talents/Abilities/AbilityConditionals/AbilityConditionDataless/AbilityConditionNoCrewDied.cs b/Barotrauma/BarotraumaShared/SharedSource/Characters/Talents/Abilities/AbilityConditionals/AbilityConditionDataless/AbilityConditionNoCrewDied.cs index acf06d837..41c0b04f3 100644 --- a/Barotrauma/BarotraumaShared/SharedSource/Characters/Talents/Abilities/AbilityConditionals/AbilityConditionDataless/AbilityConditionNoCrewDied.cs +++ b/Barotrauma/BarotraumaShared/SharedSource/Characters/Talents/Abilities/AbilityConditionals/AbilityConditionDataless/AbilityConditionNoCrewDied.cs @@ -13,13 +13,15 @@ { if (GameMain.GameSession == null) { return false; } - foreach (Character character in GameMain.GameSession.Casualties) + foreach (Character deadCharacter in GameMain.GameSession.Casualties) { - if (assistantsDontCount && character.Info?.Job?.Prefab.Identifier == "assistant") + if (deadCharacter.TeamID != character.TeamID) { continue; } + + if (assistantsDontCount && deadCharacter.Info?.Job?.Prefab.Identifier == "assistant") { continue; } - if (character.CauseOfDeath != null && character.CauseOfDeath.Type != CauseOfDeathType.Disconnected) + if (deadCharacter.CauseOfDeath != null && deadCharacter.CauseOfDeath.Type != CauseOfDeathType.Disconnected) { return false; } diff --git a/Barotrauma/BarotraumaShared/SharedSource/Characters/Talents/Abilities/AbilityConditionals/AbilityConditionDataless/AbilityConditionShipFlooded.cs b/Barotrauma/BarotraumaShared/SharedSource/Characters/Talents/Abilities/AbilityConditionals/AbilityConditionDataless/AbilityConditionShipFlooded.cs index a8d33433a..d88f2651d 100644 --- a/Barotrauma/BarotraumaShared/SharedSource/Characters/Talents/Abilities/AbilityConditionals/AbilityConditionDataless/AbilityConditionShipFlooded.cs +++ b/Barotrauma/BarotraumaShared/SharedSource/Characters/Talents/Abilities/AbilityConditionals/AbilityConditionDataless/AbilityConditionShipFlooded.cs @@ -14,7 +14,8 @@ float waterVolume = 0.0f, totalVolume = 0.0f; foreach (Hull hull in Hull.HullList) { - if (hull.Submarine != character.Submarine) { continue; } + if (hull.Submarine is not { } hullSubmarine) { continue; } + if (hullSubmarine != character.Submarine || hullSubmarine.TeamID != character.TeamID) { continue; } waterVolume += hull.WaterVolume; totalVolume += hull.Volume; } diff --git a/Barotrauma/BarotraumaShared/SharedSource/Characters/Talents/Abilities/CharacterAbility.cs b/Barotrauma/BarotraumaShared/SharedSource/Characters/Talents/Abilities/CharacterAbility.cs index 9d2ab1c13..38ba7b64e 100644 --- a/Barotrauma/BarotraumaShared/SharedSource/Characters/Talents/Abilities/CharacterAbility.cs +++ b/Barotrauma/BarotraumaShared/SharedSource/Characters/Talents/Abilities/CharacterAbility.cs @@ -100,7 +100,7 @@ namespace Barotrauma.Abilities string type = abilityElement.Name.ToString().ToLowerInvariant(); try { - abilityType = ReflectionUtils.GetTypeWithBackwardsCompatibility("Barotrauma.Abilities", type, false, true); + abilityType = ReflectionUtils.GetTypeWithBackwardsCompatibility(ToolBox.BarotraumaAssembly, "Barotrauma.Abilities", type, false, true); if (abilityType == null) { if (errorMessages) DebugConsole.ThrowError("Could not find the CharacterAbility \"" + type + "\" (" + characterAbilityGroup.CharacterTalent.DebugIdentifier + ")", diff --git a/Barotrauma/BarotraumaShared/SharedSource/Characters/Talents/Abilities/CharacterAbilityApplyStatusEffects.cs b/Barotrauma/BarotraumaShared/SharedSource/Characters/Talents/Abilities/CharacterAbilityApplyStatusEffects.cs index 5acdb2a85..4ef07d6e2 100644 --- a/Barotrauma/BarotraumaShared/SharedSource/Characters/Talents/Abilities/CharacterAbilityApplyStatusEffects.cs +++ b/Barotrauma/BarotraumaShared/SharedSource/Characters/Talents/Abilities/CharacterAbilityApplyStatusEffects.cs @@ -19,6 +19,13 @@ namespace Barotrauma.Abilities private bool effectBeingApplied; + /// + /// Should the character who has the ability be marked as the "user" of the status effect? + /// Means that e.g. enemies will consider damage from the effect to be coming from the character with the ability, and that the character will gain skills if the effect e.g. heals someone. + /// + + private readonly bool setUser; + public CharacterAbilityApplyStatusEffects(CharacterAbilityGroup characterAbilityGroup, ContentXElement abilityElement) : base(characterAbilityGroup, abilityElement) { statusEffects = CharacterAbilityGroup.ParseStatusEffects(CharacterTalent, abilityElement.GetChildElement("statuseffects")); @@ -27,6 +34,7 @@ namespace Barotrauma.Abilities nearbyCharactersAppliesToSelf = abilityElement.GetAttributeBool("nearbycharactersappliestoself", true); nearbyCharactersAppliesToAllies = abilityElement.GetAttributeBool("nearbycharactersappliestoallies", true); nearbyCharactersAppliesToEnemies = abilityElement.GetAttributeBool("nearbycharactersappliestoenemies", true); + setUser = abilityElement.GetAttributeBool("setuser", true); } protected void ApplyEffectSpecific(Character targetCharacter, Limb targetLimb = null) @@ -44,7 +52,7 @@ namespace Barotrauma.Abilities if (statusEffect.HasTargetType(StatusEffect.TargetType.UseTarget)) { // currently used to spawn items on the targeted character - statusEffect.SetUser(targetCharacter); + if (setUser) { statusEffect.SetUser(targetCharacter); } statusEffect.Apply(ActionType.OnAbility, EffectDeltaTime, targetCharacter, targetCharacter); } else if (statusEffect.HasTargetType(StatusEffect.TargetType.NearbyCharacters)) @@ -57,28 +65,28 @@ 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) { targets.RemoveAll(c => c is Character otherCharacter && !HumanAIController.IsFriendly(otherCharacter, Character)); } - statusEffect.SetUser(Character); + if (setUser) { statusEffect.SetUser(Character); } statusEffect.Apply(ActionType.OnAbility, EffectDeltaTime, targetCharacter, targets); } else if (statusEffect.HasTargetType(StatusEffect.TargetType.Limb) && targetLimb != null) { - statusEffect.SetUser(Character); + if (setUser) { statusEffect.SetUser(Character); } statusEffect.Apply(ActionType.OnAbility, EffectDeltaTime, Character, targetLimb); } else if (statusEffect.HasTargetType(StatusEffect.TargetType.Character)) { - statusEffect.SetUser(Character); + if (setUser) { statusEffect.SetUser(Character); } statusEffect.Apply(ActionType.OnAbility, EffectDeltaTime, Character, targetCharacter); } else { - statusEffect.SetUser(Character); + if (setUser) { statusEffect.SetUser(Character); } statusEffect.Apply(ActionType.OnAbility, EffectDeltaTime, Character, Character); } } diff --git a/Barotrauma/BarotraumaShared/SharedSource/Characters/Talents/Abilities/CharacterAbilityApplyStatusEffectsToApprenticeship.cs b/Barotrauma/BarotraumaShared/SharedSource/Characters/Talents/Abilities/CharacterAbilityApplyStatusEffectsToApprenticeship.cs index 453ad0d09..c616c116b 100644 --- a/Barotrauma/BarotraumaShared/SharedSource/Characters/Talents/Abilities/CharacterAbilityApplyStatusEffectsToApprenticeship.cs +++ b/Barotrauma/BarotraumaShared/SharedSource/Characters/Talents/Abilities/CharacterAbilityApplyStatusEffectsToApprenticeship.cs @@ -26,9 +26,9 @@ namespace Barotrauma.Abilities return; } - foreach (Character character in GameSession.GetSessionCrewCharacters(CharacterType.Both)) + foreach (Character character in Character.GetFriendlyCrew(Character)) { - JobPrefab? characterJob = character.Info?.Job?.Prefab; + JobPrefab? characterJob = character.Info.Job?.Prefab; if (characterJob is null) { continue; } switch (characterJob.Identifier == apprenticeJob.Identifier) diff --git a/Barotrauma/BarotraumaShared/SharedSource/Characters/Talents/Abilities/CharacterAbilityGainSimultaneousSkill.cs b/Barotrauma/BarotraumaShared/SharedSource/Characters/Talents/Abilities/CharacterAbilityGainSimultaneousSkill.cs index 2b98992fa..f1df0523c 100644 --- a/Barotrauma/BarotraumaShared/SharedSource/Characters/Talents/Abilities/CharacterAbilityGainSimultaneousSkill.cs +++ b/Barotrauma/BarotraumaShared/SharedSource/Characters/Talents/Abilities/CharacterAbilityGainSimultaneousSkill.cs @@ -30,7 +30,7 @@ foreach (Character otherCharacter in Character.GetFriendlyCrew(Character)) { if (otherCharacter == Character) { continue; } - otherCharacter.Info?.IncreaseSkillLevel(identifier, abilitySkillGain.Value, gainedFromAbility: true); + otherCharacter.Info.IncreaseSkillLevel(identifier, abilitySkillGain.Value, gainedFromAbility: true); } } else diff --git a/Barotrauma/BarotraumaShared/SharedSource/Characters/Talents/Abilities/CharacterAbilityGiveAffliction.cs b/Barotrauma/BarotraumaShared/SharedSource/Characters/Talents/Abilities/CharacterAbilityGiveAffliction.cs index 1eba6a358..36d5ac73b 100644 --- a/Barotrauma/BarotraumaShared/SharedSource/Characters/Talents/Abilities/CharacterAbilityGiveAffliction.cs +++ b/Barotrauma/BarotraumaShared/SharedSource/Characters/Talents/Abilities/CharacterAbilityGiveAffliction.cs @@ -4,14 +4,14 @@ { private readonly Identifier afflictionId; private readonly float strength; - private readonly string multiplyStrengthBySkill; + private readonly Identifier multiplyStrengthBySkill; private readonly bool setValue; public CharacterAbilityGiveAffliction(CharacterAbilityGroup characterAbilityGroup, ContentXElement abilityElement) : base(characterAbilityGroup, abilityElement) { afflictionId = abilityElement.GetAttributeIdentifier("afflictionid", abilityElement.GetAttributeIdentifier("affliction", Identifier.Empty)); strength = abilityElement.GetAttributeFloat("strength", 0f); - multiplyStrengthBySkill = abilityElement.GetAttributeString("multiplystrengthbyskill", string.Empty); + multiplyStrengthBySkill = abilityElement.GetAttributeIdentifier("multiplystrengthbyskill", Identifier.Empty); setValue = abilityElement.GetAttributeBool("setvalue", false); if (afflictionId.IsEmpty) @@ -52,7 +52,7 @@ return; } float strength = this.strength; - if (!string.IsNullOrEmpty(multiplyStrengthBySkill)) + if (!multiplyStrengthBySkill.IsEmpty) { strength *= Character.GetSkillLevel(multiplyStrengthBySkill); } diff --git a/Barotrauma/BarotraumaShared/SharedSource/Characters/Talents/Abilities/CharacterAbilityGivePermanentStat.cs b/Barotrauma/BarotraumaShared/SharedSource/Characters/Talents/Abilities/CharacterAbilityGivePermanentStat.cs index 31c58117b..a779f04e4 100644 --- a/Barotrauma/BarotraumaShared/SharedSource/Characters/Talents/Abilities/CharacterAbilityGivePermanentStat.cs +++ b/Barotrauma/BarotraumaShared/SharedSource/Characters/Talents/Abilities/CharacterAbilityGivePermanentStat.cs @@ -75,7 +75,7 @@ namespace Barotrauma.Abilities { foreach (Character c in Character.GetFriendlyCrew(Character)) { - c?.Info?.ChangeSavedStatValue(statType, value, identifier, removeOnDeath, maxValue: maxValue, setValue: setValue); + c.Info.ChangeSavedStatValue(statType, value, identifier, removeOnDeath, maxValue: maxValue, setValue: setValue); } } else diff --git a/Barotrauma/BarotraumaShared/SharedSource/Characters/Talents/Abilities/CharacterAbilityGiveTalentPointsToAllies.cs b/Barotrauma/BarotraumaShared/SharedSource/Characters/Talents/Abilities/CharacterAbilityGiveTalentPointsToAllies.cs index f3f2d91cc..b262855c2 100644 --- a/Barotrauma/BarotraumaShared/SharedSource/Characters/Talents/Abilities/CharacterAbilityGiveTalentPointsToAllies.cs +++ b/Barotrauma/BarotraumaShared/SharedSource/Characters/Talents/Abilities/CharacterAbilityGiveTalentPointsToAllies.cs @@ -20,9 +20,8 @@ namespace Barotrauma.Abilities { if (!addingFirstTime) { return; } - foreach (Character character in GameSession.GetSessionCrewCharacters(CharacterType.Both)) + foreach (Character character in Character.GetFriendlyCrew(Character)) { - if (character.Info is null) { return; } character.Info.AdditionalTalentPoints += amount; } } diff --git a/Barotrauma/BarotraumaShared/SharedSource/Characters/Talents/Abilities/CharacterAbilityModifyStatToSkill.cs b/Barotrauma/BarotraumaShared/SharedSource/Characters/Talents/Abilities/CharacterAbilityModifyStatToSkill.cs index b02b85e1d..2293f2759 100644 --- a/Barotrauma/BarotraumaShared/SharedSource/Characters/Talents/Abilities/CharacterAbilityModifyStatToSkill.cs +++ b/Barotrauma/BarotraumaShared/SharedSource/Characters/Talents/Abilities/CharacterAbilityModifyStatToSkill.cs @@ -6,7 +6,7 @@ namespace Barotrauma.Abilities { private readonly StatTypes statType; private readonly float maxValue; - private readonly string skillIdentifier; + private readonly Identifier skillIdentifier; private readonly bool useAll; private float lastValue = 0f; public override bool AllowClientSimulation => true; @@ -15,7 +15,7 @@ namespace Barotrauma.Abilities { statType = CharacterAbilityGroup.ParseStatType(abilityElement.GetAttributeString("stattype", ""), CharacterTalent.DebugIdentifier); maxValue = abilityElement.GetAttributeFloat("maxvalue", 0f); - skillIdentifier = abilityElement.GetAttributeString("skillidentifier", string.Empty); + skillIdentifier = abilityElement.GetAttributeIdentifier("skillidentifier", Identifier.Empty); useAll = skillIdentifier == "all"; } diff --git a/Barotrauma/BarotraumaShared/SharedSource/Characters/Talents/Abilities/CharacterAbilityModifyValue.cs b/Barotrauma/BarotraumaShared/SharedSource/Characters/Talents/Abilities/CharacterAbilityModifyValue.cs index 4168ec6a1..cb3e5d1da 100644 --- a/Barotrauma/BarotraumaShared/SharedSource/Characters/Talents/Abilities/CharacterAbilityModifyValue.cs +++ b/Barotrauma/BarotraumaShared/SharedSource/Characters/Talents/Abilities/CharacterAbilityModifyValue.cs @@ -5,6 +5,8 @@ private readonly float addedValue; private readonly float multiplyValue; + public override bool AllowClientSimulation => true; + public CharacterAbilityModifyValue(CharacterAbilityGroup characterAbilityGroup, ContentXElement abilityElement) : base(characterAbilityGroup, abilityElement) { addedValue = abilityElement.GetAttributeFloat("addedvalue", 0f); diff --git a/Barotrauma/BarotraumaShared/SharedSource/Characters/Talents/Abilities/CharacterAbilityReduceAffliction.cs b/Barotrauma/BarotraumaShared/SharedSource/Characters/Talents/Abilities/CharacterAbilityReduceAffliction.cs index c519be61a..70e3c0db9 100644 --- a/Barotrauma/BarotraumaShared/SharedSource/Characters/Talents/Abilities/CharacterAbilityReduceAffliction.cs +++ b/Barotrauma/BarotraumaShared/SharedSource/Characters/Talents/Abilities/CharacterAbilityReduceAffliction.cs @@ -1,4 +1,4 @@ -#nullable enable +#nullable enable namespace Barotrauma.Abilities { @@ -19,10 +19,30 @@ namespace Barotrauma.Abilities } } + protected override void ApplyEffect() + { + ApplyEffectToCharacter(Character); + } + protected override void ApplyEffect(AbilityObject abilityObject) { - if (abilityObject is not IAbilityCharacter character) { return; } - character.Character.CharacterHealth.ReduceAfflictionOnAllLimbs(afflictionId, amount, attacker: Character); + if (abilityObject is IAbilityCharacter characterData) + { + ApplyEffectToCharacter(characterData.Character); + } + } + + private void ApplyEffectToCharacter(Character character) + { + character?.CharacterHealth.ReduceAfflictionOnAllLimbs(afflictionId, amount, attacker: Character); + } + + protected override void VerifyState(bool conditionsMatched, float timeSinceLastUpdate) + { + if (conditionsMatched) + { + ApplyEffect(); + } } } } \ No newline at end of file diff --git a/Barotrauma/BarotraumaShared/SharedSource/Characters/Talents/Abilities/CharacterAbilityUpgradeSubmarine.cs b/Barotrauma/BarotraumaShared/SharedSource/Characters/Talents/Abilities/CharacterAbilityUpgradeSubmarine.cs index a7387e414..4af108dd0 100644 --- a/Barotrauma/BarotraumaShared/SharedSource/Characters/Talents/Abilities/CharacterAbilityUpgradeSubmarine.cs +++ b/Barotrauma/BarotraumaShared/SharedSource/Characters/Talents/Abilities/CharacterAbilityUpgradeSubmarine.cs @@ -6,6 +6,7 @@ internal class CharacterAbilityUpgradeSubmarine : CharacterAbility private readonly UpgradePrefab? upgradePrefab; private readonly UpgradeCategory? upgradeCategory; public readonly int level; + private readonly bool giveOnAddingFirstTime; public override bool AllowClientSimulation => true; @@ -13,7 +14,8 @@ internal class CharacterAbilityUpgradeSubmarine : CharacterAbility { var prefabIdentifier = abilityElement.GetAttributeIdentifier(nameof(upgradePrefab), Identifier.Empty); var categoryIdentifier = abilityElement.GetAttributeIdentifier(nameof(upgradeCategory), Identifier.Empty); - + giveOnAddingFirstTime = abilityElement.GetAttributeBool("giveonaddingfirsttime", characterAbilityGroup.AbilityEffectType == AbilityEffectType.None); + if (UpgradePrefab.Find(prefabIdentifier) is not { } foundUpgradePrefab) { DebugConsole.ThrowError($"Error in talent {CharacterTalent.DebugIdentifier}, {nameof(CharacterAbilityUpgradeSubmarine)} - {nameof(upgradePrefab)} not found.", @@ -47,6 +49,14 @@ internal class CharacterAbilityUpgradeSubmarine : CharacterAbility ApplyEffectSpecific(); } + public override void InitializeAbility(bool addingFirstTime) + { + if (addingFirstTime && giveOnAddingFirstTime) + { + ApplyEffectSpecific(); + } + } + private void ApplyEffectSpecific() { if (upgradePrefab == null || upgradeCategory == null) { return; } diff --git a/Barotrauma/BarotraumaShared/SharedSource/Characters/Talents/Abilities/CustomAbilities/CharacterAbilityByTheBook.cs b/Barotrauma/BarotraumaShared/SharedSource/Characters/Talents/Abilities/CustomAbilities/CharacterAbilityByTheBook.cs index b17432528..7a38bdf6f 100644 --- a/Barotrauma/BarotraumaShared/SharedSource/Characters/Talents/Abilities/CustomAbilities/CharacterAbilityByTheBook.cs +++ b/Barotrauma/BarotraumaShared/SharedSource/Characters/Talents/Abilities/CustomAbilities/CharacterAbilityByTheBook.cs @@ -24,14 +24,18 @@ namespace Barotrauma.Abilities foreach (Character enemyCharacter in enemyCharacters) { if (!enemyCharacter.IsHuman) { continue; } - if (enemyCharacter.Submarine == null || enemyCharacter.Submarine != Submarine.MainSub) { continue; } + if (enemyCharacter.Submarine == null || + (Submarine.MainSub != null && enemyCharacter.Submarine != Submarine.MainSub)) + { + continue; + } if (enemyCharacter.IsDead) { continue; } if (!enemyCharacter.LockHands) { continue; } Character.GiveMoney(moneyAmount); GameAnalyticsManager.AddMoneyGainedEvent(moneyAmount, GameAnalyticsManager.MoneySource.Ability, CharacterTalent.Prefab.Identifier.Value); foreach (Character character in Character.GetFriendlyCrew(Character)) { - character.Info?.GiveExperience(experienceAmount); + character.Info.GiveExperience(experienceAmount); } timesGiven++; if (max > 0 && timesGiven >= max) { break; } diff --git a/Barotrauma/BarotraumaShared/SharedSource/Characters/Talents/Abilities/CustomAbilities/CharacterAbilityUnlockApprenticeshipTalentTree.cs b/Barotrauma/BarotraumaShared/SharedSource/Characters/Talents/Abilities/CustomAbilities/CharacterAbilityUnlockApprenticeshipTalentTree.cs index 7ce696992..df658dfa7 100644 --- a/Barotrauma/BarotraumaShared/SharedSource/Characters/Talents/Abilities/CustomAbilities/CharacterAbilityUnlockApprenticeshipTalentTree.cs +++ b/Barotrauma/BarotraumaShared/SharedSource/Characters/Talents/Abilities/CustomAbilities/CharacterAbilityUnlockApprenticeshipTalentTree.cs @@ -1,4 +1,4 @@ -#nullable enable +#nullable enable using System; using Barotrauma.Extensions; @@ -31,7 +31,7 @@ namespace Barotrauma.Abilities if (!TalentTree.JobTalentTrees.TryGet(apprentice.Identifier, out TalentTree? talentTree)) { return; } - ImmutableHashSet characters = GameSession.GetSessionCrewCharacters(CharacterType.Both); + var characters = Character.GetFriendlyCrew(Character); HashSet> talentsTrees = new HashSet>(); foreach (TalentSubTree subTree in talentTree.TalentSubTrees) @@ -60,13 +60,14 @@ namespace Barotrauma.Abilities talentsTrees.Add(identifiers.ToImmutableHashSet()); } - ImmutableHashSet selectedTalentTree = talentsTrees.GetRandomUnsynced(); - - foreach (Identifier identifier in selectedTalentTree) + ImmutableHashSet? selectedTalentTree = talentsTrees.GetRandomUnsynced(); + if (selectedTalentTree != null) { - if (Character.HasTalent(identifier)) { continue; } - - Character.GiveTalent(identifier); + foreach (Identifier identifier in selectedTalentTree) + { + if (Character.HasTalent(identifier)) { continue; } + Character.GiveTalent(identifier); + } } static bool IsShowCaseTalent(Identifier identifier, TalentOption option) diff --git a/Barotrauma/BarotraumaShared/SharedSource/Characters/Talents/Abilities/CustomAbilities/CharacterAbilityWarStories.cs b/Barotrauma/BarotraumaShared/SharedSource/Characters/Talents/Abilities/CustomAbilities/CharacterAbilityWarStories.cs index 7348428ae..fe34283b7 100644 --- a/Barotrauma/BarotraumaShared/SharedSource/Characters/Talents/Abilities/CustomAbilities/CharacterAbilityWarStories.cs +++ b/Barotrauma/BarotraumaShared/SharedSource/Characters/Talents/Abilities/CustomAbilities/CharacterAbilityWarStories.cs @@ -12,14 +12,20 @@ internal class CharacterAbilityWarStories : CharacterAbility { private readonly Identifier targetStat; - private readonly float minCondition; + private readonly float normalQualityThreshold; + private readonly float goodQualityThreshold; + private readonly float excellentQualityThreshold; + private readonly float masterworkQualityThreshold; private readonly ItemPrefab prefab; public CharacterAbilityWarStories(CharacterAbilityGroup characterAbilityGroup, ContentXElement abilityElement) : base(characterAbilityGroup, abilityElement) { targetStat = abilityElement.GetAttributeIdentifier("target", Identifier.Empty); - minCondition = abilityElement.GetAttributeFloat("mincondition", 1); + normalQualityThreshold = abilityElement.GetAttributeFloat("normalqualitythreshold", 4); + goodQualityThreshold = abilityElement.GetAttributeFloat("goodqualitythreshold", 10); + excellentQualityThreshold = abilityElement.GetAttributeFloat("excellentqualitythreshold", 20); + masterworkQualityThreshold = abilityElement.GetAttributeFloat("masterworkqualitythreshold", 30); if (targetStat.IsEmpty) { @@ -37,23 +43,28 @@ internal class CharacterAbilityWarStories : CharacterAbility { if (prefab is null || Character is null) { return; } - float condition = Character.Info?.GetSavedStatValue(StatTypes.None, targetStat) ?? 0; - if (condition < minCondition) { return; } + float statValue = Character.Info?.GetSavedStatValue(StatTypes.None, targetStat) ?? 0; + + if (statValue < normalQualityThreshold) { return; } + + int quality = 0; + if (statValue >= masterworkQualityThreshold) { quality = 3; } + else if (statValue >= excellentQualityThreshold) { quality = 2; } + else if (statValue >= goodQualityThreshold) { quality = 1; } if (GameMain.GameSession?.RoundEnding ?? true) { Item item = new(prefab, Character.WorldPosition, Character.Submarine) { - Condition = condition, - HealthMultiplier = condition + Quality = quality, }; Character.Inventory.TryPutItem(item, Character, item.AllowedSlots); } else { - Entity.Spawner?.AddItemToSpawnQueue(prefab, Character.Inventory, condition: condition, onSpawned: item => + Entity.Spawner?.AddItemToSpawnQueue(prefab, Character.Inventory, quality: quality, onSpawned: item => { - item.HealthMultiplier = condition; + item.Quality = quality; }); } } diff --git a/Barotrauma/BarotraumaShared/SharedSource/Characters/Talents/AbilityGroups/CharacterAbilityGroup.cs b/Barotrauma/BarotraumaShared/SharedSource/Characters/Talents/AbilityGroups/CharacterAbilityGroup.cs index aa8b70120..f53ab10d0 100644 --- a/Barotrauma/BarotraumaShared/SharedSource/Characters/Talents/AbilityGroups/CharacterAbilityGroup.cs +++ b/Barotrauma/BarotraumaShared/SharedSource/Characters/Talents/AbilityGroups/CharacterAbilityGroup.cs @@ -140,7 +140,7 @@ namespace Barotrauma.Abilities string type = conditionElement.Name.ToString().ToLowerInvariant(); try { - conditionType = ReflectionUtils.GetTypeWithBackwardsCompatibility("Barotrauma.Abilities", type, false, true); + conditionType = ReflectionUtils.GetTypeWithBackwardsCompatibility(ToolBox.BarotraumaAssembly, "Barotrauma.Abilities", type, false, true); if (conditionType == null) { if (errorMessages) diff --git a/Barotrauma/BarotraumaShared/SharedSource/Characters/Talents/TalentPrefab.cs b/Barotrauma/BarotraumaShared/SharedSource/Characters/Talents/TalentPrefab.cs index 736421c36..1a38a89d2 100644 --- a/Barotrauma/BarotraumaShared/SharedSource/Characters/Talents/TalentPrefab.cs +++ b/Barotrauma/BarotraumaShared/SharedSource/Characters/Talents/TalentPrefab.cs @@ -22,6 +22,11 @@ namespace Barotrauma public readonly Sprite Icon; + /// + /// When set to true, this talent will not be visible in the "Extra Talents" panel if it is not part of the character's job talent tree. + /// + public readonly bool IsHiddenExtraTalent; + /// /// When set to a value the talent tooltip will display a text showing the current value of the stat and the max value. /// For example "Progress: 37/100". @@ -62,6 +67,8 @@ namespace Barotrauma DisplayName = TextManager.Get(nameIdentifier).Fallback(Identifier.Value); } + IsHiddenExtraTalent = element.GetAttributeBool("ishiddenextratalent", false); + Description = string.Empty; #if CLIENT diff --git a/Barotrauma/BarotraumaShared/SharedSource/Characters/Talents/TalentTree.cs b/Barotrauma/BarotraumaShared/SharedSource/Characters/Talents/TalentTree.cs index bb06f8717..5af044625 100644 --- a/Barotrauma/BarotraumaShared/SharedSource/Characters/Talents/TalentTree.cs +++ b/Barotrauma/BarotraumaShared/SharedSource/Characters/Talents/TalentTree.cs @@ -131,7 +131,7 @@ namespace Barotrauma if (character.Info.GetTotalTalentPoints() - selectedTalents.Count <= 0) { return false; } if (!JobTalentTrees.TryGet(character.Info.Job.Prefab.Identifier, out TalentTree talentTree)) { return false; } - if (IsTalentLocked(talentIdentifier)) { return false; } + if (IsTalentLocked(talentIdentifier, Character.GetFriendlyCrew(character))) { return false; } if (character.Info.GetUnlockedTalentsInTree().Contains(talentIdentifier)) { @@ -163,10 +163,8 @@ namespace Barotrauma return false; } - public static bool IsTalentLocked(Identifier talentIdentifier, ImmutableHashSet characterList = null) + public static bool IsTalentLocked(Identifier talentIdentifier, IEnumerable characterList) { - characterList ??= GameSession.GetSessionCrewCharacters(CharacterType.Both); - foreach (Character c in characterList) { if (c.Info.GetSavedStatValue(StatTypes.LockedTalents, talentIdentifier) >= 1) { return true; } 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/AfflictionsFile.cs b/Barotrauma/BarotraumaShared/SharedSource/ContentManagement/ContentFile/AfflictionsFile.cs index 2069fe68f..2e88b0de3 100644 --- a/Barotrauma/BarotraumaShared/SharedSource/ContentManagement/ContentFile/AfflictionsFile.cs +++ b/Barotrauma/BarotraumaShared/SharedSource/ContentManagement/ContentFile/AfflictionsFile.cs @@ -1,11 +1,9 @@ +using Barotrauma.Extensions; using Microsoft.Xna.Framework; using System; -using System.Collections.Generic; using System.Collections.Immutable; using System.Linq; -using System.Reflection; using System.Xml.Linq; -using Barotrauma.Extensions; namespace Barotrauma { @@ -55,18 +53,19 @@ namespace Barotrauma return; } - if (AfflictionPrefab.Prefabs.ContainsKey(identifier)) + if (AfflictionPrefab.Prefabs.TryGet(identifier, out var existingAffliction)) { if (overriding) { DebugConsole.NewMessage( - $"Overriding an affliction or a buff with the identifier '{identifier}' using the file '{Path}'", + $"Overriding an affliction or a buff with the identifier '{identifier}' using the version in '{element.ContentPackage.Name}'", Color.MediumPurple); } else { DebugConsole.ThrowError( - $"Duplicate affliction: '{identifier}' defined in {elementName} of '{Path}'", + $"Duplicate affliction: '{identifier}' defined in {element.ContentPackage.Name} is already defined in the previously loaded content package {existingAffliction.ContentPackage.Name}."+ + $" You may need to adjust the mod load order to make sure {element.ContentPackage.Name} is loaded first.", contentPackage: element?.ContentPackage); return; } 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/ContentFile/CharacterFile.cs b/Barotrauma/BarotraumaShared/SharedSource/ContentManagement/ContentFile/CharacterFile.cs index 96dc4ca72..2222c41c4 100644 --- a/Barotrauma/BarotraumaShared/SharedSource/ContentManagement/ContentFile/CharacterFile.cs +++ b/Barotrauma/BarotraumaShared/SharedSource/ContentManagement/ContentFile/CharacterFile.cs @@ -1,4 +1,4 @@ -using Barotrauma.Extensions; +using Barotrauma.Extensions; using Microsoft.Xna.Framework; using System; using System.Collections.Generic; @@ -86,23 +86,27 @@ namespace Barotrauma if (ragdollParams != null) { - HashSet texturePaths = new HashSet - { - ContentPath.FromRaw(CharacterPrefab.Prefabs[speciesName].ContentPackage, ragdollParams.Texture).Value - }; + HashSet texturePaths = new HashSet(); + AddTexturePath(ragdollParams.Texture); foreach (RagdollParams.LimbParams limb in ragdollParams.Limbs) { - if (!string.IsNullOrEmpty(limb.normalSpriteParams?.Texture)) { texturePaths.Add(limb.normalSpriteParams.Texture); } - if (!string.IsNullOrEmpty(limb.deformSpriteParams?.Texture)) { texturePaths.Add(limb.deformSpriteParams.Texture); } - if (!string.IsNullOrEmpty(limb.damagedSpriteParams?.Texture)) { texturePaths.Add(limb.damagedSpriteParams.Texture); } + AddTexturePath(limb.normalSpriteParams?.Texture); + AddTexturePath(limb.deformSpriteParams?.Texture); + AddTexturePath(limb.damagedSpriteParams?.Texture); foreach (var decorativeSprite in limb.decorativeSpriteParams) { - if (!string.IsNullOrEmpty(decorativeSprite.Texture)) { texturePaths.Add(decorativeSprite.Texture); } + AddTexturePath(decorativeSprite.Texture); } } - foreach (string texturePath in texturePaths) + foreach (ContentPath texturePath in texturePaths) { - addPreloadedSprite(new Sprite(texturePath, Vector2.Zero)); + addPreloadedSprite(new Sprite(texturePath.Value, Vector2.Zero)); + } + + void AddTexturePath(string path) + { + if (string.IsNullOrEmpty(path)) { return; } + texturePaths.Add(ContentPath.FromRaw(characterPrefab.ContentPackage, ragdollParams.Texture)); } } #endif diff --git a/Barotrauma/BarotraumaShared/SharedSource/ContentManagement/ContentFile/DisembarkPerkFile.cs b/Barotrauma/BarotraumaShared/SharedSource/ContentManagement/ContentFile/DisembarkPerkFile.cs new file mode 100644 index 000000000..728721e3c --- /dev/null +++ b/Barotrauma/BarotraumaShared/SharedSource/ContentManagement/ContentFile/DisembarkPerkFile.cs @@ -0,0 +1,17 @@ +using System.Xml.Linq; + +namespace Barotrauma +{ + internal sealed class DisembarkPerkFile : GenericPrefabFile + { + public DisembarkPerkFile(ContentPackage contentPackage, ContentPath path) : base(contentPackage, path) { } + + protected override bool MatchesSingular(Identifier identifier) => identifier == "disembarkperk"; + protected override bool MatchesPlural(Identifier identifier) => identifier == "disembarkperks"; + protected override PrefabCollection Prefabs => DisembarkPerkPrefab.Prefabs; + protected override DisembarkPerkPrefab CreatePrefab(ContentXElement element) + { + return new DisembarkPerkPrefab(element, this); + } + } +} \ No newline at end of file diff --git a/Barotrauma/BarotraumaShared/SharedSource/ContentManagement/ContentFile/RandomEventsFile.cs b/Barotrauma/BarotraumaShared/SharedSource/ContentManagement/ContentFile/RandomEventsFile.cs index 51518e428..7896e1214 100644 --- a/Barotrauma/BarotraumaShared/SharedSource/ContentManagement/ContentFile/RandomEventsFile.cs +++ b/Barotrauma/BarotraumaShared/SharedSource/ContentManagement/ContentFile/RandomEventsFile.cs @@ -1,4 +1,4 @@ -using System.Xml.Linq; +using System.Xml.Linq; namespace Barotrauma { @@ -77,12 +77,15 @@ namespace Barotrauma var rootElement = doc.Root.FromPackage(ContentPackage); LoadFromXElement(rootElement, false); + + EventSet.RefreshAllEventPrefabs(); } public override void UnloadFile() { EventPrefab.Prefabs.RemoveByFile(this); EventSet.Prefabs.RemoveByFile(this); + EventSet.RefreshAllEventPrefabs(); #if CLIENT EventSprite.Prefabs.RemoveByFile(this); #endif @@ -92,6 +95,9 @@ namespace Barotrauma { EventPrefab.Prefabs.SortAll(); EventSet.Prefabs.SortAll(); + //need to referesh, because the order of the prefabs may affect which content package overrides some event + EventSet.RefreshAllEventPrefabs(); + #if CLIENT EventSprite.Prefabs.SortAll(); #endif diff --git a/Barotrauma/BarotraumaShared/SharedSource/ContentManagement/ContentPackage/ContentPackage.cs b/Barotrauma/BarotraumaShared/SharedSource/ContentManagement/ContentPackage/ContentPackage.cs index a812a773f..2bee7b3c1 100644 --- a/Barotrauma/BarotraumaShared/SharedSource/ContentManagement/ContentPackage/ContentPackage.cs +++ b/Barotrauma/BarotraumaShared/SharedSource/ContentManagement/ContentPackage/ContentPackage.cs @@ -1,4 +1,4 @@ -#nullable enable +#nullable enable using Barotrauma.Extensions; using Barotrauma.Steam; using System; @@ -9,6 +9,9 @@ using System.Security.Cryptography; using System.Text; using System.Threading.Tasks; using System.Xml.Linq; +using System.Xml; +using Barotrauma.IO; +using Steamworks.Data; namespace Barotrauma { @@ -34,14 +37,35 @@ namespace Barotrauma public const string FileListFileName = "filelist.xml"; public const string DefaultModVersion = "1.0.0"; - public readonly string Name; + public string Name { get; private set; } public readonly ImmutableArray AltNames; - public readonly string Path; + public string Path { get; private set; } public string Dir => Barotrauma.IO.Path.GetDirectoryName(Path) ?? ""; public readonly Option UgcId; 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; @@ -63,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() { @@ -230,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()); @@ -265,7 +306,7 @@ namespace Barotrauma //The game should be able to work just fine with a completely arbitrary file load order. //To make sure we don't mess this up, debug builds randomize it so it has a higher chance //of breaking anything that's not implemented correctly. - .Randomize() + .Randomize(Rand.RandSync.Unsynced) #endif ; @@ -326,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()); @@ -397,5 +508,51 @@ namespace Barotrauma static string errorToStr(LoadError error) => error.ToString(); } + + public bool TryRenameLocal(string newName) + { + if (!ContentPackageManager.LocalPackages.Contains(this)) { return false; } + + if (newName.IsNullOrWhiteSpace()) + { + DebugConsole.ThrowError($"New name is blank!"); + return false; + } + + string newDir = IO.Path.Combine(IO.Path.GetFullPath(LocalModsDir), File.SanitizeName(newName)); + if (ContentPackageManager.LocalPackages.Any(lp => lp.NameMatches(newName)) || Directory.Exists(newDir)) + { + DebugConsole.ThrowError($"A local package with the name or directory \"{newName}\" already exists!"); + return false; + } + + XDocument doc = XMLExtensions.TryLoadXml(Path); + doc.Root!.SetAttributeValue("name", newName); + using (IO.XmlWriter writer = IO.XmlWriter.Create(Path, new XmlWriterSettings { Indent = true })) + { + doc.WriteTo(writer); + writer.Flush(); + } + + Directory.Move(Dir, newDir); + return true; + } + + public bool TryDeleteLocal() => ContentPackageManager.LocalPackages.Contains(this) && Directory.TryDelete(Dir); + + public bool TryCreateLocalFromWorkshop() + { + if (!ContentPackageManager.WorkshopPackages.Contains(this)) { return false; } + + string newDir = IO.Path.Combine(IO.Path.GetFullPath(LocalModsDir), File.SanitizeName(Name)); + if (ContentPackageManager.LocalPackages.Any(lp => lp.NameMatches(Name)) || Directory.Exists(newDir)) + { + DebugConsole.ThrowError($"A local package with the name or directory \"{Name}\" already exists!"); + return false; + } + + Directory.Copy(Dir, newDir); + return true; + } } } \ No newline at end of file diff --git a/Barotrauma/BarotraumaShared/SharedSource/ContentManagement/ContentPackageManager.cs b/Barotrauma/BarotraumaShared/SharedSource/ContentManagement/ContentPackageManager.cs index 29c26ee54..6c4a00fe1 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/ContentManagement/ContentXElement.cs b/Barotrauma/BarotraumaShared/SharedSource/ContentManagement/ContentXElement.cs index 7559fe604..b0a296e7c 100644 --- a/Barotrauma/BarotraumaShared/SharedSource/ContentManagement/ContentXElement.cs +++ b/Barotrauma/BarotraumaShared/SharedSource/ContentManagement/ContentXElement.cs @@ -93,6 +93,7 @@ namespace Barotrauma public Rectangle GetAttributeRect(string key, in Rectangle def) => Element.GetAttributeRect(key, def); public Version GetAttributeVersion(string key, Version def) => Element.GetAttributeVersion(key, def); public T GetAttributeEnum(string key, in T def) where T : struct, Enum => Element.GetAttributeEnum(key, def); + public T[] GetAttributeEnumArray(string key, T[] def) where T : struct, Enum => Element.GetAttributeEnumArray(key, def); public (T1, T2) GetAttributeTuple(string key, in (T1, T2) def) => Element.GetAttributeTuple(key, def); public (T1, T2)[] GetAttributeTupleArray(string key, in (T1, T2)[] def) => Element.GetAttributeTupleArray(key, def); public Range GetAttributeRange(string key, in Range def) => Element.GetAttributeRange(key, def); diff --git a/Barotrauma/BarotraumaShared/SharedSource/DebugConsole.cs b/Barotrauma/BarotraumaShared/SharedSource/DebugConsole.cs index d3df8e41c..58128afba 100644 --- a/Barotrauma/BarotraumaShared/SharedSource/DebugConsole.cs +++ b/Barotrauma/BarotraumaShared/SharedSource/DebugConsole.cs @@ -249,7 +249,7 @@ namespace Barotrauma GameMain.NetworkMember.ShowNetStats = !GameMain.NetworkMember.ShowNetStats; })); - commands.Add(new Command("spawn|spawncharacter", "spawn [creaturename/jobname] [near/inside/outside/cursor] [team (0-3)] [add to crew (true/false)]: Spawn a creature at a random spawnpoint (use the second parameter to only select spawnpoints near/inside/outside the submarine). You can also enter the name of a job (e.g. \"Mechanic\") to spawn a character with a specific job and the appropriate equipment.", null, + commands.Add(new Command("spawn|spawncharacter", "spawn [creaturename/jobname] [near/inside/outside/cursor] [team] [add to crew (true/false)]: Spawn a creature at a random spawnpoint (use the second parameter to only select spawnpoints near/inside/outside the submarine). You can also enter the name of a job (e.g. \"Mechanic\") to spawn a character with a specific job and the appropriate equipment.", null, () => { string[] creatureAndJobNames = @@ -262,55 +262,63 @@ namespace Barotrauma { creatureAndJobNames.ToArray(), new string[] { "near", "inside", "outside", "cursor" }, - new string[] { "0", "1", "2", "3" }, + 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]: 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 last parameter is omitted or \"random\".", + + commands.Add(new Command("give|giveitem", "give|giveitem [itemname/itemidentifier] [amount] [condition]: Spawn an item in the inventory of the controlled character", (string[] args) => { - try + if (Character.Controlled == null) { -#if CLIENT - SpawnItem(args, Screen.Selected.Cam?.ScreenToWorld(PlayerInput.MousePosition) ?? PlayerInput.MousePosition, Character.Controlled, out string errorMsg); -#elif SERVER - SpawnItem(args, Vector2.Zero, null, out string errorMsg); -#endif - if (!string.IsNullOrWhiteSpace(errorMsg)) - { - ThrowError(errorMsg); - } + ThrowError("No character is selected!"); + return; } - catch (Exception e) + + if (args.Length == 0) { - string errorMsg = "Failed to spawn an item. Arguments: \"" + string.Join(" ", args) + "\"."; - ThrowError(errorMsg, e); - GameAnalyticsManager.AddErrorEventOnce("DebugConsole.SpawnItem:Error", GameAnalyticsManager.ErrorSeverity.Error, errorMsg + '\n' + e.Message + '\n' + e.StackTrace.CleanupStackTrace()); + ThrowError("Please give the name or identifier of the item to spawn."); + return; } + + var modifiedArgs = new List(args); + modifiedArgs.Insert(1, "inventory"); + TrySpawnItem(modifiedArgs.ToArray()); + }, + getValidArgs: () => + { + return new string[][] + { + GetItemNameOrIdParams().ToArray() + }; + }, 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) => + { + TrySpawnItem(args); }, () => { - List itemNames = new List(); - foreach (ItemPrefab itemPrefab in ItemPrefab.Prefabs) - { - if (!itemNames.Contains(itemPrefab.Name.Value)) - { - itemNames.Add(itemPrefab.Name.Value); - } - } - - List spawnPosParams = new List() { "cursor", "inventory" }; -#if SERVER - if (GameMain.Server != null) spawnPosParams.AddRange(GameMain.Server.ConnectedClients.Select(c => c.Name)); -#endif - spawnPosParams.AddRange(Character.CharacterList.Where(c => c.Inventory != null).Select(c => c.Name).Distinct()); - return new string[][] { - itemNames.ToArray(), - spawnPosParams.ToArray() + GetItemNameOrIdParams().ToArray(), + GetSpawnPosParams().ToArray() }; }, isCheat: true)); @@ -574,15 +582,66 @@ 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)); + commands.Add(new Command("monstersignoreplayer", "Toggle if monsters should ignore the player character (and their equipment) when targeting.", + onExecute: (string[] args) => + { + ToggleEnemyAITargetingRestrictions(EnemyTargetingRestrictions.PlayerCharacters); + }, + getValidArgs: null, + isCheat: true)); + + commands.Add(new Command("monstersignoresub", "Toggle if monsters should ignore the player submarines when targeting.", + onExecute: (string[] args) => + { + ToggleEnemyAITargetingRestrictions(EnemyTargetingRestrictions.PlayerSubmarines); + }, + getValidArgs: null, + isCheat: true)); + + commands.Add(new Command("monstersrestoretargets", "Remove any targeting restrictions from monsters.", + onExecute: (string[] args) => + { + ToggleEnemyAITargetingRestrictions(EnemyTargetingRestrictions.None); + }, + getValidArgs: null, + isCheat: true)); + + commands.Add(new Command("monstertargetingrestrictions", "monstertargetingrestrictions [restrictions]: Set targeting restrictions for all monsters. Supports multiple options comma-separated: 'monsterargetingrestrictions PlayerCharacters,PlayerSubmarines'. Use 'None' to remove all restrictions.", + onExecute:(string[] args) => + { + if (args.Length == 0) + { + // use the set function to keep log consistent + ToggleEnemyAITargetingRestrictions(EnemyAIController.TargetingRestrictions); + return; + } + + // try parse the complete flags from first arg + if (Enum.TryParse(args[0], ignoreCase: true, out var restrictions)) + { + ToggleEnemyAITargetingRestrictions(restrictions); + } + else + { + NewMessage($"Failed to parse argument '{args[0]}'", Color.Red); + } + }, + getValidArgs: () => + { + return new string[][] + { + Enum.GetNames(typeof(EnemyTargetingRestrictions)) + }; + }, + isCheat: true)); + commands.Add(new Command("listlocations|locations", "listlocations: List all the locations in the level: subs, outposts, ruins, caves.", onExecute:(string[] args) => { @@ -598,21 +657,24 @@ 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) => { - if (Submarine.MainSub == null) return; + if (Submarine.MainSub == null) { return; } Submarine.MainSub.GodMode = !Submarine.MainSub.GodMode; NewMessage(Submarine.MainSub.GodMode ? "Godmode on" : "Godmode off", Color.White); @@ -690,7 +752,14 @@ namespace Barotrauma commands.Add(new Command("giveaffliction", "giveaffliction [affliction name] [affliction strength] [character name] [limb type] [use relative strength]: Add an affliction to a character. If the name parameter is omitted, the affliction is added to the controlled character.", (string[] args) => { - if (args.Length < 2) { return; } + if (args.Length < 2) + { + if (args.Length == 1) + { + ThrowError("Must give a strength value!"); + } + return; + } string affliction = args[0]; AfflictionPrefab afflictionPrefab = AfflictionPrefab.List.FirstOrDefault(a => a.Identifier == affliction); if (afflictionPrefab == null) @@ -712,7 +781,7 @@ namespace Barotrauma { bool.TryParse(args[4], out relativeStrength); } - Character targetCharacter = args.Length <= 2 ? Character.Controlled : FindMatchingCharacter(new string[] { args[2] }); + Character targetCharacter = args.Length <= 2 ? Character.Controlled : FindMatchingCharacter(args.Skip(2).ToArray()); if (targetCharacter != null) { Limb targetLimb = targetCharacter.AnimController.MainLimb; @@ -731,34 +800,40 @@ namespace Barotrauma { return new string[][] { - AfflictionPrefab.Prefabs.Select(a => a.Name.Value).ToArray(), + AfflictionPrefab.Prefabs.Select(a => a.Name.Value).ToArray().Concat(AfflictionPrefab.Prefabs.Select(a => a.Identifier.Value)).ToArray(), new string[] { "1" }, - Character.CharacterList.Select(c => c.Name).ToArray(), + ListCharacterNames(), Enum.GetNames(typeof(LimbType)).ToArray() }; }, isCheat: true)); + + commands.Add(new Command("healme", "healme [all]: Restore controlled character to full health. 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 > 0 && args[0].Equals("all", StringComparison.OrdinalIgnoreCase); + if (Character.Controlled != null) + { + HealCharacter(Character.Controlled, healAll); + } + }, + () => + { + return new string[][] + { + new string[] { "all" } + }; + }, isCheat: true)); 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) - { - healedCharacter.SetAllDamage(0.0f, 0.0f, 0.0f); - healedCharacter.Oxygen = 100.0f; - healedCharacter.Bloodloss = 0.0f; - healedCharacter.SetStun(0.0f, true); - if (healAll) - { - healedCharacter.CharacterHealth.RemoveAllAfflictions(); - } - } + 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)); @@ -769,7 +844,9 @@ namespace Barotrauma if (character != null) { Dictionary treatments = new Dictionary(); - character.CharacterHealth.GetSuitableTreatments(treatments, user: null); + character.CharacterHealth.GetSuitableTreatments(treatments, user: null, + checkTreatmentThreshold: true, + checkTreatmentSuggestionThreshold: false); foreach (var treatment in treatments.OrderByDescending(t => t.Value)) { Color color = Color.White; @@ -806,7 +883,7 @@ namespace Barotrauma // If killed in ironman mode, the character has been wiped from the save mid-round, so its // original data needs to be restored to the save file (without making a backup of the dead character) - if (GameMain.Server.ServerSettings.IronmanMode && GameMain.GameSession?.Campaign is MultiPlayerCampaign mpCampaign) + if (GameMain.Server?.ServerSettings is { IronmanModeActive: true } && GameMain.GameSession?.Campaign is MultiPlayerCampaign mpCampaign) { if (mpCampaign.RestoreSingleCharacterFromBackup(c) is CharacterCampaignData characterToRestore) { @@ -877,32 +954,51 @@ namespace Barotrauma } }, isCheat: true)); - commands.Add(new Command("triggerevent", "triggerevent [identifier]: Created a new event.", (string[] args) => + commands.Add(new Command("triggerevent", "triggerevent [identifier]: Trigger an event based on identifier.", (string[] args) => { - List eventPrefabs = EventSet.GetAllEventPrefabs().Where(prefab => prefab.Identifier != Identifier.Empty).ToList(); + List allEventPrefabsWithId = EventSet.GetAllEventPrefabs().Where(prefab => prefab.Identifier != Identifier.Empty).ToList(); if (GameMain.GameSession?.EventManager != null && args.Length > 0) { - EventPrefab eventPrefab = eventPrefabs.Find(prefab => prefab.Identifier == args[0]); - if (eventPrefab is TraitorEventPrefab) + string eventPrefabId = args[0]; + if (eventPrefabId == "all") { - ThrowError($"{eventPrefab.Identifier} is a traitor event. You need to use the 'triggertraitorevent' command to start it."); - return; - } - else if (eventPrefab != null) - { - var newEvent = eventPrefab.CreateInstance(GameMain.GameSession.EventManager.RandomSeed); - if (newEvent == null) + foreach (var eventPrefab in allEventPrefabsWithId.Where(e => e.EventType == typeof(ScriptedEvent))) { - NewMessage($"Could not initialize event {args[0]} because level did not meet requirements"); + var newEvent = eventPrefab.CreateInstance(GameMain.GameSession.EventManager.RandomSeed); + if (newEvent == null) + { + NewMessage($"Could not initialize event {eventPrefabId} because level did not meet requirements"); + return; + } + GameMain.GameSession.EventManager.ActivateEvent(newEvent); + } + } + else + { + EventPrefab eventPrefab = allEventPrefabsWithId.Find(prefab => prefab.Identifier == eventPrefabId); + if (eventPrefab is TraitorEventPrefab) + { + ThrowError($"{eventPrefab.Identifier} is a traitor event. You need to use the 'triggertraitorevent' command to start it."); return; } - GameMain.GameSession.EventManager.ActivateEvent(newEvent); - NewMessage($"Initialized event {eventPrefab.Identifier}", Color.Aqua); + else if (eventPrefab != null) + { + var newEvent = eventPrefab.CreateInstance(GameMain.GameSession.EventManager.RandomSeed); + if (newEvent == null) + { + NewMessage($"Could not initialize event {eventPrefabId} because level did not meet requirements"); + return; + } + GameMain.GameSession.EventManager.ActivateEvent(newEvent); + NewMessage($"Initialized event {eventPrefab.Identifier}", Color.Aqua); + return; + } + else + { + NewMessage($"Failed to trigger event because {eventPrefabId} is not a valid event identifier.", Color.Red); return; + } } - - NewMessage($"Failed to trigger event because {args[0]} is not a valid event identifier.", Color.Red); - return; } NewMessage("Failed to trigger event", Color.Red); }, isCheat: true, getValidArgs: () => @@ -913,8 +1009,8 @@ namespace Barotrauma { eventPrefabs.Select(prefab => prefab.Identifier).Distinct().Select(id => id.Value).ToArray() }; - })); - + })); + commands.Add(new Command("debugevent", "debugevent [identifier]: outputs debug info about a specific event that's currently active. Mainly intended for debugging events in multiplayer: in single player, the same information is available by enabling debugdraw.", (string[] args) => { if (args.Length == 0) @@ -1338,6 +1434,46 @@ namespace Barotrauma }; }, isCheat: true)); + commands.Add(new Command("replaceitem", "replaceitem [item name (index)] [new item]: Replaces the specified item with another one.", (string[] args) => + { + if (args.Length < 2) { return; } + + string itemName = args[0]; + int itemIndex = 0; + string newItemName = args[1]; + if (args.Length == 3) + { + if (!int.TryParse(args[1], out itemIndex)) + { + ThrowError($"Failed to parse the argument {args[1]} as an index. Please give the arguments either in the format [old_item] [new_item] or [old_item] [index] [new_item]"); + return; + } + newItemName = args[2]; + } + + var oldItem = Item.ItemList.FindAll(it => it.Name == args[0]).ElementAtOrDefault(itemIndex); + if (oldItem == null) + { + ThrowError($"Could not find an item with the name {args[0]} (index {itemIndex})."); + return; + } + if ((MapEntityPrefab.FindByIdentifier(args[1].ToIdentifier()) ?? MapEntityPrefab.FindByName(args[1])) is not ItemPrefab newItem) + { + ThrowError($"Could not find an item with the name or identifier {args[1]}."); + return; + } + oldItem.ReplaceWithLinkedItems(newItem); + NewMessage($"Replaced {oldItem.Name} with {newItem.Name}."); + }, + () => + { + return new string[][] + { + Item.ItemList.Select(it => it.Name).Distinct().ToArray(), + ItemPrefab.Prefabs.Select(it => it.Name.Value).Distinct().ToArray(), + }; + }, isCheat: true)); + commands.Add(new Command("waterphysicsparams", "waterphysicsparams [stiffness] [spread] [damping]: defaults 0.02, 0.05, 0.05", (string[] args) => { float stiffness = 0.02f, spread = 0.05f, damp = 0.01f; @@ -1349,13 +1485,104 @@ namespace Barotrauma Hull.WaveDampening = damp; }, null)); - commands.Add(new Command("testlevels", "testlevels", (string[] args) => + commands.Add(new Command("testmaps", "testmaps [amount]: generates campaign maps and checks whether there are any errors or exceptions. If the amount argument is omitted, the command will keep testing maps until it's cancelled.", (string[] args) => { - CoroutineManager.StartCoroutine(TestLevels()); + if (args.Length > 0 && int.TryParse(args[0], out int amount)) + { + CoroutineManager.StartCoroutine(TestMaps(amount: amount)); + } + else + { + CoroutineManager.StartCoroutine(TestMaps()); + } }, null)); - IEnumerable TestLevels() + commands.Add(new Command("testmap", "testmap [seed]: generates a campaign map and checks whether there are any errors or exceptions.", (string[] args) => + { + if (args.Length == 0) + { + ThrowError("Please provide the seed of the map to test."); + return; + } + CoroutineManager.StartCoroutine(TestMaps(fixedSeed: args[0], amount: 1)); + }, + null)); + + IEnumerable TestMaps(string fixedSeed = null, int? amount = null) + { + int count = 0; +#if CLIENT + while (!PlayerInput.KeyHit(Microsoft.Xna.Framework.Input.Keys.C)) +#else + while (!input.Equals("c", StringComparison.OrdinalIgnoreCase)) +#endif + { + using var errorCatcher = DebugConsole.ErrorCatcher.Create(); + { + string seed = fixedSeed ?? ToolBox.RandomSeed(16); + Map map = new Map(campaign: MultiPlayerCampaign.StartNew(seed, new CampaignSettings()), seed: seed); + + //check path to the first end location, because there are no normal connections between the end locations + var lastLocation = map.EndLocations[0]; + int endDistance = Map.GetDistanceToClosestLocationOrConnection(map.StartLocation, maxDistance: int.MaxValue, criteria: (Location location) => location == lastLocation); + if (endDistance == int.MaxValue) + { + ThrowError($"No path to the end of the map found. Seed: {seed}"); + } + + if (map.Locations.None(l => l.Type.Identifier == "outpost" && map.GetZoneIndex(l.MapPosition.X) == 1)) + { + ThrowError($"No outpost in the first zone of the map. Seed: {seed}"); + } + + if (errorCatcher.Errors.Any()) + { + ThrowError($"Error(s) found when generating a level. Seed: {seed}"); + yield return CoroutineStatus.Success; + } + + count++; + NewMessage($"Map seed {seed} ok (test #{count}). Press C to abort."); + + map.Remove(); + + if (amount.HasValue && count >= amount) + { + NewMessage("Testing finished successfully."); + break; + } + } + + yield return CoroutineStatus.Running; + } + } + + commands.Add(new Command("testlevels", "testlevels [amount]: generates levels and checks whether there are any errors or exceptions. If the amount argument is omitted, the command will keep testing levels until it's cancelled.", (string[] args) => + { + if (args.Length > 0 && int.TryParse(args[0], out int amount)) + { + CoroutineManager.StartCoroutine(TestLevels(amount: amount)); + } + else + { + CoroutineManager.StartCoroutine(TestLevels()); + } + }, + null)); + + commands.Add(new Command("testlevel", "testlevel [seed]: generates a levels and checks whether there are any errors or exceptions.", (string[] args) => + { + if (args.Length == 0) + { + ThrowError("Please provide the seed of the level to test."); + return; + } + CoroutineManager.StartCoroutine(TestLevels(fixedSeed: args[0], amount: 1)); + }, + null)); + + IEnumerable TestLevels(string fixedSeed = null, int? amount = null) { SubmarineInfo selectedSub = null; Identifier subName = GameSettings.CurrentConfig.QuickStartSub; @@ -1365,12 +1592,17 @@ namespace Barotrauma } int count = 0; - while (true) +#if CLIENT + while (!PlayerInput.KeyHit(Microsoft.Xna.Framework.Input.Keys.C)) +#else + while (!input.Equals("c", StringComparison.OrdinalIgnoreCase)) +#endif { var gamesession = new GameSession( SubmarineInfo.SavedSubmarines.GetRandomUnsynced(s => s.Type == SubmarineType.Player && !s.HasTag(SubmarineTag.HideInMenus)), + Option.None, GameModePreset.DevSandbox ?? GameModePreset.Sandbox); - string seed = ToolBox.RandomSeed(16); + string seed = fixedSeed ?? ToolBox.RandomSeed(16); gamesession.StartRound(seed); Rectangle subWorldRect = Submarine.MainSub.Borders; @@ -1410,11 +1642,17 @@ namespace Barotrauma Submarine.Unload(); count++; - NewMessage("Level seed " + seed + " ok (test #" + count + ")"); + NewMessage("Level seed " + seed + " ok (test #" + count + "). Press C to abort."); #if CLIENT //dismiss round summary and any other message boxes GUIMessageBox.CloseAll(); #endif + if (amount.HasValue && count >= amount) + { + NewMessage("Testing finished successfully."); + break; + } + yield return CoroutineStatus.Running; } } @@ -1629,6 +1867,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) @@ -2056,6 +2303,28 @@ namespace Barotrauma commands.Sort((c1, c2) => c1.Names.First().CompareTo(c2.Names.First())); } + private static void HealCharacter(Character healedCharacter, bool healAll, Client targetClient = null) + { + healedCharacter.SetAllDamage(0.0f, 0.0f, 0.0f); + healedCharacter.Oxygen = 100.0f; + healedCharacter.Bloodloss = 0.0f; + healedCharacter.SetStun(0.0f, true); + if (healAll) + { + healedCharacter.CharacterHealth.RemoveAllAfflictions(); + } + + 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) { string[] splitCommand = ToolBox.SplitCommand(command); @@ -2231,12 +2500,10 @@ namespace Barotrauma } } - private static string[] ListCharacterNames() => Character.CharacterList.OrderBy(c => c.IsDead).ThenByDescending(c => c.IsHuman).ThenBy(c => c.Name).Select(c => c.Name).Distinct().ToArray(); - private static string[] ListAvailableLocations() { List locationNames = new(); - foreach(var submarine in Submarine.Loaded) + foreach (var submarine in Submarine.Loaded) { locationNames.Add(submarine.Info.Name); } @@ -2255,7 +2522,10 @@ namespace Barotrauma locationNames.Add($"{caveName}_{index}"); } } - + + if (Submarine.MainSub != null) { locationNames.Add("mainsub"); } + locationNames.Add("cursor"); + return locationNames.ToArray(); } @@ -2347,33 +2617,164 @@ namespace Barotrauma } } - private static Character FindMatchingCharacter(string[] args, bool ignoreRemotePlayers = false, Client allowedRemotePlayer = null, bool botsOnly = false) + + private static TFile GetSubmarineFile(string submarineName) where TFile : BaseSubFile { - if (args.Length == 0) return null; - - string characterName; - if (int.TryParse(args.Last(), out int characterIndex) && args.Length > 1) + List submarineFiles = GetContentFiles(); + + foreach (var file in submarineFiles) { - characterName = string.Join(" ", args.Take(args.Length - 1)).ToLowerInvariant(); + var matchingSub = SubmarineInfo.SavedSubmarines.FirstOrDefault(i => i.FilePath == file.Path.Value); + if (matchingSub != null && string.Equals(matchingSub.Name, submarineName, StringComparison.InvariantCultureIgnoreCase)) + { + return file; + } + } + + return null; + } + + private static List GetContentFiles() where TFile : ContentFile + { + var contentFiles = ContentPackageManager.EnabledPackages.All + .SelectMany(p => p.GetFiles()) + .ToList(); + + return contentFiles; + } + + private static List GetSubmarineFiles() where TFile : BaseSubFile + { + var submarineFiles = GetContentFiles() + .OrderBy(f => f.UintIdentifier).ToList(); + + return submarineFiles; + } + + private static ContentFile GetContentFile(string path) + { + var contentFiles = GetContentFiles(); + return contentFiles.FirstOrDefault(file => string.Equals(file.Path.Value, path, StringComparison.InvariantCultureIgnoreCase)); + } + + private static string[] ListContentFilePaths() + { + List contentFilePaths = new(); + + var contentFiles = GetContentFiles(); + foreach (var contentFile in contentFiles) + { + contentFilePaths.Add(contentFile.Path.Value); + } + + return contentFilePaths.ToArray(); + } + + private static string[] ListSubmarineFileNames() where TFile : BaseSubFile + { + List submarineFileNames = new List(); + + var submarineFiles = GetSubmarineFiles(); + + foreach (var file in submarineFiles) + { + var matchingSub = SubmarineInfo.SavedSubmarines.FirstOrDefault(i => i.FilePath == file.Path.Value); + if (matchingSub != null) + { + submarineFileNames.Add(matchingSub.Name); + } + } + + return submarineFileNames.ToArray(); + } + + private static IOrderedEnumerable SortSpawnedSpecies(IEnumerable characterList) => characterList.OrderBy(c => c.IsDead).ThenByDescending(c => c.IsHuman).ThenBy(c => c.Name); + + private static string[] ListCharacterNames(bool includeMeArgument = false, bool includeCrewArgument = false) => + GetCharacterNames(includeMeArgument, includeCrewArgument); + + 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(); + + private static IEnumerable FindMatchingSpecies(string[] args) + { + if (args.Length == 0) { return Array.Empty(); } + string speciesName = args[0].ToLowerInvariant(); + return FindMatchingSpecies(speciesName); + } + + 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 { - characterName = string.Join(" ", args).ToLowerInvariant(); - characterIndex = -1; + 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; } + + List matchingCharacters = null; + string characterName = null; + int characterIndex = -1; + foreach (string arg in args) + { + 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(); + matchingCharacters = Character.CharacterList.FindAll(c => + c.Name.Equals(possibleCharacterName, StringComparison.OrdinalIgnoreCase) && + (!c.IsRemotePlayer || !ignoreRemotePlayers || allowedRemotePlayer?.Character == c)); + + if (botsOnly) + { + matchingCharacters = matchingCharacters.FindAll(c => c is AICharacter); + } + if (matchingCharacters.Any()) + { + characterName = possibleCharacterName; + } + } + else if (characterName != null && int.TryParse(arg, out int possibleIndex)) + { + // If we've already found the character name, let's seek for the index. + characterIndex = possibleIndex; + } } - var matchingCharacters = Character.CharacterList.FindAll(c => - c.Name.Equals(characterName, StringComparison.OrdinalIgnoreCase) && - (!c.IsRemotePlayer || !ignoreRemotePlayers || allowedRemotePlayer?.Character == c)); - - if (botsOnly) + if (matchingCharacters == null || matchingCharacters.None()) { - matchingCharacters = matchingCharacters.FindAll(c => c is AICharacter); - } - - if (!matchingCharacters.Any()) - { - NewMessage("Character \""+ characterName + "\" not found", Color.Red); + NewMessage("No matching character found!", Color.Red); return null; } @@ -2413,35 +2814,48 @@ namespace Barotrauma Character targetCharacter = controlledCharacter; Vector2 worldPosition = cursorWorldPos; string locationNameArgument = ""; - - var availableLocations = ListAvailableLocations(); + string firstArgument = args.FirstOrDefault()?.ToLowerInvariant() ?? string.Empty; if (args.Length > 0) { - if (args.Length > 1) + string lastArgument = args.Last(); + // First seek the matching character. + if (firstArgument is not ("/me" or "/crew")) { - // remove location name from args - if (availableLocations.Contains(args.Last()) - || string.Equals(args.Last(), "mainsub", StringComparison.InvariantCultureIgnoreCase)) + var availableLocations = ListAvailableLocations(); + if (args.Length > 1 || availableLocations.None(locationName => string.Equals(locationName, lastArgument, StringComparison.OrdinalIgnoreCase))) { - locationNameArgument = args.Last(); - args = args.Take(args.Length - 1).ToArray(); - } - else - { - NewMessage("Invalid arguments", color: Color.Yellow); - return; + // Try to find a matching character, if there's more than one argument or if the last argument is not a valid location argument. + // If there's only one argument, and it's a valid location argument, we shouldn't try to parse a target character from it. + targetCharacter = FindMatchingCharacter(args, ignoreRemotePlayers: false); } } - - // the remaining args should be the character name and a possible index - if (args[0].ToLowerInvariant() != "me") + // Then seek the possible location argument. + if (args.Count() > 1) { - Character match = FindMatchingCharacter(args, ignoreRemotePlayers:false); - targetCharacter = match; + if (targetCharacter == null || !targetCharacter.Name.Equals(lastArgument, StringComparison.OrdinalIgnoreCase) && + !int.TryParse(lastArgument, out _)) + { + locationNameArgument = lastArgument; + } } } - - if (!string.IsNullOrWhiteSpace(locationNameArgument)) + 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)) { @@ -2453,10 +2867,11 @@ namespace Barotrauma return; } } - + if (targetCharacter != null) { targetCharacter.TeleportTo(worldPosition); + targetCharacter.AnimController.BodyInRest = false; } else { @@ -2464,119 +2879,238 @@ namespace Barotrauma } } - private 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; - bool addToCrew = false; - 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); - 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)); } - addToCrew = - args.Length > 3 ? - args[3].Equals("true", StringComparison.OrdinalIgnoreCase) : - isHuman; - } - else - { - spawnPoint = WayPoint.GetRandom(isHuman ? SpawnType.Human : SpawnType.Enemy); - } - - if (string.IsNullOrWhiteSpace(args[0])) { return; } - CharacterTeamType teamType = Character.Controlled != null ? Character.Controlled.TeamID : CharacterTeamType.Team1; - if (args.Length > 2) - { - try + else { - teamType = (CharacterTeamType)int.Parse(args[2]); - } - catch - { - ThrowError($"\"{args[2]}\" is not a valid team id."); + job = JobPrefab.Prefabs[characterLowerCase]; } + isHuman = job != null || characterLowerCase == CharacterPrefab.HumanSpeciesName; } - if (spawnPoint != null) { spawnPosition = spawnPoint.WorldPosition; } + ParseOptionalArgs(out Vector2 spawnPosition, out WayPoint spawnPoint, out CharacterTeamType teamType, out bool addToCrew); - if (isHuman) + 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)); - - spawnedCharacter.GiveJobItems(spawnPoint); - spawnedCharacter.GiveIdCardTags(spawnPoint); - spawnedCharacter.Info.StartItemsGiven = true; - } - 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); + newCharacter.HumanPrefab = humanPrefab; + humanPrefab.GiveItems(newCharacter, newCharacter.Submarine, spawnPoint); + humanPrefab.InitializeCharacter(newCharacter); +#if SERVER + newCharacter.LoadTalents(); + GameMain.NetworkMember.CreateEntityEvent(newCharacter, new Character.UpdateTalentsEventData()); +#endif + PostSpawnHuman(newCharacter); + }); } } - if (addToCrew && GameMain.GameSession != null) + else if (isHuman) { - spawnedCharacter.TeamID = teamType; -#if CLIENT - GameMain.GameSession.CrewManager.AddCharacter(spawnedCharacter); + 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 => + { + newCharacter.GiveJobItems(isPvPMode: GameMain.GameSession?.GameMode is PvPMode, spawnPoint); + newCharacter.GiveIdCardTags(spawnPoint); + newCharacter.Info.StartItemsGiven = true; + PostSpawnHuman(newCharacter); + }); + } + else if (CharacterPrefab.FindBySpeciesName(args[0].ToIdentifier()) is { } prefab) + { + Entity.Spawner.AddCharacterToSpawnQueue(args[0].ToIdentifier(), spawnPosition, prefab.HasCharacterInfo ? new CharacterInfo(prefab.Identifier) : null); + } + + void PostSpawnHuman(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}."); + } + } + } + } + + private static IEnumerable GetSpawnPosParams() + { + yield return "cursor"; + yield return "inventory"; + +#if SERVER + if (GameMain.Server != null) + { + foreach (var clientName in GameMain.Server.ConnectedClients.Select(c => c.Name)) + { + yield return clientName; + } + } #endif + + foreach (var characterName in Character.CharacterList.Where(c => c.Inventory != null).Select(c => c.Name).Distinct()) + { + yield return characterName; + } + } + + private static IEnumerable GetItemNameOrIdParams() + { + HashSet seen = new HashSet(); + + foreach (ItemPrefab itemPrefab in ItemPrefab.Prefabs) + { + if (seen.Add(itemPrefab.Name.Value)) + { + yield return itemPrefab.Name.Value; + } + } + + seen.Clear(); + + foreach (ItemPrefab itemPrefab in ItemPrefab.Prefabs) + { + if (seen.Add(itemPrefab.Identifier.Value)) + { + yield return itemPrefab.Identifier.Value; + } + } + } + + private static void TrySpawnItem(string[] args) + { + try + { +#if CLIENT + SpawnItem(args, Screen.Selected.Cam?.ScreenToWorld(PlayerInput.MousePosition) ?? PlayerInput.MousePosition, Character.Controlled, out string errorMsg); +#elif SERVER + SpawnItem(args, Vector2.Zero, null, out string errorMsg); +#endif + if (!string.IsNullOrWhiteSpace(errorMsg)) + { + ThrowError(errorMsg); + } + } + catch (Exception e) + { + string errorMsg = "Failed to spawn an item. Arguments: \"" + string.Join(" ", args) + "\"."; + ThrowError(errorMsg, e); + GameAnalyticsManager.AddErrorEventOnce("DebugConsole.SpawnItem:Error", GameAnalyticsManager.ErrorSeverity.Error, errorMsg + '\n' + e.Message + '\n' + e.StackTrace.CleanupStackTrace()); } } @@ -2607,17 +3141,20 @@ namespace Barotrauma return; } - int amount = 1; - if (args.Length > 1) + bool TryGetSpawnPosParam(out string spawnLocation, out int spawnLocationIndex) + { + var allSpawnPosParams = GetSpawnPosParams(); + spawnLocation = args.FirstOrDefault(s => allSpawnPosParams.Contains(s)); + spawnLocationIndex = spawnLocation != null ? args.IndexOf(spawnLocation) : -1; + + return spawnLocation != null; + } + + int amount = 1; + int conditionPrc = 100; + + if (TryGetSpawnPosParam(out string spawnLocation, out int spawnLocationIndex)) { - string spawnLocation = args.Last(); - if (args.Length > 2) - { - spawnLocation = args[^2]; - if (!int.TryParse(args[^1], NumberStyles.Any, CultureInfo.InvariantCulture, out amount)) { amount = 1; } - amount = Math.Min(amount, 100); - } - switch (spawnLocation) { case "cursor": @@ -2638,8 +3175,21 @@ namespace Barotrauma if (matchingCharacter != null){ spawnInventory = matchingCharacter.Inventory; } break; } + + if (args.Length > spawnLocationIndex + 1) + { + if (!int.TryParse(args[spawnLocationIndex + 1], NumberStyles.Any, CultureInfo.InvariantCulture, out amount)) { amount = 1; } + amount = Math.Min(amount, 100); + } + + if (args.Length > spawnLocationIndex + 2) + { + if (!int.TryParse(args[^1], NumberStyles.Any, CultureInfo.InvariantCulture, out conditionPrc)) { conditionPrc = 100; } + } } + float itemCondition = itemPrefab.Health * Math.Clamp(conditionPrc / 100f, 0f, 1f); + if ((spawnPos == null || spawnPos == Vector2.Zero) && spawnInventory == null) { var wp = WayPoint.GetRandom(SpawnType.Human, null, Submarine.MainSub); @@ -2656,7 +3206,7 @@ namespace Barotrauma } else { - Entity.Spawner?.AddItemToSpawnQueue(itemPrefab, spawnPos.Value); + Entity.Spawner?.AddItemToSpawnQueue(itemPrefab, spawnPos.Value, condition: itemCondition); } } else if (spawnInventory != null) @@ -2672,7 +3222,7 @@ namespace Barotrauma Entity.Spawner?.AddItemToSpawnQueue(itemPrefab, spawnInventory, onSpawned: onItemSpawned); } - static void onItemSpawned(Item item) + void onItemSpawned(Item item) { if (item.ParentInventory?.Owner is Character character) { @@ -2681,6 +3231,8 @@ namespace Barotrauma wifiComponent.TeamID = character.TeamID; } } + + item.Condition = item.Health * Math.Clamp(conditionPrc / 100f, 0f, 1f); } } } @@ -2916,7 +3468,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 @@ -2928,7 +3480,7 @@ namespace Barotrauma { try { - Directory.CreateDirectory(SavePath); + Directory.CreateDirectory(SavePath, catchUnauthorizedAccessExceptions: false); } catch (Exception e) { @@ -2964,7 +3516,7 @@ namespace Barotrauma try { - File.WriteAllLines(filePath + ".txt", unsavedMessages.Select(l => "[" + l.Time + "] " + l.Text)); + File.WriteAllLines(filePath + ".txt", unsavedMessages.Select(l => "[" + l.Time + "] " + l.Text), catchUnauthorizedAccessExceptions: false); } catch (Exception e) { @@ -2972,6 +3524,31 @@ namespace Barotrauma ThrowError("Saving debug console log to " + filePath + " failed", e); } } + + private static void ToggleEnemyAITargetingRestrictions(EnemyTargetingRestrictions restrictions) + { + if (restrictions == EnemyTargetingRestrictions.None) + { + // If restriction is None, clear all restrictions + EnemyAIController.TargetingRestrictions = EnemyTargetingRestrictions.None; + } + else + { + // Toggle the restriction + if (EnemyAIController.TargetingRestrictions.HasFlag(restrictions)) + { + // If the restriction is already set, remove it + EnemyAIController.TargetingRestrictions &= ~restrictions; + } + else + { + // If the restriction is not set, add it + EnemyAIController.TargetingRestrictions |= restrictions; + } + } + + NewMessage($"Monster targeting restrictions is now '{EnemyAIController.TargetingRestrictions}'", Color.Yellow); + } public static void DeactivateCheats() { diff --git a/Barotrauma/BarotraumaShared/SharedSource/DisembarkPerks/DisembarkPerkPrefab.cs b/Barotrauma/BarotraumaShared/SharedSource/DisembarkPerks/DisembarkPerkPrefab.cs new file mode 100644 index 000000000..657e7fc97 --- /dev/null +++ b/Barotrauma/BarotraumaShared/SharedSource/DisembarkPerks/DisembarkPerkPrefab.cs @@ -0,0 +1,58 @@ +using System.Collections.Immutable; +using Barotrauma.PerkBehaviors; + +namespace Barotrauma +{ + internal sealed class DisembarkPerkPrefab : PrefabWithUintIdentifier + { + public static readonly PrefabCollection Prefabs = new PrefabCollection(); + + public LocalizedString Name { get; } + public LocalizedString Description { get; } + public Identifier SortCategory { get; } + + /// + /// After the perks have been sorted by category and cost, they are sorted using this key. + /// Use if you want the perks to be arranged in specific order when their cost are the same. + /// + public int SortKey { get; } + + /// + /// When set to an identifier of another perk, this perk cannot be selected unless the prerequisite perk is selected. + /// + public Identifier Prerequisite { get; } + + /// + /// When this perk is selected, the perks in this set cannot be selected at the same time. + /// + public ImmutableHashSet MutuallyExclusivePerks { get; } + + public int Cost { get; } + + public ImmutableArray PerkBehaviors { get; } + + public DisembarkPerkPrefab(ContentXElement element, DisembarkPerkFile prefabFile) : base(prefabFile, element.GetAttributeIdentifier("identifier", "")) + { + Name = TextManager.Get($"disembarkperk.{Identifier}").Fallback(Identifier.ToString()); + Description = TextManager.Get($"disembarkperkdescription.{Identifier}").Fallback(""); + Cost = element.GetAttributeInt("cost", 0); + SortCategory = element.GetAttributeIdentifier("sortcategory", Identifier); + Prerequisite = element.GetAttributeIdentifier("prerequisite", Identifier.Empty); + MutuallyExclusivePerks = element.GetAttributeIdentifierImmutableHashSet("mutuallyexclusiveperks", ImmutableHashSet.Empty); + SortKey = element.GetAttributeInt("sortkey", 0); + + var builder = ImmutableArray.CreateBuilder(); + foreach (var child in element.Elements()) + { + if (PerkBase.TryLoadFromXml(child, this, out var perk)) + { + builder.Add(perk); + } + } + + PerkBehaviors = builder.ToImmutable(); + } + + public override void Dispose() { } + } +} \ No newline at end of file diff --git a/Barotrauma/BarotraumaShared/SharedSource/DisembarkPerks/PerkBehaviors/GiveTalentPointPerk.cs b/Barotrauma/BarotraumaShared/SharedSource/DisembarkPerks/PerkBehaviors/GiveTalentPointPerk.cs new file mode 100644 index 000000000..1e351f45e --- /dev/null +++ b/Barotrauma/BarotraumaShared/SharedSource/DisembarkPerks/PerkBehaviors/GiveTalentPointPerk.cs @@ -0,0 +1,20 @@ +using System.Collections.Generic; + +namespace Barotrauma.PerkBehaviors +{ + internal class GiveTalentPointPerk : PerkBase + { + [Serialize(0, IsPropertySaveable.Yes)] + public int Amount { get; set; } + + public GiveTalentPointPerk(ContentXElement element, DisembarkPerkPrefab prefab) : base(element, prefab) { } + + public override void ApplyOnRoundStart(IReadOnlyCollection teamCharacters, Submarine teamSubmarine) + { + foreach (Character character in teamCharacters) + { + character.Info.AdditionalTalentPoints += Amount; + } + } + } +} \ No newline at end of file diff --git a/Barotrauma/BarotraumaShared/SharedSource/DisembarkPerks/PerkBehaviors/PerkBase.cs b/Barotrauma/BarotraumaShared/SharedSource/DisembarkPerks/PerkBehaviors/PerkBase.cs new file mode 100644 index 000000000..280672b98 --- /dev/null +++ b/Barotrauma/BarotraumaShared/SharedSource/DisembarkPerks/PerkBehaviors/PerkBase.cs @@ -0,0 +1,84 @@ +#nullable enable +using System; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; + +namespace Barotrauma.PerkBehaviors +{ + internal enum PerkSimulation + { + /// + /// Perk is only run on the server, + /// other parts of the game handle the client-side effects. + /// Like serializable properties and affliction syncing. + /// + ServerOnly, + /// + /// Both the server and clients run the perk. + /// + ServerAndClients + } + + internal abstract class PerkBase : ISerializableEntity + { + public string Name { get; } + public Dictionary SerializableProperties { get; } + + public virtual PerkSimulation Simulation => PerkSimulation.ServerOnly; + + public readonly DisembarkPerkPrefab Prefab; + + protected PerkBase(ContentXElement element, DisembarkPerkPrefab prefab) + { + Name = element.Name.ToString(); + Prefab = prefab; + SerializableProperties = SerializableProperty.DeserializeProperties(this, element); + } + + public virtual bool CanApply(SubmarineInfo submarine) + { + return true; + } + + /// + /// You might notice that this function is not virtual. + /// It was at first, but there was a misunderstanding in design, + /// so I turned it into a kill switch for all perks for now. + /// If we ever want to add perks that do work when a submarine is present, + /// this function can be made virtual again and set to true in the appropriate perks. + /// + public bool CanApplyWithoutSubmarine() + => false; + + public abstract void ApplyOnRoundStart(IReadOnlyCollection teamCharacters, Submarine? teamSubmarine); + + public static bool TryLoadFromXml(ContentXElement element, DisembarkPerkPrefab prefab, [NotNullWhen(true)] out PerkBase? perk) + { + Type? type = ReflectionUtils.GetTypeWithBackwardsCompatibility(ToolBox.BarotraumaAssembly, "Barotrauma.PerkBehaviors", element.Name.ToString(), throwOnError: false, ignoreCase: true); + if (type is null) + { + DebugConsole.ThrowError($"Could not find a perk behavior of the type \"{element.Name}\".", contentPackage: element.ContentPackage); + perk = null; + return false; + } + + try + { + object? instance = Activator.CreateInstance(type, element, prefab); + if (instance is PerkBase perkInstance) + { + perk = perkInstance; + return true; + } + + throw new InvalidCastException($"Could not cast the instance of type \"{type}\" to a {nameof(PerkBase)}."); + } + catch (Exception e) + { + DebugConsole.ThrowError(e.InnerException != null ? e.InnerException.ToString() : e.ToString(), contentPackage: element.ContentPackage); + perk = null; + return false; + } + } + } +} \ No newline at end of file diff --git a/Barotrauma/BarotraumaShared/SharedSource/DisembarkPerks/PerkBehaviors/SpawnItemPerk.cs b/Barotrauma/BarotraumaShared/SharedSource/DisembarkPerks/PerkBehaviors/SpawnItemPerk.cs new file mode 100644 index 000000000..468bf582e --- /dev/null +++ b/Barotrauma/BarotraumaShared/SharedSource/DisembarkPerks/PerkBehaviors/SpawnItemPerk.cs @@ -0,0 +1,210 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Barotrauma.Extensions; +using Barotrauma.Items.Components; + +namespace Barotrauma.PerkBehaviors +{ + internal class SpawnItemPerk : PerkBase + { + public SpawnItemPerk(ContentXElement element, DisembarkPerkPrefab prefab) : base(element, prefab) { } + + public override PerkSimulation Simulation + => PerkSimulation.ServerOnly; + + [Serialize("", IsPropertySaveable.Yes)] + public Identifier Identifier { get; set; } + + [Serialize("", IsPropertySaveable.Yes)] + public Identifier Tag { get; set; } + + [Serialize(0, IsPropertySaveable.Yes)] + public int MinAmount { get; set; } + + [Serialize(0f, IsPropertySaveable.Yes)] + public float PerPlayer { get; set; } + + /// + /// When set to non-empty value, the perk will prioritize spawning items in containers + /// with this tag or identifier over the item's primary and secondary preferred containers. + /// + [Serialize("", IsPropertySaveable.Yes)] + public Identifier PriorityContainerTag { get; set; } + + public override void ApplyOnRoundStart(IReadOnlyCollection teamCharacters, Submarine teamSubmarine) + { + if (teamSubmarine is null) { return; } + + if (Entity.Spawner is null) + { + DebugConsole.ThrowError($"{nameof(SpawnItemPerk)} ({Prefab.Identifier}) failed to spawn items because EntitySpawner is null."); + return; + } + + int amount = Math.Max(MinAmount, (int)MathF.Ceiling(PerPlayer * teamCharacters.Count)); + + if (Identifier.IsEmpty) + { + if (Tag.IsEmpty) + { + DebugConsole.ThrowError($"{nameof(SpawnItemPerk)} ({Prefab.Identifier}) failed to spawn items: neither identifier or tag is set.", + contentPackage: Prefab.ContentPackage); + return; + } + var matchingItems = ItemPrefab.Prefabs.Where(ip => ip.Tags.Contains(Tag)); + if (matchingItems.None()) + { + DebugConsole.ThrowError($"{nameof(SpawnItemPerk)} ({Prefab.Identifier}) failed to spawn items: no items found with the tag \"{Tag}\".", + contentPackage: Prefab.ContentPackage); + return; + } + for (int i = 0; i < amount; i++) + { + SpawnItem(matchingItems.GetRandomUnsynced(), amount: 1); + } + } + else + { + + ItemPrefab prefab = ItemPrefab.Find(null, Identifier); + if (prefab is null) + { + DebugConsole.ThrowError($"{nameof(SpawnItemPerk)} ({Prefab.Identifier}) failed to spawn items because the ItemPrefab \"{Identifier}\" was not found.", + contentPackage: Prefab.ContentPackage); + return; + } + SpawnItem(prefab, amount); + } + + void SpawnItem(ItemPrefab prefab, int amount) + { + SuitableContainers suitableContainers = FindSuitableContainers(prefab, teamSubmarine); + + if (!suitableContainers.Any()) + { + SpawnItemInCrate(prefab, teamSubmarine, amount); + return; + } + SpawnInContainer(prefab, amount, suitableContainers, teamSubmarine); + } + } + + private readonly record struct SuitableContainers( + ICollection PriorityContainers, + ICollection PreferredContainers, + ICollection SecondaryContainers) + { + public bool Any() + => PriorityContainers.Count > 0 + || PreferredContainers.Count > 0 + || SecondaryContainers.Count > 0; + } + + private SuitableContainers FindSuitableContainers(ItemPrefab prefab, Submarine submarine) + { + HashSet priorityContainers = new(); + HashSet primaryContainers = new(); + HashSet secondaryContainers = new(); + + foreach (Item item in submarine.GetItems(alsoFromConnectedSubs: true)) + { + if (item.GetComponent() != null || item.GetComponent() != null) { continue; } + if (item.NonInteractable || item.NonPlayerTeamInteractable || item.IsHidden) { continue; } + + if (item.GetComponent() is { } container) + { + if (!container.CanBeContained(prefab)) { continue; } + + var tags = item.GetTags(); + + if (!PriorityContainerTag.IsEmpty && (tags.Contains(PriorityContainerTag) || item.Prefab.Identifier == PriorityContainerTag)) + { + priorityContainers.Add(container); + continue; + } + + if (prefab.PreferredContainers.Any(pc => pc.Primary.Any(tags.Contains))) + { + primaryContainers.Add(container); + continue; + } + + if (prefab.PreferredContainers.Any(pc => pc.Secondary.Any(tags.Contains))) + { + secondaryContainers.Add(container); + } + } + } + + return new SuitableContainers(priorityContainers, primaryContainers, secondaryContainers); + } + + private static void SpawnItemInCrate(ItemPrefab prefab, Submarine submarine, int amount) + { + var purchasedItem = new PurchasedItem(prefab, amount, buyer: null); + CargoManager.DeliverItemsToSub(new []{ purchasedItem }, submarine, cargoManager: null, showNotification: false); + } + + private static void SpawnInContainer(ItemPrefab prefab, int amount, SuitableContainers containers, Submarine submarine) + { + Dictionary containerAllocation = new(); + + int remaining = amount; + + TryAllocate(containers.PriorityContainers); + if (remaining > 0) + { + TryAllocate(containers.PreferredContainers); + if (remaining > 0) + { + TryAllocate(containers.SecondaryContainers); + } + } + + void TryAllocate(ICollection targetContainers) + => AllocateContainers(prefab, targetContainers, ref remaining, ref containerAllocation); + + foreach (var (container, howManyToPut) in containerAllocation) + { + for (int i = 0; i < howManyToPut; i++) + { + SpawnItem(prefab, container); + } + } + + if (remaining > 0) + { + SpawnItemInCrate(prefab, submarine, remaining); + } + + static void AllocateContainers(ItemPrefab prefab, ICollection containers, ref int remaining, ref Dictionary containerAllocation) + { + foreach (ItemContainer ic in containers) + { + int fit = ic.Inventory.HowManyCanBePut(prefab); + if (fit <= 0) { continue; } + + fit = Math.Min(fit, remaining); + + containerAllocation.Add(ic, fit); + remaining -= fit; + + if (remaining <= 0) { break; } + } + } + + static void SpawnItem(ItemPrefab itemPrefab, ItemContainer container) + { + if (container?.Item is null) { return; } + + Item item = new Item(itemPrefab, container.Item.Position, container.Item.Submarine); + container.Inventory.TryPutItem(item, user: null); + CargoManager.ItemSpawned(item); +#if SERVER + Entity.Spawner?.CreateNetworkEvent(new EntitySpawner.SpawnEntity(item)); +#endif + } + } + } +} \ No newline at end of file diff --git a/Barotrauma/BarotraumaShared/SharedSource/DisembarkPerks/PerkBehaviors/SubItemSwapPerk.cs b/Barotrauma/BarotraumaShared/SharedSource/DisembarkPerks/PerkBehaviors/SubItemSwapPerk.cs new file mode 100644 index 000000000..b013eaa90 --- /dev/null +++ b/Barotrauma/BarotraumaShared/SharedSource/DisembarkPerks/PerkBehaviors/SubItemSwapPerk.cs @@ -0,0 +1,61 @@ +using System; +using System.Collections.Generic; +using System.Xml.Linq; + +namespace Barotrauma.PerkBehaviors +{ + internal class SubItemSwapPerk : PerkBase + { + [Serialize("", IsPropertySaveable.Yes)] + public Identifier TargetItem { get; set; } + + [Serialize("", IsPropertySaveable.Yes)] + public Identifier ReplacementItem { get; set; } + + public override PerkSimulation Simulation + => PerkSimulation.ServerOnly; + + public SubItemSwapPerk(ContentXElement element, DisembarkPerkPrefab prefab) : base(element, prefab) { } + + public override bool CanApply(SubmarineInfo submarine) + { + XElement subElement = submarine.SubmarineElement; + + foreach (XElement element in subElement.Elements()) + { + if (!element.Name.ToString().Equals(nameof(Item), StringComparison.OrdinalIgnoreCase)) { continue; } + + Identifier identifier = element.GetAttributeIdentifier("identifier", Identifier.Empty); + if (identifier == TargetItem) + { + return true; + } + } + + return false; + } + + public override void ApplyOnRoundStart(IReadOnlyCollection teamCharacters, Submarine teamSubmarine) + { + if (teamSubmarine is null) { return; } + + List items = teamSubmarine.GetItems(true); + + ItemPrefab itemToInstall = ItemPrefab.Find(null, ReplacementItem); + if (itemToInstall is null) + { + DebugConsole.ThrowError($"Could not find item \"{ReplacementItem}\" to swap with \"{TargetItem}\"."); + return; + } + + foreach (Item item in items) + { + if (item.Prefab.Identifier == TargetItem) + { + item.ReplaceWithLinkedItems(itemToInstall); + return; + } + } + } + } +} \ No newline at end of file diff --git a/Barotrauma/BarotraumaShared/SharedSource/DisembarkPerks/PerkBehaviors/UpgradeSubmarinePerk.cs b/Barotrauma/BarotraumaShared/SharedSource/DisembarkPerks/PerkBehaviors/UpgradeSubmarinePerk.cs new file mode 100644 index 000000000..5d209f9fd --- /dev/null +++ b/Barotrauma/BarotraumaShared/SharedSource/DisembarkPerks/PerkBehaviors/UpgradeSubmarinePerk.cs @@ -0,0 +1,57 @@ +using System.Collections.Generic; + +namespace Barotrauma.PerkBehaviors +{ + internal class UpgradeSubmarinePerk : PerkBase + { + [Serialize("", IsPropertySaveable.Yes)] + public Identifier UpgradeIdentifier { get; set; } + + [Serialize("", IsPropertySaveable.Yes)] + public Identifier CategoryIdentifier { get; set; } + + [Serialize(0, IsPropertySaveable.Yes)] + public int Level { get; set; } + + public override PerkSimulation Simulation + => PerkSimulation.ServerAndClients; + + public UpgradeSubmarinePerk(ContentXElement element, DisembarkPerkPrefab prefab) : base(element, prefab) { } + + public override void ApplyOnRoundStart(IReadOnlyCollection teamCharacters, Submarine teamSubmarine) + { + if (teamSubmarine is null) { return; } + + bool prefabFound = UpgradePrefab.Prefabs.TryGet(UpgradeIdentifier, out UpgradePrefab upgradePrefab); + bool categoryFound = UpgradeCategory.Categories.TryGet(CategoryIdentifier, out UpgradeCategory upgradeCategory); + + if (!prefabFound) + { + DebugConsole.ThrowError($"{nameof(UpgradeSubmarinePerk)}: Upgrade prefab not found"); + return; + } + + if (upgradePrefab.IsWallUpgrade) + { + foreach (Structure structure in teamSubmarine.GetWalls(UpgradeManager.UpgradeAlsoConnectedSubs)) + { + structure.AddUpgrade(new Upgrade(structure, upgradePrefab, Level), createNetworkEvent: true); + } + } + else if (categoryFound) + { + foreach (Item item in teamSubmarine.GetItems(UpgradeManager.UpgradeAlsoConnectedSubs)) + { + if (upgradeCategory.CanBeApplied(item, upgradePrefab)) + { + item.AddUpgrade(new Upgrade(item, upgradePrefab, Level), createNetworkEvent: true); + } + } + } + else + { + DebugConsole.ThrowError($"{nameof(UpgradeSubmarinePerk)}: Upgrade category not found"); + } + } + } +} \ No newline at end of file diff --git a/Barotrauma/BarotraumaShared/SharedSource/Enums.cs b/Barotrauma/BarotraumaShared/SharedSource/Enums.cs index 42a47e8de..bc7e4df7f 100644 --- a/Barotrauma/BarotraumaShared/SharedSource/Enums.cs +++ b/Barotrauma/BarotraumaShared/SharedSource/Enums.cs @@ -12,6 +12,13 @@ namespace Barotrauma Exponential } + public enum SelectedSubType + { + Shuttle, + Sub, + EnemySub + } + /// /// ActionTypes define when a is executed. /// @@ -116,6 +123,14 @@ namespace Barotrauma /// OnAbility = 23, /// + /// Executes once when a specific Containable is placed inside an ItemContainer. Only valid for Containables defined in an ItemContainer component. + /// + OnInserted = 24, + /// + /// Executes once when a specific Containable is removed from an ItemContainer. Only valid for Containables defined in an ItemContainer component. + /// + OnRemoved = 25, + /// /// Executes when the character dies. Only valid for characters. /// OnDeath = OnBroken @@ -296,6 +311,11 @@ namespace Barotrauma /// RangedAttackSpeed, + /// + /// Increases the damage dealt by ranged weapons held by the character by a percentage. + /// + RangedAttackMultiplier, + /// /// Decreases the reload time of submarine turrets operated by the character by a percentage. /// @@ -588,7 +608,17 @@ namespace Barotrauma /// /// Reduces the dual wielding penalty by a percentage. /// - DualWieldingPenaltyReduction + DualWieldingPenaltyReduction, + + /// + /// Multiplier bonus to melee attacks coming from a natural weapon (limb). + /// + NaturalMeleeAttackMultiplier, + + /// + /// Multiplier bonus to ranged attacks coming from a natural weapon (limb). + /// + NaturalRangedAttackMultiplier } internal enum ItemTalentStats @@ -599,12 +629,13 @@ namespace Barotrauma EngineSpeed, EngineMaxSpeed, PumpSpeed, - PumpMaxFlow, ReactorMaxOutput, ReactorFuelConsumption, DeconstructorSpeed, FabricationSpeed, - ExtraStackSize + ExtraStackSize, + [Obsolete("Use PumpSpeed instead.")] + PumpMaxFlow = PumpSpeed, } /// @@ -723,4 +754,18 @@ namespace Barotrauma Local, Radio } + + public enum PvpTeamSelectionMode + { + PlayerPreference, + PlayerChoice, + } + + public enum BotStatus + { + PendingHireToActiveService, + PendingHireToReserveBench, + ActiveService, + ReserveBench + } } \ No newline at end of file diff --git a/Barotrauma/BarotraumaShared/SharedSource/Events/EventActions/AddScoreAction.cs b/Barotrauma/BarotraumaShared/SharedSource/Events/EventActions/AddScoreAction.cs new file mode 100644 index 000000000..ba59e5421 --- /dev/null +++ b/Barotrauma/BarotraumaShared/SharedSource/Events/EventActions/AddScoreAction.cs @@ -0,0 +1,84 @@ +namespace Barotrauma +{ + /// + /// Modifies the win score of a team in the PvP mode. + /// + class AddScoreAction : EventAction + { + [Serialize("", IsPropertySaveable.Yes, description: "Tag of a target (character) whose team the score should be given to.")] + public Identifier TargetTag { get; set; } + + [Serialize(CharacterTeamType.None, IsPropertySaveable.Yes, description: $"Which team's score to add to? Ignored if {nameof(TargetTag)} is set.")] + public CharacterTeamType Team { get; set; } + + [Serialize(1, IsPropertySaveable.Yes, description: "How much to add to the score? Can also be negative.")] + public int Amount { get; set; } + + public AddScoreAction(ScriptedEvent parentEvent, ContentXElement element) : base(parentEvent, element) + { + if (Amount == 0) + { + DebugConsole.ThrowError($"Error in {nameof(AddScoreAction)}, event {parentEvent.Prefab.Identifier}: score set to 0, the action will do nothing.", contentPackage: element.ContentPackage); + } + if (TargetTag.IsEmpty && Team == CharacterTeamType.None) + { + DebugConsole.ThrowError($"Error in {nameof(AddScoreAction)}, event {parentEvent.Prefab.Identifier}: neither {nameof(Team)} or {nameof(TargetTag)} is set.", contentPackage: element.ContentPackage); + } + } + + private bool isFinished = false; + + public override bool IsFinished(ref string goTo) + { + return isFinished; + } + + public override void Reset() + { + isFinished = false; + } + + public override void Update(float deltaTime) + { + if (isFinished) { return; } + + CharacterTeamType targetTeam = CharacterTeamType.None; + if (TargetTag.IsEmpty) + { + targetTeam = Team; + } + else + { + foreach (var target in ParentEvent.GetTargets(TargetTag)) + { + if (target is Character character) + { + targetTeam = character.TeamID; + break; + } + } + } + if (targetTeam == CharacterTeamType.None) { return; } + +#if SERVER + if (GameMain.GameSession?.Missions is { } missions) + { + foreach (var mission in missions) + { + if (mission is CombatMission combatMission) + { + combatMission.AddToScore(targetTeam, Amount); + } + } + } +#endif + isFinished = true; + } + + public override string ToDebugString() + { + string target = TargetTag.IsEmpty ? $"team: {Team.ColorizeObject()}" : $"target: {TargetTag}"; + return $"{ToolBox.GetDebugSymbol(isFinished)} {nameof(AddScoreAction)} -> ({target}, amount: {Amount.ColorizeObject()})"; + } + } +} \ 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 987932e55..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) { @@ -45,7 +48,6 @@ namespace Barotrauma foreach (ContentXElement subElement in conditionalElements) { conditionalList.AddRange(PropertyConditional.FromXElement(subElement)); - break; } Conditionals = conditionalList.ToImmutableArray(); } @@ -85,7 +87,7 @@ namespace Barotrauma { foreach (var target in targets) { - ApplyTagsToHulls(target as Entity, ApplyTagToHull, ApplyTagToLinkedHulls); + ApplyTagsToTarget(target); } return true; } @@ -96,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/CheckDataAction.cs b/Barotrauma/BarotraumaShared/SharedSource/Events/EventActions/CheckDataAction.cs index 91f328b5e..42f159021 100644 --- a/Barotrauma/BarotraumaShared/SharedSource/Events/EventActions/CheckDataAction.cs +++ b/Barotrauma/BarotraumaShared/SharedSource/Events/EventActions/CheckDataAction.cs @@ -200,6 +200,10 @@ namespace Barotrauma { condition = $"{value1.ColorizeObject()} {Operator.ColorizeObject()} {value2.ColorizeObject()}"; } + else if (!Identifier.IsEmpty) + { + condition = $"{Identifier} {Condition}".ColorizeObject(); + } return $"{ToolBox.GetDebugSymbol(succeeded.HasValue)} {nameof(CheckDataAction)} -> (Data: {Identifier.ColorizeObject()}, Success: {succeeded.ColorizeObject()}, Expression: {condition})"; } diff --git a/Barotrauma/BarotraumaShared/SharedSource/Events/EventActions/CheckItemAction.cs b/Barotrauma/BarotraumaShared/SharedSource/Events/EventActions/CheckItemAction.cs index b9a819843..6d2e16e74 100644 --- a/Barotrauma/BarotraumaShared/SharedSource/Events/EventActions/CheckItemAction.cs +++ b/Barotrauma/BarotraumaShared/SharedSource/Events/EventActions/CheckItemAction.cs @@ -66,13 +66,12 @@ namespace Barotrauma public CheckItemAction(ScriptedEvent parentEvent, ContentXElement element) : base(parentEvent, element) { - itemIdentifierSplit = ItemIdentifiers.Split(',').ToIdentifiers(); - itemTags = ItemTags.Split(",").ToIdentifiers(); + itemIdentifierSplit = ItemIdentifiers.ToIdentifiers().ToArray(); + itemTags = ItemTags.ToIdentifiers().ToArray(); var conditionalList = new List(); foreach (ContentXElement subElement in element.GetChildElements("conditional")) { conditionalList.AddRange(PropertyConditional.FromXElement(subElement)); - break; } conditionals = conditionalList; diff --git a/Barotrauma/BarotraumaShared/SharedSource/Events/EventActions/CheckVisibilityAction.cs b/Barotrauma/BarotraumaShared/SharedSource/Events/EventActions/CheckVisibilityAction.cs index 13a217c0d..54fb8f59b 100644 --- a/Barotrauma/BarotraumaShared/SharedSource/Events/EventActions/CheckVisibilityAction.cs +++ b/Barotrauma/BarotraumaShared/SharedSource/Events/EventActions/CheckVisibilityAction.cs @@ -50,7 +50,7 @@ namespace Barotrauma { if (!AllowSameEntity && entity == target) { continue; } if (Vector2.DistanceSquared(target.WorldPosition, entity.WorldPosition) > MaxDistance * MaxDistance) { continue; } - if (Character.IsTargetVisible(target, entity, seeThroughWindows: true, CheckFacing)) + if (ISpatialEntity.IsTargetVisible(target, entity, seeThroughWindows: true, CheckFacing)) { if (!ApplyTagToEntity.IsEmpty) { diff --git a/Barotrauma/BarotraumaShared/SharedSource/Events/EventActions/ConversationAction.cs b/Barotrauma/BarotraumaShared/SharedSource/Events/EventActions/ConversationAction.cs index dd9c5b7a0..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; @@ -113,13 +115,15 @@ namespace Barotrauma Text = elem.GetAttributeString("tag", string.Empty); textElement = elem; } - } - if (element.GetChildElement("Replace") != null) - { - DebugConsole.ThrowError( - $"Error in {nameof(EventObjectiveAction)} in the event \"{parentEvent.Prefab.Identifier}\"" + - $" - unrecognized child element \"Replace\".", - contentPackage: element.ContentPackage); + else + { + string thisName = nameof(ConversationAction); + DebugConsole.ThrowError( + $"Error in {thisName} in the event \"{parentEvent.Prefab.Identifier}\"" + + $" - unrecognized child element \"{elem.Name}\". If it's an action intended to execute after the {thisName}, " + + $"it should be after the {thisName}, not inside it.", + contentPackage: element.ContentPackage); + } } } @@ -165,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(); @@ -209,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; @@ -238,20 +256,33 @@ namespace Barotrauma { humanAI.ClearForcedOrder(); if (prevIdleObjective != null) { humanAI.ObjectiveManager.AddObjective(prevIdleObjective); } - if (prevGotoObjective != null) { humanAI.ObjectiveManager.AddObjective(prevGotoObjective); } + if (prevGotoObjective != null && !prevGotoObjective.Abandon) { humanAI.ObjectiveManager.AddObjective(prevGotoObjective); } humanAI.ObjectiveManager.SortObjectives(); } } public int[] GetEndingOptions() { - List endings = Options.Where(group => !group.Actions.Any() || group.EndConversation).Select(group => Options.IndexOf(group)).ToList(); + List endings = Options + .Where(group => + group.EndConversation || + //no actions = safe to assume this must end the conversation + !group.Actions.Any() || + //no follow-up conversation and a goto makes the event jump somewhere else + //we cannot easily determine whether that goto will lead to a follow-up conversation, + //so it's safest to close this conversation to prevent it from getting stuck (the potential follow-up will open a new one) + (group.Actions.None(a => a is ConversationAction) && group.Actions.Any(a => a is GoTo { EndConversation: true }))) + .Select(group => Options.IndexOf(group)) + .ToList(); if (!ContinueConversation) { endings.Add(-1); } return endings.ToArray(); } public override void Update(float deltaTime) { + startDelay -= deltaTime; + if (startDelay > 0) { return; } + if (interrupt) { Interrupted?.Update(deltaTime); @@ -388,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 (targetCharacter != null && IsBlockedByAnotherConversation(targetCharacter.ToEnumerable(), 0.1f)) { return; } if (speaker?.AIController is HumanAIController humanAI) { diff --git a/Barotrauma/BarotraumaShared/SharedSource/Events/EventActions/EventAction.cs b/Barotrauma/BarotraumaShared/SharedSource/Events/EventActions/EventAction.cs index 97e01c2b3..f1a54e742 100644 --- a/Barotrauma/BarotraumaShared/SharedSource/Events/EventActions/EventAction.cs +++ b/Barotrauma/BarotraumaShared/SharedSource/Events/EventActions/EventAction.cs @@ -11,6 +11,10 @@ namespace Barotrauma { public string Text; public List Actions; + /// + /// Should this option end the conversation (closing the conversation prompt?). By default, options that don't have any actions inside them, or that only have a GoTo action, end the conversation. + /// But if there are other actions inside the option, the game assumes there may be some kind of a follow-up coming to the conversation, and by default leaves it open. + /// public bool EndConversation; private int currentSubAction = 0; diff --git a/Barotrauma/BarotraumaShared/SharedSource/Events/EventActions/GoTo.cs b/Barotrauma/BarotraumaShared/SharedSource/Events/EventActions/GoTo.cs index bc83b8afd..7df0cc135 100644 --- a/Barotrauma/BarotraumaShared/SharedSource/Events/EventActions/GoTo.cs +++ b/Barotrauma/BarotraumaShared/SharedSource/Events/EventActions/GoTo.cs @@ -1,4 +1,4 @@ -namespace Barotrauma +namespace Barotrauma { /// /// Makes the event jump to a somewhere else in the event. @@ -11,6 +11,9 @@ namespace Barotrauma [Serialize(-1, IsPropertySaveable.Yes, description: "How many times can this GoTo action be repeated? Can be used to make some parts of an event repeat a limited number of times. If negative or zero, there's no limit.")] public int MaxTimes { get; set; } + [Serialize(true, IsPropertySaveable.Yes, description: "By default, jumping to another part in the event closes the active conversation prompt. Use this if if you want to keep it open instead.")] + public bool EndConversation { get; set; } + private int counter; public GoTo(ScriptedEvent parentEvent, ContentXElement element) : base(parentEvent, element) { } diff --git a/Barotrauma/BarotraumaShared/SharedSource/Events/EventActions/MissionAction.cs b/Barotrauma/BarotraumaShared/SharedSource/Events/EventActions/MissionAction.cs index a423ce459..bef4acc57 100644 --- a/Barotrauma/BarotraumaShared/SharedSource/Events/EventActions/MissionAction.cs +++ b/Barotrauma/BarotraumaShared/SharedSource/Events/EventActions/MissionAction.cs @@ -1,4 +1,4 @@ -using Barotrauma.Extensions; +using Barotrauma.Extensions; using Microsoft.Xna.Framework; using System; using System.Collections.Generic; @@ -213,7 +213,7 @@ namespace Barotrauma return false; } } - if (!locationTypes.Contains(location.Type.Identifier) && !(location.HasOutpost() && locationTypes.Contains("AnyOutpost".ToIdentifier()))) + if (!locationTypes.Contains(location.Type.Identifier) && !(location.HasOutpost() && locationTypes.Contains(Tags.AnyOutpost))) { return false; } diff --git a/Barotrauma/BarotraumaShared/SharedSource/Events/EventActions/MissionStateAction.cs b/Barotrauma/BarotraumaShared/SharedSource/Events/EventActions/MissionStateAction.cs index 98dcfd8f7..48ef33028 100644 --- a/Barotrauma/BarotraumaShared/SharedSource/Events/EventActions/MissionStateAction.cs +++ b/Barotrauma/BarotraumaShared/SharedSource/Events/EventActions/MissionStateAction.cs @@ -1,4 +1,4 @@ -namespace Barotrauma +namespace Barotrauma { /// @@ -31,6 +31,11 @@ namespace Barotrauma DebugConsole.ThrowError($"Error in event \"{parentEvent.Prefab.Identifier}\": MissionIdentifier has not been configured.", contentPackage: element.ContentPackage); } + if (Operation == OperationType.Add && State == 0) + { + DebugConsole.AddWarning($"Potential error in event \"{parentEvent.Prefab.Identifier}\": {nameof(MissionStateAction)} is set to add 0 to the mission state, which will do nothing.", + contentPackage: element.ContentPackage); + } } public override bool IsFinished(ref string goTo) @@ -55,7 +60,7 @@ namespace Barotrauma mission.State = State; break; case OperationType.Add: - mission.State += 1; + mission.State += State; break; } } diff --git a/Barotrauma/BarotraumaShared/SharedSource/Events/EventActions/NPCChangeTeamAction.cs b/Barotrauma/BarotraumaShared/SharedSource/Events/EventActions/NPCChangeTeamAction.cs index 7269d169f..675f07bf5 100644 --- a/Barotrauma/BarotraumaShared/SharedSource/Events/EventActions/NPCChangeTeamAction.cs +++ b/Barotrauma/BarotraumaShared/SharedSource/Events/EventActions/NPCChangeTeamAction.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.Linq; @@ -42,13 +42,13 @@ namespace Barotrauma { if (isFinished) { return; } - bool isPlayerTeam = TeamID == CharacterTeamType.Team1 || TeamID == CharacterTeamType.Team2; + bool isPlayerTeam = TeamID is CharacterTeamType.Team1 or CharacterTeamType.Team2; - affectedNpcs = ParentEvent.GetTargets(NPCTag).Where(c => c is Character).Select(c => c as Character).ToList(); - foreach (var npc in affectedNpcs) + affectedNpcs = ParentEvent.GetTargets(NPCTag).OfType().ToList(); + foreach (Character npc in affectedNpcs) { // characters will still remain on friendlyNPC team for rest of the tick - npc.SetOriginalTeam(TeamID); + npc.SetOriginalTeamAndChangeTeam(TeamID); foreach (Item item in npc.Inventory.AllItems) { var idCard = item.GetComponent(); @@ -61,26 +61,43 @@ namespace Barotrauma } } } - if (AddToCrew && isPlayerTeam) + if (GameMain.GameSession.CrewManager is CrewManager crewManager) { - npc.Info.StartItemsGiven = true; - GameMain.GameSession.CrewManager.AddCharacter(npc); - ChangeItemTeam(Submarine.MainSub, true); - if (GameMain.NetworkMember != null && GameMain.NetworkMember.IsServer) + if (AddToCrew && isPlayerTeam) { - GameMain.NetworkMember.CreateEntityEvent(npc, new Character.AddToCrewEventData(TeamID, npc.Inventory.AllItems)); - } - } - else if (RemoveFromCrew && (npc.TeamID == CharacterTeamType.Team1 || npc.TeamID == CharacterTeamType.Team2)) - { - npc.Info.StartItemsGiven = true; - GameMain.GameSession.CrewManager.RemoveCharacter(npc, removeInfo: true); - var sub = Submarine.Loaded.FirstOrDefault(s => s.TeamID == TeamID); - ChangeItemTeam(sub, false); - if (GameMain.NetworkMember != null && GameMain.NetworkMember.IsServer) - { - GameMain.NetworkMember.CreateEntityEvent(npc, new Character.RemoveFromCrewEventData(TeamID, npc.Inventory.AllItems)); + if (npc.Info is CharacterInfo info) + { + info.StartItemsGiven = true; + crewManager.AddCharacter(npc); + } + else + { + DebugConsole.AddWarning($"Attempted to change the team of a character ({npc.Name}) that doesn't have Character Info. Can't add to the crew."); + } + 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.FindAllItems(recursive: true))); + } } + else if (RemoveFromCrew && npc.TeamID is CharacterTeamType.Team1 or CharacterTeamType.Team2) + { + if (npc.Info is CharacterInfo info) + { + info.StartItemsGiven = true; + crewManager.RemoveCharacter(npc, removeInfo: true); + } + else + { + DebugConsole.AddWarning($"Attempted to change the team of a character ({npc.Name}) that doesn't have Character Info. Can't remove from the crew."); + } + Submarine sub = Submarine.Loaded.FirstOrDefault(s => s.TeamID == TeamID); + ChangeItemTeam(sub, false); + if (GameMain.NetworkMember != null && GameMain.NetworkMember.IsServer) + { + GameMain.NetworkMember.CreateEntityEvent(npc, new Character.RemoveFromCrewEventData(TeamID, npc.Inventory.FindAllItems(recursive: true))); + } + } } void ChangeItemTeam(Submarine sub, bool allowStealing) @@ -98,7 +115,7 @@ namespace Barotrauma } } WayPoint subWaypoint = - WayPoint.WayPointList.Find(wp => wp.Submarine == sub && wp.SpawnType == SpawnType.Human && wp.AssignedJob == npc.Info.Job?.Prefab) ?? + WayPoint.WayPointList.Find(wp => wp.Submarine == sub && wp.SpawnType == SpawnType.Human && wp.AssignedJob == npc.Info?.Job?.Prefab) ?? WayPoint.WayPointList.Find(wp => wp.Submarine == sub && wp.SpawnType == SpawnType.Human); if (subWaypoint != null) { 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 472b60461..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; } @@ -86,7 +98,7 @@ namespace Barotrauma { foreach (var objective in humanAiController.ObjectiveManager.Objectives) { - if (objective is AIObjectiveOperateItem operateItemObjective && operateItemObjective.OperateTarget == target) + if (objective is AIObjectiveOperateItem operateItemObjective && operateItemObjective.Component.Item == target) { objective.Abandon = true; } @@ -115,7 +127,7 @@ namespace Barotrauma if (npc.Removed || npc.AIController is not HumanAIController humanAiController) { continue; } foreach (var operateItemObjective in humanAiController.ObjectiveManager.GetActiveObjectives()) { - if (operateItemObjective.OperateTarget == target) + if (operateItemObjective.Component.Item == target) { operateItemObjective.Abandon = true; } 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/RemoveItemAction.cs b/Barotrauma/BarotraumaShared/SharedSource/Events/EventActions/RemoveItemAction.cs index 75c446b46..7b1680303 100644 --- a/Barotrauma/BarotraumaShared/SharedSource/Events/EventActions/RemoveItemAction.cs +++ b/Barotrauma/BarotraumaShared/SharedSource/Events/EventActions/RemoveItemAction.cs @@ -25,7 +25,7 @@ namespace Barotrauma { ItemIdentifiers = element.GetAttributeString("itemidentifier", element.GetAttributeString("identifier", string.Empty)); } - itemIdentifierSplit = ItemIdentifiers.Split(',').ToIdentifiers().ToImmutableHashSet(); + itemIdentifierSplit = ItemIdentifiers.ToIdentifiers().ToImmutableHashSet(); } private bool isFinished = false; diff --git a/Barotrauma/BarotraumaShared/SharedSource/Events/EventActions/SpawnAction.cs b/Barotrauma/BarotraumaShared/SharedSource/Events/EventActions/SpawnAction.cs index 507d547bc..aead92a1d 100644 --- a/Barotrauma/BarotraumaShared/SharedSource/Events/EventActions/SpawnAction.cs +++ b/Barotrauma/BarotraumaShared/SharedSource/Events/EventActions/SpawnAction.cs @@ -17,6 +17,8 @@ namespace Barotrauma MainSub, Outpost, MainPath, + Cave, + AbyssCave, Ruin, Wreck, BeaconStation, @@ -38,6 +40,9 @@ namespace Barotrauma [Serialize("", IsPropertySaveable.Yes, description: "Identifier of the item to spawn.")] public Identifier ItemIdentifier { get; set; } + [Serialize("", IsPropertySaveable.Yes, description: "Tag of the item to spawn.")] + public Identifier ItemTag { get; set; } + [Serialize("", IsPropertySaveable.Yes, description: "The spawned entity will be assigned this tag. The tag can be used to refer to the entity by other actions of the event.")] public Identifier TargetTag { get; set; } @@ -67,6 +72,9 @@ namespace Barotrauma [Serialize(1, IsPropertySaveable.Yes, description: "Number of entities to spawn.")] public int Amount { get; set; } + [Serialize(true, IsPropertySaveable.Yes, description: "Should the item be spawned even if the target inventory is full (just spawning it at the position of the target)? Only valid if spawning an item in an inventory.")] + public bool SpawnIfInventoryFull { get; set; } + [Serialize(100.0f, IsPropertySaveable.Yes, description: "Random offset to add to the spawn position.")] public float Offset { get; set; } @@ -94,6 +102,9 @@ namespace Barotrauma [Serialize(true, IsPropertySaveable.Yes, description: "If disabled, the action will choose a spawn position away from players' views if one is available.")] public bool AllowInPlayerView { get; set; } + [Serialize(false, IsPropertySaveable.Yes, description: "Should the event continue even if the entity failed to spawn for whatever reason?")] + public bool ContinueIfFailedToSpawn { get; set; } + private bool spawned; private Entity spawnedEntity; @@ -115,9 +126,9 @@ namespace Barotrauma public override bool IsFinished(ref string goTo) { - if (spawnedEntity != null) + if (spawnedEntity != null || ContinueIfFailedToSpawn) { - return true; + return spawned; } else { @@ -157,7 +168,7 @@ namespace Barotrauma logError: false); } - humanPrefab ??= NPCSet.Get(NPCSetIdentifier, NPCIdentifier, logError: true); + humanPrefab ??= NPCSet.Get(NPCSetIdentifier, NPCIdentifier, logError: true, contentPackageToLogInError: ParentEvent.Prefab.ContentPackage); if (humanPrefab != null) { @@ -176,7 +187,11 @@ namespace Barotrauma { if (newCharacter == null) { return; } newCharacter.HumanPrefab = humanPrefab; - newCharacter.TeamID = TeamID; + //don't set the TeamID directly: we want to leave the character's original team untouched, + //so they can behave offensively (and otherwise act "normally") if we spawn them in a hostile team inside a sub/outpost that doesn't belong to that team + + //process the team change immediately in case the character is killed or made unconscious by the event (in which case the team change would not be processed) + newCharacter.SetOriginalTeamAndChangeTeam(TeamID, processImmediately: true); newCharacter.EnableDespawn = false; humanPrefab.GiveItems(newCharacter, newCharacter.Submarine, spawnPos as WayPoint); if (LootingIsStealing) @@ -233,74 +248,82 @@ namespace Barotrauma } } } - else if (!ItemIdentifier.IsEmpty) + else if (!ItemIdentifier.IsEmpty || !ItemTag.IsEmpty) { - if (MapEntityPrefab.FindByIdentifier(ItemIdentifier) is not ItemPrefab itemPrefab) + ItemPrefab itemPrefab = null; + if (!ItemIdentifier.IsEmpty) { - DebugConsole.ThrowError("Error in SpawnAction (item prefab \"" + ItemIdentifier + "\" not found)", - contentPackage: ParentEvent.Prefab.ContentPackage); - } - else - { - Inventory spawnInventory = null; - if (!TargetInventory.IsEmpty) + itemPrefab = MapEntityPrefab.FindByIdentifier(ItemIdentifier) as ItemPrefab; + if (itemPrefab == null) { - var targets = ParentEvent.GetTargets(TargetInventory); - if (targets.Any()) - { - var target = targets.First(t => t is Item || t is Character); - if (target is Character character) - { - spawnInventory = character.Inventory; - } - else if (target is Item item) - { - spawnInventory = item.OwnInventory; - } - } + DebugConsole.ThrowError($"Error in SpawnAction (item prefab \"{ItemIdentifier}\" not found)", + contentPackage: ParentEvent.Prefab.ContentPackage); + } + } + else if (!ItemTag.IsEmpty) + { + itemPrefab = ItemPrefab.Prefabs.Where(ip => ip.Tags.Contains(ItemTag)).GetRandom(Rand.RandSync.Unsynced); + } - if (spawnInventory == null) + Inventory spawnInventory = null; + if (!TargetInventory.IsEmpty) + { + var targets = ParentEvent.GetTargets(TargetInventory); + if (targets.Any()) + { + var target = targets.First(t => t is Item || t is Character); + if (target is Character character) { - DebugConsole.ThrowError($"Could not spawn \"{ItemIdentifier}\" in target inventory \"{TargetInventory}\" - matching target not found.", - contentPackage: ParentEvent.Prefab.ContentPackage); + spawnInventory = character.Inventory; + } + else if (target is Item item) + { + spawnInventory = item.OwnInventory; } } if (spawnInventory == null) { - ISpatialEntity spawnPos = GetSpawnPos(); - if (spawnPos != null) - { - for (int i = 0; i < Amount; i++) - { - Entity.Spawner.AddItemToSpawnQueue(itemPrefab, OffsetSpawnPos(spawnPos.WorldPosition, Rand.Range(0.0f, Offset)), onSpawned: onSpawned); - } - } + DebugConsole.ThrowError($"Could not spawn \"{ItemIdentifier}\" in target inventory \"{TargetInventory}\" - matching target not found.", + contentPackage: ParentEvent.Prefab.ContentPackage); } - else + } + + if (spawnInventory == null) + { + ISpatialEntity spawnPos = GetSpawnPos(); + if (spawnPos != null) { for (int i = 0; i < Amount; i++) { - Entity.Spawner.AddItemToSpawnQueue(itemPrefab, spawnInventory, onSpawned: onSpawned); - + Entity.Spawner.AddItemToSpawnQueue(itemPrefab, OffsetSpawnPos(spawnPos.WorldPosition, Rand.Range(0.0f, Offset)), onSpawned: onSpawned); } } - void onSpawned(Item newItem) - { - if (newItem != null) - { - if (!TargetTag.IsEmpty) - { - ParentEvent.AddTarget(TargetTag, newItem); - } - if (IgnoreByAI) - { - newItem.AddTag("ignorebyai"); - } - } - spawnedEntity = newItem; - } } + else + { + for (int i = 0; i < Amount; i++) + { + Entity.Spawner.AddItemToSpawnQueue(itemPrefab, spawnInventory, spawnIfInventoryFull: SpawnIfInventoryFull, onSpawned: onSpawned); + + } + } + void onSpawned(Item newItem) + { + if (newItem != null) + { + if (!TargetTag.IsEmpty) + { + ParentEvent.AddTarget(TargetTag, newItem); + } + if (IgnoreByAI) + { + newItem.AddTag("ignorebyai"); + } + } + spawnedEntity = newItem; + } + } spawned = true; @@ -353,8 +376,7 @@ namespace Barotrauma { SpawnLocationType.Any => true, SpawnLocationType.MainSub => submarine == Submarine.MainSub, - SpawnLocationType.NearMainSub => submarine == null, - SpawnLocationType.MainPath => submarine == null, + SpawnLocationType.NearMainSub or SpawnLocationType.MainPath or SpawnLocationType.Cave or SpawnLocationType.AbyssCave => submarine == null, SpawnLocationType.Outpost => submarine is { Info.IsOutpost: true }, SpawnLocationType.Wreck => submarine is { Info.IsWreck: true }, SpawnLocationType.Ruin => submarine is { Info.IsRuin: true }, @@ -443,10 +465,25 @@ namespace Barotrauma return potentialSpawnPoints.GetRandomUnsynced(); } - if (spawnLocation == SpawnLocationType.MainPath || spawnLocation == SpawnLocationType.NearMainSub) + switch (spawnLocation) { - validSpawnPoints = validSpawnPoints.Where(p => - Submarine.Loaded.None(s => ToolBox.GetWorldBounds(s.Borders.Center, s.Borders.Size).ContainsWorld(p.WorldPosition))); + case SpawnLocationType.MainPath: + case SpawnLocationType.NearMainSub: + validSpawnPoints = validSpawnPoints.Where(p => + Submarine.Loaded.None(s => ToolBox.GetWorldBounds(s.Borders.Center, s.Borders.Size).ContainsWorld(p.WorldPosition))); + if (Level.Loaded != null) + { + validSpawnPoints = validSpawnPoints.Where(p => + p.WorldPosition.Y > Level.Loaded.AbyssStart && + p.Cave == null && p.Ruin == null); + } + break; + case SpawnLocationType.Cave: + validSpawnPoints = validSpawnPoints.Where(p => p.WorldPosition.Y > Level.Loaded.AbyssStart && p.Cave != null); + break; + case SpawnLocationType.AbyssCave: + validSpawnPoints = validSpawnPoints.Where(p => p.WorldPosition.Y < Level.Loaded.AbyssStart && p.Cave != null); + break; } //avoid using waypoints if there's any actual spawnpoints available diff --git a/Barotrauma/BarotraumaShared/SharedSource/Events/EventActions/TagAction.cs b/Barotrauma/BarotraumaShared/SharedSource/Events/EventActions/TagAction.cs index e607b2b7c..d448ad904 100644 --- a/Barotrauma/BarotraumaShared/SharedSource/Events/EventActions/TagAction.cs +++ b/Barotrauma/BarotraumaShared/SharedSource/Events/EventActions/TagAction.cs @@ -11,9 +11,10 @@ namespace Barotrauma /// class TagAction : EventAction { - public enum SubType { Any = 0, Player = 1, Outpost = 2, Wreck = 4, BeaconStation = 8 } + public enum SubType { Any = 0, Player = 1, Outpost = 2, Wreck = 4, BeaconStation = 8, Enemy = 16, Ruin = 32 } + public enum CharacterTeam { Any = 0, None = 1, Team1 = 2, Team2 = 4, FriendlyNPC = 8 } - [Serialize("", IsPropertySaveable.Yes, description: "What criteria to use to select the entities to target. Valid values are players, player, traitor, nontraitor, nontraitorplayer, bot, crew, humanprefabidentifier:[id], jobidentifier:[id], structureidentifier:[id], structurespecialtag:[tag], itemidentifier:[id], itemtag:[tag], hull, hullname:[name], submarine:[type], eventtag:[tag].")] + [Serialize("", IsPropertySaveable.Yes, description: "What criteria to use to select the entities to target. Valid values are players, player, traitor, nontraitor, nontraitorplayer, bot, crew, humanprefabidentifier:[id], jobidentifier:[id], structureidentifier:[id], structurespecialtag:[tag], itemidentifier:[id], itemtag:[tag], hull, hullname:[name], submarine:[type], eventtag:[tag], speciesname:[id].")] public string Criteria { get; set; } [Serialize("", IsPropertySaveable.Yes, description: "The tag to apply to the target.")] @@ -22,6 +23,9 @@ namespace Barotrauma [Serialize(SubType.Any, IsPropertySaveable.Yes, description: "The type of submarine the target needs to be in.")] public SubType SubmarineType { get; set; } + [Serialize(CharacterTeam.Any, IsPropertySaveable.Yes, description: "The team the target needs to be on.")] + public CharacterTeam Team { get; set; } + [Serialize("", IsPropertySaveable.Yes, "If set, the target must be in an outpost module that has this tag.")] public Identifier RequiredModuleTag { get; set; } @@ -34,6 +38,9 @@ namespace Barotrauma [Serialize(false, IsPropertySaveable.Yes, description: "If there are multiple matching targets, should all of them be tagged or one chosen randomly?")] public bool ChooseRandom { get; set; } + [Serialize("", IsPropertySaveable.Yes, description: "If choosing a random target, targets with this tag can optionally be excluded.")] + public Identifier ChooseRandomExcludingTag { get; set; } + [Serialize(false, IsPropertySaveable.Yes, description: "Should the event continue if the TagAction can't find any valid targets?")] public bool ContinueIfNoTargetsFound { get; set; } @@ -78,6 +85,7 @@ namespace Barotrauma ("hullname", TagHullsByName), ("submarine", TagSubmarinesByType), ("eventtag", TagByEventTag), + ("speciesname", TagBySpeciesName) }.Select(t => (t.k.ToIdentifier(), t.v)).ToImmutableDictionary(); } @@ -87,9 +95,16 @@ namespace Barotrauma } public override void Reset() { + taggingDone = false; + cantFindTargets = false; isFinished = false; } + private void TagBySpeciesName(Identifier speciesName) + { + AddTarget(Tag, Character.CharacterList.Where(c => c.SpeciesName == speciesName && CharacterTeamMatches(c))); + } + private void TagByEventTag(Identifier eventTag) { AddTarget(Tag, ParentEvent.GetTargets(eventTag).Where(t => MatchesRequirements(t))); @@ -100,7 +115,7 @@ namespace Barotrauma AddTargetPredicate( Tag, ScriptedEvent.TargetPredicate.EntityType.Character, - e => e is Character c && c.IsPlayer && (!c.IsIncapacitated || !IgnoreIncapacitatedCharacters)); + e => e is Character c && c.IsPlayer && (!c.IsIncapacitated || !IgnoreIncapacitatedCharacters) && CharacterTeamMatches(c)); } private void TagTraitors() @@ -151,7 +166,7 @@ namespace Barotrauma private void TagHumansByIdentifier(Identifier identifier) { - AddTarget(Tag, Character.CharacterList.Where(c => c.HumanPrefab?.Identifier == identifier)); + AddTarget(Tag, Character.CharacterList.Where(c => c.HumanPrefab?.Identifier == identifier && CharacterTeamMatches(c))); } private void TagHumansByTag(Identifier tag) @@ -161,7 +176,7 @@ namespace Barotrauma private void TagHumansByJobIdentifier(Identifier jobIdentifier) { - AddTarget(Tag, Character.CharacterList.Where(c => c.HasJob(jobIdentifier))); + AddTarget(Tag, Character.CharacterList.Where(c => c.HasJob(jobIdentifier) && CharacterTeamMatches(c))); } private void TagStructuresByIdentifier(Identifier identifier) @@ -233,7 +248,7 @@ namespace Barotrauma private bool MatchesRequirements(Entity e) { - return ModuleTagMatches(e) && SubmarineTypeMatches(e.Submarine); + return ModuleTagMatches(e) && SubmarineTypeMatches(e as Submarine ?? e.Submarine); } private bool ModuleTagMatches(Entity e) @@ -267,6 +282,23 @@ namespace Barotrauma return hull != null && hull.OutpostModuleTags.Contains(RequiredModuleTag); } + private bool CharacterTeamMatches(Character character) + { + if (Team == CharacterTeam.Any) { return true; } + switch (Team) + { + case CharacterTeam.None: + return character.TeamID == CharacterTeamType.None; + case CharacterTeam.Team1: + return character.TeamID == CharacterTeamType.Team1; + case CharacterTeam.Team2: + return character.TeamID == CharacterTeamType.Team2; + case CharacterTeam.FriendlyNPC: + return character.TeamID == CharacterTeamType.FriendlyNPC; + default: + return false; + } + } private bool SubmarineTypeMatches(Submarine sub) { @@ -280,7 +312,7 @@ namespace Barotrauma switch (sub.Info.Type) { case Barotrauma.SubmarineType.Player: - return submarineType.HasFlag(SubType.Player) && sub != GameMain.NetworkMember?.RespawnManager?.RespawnShuttle; + return submarineType.HasFlag(SubType.Player) && !sub.IsRespawnShuttle; case Barotrauma.SubmarineType.Outpost: case Barotrauma.SubmarineType.OutpostModule: return submarineType.HasFlag(SubType.Outpost); @@ -288,6 +320,10 @@ namespace Barotrauma return submarineType.HasFlag(SubType.Wreck); case Barotrauma.SubmarineType.BeaconStation: return submarineType.HasFlag(SubType.BeaconStation); + case Barotrauma.SubmarineType.EnemySubmarine: + return submarineType.HasFlag(SubType.Enemy); + case Barotrauma.SubmarineType.Ruin: + return submarineType.HasFlag(SubType.Ruin); default: return false; } @@ -357,6 +393,11 @@ namespace Barotrauma private void TagRandom(Identifier tag, IEnumerable entities) { + if (!ChooseRandomExcludingTag.IsEmpty) + { + var excludedTargets = ParentEvent.GetTargets(ChooseRandomExcludingTag); + entities = entities.Except(excludedTargets); + } if (entities.None()) { cantFindTargets = true; @@ -414,7 +455,7 @@ namespace Barotrauma public override string ToDebugString() { - return $"{ToolBox.GetDebugSymbol(isFinished)} {nameof(TagAction)} -> (Criteria: {Criteria.ColorizeObject()}, Tag: {Tag.ColorizeObject()}, Sub: {SubmarineType.ColorizeObject()})"; + return $"{ToolBox.GetDebugSymbol(isFinished)} {nameof(TagAction)} -> (Criteria: {Criteria.ColorizeObject()}, Tag: {Tag.ColorizeObject()}, Sub: {SubmarineType.ColorizeObject()}, Team: {Team.ColorizeObject()})"; } } } \ No newline at end of file diff --git a/Barotrauma/BarotraumaShared/SharedSource/Events/EventActions/TriggerAction.cs b/Barotrauma/BarotraumaShared/SharedSource/Events/EventActions/TriggerAction.cs index 69b1b1a21..e0c836ef0 100644 --- a/Barotrauma/BarotraumaShared/SharedSource/Events/EventActions/TriggerAction.cs +++ b/Barotrauma/BarotraumaShared/SharedSource/Events/EventActions/TriggerAction.cs @@ -1,4 +1,4 @@ -using Microsoft.Xna.Framework; +using Microsoft.Xna.Framework; using System.Collections.Generic; using System.Linq; @@ -56,7 +56,16 @@ namespace Barotrauma private float distance; - public TriggerAction(ScriptedEvent parentEvent, ContentXElement element) : base(parentEvent, element) { } + public TriggerAction(ScriptedEvent parentEvent, ContentXElement element) : base(parentEvent, element) + { + if (element.GetAttribute(nameof(TagAction.IgnoreIncapacitatedCharacters)) != null) + { + DebugConsole.AddWarning( + $"Potential error in {nameof(TriggerAction)}, event \"{parentEvent.Prefab.Identifier}\": "+ + $"{nameof(TagAction.IgnoreIncapacitatedCharacters)} is a property of {nameof(TagAction)}, did you mean {nameof(DisableIfTargetIncapacitated)}?", + contentPackage: element.ContentPackage); + } + } private bool isFinished = false; public override bool IsFinished(ref string goTo) @@ -157,13 +166,13 @@ namespace Barotrauma Item item = null; if (e1 is Character char1) { - if (char1.IsBot) - { - npc ??= char1; + if (char1.IsPlayer) + { + player = char1; } - else - { - player = char1; + else + { + npc ??= char1; } } else @@ -172,13 +181,13 @@ namespace Barotrauma } if (e2 is Character char2) { - if (char2.IsBot) - { - npc ??= char2; - } - else + if (char2.IsPlayer) { - player = char2; + player = char2; + } + else + { + npc ??= char2; } } else @@ -190,6 +199,10 @@ namespace Barotrauma { if (npc != null) { + if (!npcsOrItems.Any(n => n.TryGet(out Character npc2) && npc2 == npc)) + { + npcsOrItems.Add(npc); + } if (npc.CampaignInteractionType == CampaignMode.InteractionType.Talk) { //if the NPC has a conversation available, don't assign the trigger until the conversation is done @@ -197,19 +210,26 @@ namespace Barotrauma } else if (npc.CampaignInteractionType != CampaignMode.InteractionType.Examine) { - if (!npcsOrItems.Any(n => n.TryGet(out Character npc2) && npc2 == npc)) - { - npcsOrItems.Add(npc); - } 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 && targets2.Contains(interactor)) + { + Trigger(npc, interactor); + } + else if (targets1.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 @@ -339,7 +359,7 @@ namespace Barotrauma return true; } } - else if (c.AIController is EnemyAIController enemyAI && (enemyAI.State == AIState.Aggressive || enemyAI.State == AIState.Attack)) + else if (c.AIController is EnemyAIController { State: AIState.Aggressive or AIState.Attack } enemyAI) { if (enemyAI.SelectedAiTarget?.Entity == character || c.CurrentHull == character.CurrentHull) { @@ -401,10 +421,18 @@ namespace Barotrauma { if (TargetModuleType.IsEmpty) { + string targetStr = "none"; + if (npcsOrItems.Any()) + { + targetStr = string.Join(", ", + npcsOrItems.Select(npcOrItem => + npcOrItem.TryGet(out Character character) ? character.Name : (npcOrItem.TryGet(out Item item) ? item.Name : "none"))); + } + return $"{ToolBox.GetDebugSymbol(isFinished, isRunning)} {nameof(TriggerAction)} -> (" + (WaitForInteraction ? - $"Selected non-player target: {(npcsOrItems?.ToString() ?? "").ColorizeObject()}, " : + $"Selected non-player target: {targetStr.ColorizeObject()}, " : $"Distance: {((int)distance).ColorizeObject()}, ") + $"Radius: {Radius.ColorizeObject()}, " + $"TargetTags: {Target1Tag.ColorizeObject()}, " + diff --git a/Barotrauma/BarotraumaShared/SharedSource/Events/EventActions/TriggerEventAction.cs b/Barotrauma/BarotraumaShared/SharedSource/Events/EventActions/TriggerEventAction.cs index e75d563c2..8a770ba75 100644 --- a/Barotrauma/BarotraumaShared/SharedSource/Events/EventActions/TriggerEventAction.cs +++ b/Barotrauma/BarotraumaShared/SharedSource/Events/EventActions/TriggerEventAction.cs @@ -1,13 +1,16 @@ namespace Barotrauma { /// - /// Triggers another scripted event. + /// Triggers another event (can also trigger things other than scripted events, for example monster events). /// class TriggerEventAction : EventAction { [Serialize("", IsPropertySaveable.Yes, description: "Identifier of the event to trigger.")] public Identifier Identifier { get; set; } + [Serialize("", IsPropertySaveable.Yes, description: "Tag of the event to trigger.")] + public Identifier EventTag { get; set; } + [Serialize(false, IsPropertySaveable.Yes, description: "If set to true, the event will trigger at the beginning of the next round. Useful for e.g. triggering some scripted event in the outpost after you finish a mission.")] public bool NextRound { get; set; } @@ -36,13 +39,8 @@ } else { - var eventPrefab = EventSet.GetEventPrefab(Identifier); - if (eventPrefab == null) - { - DebugConsole.ThrowError($"Error in TriggerEventAction - could not find an event with the identifier {Identifier}.", - contentPackage: ParentEvent.Prefab.ContentPackage); - } - else + EventPrefab eventPrefab = EventPrefab.FindEventPrefab(Identifier, EventTag, ParentEvent.Prefab.ContentPackage); + if (eventPrefab != null) { var ev = eventPrefab.CreateInstance(GameMain.GameSession.EventManager.RandomSeed); if (ev != null) 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/EventActions/WaitForItemUsedAction.cs b/Barotrauma/BarotraumaShared/SharedSource/Events/EventActions/WaitForItemUsedAction.cs index dd3375657..f072202a4 100644 --- a/Barotrauma/BarotraumaShared/SharedSource/Events/EventActions/WaitForItemUsedAction.cs +++ b/Barotrauma/BarotraumaShared/SharedSource/Events/EventActions/WaitForItemUsedAction.cs @@ -1,4 +1,4 @@ -#nullable enable +#nullable enable using Barotrauma.Extensions; using Barotrauma.Items.Components; @@ -12,6 +12,11 @@ namespace Barotrauma /// class WaitForItemUsedAction : EventAction { + /// + /// Counter used to ensure we have a unique identifier to use for the ItemComponent.OnUsed event + /// + private static int IdCounter; + [Serialize("", IsPropertySaveable.Yes, description: "Tag of the item that must be used. Note that the item needs to have been tagged by the event - this does not refer to the tags that can be set per-item in the sub editor.")] public Identifier ItemTag { get; set; } @@ -50,7 +55,8 @@ namespace Barotrauma { if (onUseEventIdentifier.IsEmpty) { - onUseEventIdentifier = (ParentEvent.Prefab.Identifier + ParentEvent.Actions.IndexOf(this).ToString()).ToIdentifier(); + onUseEventIdentifier = (ParentEvent.Prefab.Identifier + ParentEvent.Actions.IndexOf(this).ToString() + IdCounter).ToIdentifier(); + IdCounter++; } return onUseEventIdentifier; } diff --git a/Barotrauma/BarotraumaShared/SharedSource/Events/EventManager.cs b/Barotrauma/BarotraumaShared/SharedSource/Events/EventManager.cs index 83b2ff4d3..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; @@ -180,11 +181,46 @@ namespace Barotrauma random = new MTRandom(RandomSeed); bool playingCampaign = GameMain.GameSession?.GameMode is CampaignMode; - EventSet initialEventSet = SelectRandomEvents( - EventSet.Prefabs.ToList(), - requireCampaignSet: playingCampaign, - random: random); + + //ensure that the sets that have been configured to be always selected get selected if there's any available + EventSet initialEventSet = null; EventSet additiveSet = null; + 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; + } + else + { + if (initialEventSet == null) + { + initialEventSet = eventSet; + } + else //initial set already chosen, ignore this one + { + continue; + } + } + AddSet(eventSet); + } + + if (initialEventSet == null) + { + initialEventSet = SelectRandomEvents( + EventSet.Prefabs.ToList(), + requireCampaignSet: playingCampaign, + random: random); + } + + //we happened to choose an additive set as the initial one, choose an additive one too if (initialEventSet != null && initialEventSet.Additive) { additiveSet = initialEventSet; @@ -193,15 +229,15 @@ namespace Barotrauma requireCampaignSet: playingCampaign, random: random); } - if (initialEventSet != null) + + if (initialEventSet != null) { AddSet(initialEventSet); } + if (additiveSet != null) { AddSet(additiveSet); } + + void AddSet(EventSet eventSet) { - pendingEventSets.Add(initialEventSet); - CreateEvents(initialEventSet); - } - if (additiveSet != null) - { - pendingEventSets.Add(additiveSet); - CreateEvents(additiveSet); + if (pendingEventSets.Contains(eventSet)) { return; } + pendingEventSets.Add(eventSet); + CreateEvents(eventSet); } if (level?.LevelData != null) @@ -223,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) @@ -244,7 +296,7 @@ namespace Barotrauma while (QueuedEventsForNextRound.TryDequeue(out var id)) { - var eventPrefab = EventSet.GetEventPrefab(id); + var eventPrefab = EventSet.GetEventPrefab(id) ?? EventSet.GetAllEventPrefabs().Where(e => e.Tags.Contains(id)).GetRandomUnsynced(); if (eventPrefab == null) { DebugConsole.ThrowError($"Error in EventManager.StartRound - could not find an event with the identifier {id}."); @@ -421,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) @@ -437,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)) { @@ -484,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); @@ -609,12 +659,11 @@ namespace Barotrauma } } - private EventSet SelectRandomEvents(IReadOnlyList eventSets, bool? requireCampaignSet = null, Random random = null) + private IEnumerable GetAllowedEventSets(IReadOnlyList eventSets, bool? requireCampaignSet = null) { - if (level == null) { return null; } - Random rand = random ?? new MTRandom(ToolBox.StringToInt(level.Seed)); + if (level == null) { return Enumerable.Empty(); } - var allowedEventSets = + var allowedEventSets = eventSets.Where(set => IsValidForLevel(set, level)); if (requireCampaignSet.HasValue) @@ -659,6 +708,12 @@ namespace Barotrauma // When there are no forced sets, only allow sets that aren't forced at any specific location allowedEventSets = allowedEventSets.Where(set => set.ForceAtDiscoveredNr < 0 && set.ForceAtVisitedNr < 0); } + return allowedEventSets; + } + + private EventSet SelectRandomEvents(IReadOnlyList eventSets, bool? requireCampaignSet = null, Random random = null) + { + var allowedEventSets = GetAllowedEventSets(eventSets, requireCampaignSet); if (allowedEventSets.Count() == 1) { @@ -666,6 +721,7 @@ namespace Barotrauma return allowedEventSets.First(); } + Random rand = random ?? new MTRandom(ToolBox.StringToInt(level.Seed)); float totalCommonness = allowedEventSets.Sum(e => e.GetCommonness(level)); float randomNumber = (float)rand.NextDouble(); randomNumber *= totalCommonness; @@ -692,6 +748,7 @@ namespace Barotrauma return (e.BiomeIdentifier.IsEmpty || e.BiomeIdentifier == level.LevelData?.Biome?.Identifier) && (e.RequiredLayer.IsEmpty || Submarine.LayerExistsInAnySub(e.RequiredLayer)) && + (e.RequiredSpawnPointTag.IsEmpty || WayPoint.WayPointList.Any(wp => wp.Tags.Contains(e.RequiredSpawnPointTag))) && !level.LevelData.NonRepeatableEvents.Contains(e.Identifier); } @@ -704,8 +761,9 @@ 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); } @@ -716,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); @@ -965,9 +1023,9 @@ namespace Barotrauma monsterStrength += enemyAI.CombatStrength; } - if (character.CurrentHull?.Submarine?.Info != null && - (character.CurrentHull.Submarine == Submarine.MainSub || Submarine.MainSub.DockedTo.Contains(character.CurrentHull.Submarine)) && - character.CurrentHull.Submarine.Info.Type == SubmarineType.Player) + if (Submarine.MainSub != null && + character.CurrentHull?.Submarine.Info is { Type: SubmarineType.Player } && + (character.CurrentHull.Submarine == Submarine.MainSub || Submarine.MainSub.DockedTo.Contains(character.CurrentHull.Submarine))) { // Enemy onboard -> Crawler inside the sub adds 0.2 to enemy danger, Mudraptor 0.42 enemyDanger += enemyAI.CombatStrength / 500.0f; @@ -981,8 +1039,8 @@ namespace Barotrauma } else if (character.AIController is HumanAIController humanAi && !character.IsOnFriendlyTeam(CharacterTeamType.Team1)) { - if (character.Submarine != null && - character.Submarine.PhysicsBody is { BodyType: BodyType.Dynamic } && + if (character.Submarine != null && Submarine.MainSub != null && + character.Submarine.PhysicsBody is { BodyType: BodyType.Dynamic } && Vector2.DistanceSquared(character.Submarine.WorldPosition, Submarine.MainSub.WorldPosition) < Sonar.DefaultSonarRange * Sonar.DefaultSonarRange) { //we have no easy way to define the strength of a human enemy (depends more on the sub and it's state than the character), @@ -1111,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 { @@ -1128,22 +1185,44 @@ namespace Barotrauma } } #else + if (refEntity == null) { return null; } + 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/EventPrefab.cs b/Barotrauma/BarotraumaShared/SharedSource/Events/EventPrefab.cs index afc0b5645..890d79131 100644 --- a/Barotrauma/BarotraumaShared/SharedSource/Events/EventPrefab.cs +++ b/Barotrauma/BarotraumaShared/SharedSource/Events/EventPrefab.cs @@ -1,4 +1,6 @@ -using System; +using Barotrauma.Extensions; +using System; +using System.Collections.Immutable; using System.Linq; using System.Reflection; @@ -11,6 +13,9 @@ namespace Barotrauma public readonly ContentXElement ConfigElement; public readonly Type EventType; + private readonly ImmutableHashSet tags; + public ImmutableHashSet Tags => tags; + /// /// The probability for the event to do something if it gets selected. For example, the probability for a MonsterEvent to spawn the monster(s). /// @@ -37,6 +42,11 @@ namespace Barotrauma /// public readonly Identifier RequiredLayer; + /// + /// If set, this spawn point tag must be present somewhere in the level. + /// + public readonly Identifier RequiredSpawnPointTag; + /// /// If set, the event set can only be chosen in locations that belong to this faction. /// @@ -93,6 +103,7 @@ namespace Barotrauma Name = TextManager.Get($"eventname.{Identifier}").Fallback(Identifier.ToString()); + tags = ConfigElement.GetAttributeIdentifierImmutableHashSet(nameof(tags), ImmutableHashSet.Empty); BiomeIdentifier = ConfigElement.GetAttributeIdentifier("biome", Identifier.Empty); Faction = ConfigElement.GetAttributeIdentifier("faction", Identifier.Empty); Commonness = element.GetAttributeFloat("commonness", 1.0f); @@ -100,6 +111,7 @@ namespace Barotrauma TriggerEventCooldown = element.GetAttributeBool("triggereventcooldown", EventType != typeof(ScriptedEvent)); RequiredLayer = element.GetAttributeIdentifier(nameof(RequiredLayer), Identifier.Empty); + RequiredSpawnPointTag = element.GetAttributeIdentifier(nameof(RequiredSpawnPointTag), Identifier.Empty); UnlockPathEvent = element.GetAttributeBool("unlockpathevent", false); UnlockPathTooltip = element.GetAttributeString("unlockpathtooltip", "lockedpathtooltip"); @@ -146,5 +158,39 @@ namespace Barotrauma unlockPathEvents.FirstOrDefault(ep => ep.BiomeIdentifier == biomeIdentifier) ?? unlockPathEvents.FirstOrDefault(ep => ep.BiomeIdentifier == Identifier.Empty); } + + /// + /// Finds an event prefab with the specified identifier, or if it isn't defined, a random event prefab with the specified tag. + /// + /// Which content package is trying to find the event (if any)? Only used for logging error messages. + /// + public static EventPrefab FindEventPrefab(Identifier identifier, Identifier tag, ContentPackage source) + { + EventPrefab eventPrefab = null; + if (!identifier.IsEmpty) + { + eventPrefab = EventSet.GetEventPrefab(identifier); + if (eventPrefab == null) + { + DebugConsole.ThrowError($"Failed to find an event prefab with the identifier {identifier}.", + contentPackage: source); + } + } + else if (!tag.IsEmpty) + { + eventPrefab = EventSet.GetAllEventPrefabs().Where(e => e.Tags.Contains(tag)).GetRandomUnsynced(); + if (eventPrefab == null) + { + DebugConsole.ThrowError($"Failed to find an event prefab with the tag {tag}.", + contentPackage: source); + } + } + else + { + DebugConsole.ThrowError($"Failed to find an event prefab: neither an identifier or tag were defined.", + contentPackage: source); + } + return eventPrefab; + } } } diff --git a/Barotrauma/BarotraumaShared/SharedSource/Events/EventSet.cs b/Barotrauma/BarotraumaShared/SharedSource/Events/EventSet.cs index b1354868c..915795961 100644 --- a/Barotrauma/BarotraumaShared/SharedSource/Events/EventSet.cs +++ b/Barotrauma/BarotraumaShared/SharedSource/Events/EventSet.cs @@ -61,25 +61,46 @@ namespace Barotrauma } #endif - public static List GetAllEventPrefabs() + private static readonly Dictionary AllEventPrefabs = new Dictionary(); + + public static IEnumerable GetAllEventPrefabs() { - List eventPrefabs = EventPrefab.Prefabs.ToList(); - foreach (var eventSet in Prefabs) - { - AddSetEventPrefabsToList(eventPrefabs, eventSet); - } - return eventPrefabs; + return AllEventPrefabs.Values; } - public static void AddSetEventPrefabsToList(List list, EventSet set) + /// + /// Finds all the event prefabs (both "normal prefabs" that exists by themselves, present in , and the ones that exists only inside child event sets), + /// and adds them to . + /// + public static void RefreshAllEventPrefabs() { - list.AddRange(set.EventPrefabs.SelectMany(ep => ep.EventPrefabs)); - foreach (var childSet in set.ChildSets) { AddSetEventPrefabsToList(list, childSet); } + AllEventPrefabs.Clear(); + foreach (var eventPrefab in EventPrefab.Prefabs) + { + AllEventPrefabs.TryAdd(eventPrefab.Identifier, eventPrefab); + } + foreach (var eventSet in Prefabs) + { + AddChildEventPrefabs(eventSet); + } + } + + private static void AddChildEventPrefabs(EventSet set) + { + foreach (var subEventPrefabs in set.EventPrefabs) + { + foreach (var eventPrefab in subEventPrefabs.EventPrefabs) + { + AllEventPrefabs.TryAdd(eventPrefab.Identifier, eventPrefab); + } + } + + foreach (var childSet in set.ChildSets) { AddChildEventPrefabs(childSet); } } public static EventPrefab GetEventPrefab(Identifier identifier) { - return GetAllEventPrefabs().Find(prefab => prefab.Identifier == identifier); + return AllEventPrefabs.GetValueOrDefault(identifier); } /// @@ -111,6 +132,11 @@ namespace Barotrauma /// public readonly Identifier RequiredLayer; + /// + /// If set, this spawn point tag must be present somewhere in the level. + /// + public readonly Identifier RequiredSpawnPointTag; + /// /// If set, the event set can only be chosen in locations of this type. /// @@ -207,7 +233,14 @@ namespace Barotrauma /// monsters in addition to the vanilla monsters spawned by vanilla sets, without you having to add your custom monsters to every single vanilla set. /// public readonly bool Additive; - + + /// + /// This will force the game to always choose this event set if it's suitable for the current level. + /// If the set is additive, it is guaranteed to get chosen regardless of what other sets get selected. + /// If the set is NOT additive, the game will choose the first available non-additive set that is configured to be always selected. + /// + public readonly bool SelectAlways; + /// /// The commonness of the event set (i.e. how likely it is for this specific set to be chosen). /// @@ -349,7 +382,8 @@ namespace Barotrauma MinLevelDifficulty = element.GetAttributeFloat("minleveldifficulty", 0); MaxLevelDifficulty = Math.Max(element.GetAttributeFloat("maxleveldifficulty", 100), MinLevelDifficulty); - Additive = element.GetAttributeBool("additive", false); + Additive = element.GetAttributeBool(nameof(Additive), false); + SelectAlways = element.GetAttributeBool(nameof(SelectAlways), false); string levelTypeStr = element.GetAttributeString("leveltype", parentSet?.LevelType.ToString() ?? "LocationConnection"); if (!Enum.TryParse(levelTypeStr, true, out LevelType)) @@ -392,6 +426,7 @@ namespace Barotrauma CampaignTutorialOnly = element.GetAttributeBool(nameof(CampaignTutorialOnly), parentSet?.CampaignTutorialOnly ?? false); RequiredLayer = element.GetAttributeIdentifier(nameof(RequiredLayer), Identifier.Empty); + RequiredSpawnPointTag = element.GetAttributeIdentifier(nameof(RequiredSpawnPointTag), Identifier.Empty); ForceAtDiscoveredNr = element.GetAttributeInt(nameof(ForceAtDiscoveredNr), -1); ForceAtVisitedNr = element.GetAttributeInt(nameof(ForceAtVisitedNr), -1); diff --git a/Barotrauma/BarotraumaShared/SharedSource/Events/Missions/AbandonedOutpostMission.cs b/Barotrauma/BarotraumaShared/SharedSource/Events/Missions/AbandonedOutpostMission.cs index 53ead40a1..37e9c078d 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 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(); @@ -27,9 +22,9 @@ namespace Barotrauma private const float EndDelay = 5.0f; private float endTimer; - private bool allowOrderingRescuees; + private readonly bool allowOrderingRescuees; - public override bool AllowRespawn => false; + public override bool AllowRespawning => false; public override bool AllowUndocking { @@ -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,141 +156,7 @@ 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); - Character spawnedCharacter = CreateHuman(humanPrefab, characters, characterItems, submarine, teamId, spawnPos); - 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); - } - - if (element.GetAttributeBool("requirekill", false)) - { - requireKill.Add(spawnedCharacter); - } - } - - 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 (element.GetAttributeBool("requirekill", false)) - { - requireKill.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); - } - } - } - - + protected override void UpdateMissionSpecific(float deltaTime) { if (State != HostagesKilledState) @@ -316,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(); } @@ -337,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))) { @@ -360,5 +217,45 @@ 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); + var originalTeam = Level.Loaded.StartOutpost?.TeamID ?? teamId; + if (teamId != originalTeam) + { + spawnedCharacter.SetOriginalTeamAndChangeTeam(teamId); + } + return spawnedCharacter; + } } } \ No newline at end of file diff --git a/Barotrauma/BarotraumaShared/SharedSource/Events/Missions/BeaconMission.cs b/Barotrauma/BarotraumaShared/SharedSource/Events/Missions/BeaconMission.cs index 81e579c80..b79d4e13f 100644 --- a/Barotrauma/BarotraumaShared/SharedSource/Events/Missions/BeaconMission.cs +++ b/Barotrauma/BarotraumaShared/SharedSource/Events/Missions/BeaconMission.cs @@ -160,7 +160,9 @@ namespace Barotrauma #if DEBUG || UNSTABLE if (State == 1 && !level.CheckBeaconActive()) { - DebugConsole.ThrowError("Beacon became inactive!"); + DebugConsole.ThrowError( + "Debug/unstable only error message: beacon became inactive mid-mission after it had been activated! If this happened unexpectedly while you were away from the beacon, it may be a sign of a bug."+ + " If possible, please try to check what caused the beacon to go inactive."); State = 2; } #endif diff --git a/Barotrauma/BarotraumaShared/SharedSource/Events/Missions/CargoMission.cs b/Barotrauma/BarotraumaShared/SharedSource/Events/Missions/CargoMission.cs index 47b0092de..f62efa2ef 100644 --- a/Barotrauma/BarotraumaShared/SharedSource/Events/Missions/CargoMission.cs +++ b/Barotrauma/BarotraumaShared/SharedSource/Events/Missions/CargoMission.cs @@ -150,7 +150,7 @@ namespace Barotrauma int maxCount = subElement.GetAttributeInt("maxcount", 10); if (itemsToSpawn.Count(it => it.element == subElement) >= maxCount) { continue; } ItemPrefab itemPrefab = FindItemPrefab(subElement); - while (containers[i].freeSlots > 0 && containers[i].container.Inventory.CanBePut(itemPrefab)) + while (containers[i].freeSlots > 0 && containers[i].container.Inventory.CanProbablyBePut(itemPrefab)) { containers[i] = (containers[i].container, containers[i].freeSlots - 1); itemsToSpawn.Add((subElement, containers[i].container)); @@ -211,7 +211,7 @@ namespace Barotrauma if (descriptionWithoutReward != null) { description = descriptionWithoutReward.Replace("[reward]", rewardText); } } - public override int GetBaseReward(Submarine sub) + public override float GetBaseReward(Submarine sub) { // If we are not at the location of the mission, skip the calculation of the reward if (GameMain.GameSession?.StartLocation != Locations[0]) diff --git a/Barotrauma/BarotraumaShared/SharedSource/Events/Missions/CombatMission.cs b/Barotrauma/BarotraumaShared/SharedSource/Events/Missions/CombatMission.cs index f4c2a781c..64bf201c7 100644 --- a/Barotrauma/BarotraumaShared/SharedSource/Events/Missions/CombatMission.cs +++ b/Barotrauma/BarotraumaShared/SharedSource/Events/Missions/CombatMission.cs @@ -1,5 +1,6 @@ -using Barotrauma.Extensions; +using Barotrauma.Extensions; using System.Collections.Generic; +using System.Linq; namespace Barotrauma { @@ -10,12 +11,59 @@ namespace Barotrauma private readonly LocalizedString[] descriptions; private static LocalizedString[] teamNames = { "Team A", "Team B" }; - public override bool AllowRespawn + private readonly bool allowRespawning; + + enum WinCondition { - get { return false; } + /// + /// The winner is the team with the last living player(s) + /// + LastManStanding, + /// + /// The team who reaches a specific number of kills (determined by WinScore) is the winner + /// + KillCount, + /// + /// The team who controls a specific submarine (can be a ruin, outpost or a beacon station too) for some time (determined by WinScore) is the winner + /// + ControlSubmarine } - private CharacterTeamType Winner + private readonly WinCondition winCondition; + + public override bool AllowRespawning + { + get => allowRespawning; + } + + private Submarine targetSubmarine; + + private LocalizedString targetSubmarineSonarLabel; + + /// + /// Which type of submarine the team needs to stay in control of to win + /// + public TagAction.SubType TargetSubmarineType { get; set; } + + public readonly int PointsPerKill; + + /// + /// The score required to win the mission. + /// + public int WinScore => GameMain.NetworkMember?.ServerSettings.WinScorePvP ?? 10; + + /// + /// Is the winner determined by some kind of a scoring mechanism? + /// + public bool HasWinScore => + winCondition != WinCondition.LastManStanding || PointsPerKill != 0; + + /// + /// Scores of both teams. What the scoring represents depends on how the mission is configured (kills, time in control of a beacon station?) + /// + public readonly int[] Scores = new int[2]; + + public static CharacterTeamType Winner { get { @@ -46,6 +94,27 @@ namespace Barotrauma public CombatMission(MissionPrefab prefab, Location[] locations, Submarine sub) : base(prefab, locations, sub) { + allowRespawning = prefab.ConfigElement.GetAttributeBool(nameof(AllowRespawning), false); + + winCondition = prefab.ConfigElement.GetAttributeEnum(nameof(WinCondition), + allowRespawning ? WinCondition.KillCount : WinCondition.LastManStanding); + + PointsPerKill = prefab.ConfigElement.GetAttributeInt(nameof(PointsPerKill), 0); + + TargetSubmarineType = prefab.ConfigElement.GetAttributeEnum(nameof(TargetSubmarineType), TagAction.SubType.Any); + + string sonarTag = prefab.ConfigElement.GetAttributeString(nameof(targetSubmarineSonarLabel), string.Empty); + if (!sonarTag.IsNullOrEmpty()) + { + targetSubmarineSonarLabel = TextManager.Get(sonarTag); + } + + if (allowRespawning && winCondition == WinCondition.LastManStanding) + { + DebugConsole.ThrowError($"Error in mission {prefab.Identifier}: win condition cannot be \"last man standing\" when respawning is enabled.", + contentPackage: prefab.ContentPackage); + } + descriptions = new LocalizedString[] { TextManager.Get("MissionDescriptionNeutral." + prefab.TextIdentifier).Fallback(prefab.ConfigElement.GetAttributeString("descriptionneutral", "")), @@ -57,15 +126,23 @@ namespace Barotrauma { for (int n = 0; n < 2; n++) { - descriptions[i] = descriptions[i].Replace("[location" + (n + 1) + "]", locations[n].DisplayName); + descriptions[i] = + descriptions[i] + .Replace($"[location{n + 1}]", locations[n].DisplayName) + .Replace("[winscore]", WinScore.ToString()); } } teamNames = new LocalizedString[] { - TextManager.Get("MissionTeam1." + prefab.TextIdentifier).Fallback(prefab.ConfigElement.GetAttributeString("teamname1", "Team A")), - TextManager.Get("MissionTeam2." + prefab.TextIdentifier).Fallback(prefab.ConfigElement.GetAttributeString("teamname2", "Team B")) + TextManager.Get("MissionTeam1." + prefab.TextIdentifier).Fallback(TextManager.Get(prefab.ConfigElement.GetAttributeString("teamname1", "missionteam1.pvpmission"))), + TextManager.Get("MissionTeam2." + prefab.TextIdentifier).Fallback(TextManager.Get(prefab.ConfigElement.GetAttributeString("teamname2", "missionteam2.pvpmission"))), }; + + if (winCondition == WinCondition.KillCount && PointsPerKill == 0) + { + DebugConsole.AddWarning($"Potential error in mission {Prefab.Identifier}: win condition is kill count, but {nameof(PointsPerKill)} is set to 0."); + } } public static LocalizedString GetTeamName(CharacterTeamType teamID) @@ -82,9 +159,9 @@ namespace Barotrauma return "Invalid Team"; } - public bool IsInWinningTeam(Character character) + public static bool IsInWinningTeam(Character character) { - return character != null && + return character != null && Winner != CharacterTeamType.None && Winner == character.TeamID; } @@ -99,19 +176,32 @@ namespace Barotrauma subs = new Submarine[] { Submarine.MainSubs[0], Submarine.MainSubs[1] }; - subs[0].NeutralizeBallast(); - subs[0].TeamID = CharacterTeamType.Team1; - subs[0].GetConnectedSubs().ForEach(s => s.TeamID = CharacterTeamType.Team1); + if (Prefab.LoadSubmarines) + { + subs[0].NeutralizeBallast(); + subs[0].TeamID = CharacterTeamType.Team1; + subs[0].GetConnectedSubs().ForEach(s => s.TeamID = CharacterTeamType.Team1); - subs[1].NeutralizeBallast(); - subs[1].TeamID = CharacterTeamType.Team2; - subs[1].GetConnectedSubs().ForEach(s => s.TeamID = CharacterTeamType.Team2); - subs[1].SetPosition(subs[1].FindSpawnPos(Level.Loaded.EndPosition)); - subs[1].FlipX(); + subs[1].NeutralizeBallast(); + subs[1].TeamID = CharacterTeamType.Team2; + subs[1].GetConnectedSubs().ForEach(s => s.TeamID = CharacterTeamType.Team2); + GameSession.PlaceSubAtInitialPosition(subs[1], level, placeAtStart: false); + subs[1].FlipX(); + } #if SERVER crews = new List[] { new List(), new List() }; roundEndTimer = RoundEndDuration; #endif + + if (TargetSubmarineType != TagAction.SubType.Any) + { + targetSubmarine = Submarine.Loaded.FirstOrDefault(s => TagAction.SubmarineTypeMatches(s, TargetSubmarineType)); + if (targetSubmarine == null) + { + DebugConsole.ThrowError($"Error in mission {Prefab.Identifier}: could not find a submarine of the type {TargetSubmarineType}.", + contentPackage: Prefab.ContentPackage); + } + } } protected override bool DetermineCompleted() diff --git a/Barotrauma/BarotraumaShared/SharedSource/Events/Missions/EscortMission.cs b/Barotrauma/BarotraumaShared/SharedSource/Events/Missions/EscortMission.cs index 39838a1d6..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); @@ -61,7 +56,7 @@ namespace Barotrauma if (descriptionWithoutReward != null) { description = descriptionWithoutReward.Replace("[reward]", rewardText); } } - public override int GetBaseReward(Submarine sub) + public override float GetBaseReward(Submarine sub) { if (sub != missionSub) { @@ -192,7 +187,6 @@ namespace Barotrauma foreach (ContentXElement element in characterConfig.Elements()) { string escortIdentifier = element.GetAttributeString("escortidentifier", string.Empty); - string colorIdentifier = element.GetAttributeString("color", string.Empty); for (int k = 0; k < scalingCharacterCount; k++) { // for each element defined, we need to initialize that type of character equal to the scaling escorted character count diff --git a/Barotrauma/BarotraumaShared/SharedSource/Events/Missions/Mission.cs b/Barotrauma/BarotraumaShared/SharedSource/Events/Missions/Mission.cs index 2e324fa0b..3d2e94d78 100644 --- a/Barotrauma/BarotraumaShared/SharedSource/Events/Missions/Mission.cs +++ b/Barotrauma/BarotraumaShared/SharedSource/Events/Missions/Mission.cs @@ -111,7 +111,7 @@ namespace Barotrauma get { return failed; } } - public virtual bool AllowRespawn + public virtual bool AllowRespawning { get { return true; } } @@ -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) @@ -211,21 +217,21 @@ namespace Barotrauma public virtual void SetLevel(LevelData level) { } - public static Mission LoadRandom(Location[] locations, string seed, bool requireCorrectLocationType, MissionType missionType, bool isSinglePlayer = false, float? difficultyLevel = null) + public static Mission LoadRandom(Location[] locations, string seed, bool requireCorrectLocationType, IEnumerable missionTypes, bool isSinglePlayer = false, float? difficultyLevel = null) { - return LoadRandom(locations, new MTRandom(ToolBox.StringToInt(seed)), requireCorrectLocationType, missionType, isSinglePlayer, difficultyLevel); + return LoadRandom(locations, new MTRandom(ToolBox.StringToInt(seed)), requireCorrectLocationType, missionTypes, isSinglePlayer, difficultyLevel); } - public static Mission LoadRandom(Location[] locations, MTRandom rand, bool requireCorrectLocationType, MissionType missionType, bool isSinglePlayer = false, float? difficultyLevel = null) + public static Mission LoadRandom(Location[] locations, MTRandom rand, bool requireCorrectLocationType, IEnumerable missionTypes, bool isSinglePlayer = false, float? difficultyLevel = null) { List allowedMissions = new List(); - if (missionType == MissionType.None) + if (missionTypes.None()) { return null; } else { - allowedMissions.AddRange(MissionPrefab.Prefabs.Where(m => m.Type.HasAnyFlag(missionType))); + allowedMissions.AddRange(MissionPrefab.Prefabs.Where(m => missionTypes.Contains(m.Type))); } allowedMissions.RemoveAll(m => isSinglePlayer ? m.MultiplayerOnly : m.SingleplayerOnly); if (requireCorrectLocationType) @@ -244,25 +250,179 @@ namespace Barotrauma /// /// Calculates the base reward, can be overridden for different mission types /// - public virtual int GetBaseReward(Submarine sub) + public virtual float GetBaseReward(Submarine sub) { return Prefab.Reward; } /// - /// Calculates the available reward, taking into account universal modifiers such as campaign settings + /// Calculates the available monetary reward, taking into account universal modifiers such as campaign settings. /// public int GetReward(Submarine sub) { - int reward = GetBaseReward(sub); - + float reward = GetBaseReward(sub); // Some modifiers should apply universally to all implementations of GetBaseReward if (GameMain.GameSession?.Campaign is CampaignMode campaign) { - reward = (int)Math.Round(reward * campaign.Settings.MissionRewardMultiplier); + reward *= campaign.Settings.MissionRewardMultiplier; } + 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(); - return reward; + 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); + } + 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) @@ -350,11 +510,12 @@ namespace Barotrauma private void TriggerEvent(MissionPrefab.TriggerEvent trigger) { if (trigger.CampaignOnly && GameMain.GameSession?.Campaign == null) { return; } - var eventPrefab = EventSet.GetAllEventPrefabs().Find(p => p.Identifier == trigger.EventIdentifier); + //clients are not allowed to trigger events, they're handled by the server + if (GameMain.NetworkMember is { IsClient: true }) { return; } + EventPrefab eventPrefab = EventPrefab.FindEventPrefab(trigger.EventIdentifier, trigger.EventTag, Prefab.ContentPackage); if (eventPrefab == null) { - DebugConsole.ThrowError($"Mission \"{Name}\" failed to trigger an event (couldn't find an event with the identifier \"{trigger.EventIdentifier}\").", - contentPackage: Prefab.ContentPackage); + DebugConsole.ThrowError($"Mission {Prefab.Identifier} failed to trigger an event (identifier: {trigger.EventIdentifier}, tag: {trigger.EventTag}).", contentPackage: Prefab.ContentPackage); return; } if (GameMain.GameSession?.EventManager != null) @@ -427,15 +588,23 @@ namespace Barotrauma finalReward = (int)(reward * missionMoneyGainMultiplier.Value); } + private float CalculateDifficultyXPMultiplier() + { + const float minMissionDifficulty = 1; + const float maxMissionDifficulty = 4; + const float maxXpBonus = 1.3f; + float selectedMissionDifficulty = MathUtils.InverseLerp(minMissionDifficulty, maxMissionDifficulty, Prefab.Difficulty.GetValueOrDefault()); + float xpBonusMultiplier = MathHelper.Lerp(1.0f, maxXpBonus, selectedMissionDifficulty); + + return xpBonusMultiplier; + } + private void GiveReward() { if (GameMain.GameSession.GameMode is not CampaignMode campaign) { return; } - int reward = GetReward(Submarine.MainSub); - float baseExperienceGain = reward * 0.09f; - - float difficultyMultiplier = 1 + level.Difficulty / 100f; - baseExperienceGain *= difficultyMultiplier; + float xpReward = GetBaseReward(Submarine.MainSub) * Prefab.ExperienceMultiplier * campaign.Settings.ExperienceRewardMultiplier; + float xpGain = xpReward * level.LevelData.Biome.ExperienceFromMissionRewards * CalculateDifficultyXPMultiplier(); IEnumerable crewCharacters = GameSession.GetSessionCrewCharacters(CharacterType.Both); @@ -443,7 +612,7 @@ namespace Barotrauma var experienceGainMultiplier = new AbilityMissionExperienceGainMultiplier(this, 1f, character: null); crewCharacters.ForEach(c => experienceGainMultiplier.Value += c.GetStatValue(StatTypes.MissionExperienceGainMultiplier)); - DistributeExperienceToCrew(crewCharacters, (int)(baseExperienceGain * experienceGainMultiplier.Value)); + DistributeExperienceToCrew(crewCharacters, (int)(xpGain * experienceGainMultiplier.Value)); CalculateFinalReward(Submarine.MainSub); #if SERVER @@ -567,7 +736,7 @@ namespace Barotrauma Identifier characterIdentifier = element.GetAttributeIdentifier("identifier", Identifier.Empty); Identifier characterFrom = element.GetAttributeIdentifier("from", Identifier.Empty); - HumanPrefab humanPrefab = NPCSet.Get(characterFrom, characterIdentifier); + HumanPrefab humanPrefab = NPCSet.Get(characterFrom, characterIdentifier, contentPackageToLogInError: Prefab.ContentPackage); if (humanPrefab == null) { DebugConsole.ThrowError($"Couldn't spawn character for mission: character prefab \"{characterIdentifier}\" not found in the NPC set \"{characterFrom}\".", diff --git a/Barotrauma/BarotraumaShared/SharedSource/Events/Missions/MissionPrefab.cs b/Barotrauma/BarotraumaShared/SharedSource/Events/Missions/MissionPrefab.cs index e0462ed76..b692d0725 100644 --- a/Barotrauma/BarotraumaShared/SharedSource/Events/Missions/MissionPrefab.cs +++ b/Barotrauma/BarotraumaShared/SharedSource/Events/Missions/MissionPrefab.cs @@ -1,4 +1,5 @@ -using System; +using Barotrauma.Networking; +using System; using System.Collections.Generic; using System.Collections.Immutable; using System.Linq; @@ -7,52 +8,42 @@ using System.Xml.Linq; namespace Barotrauma { - [Flags] - public enum MissionType - { - None = 0x0, - Salvage = 0x1, - Monster = 0x2, - Cargo = 0x4, - Beacon = 0x8, - Nest = 0x10, - Mineral = 0x20, - Combat = 0x40, - AbandonedOutpost = 0x80, - Escort = 0x100, - Pirate = 0x200, - GoTo = 0x400, - ScanAlienRuins = 0x800, - EliminateTargets = 0x1000, - End = 0x2000, - All = Salvage | Monster | Cargo | Beacon | Nest | Mineral | Combat | AbandonedOutpost | Escort | Pirate | GoTo | ScanAlienRuins | EliminateTargets | End - } - partial class MissionPrefab : PrefabWithUintIdentifier { public static readonly PrefabCollection Prefabs = new PrefabCollection(); - public static readonly Dictionary CoOpMissionClasses = new Dictionary() + /// + /// The keys here are for backwards compatibility, tying the old mission types to the appropriate class. + /// Now the mission class is defined by the name of the mission element, and the type can be any arbitrary string. + /// + public static readonly Dictionary CoOpMissionClasses = new Dictionary() { - { MissionType.Salvage, typeof(SalvageMission) }, - { MissionType.Monster, typeof(MonsterMission) }, - { MissionType.Cargo, typeof(CargoMission) }, - { MissionType.Beacon, typeof(BeaconMission) }, - { MissionType.Nest, typeof(NestMission) }, - { MissionType.Mineral, typeof(MineralMission) }, - { MissionType.AbandonedOutpost, typeof(AbandonedOutpostMission) }, - { MissionType.Escort, typeof(EscortMission) }, - { MissionType.Pirate, typeof(PirateMission) }, - { MissionType.GoTo, typeof(GoToMission) }, - { MissionType.ScanAlienRuins, typeof(ScanMission) }, - { MissionType.EliminateTargets, typeof(EliminateTargetsMission) }, - { MissionType.End, typeof(EndMission) } + { "Salvage".ToIdentifier(), typeof(SalvageMission) }, + { "Monster".ToIdentifier(), typeof(MonsterMission) }, + { "Cargo".ToIdentifier(), typeof(CargoMission) }, + { "Beacon".ToIdentifier(), typeof(BeaconMission) }, + { "Nest".ToIdentifier(), typeof(NestMission) }, + { "Mineral".ToIdentifier(), typeof(MineralMission) }, + { "AbandonedOutpost".ToIdentifier(), typeof(AbandonedOutpostMission) }, + { "Escort".ToIdentifier(), typeof(EscortMission) }, + { "Pirate".ToIdentifier(), typeof(PirateMission) }, + { "GoTo".ToIdentifier(), typeof(GoToMission) }, + { "ScanAlienRuins".ToIdentifier(), typeof(ScanMission) }, + { "EliminateTargets".ToIdentifier(), typeof(EliminateTargetsMission) }, + { "End".ToIdentifier(), typeof(EndMission) } }; - public static readonly Dictionary PvPMissionClasses = new Dictionary() + + /// + /// The keys here are for backwards compatibility, tying the old mission types to the appropriate class. + /// Now the mission class is defined by the name of the mission element, and the type can be any arbitrary string. + /// + public static readonly Dictionary PvPMissionClasses = new Dictionary() { - { MissionType.Combat, typeof(CombatMission) } + { "Combat".ToIdentifier(), typeof(CombatMission) } }; + public static readonly HashSet HiddenMissionTypes = new HashSet() { "GoTo".ToIdentifier(), "End".ToIdentifier() }; + public class ReputationReward { public readonly Identifier FactionIdentifier; @@ -67,11 +58,11 @@ namespace Barotrauma } } - public static readonly HashSet HiddenMissionClasses = new HashSet() { MissionType.GoTo, MissionType.End }; - private readonly ConstructorInfo constructor; - public readonly MissionType Type; + public readonly Identifier Type; + + public readonly Type MissionClass; public readonly bool MultiplayerOnly, SingleplayerOnly; @@ -110,6 +101,8 @@ namespace Barotrauma public readonly int Reward; + public readonly float ExperienceMultiplier; + // The titles and bodies of the popup messages during the mission, shown when the state of the mission changes. The order matters. public readonly ImmutableArray Headers; public readonly ImmutableArray Messages; @@ -122,7 +115,21 @@ namespace Barotrauma public readonly bool AllowOtherMissionsInLevel; - public readonly bool RequireWreck, RequireRuin, RequireThalamusWreck; + public readonly bool RequireWreck, RequireRuin, RequireBeaconStation, RequireThalamusWreck; + public readonly bool SpawnBeaconStationInMiddle; + + public readonly bool AllowOutpostNPCs; + + public readonly Identifier ForceOutpostGenerationParameters; + + public readonly RespawnMode? ForceRespawnMode; + + /// + /// If set, the players can choose which outpost is used for the mission (selected from the outposts that have this tag). Only works in multiplayer. + /// + public readonly Identifier AllowOutpostSelectionFromTag; + + public readonly bool LoadSubmarines = true; /// /// If enabled, locations this mission takes place in cannot change their type @@ -157,7 +164,10 @@ namespace Barotrauma public class TriggerEvent { [Serialize("", IsPropertySaveable.Yes)] - public string EventIdentifier { get; private set; } + public Identifier EventIdentifier { get; private set; } + + [Serialize("", IsPropertySaveable.Yes)] + public Identifier EventTag { get; private set; } [Serialize(0, IsPropertySaveable.Yes)] public int State { get; private set; } @@ -210,16 +220,21 @@ namespace Barotrauma } Reward = element.GetAttributeInt("reward", 1); + ExperienceMultiplier = element.GetAttributeFloat("experiencemultiplier", 1.0f); AllowRetry = element.GetAttributeBool("allowretry", false); ShowInMenus = element.GetAttributeBool("showinmenus", true); ShowStartMessage = element.GetAttributeBool("showstartmessage", true); IsSideObjective = element.GetAttributeBool("sideobjective", false); - RequireWreck = element.GetAttributeBool("requirewreck", false); - RequireRuin = element.GetAttributeBool("requireruin", false); - RequireThalamusWreck = element.GetAttributeBool("requirethalamuswreck", false); + RequireWreck = element.GetAttributeBool(nameof(RequireWreck), false); + RequireThalamusWreck = element.GetAttributeBool(nameof(RequireThalamusWreck), false); + RequireRuin = element.GetAttributeBool(nameof(RequireRuin), false); + RequireBeaconStation = element.GetAttributeBool(nameof(RequireBeaconStation), false); + SpawnBeaconStationInMiddle = element.GetAttributeBool(nameof(SpawnBeaconStationInMiddle), false); if (RequireThalamusWreck) { RequireWreck = true; } + LoadSubmarines = element.GetAttributeBool(nameof(LoadSubmarines), true); + BlockLocationTypeChanges = element.GetAttributeBool(nameof(BlockLocationTypeChanges), false); RequiredLocationFaction = element.GetAttributeIdentifier(nameof(RequiredLocationFaction), Identifier.Empty); Commonness = element.GetAttributeInt("commonness", 1); @@ -234,6 +249,15 @@ namespace Barotrauma MinLevelDifficulty = Math.Clamp(MinLevelDifficulty, 0, Math.Min(MaxLevelDifficulty, 100)); MaxLevelDifficulty = Math.Clamp(MaxLevelDifficulty, Math.Max(MinLevelDifficulty, 0), 100); + AllowOutpostNPCs = element.GetAttributeBool(nameof(AllowOutpostNPCs), true); + ForceOutpostGenerationParameters = element.GetAttributeIdentifier(nameof(ForceOutpostGenerationParameters), Identifier.Empty); + AllowOutpostSelectionFromTag = element.GetAttributeIdentifier(nameof(AllowOutpostSelectionFromTag), Identifier.Empty); + + if (element.GetAttribute(nameof(ForceRespawnMode)) != null) + { + ForceRespawnMode = element.GetAttributeEnum(nameof(ForceRespawnMode), RespawnMode.MidRound); + } + ShowProgressBar = element.GetAttributeBool(nameof(ShowProgressBar), false); ShowProgressInNumbers = element.GetAttributeBool(nameof(ShowProgressInNumbers), false); MaxProgressState = element.GetAttributeInt(nameof(MaxProgressState), 1); @@ -362,47 +386,23 @@ namespace Barotrauma Messages = messages.ToImmutableArray(); ReputationRewards = reputationRewards.ToImmutableList(); - Identifier missionTypeName = element.GetAttributeIdentifier("type", Identifier.Empty); - //backwards compatibility - - if (missionTypeName == "outpostdestroy" || missionTypeName == "outpostrescue") - { - missionTypeName = nameof(MissionType.AbandonedOutpost).ToIdentifier(); - } - else if (missionTypeName == "clearalienruins") - { - missionTypeName = nameof(MissionType.EliminateTargets).ToIdentifier(); - } - - if (!Enum.TryParse(missionTypeName.Value, true, out Type)) - { - DebugConsole.ThrowErrorLocalized("Error in mission prefab \"" + Name + "\" - \"" + missionTypeName + "\" is not a valid mission type."); - return; - } - if (Type == MissionType.None) - { - DebugConsole.ThrowErrorLocalized("Error in mission prefab \"" + Name + "\" - mission type cannot be none."); - return; - } + MissionClass = FindMissionClass(element); + Type = element.GetAttributeIdentifier(nameof(Type), Identifier.Empty); + #if DEBUG - if (Type == MissionType.Monster && SonarLabel.IsNullOrEmpty()) + if (MissionClass == typeof(MonsterMission) && SonarLabel.IsNullOrEmpty()) { DebugConsole.AddWarning($"Potential error in mission prefab \"{Identifier}\" - sonar label not set."); } #endif - if (CoOpMissionClasses.ContainsKey(Type)) + if (!LoadSubmarines && MissionClass != typeof(CombatMission)) { - constructor = CoOpMissionClasses[Type].GetConstructor(new[] { typeof(MissionPrefab), typeof(Location[]), typeof(Submarine) }); - } - else if (PvPMissionClasses.ContainsKey(Type)) - { - constructor = PvPMissionClasses[Type].GetConstructor(new[] { typeof(MissionPrefab), typeof(Location[]), typeof(Submarine) }); - } - else - { - DebugConsole.ThrowErrorLocalized("Error in mission prefab \"" + Name + "\" - unsupported mission type \"" + Type.ToString() + "\""); + DebugConsole.AddWarning($"Potential error in mission {Identifier}: Disabling submarines is only intended for combat missions taking place in an outpost, and may lead to issues in other types of missions.", + contentPackage: element.ContentPackage); } + + constructor = FindMissionConstructor(element, MissionClass); if (constructor == null) { DebugConsole.ThrowError($"Failed to find a constructor for the mission type \"{Type}\"!", @@ -411,6 +411,67 @@ namespace Barotrauma InitProjSpecific(element); } + + private Type FindMissionClass(ContentXElement element) + { + Type type; + Identifier typeName = element.NameAsIdentifier(); + type = TryGetClass(typeName.RemoveFromEnd("Mission")); + + if (type == null) + { + //backwards compatibility: the actual mission class used to be defined by the "type" attribute, + //Now the mission class is defined by the name of the mission element, and the type can be any arbitrary string, + //but if we failed to find the class based on the name, let's try the type attribute. + Identifier typeNameLegacy = (element.GetAttributeIdentifier("type", Identifier.Empty)).ToIdentifier(); + if (typeNameLegacy == "OutpostDestroy" || typeNameLegacy == "OutpostRescue") + { + typeNameLegacy = "AbandonedOutpost".ToIdentifier(); + } + else if (typeNameLegacy == "clearalienruins") + { + typeNameLegacy = "EliminateTargets".ToIdentifier(); + } + type = TryGetClass(typeNameLegacy) ?? TryGetClass(typeNameLegacy.AppendIfMissing("Mission")); + if (type == null) + { + DebugConsole.ThrowError($"Failed to find the mission type \"{typeNameLegacy}\" for the mission {Identifier}.", + contentPackage: element.ContentPackage); + return null; + } + } + + static Type TryGetClass(Identifier typeName) + { + if (CoOpMissionClasses.TryGetValue(typeName, out Type coOpMissionClass)) + { + return coOpMissionClass; + } + else if (PvPMissionClasses.TryGetValue(typeName, out Type pvpMissionClass)) + { + return pvpMissionClass; + } + return null; + } + + return type; + } + + private ConstructorInfo FindMissionConstructor(ContentXElement element, Type missionClass) + { + ConstructorInfo constructor; + if (missionClass == null) { return null; } + if (missionClass != typeof(Mission) && !missionClass.IsSubclassOf(typeof(Mission))) { return null; } + constructor = missionClass.GetConstructor(new Type[] { typeof(MissionPrefab), typeof(Location[]), typeof(Submarine) }); + if (constructor == null) + { + DebugConsole.ThrowError( + $"Could not find the constructor of the mission type \"{missionClass}\" for the mission {Identifier}", + contentPackage: element.ContentPackage); + return null; + } + return constructor; + } partial void InitProjSpecific(ContentXElement element); @@ -424,7 +485,7 @@ namespace Barotrauma } return AllowedLocationTypes.Any(lt => lt == "any") || - AllowedLocationTypes.Any(lt => lt == "anyoutpost" && from.HasOutpost()) || + AllowedLocationTypes.Any(lt => lt == Barotrauma.Tags.AnyOutpost && from.HasOutpost() && from.Type.IsAnyOutpost) || AllowedLocationTypes.Any(lt => lt == from.Type.Identifier); } @@ -432,11 +493,11 @@ namespace Barotrauma { if (fromType == "any" || fromType == from.Type.Identifier || - (fromType == "anyoutpost" && from.HasOutpost() && from.Type.Identifier != "abandoned")) + (fromType == Barotrauma.Tags.AnyOutpost && from.HasOutpost() && from.Type.IsAnyOutpost && from.Type.Identifier != "abandoned")) { if (toType == "any" || toType == to.Type.Identifier || - (toType == "anyoutpost" && to.HasOutpost() && to.Type.Identifier != "abandoned")) + (toType == Barotrauma.Tags.AnyOutpost && to.HasOutpost() && to.Type.IsAnyOutpost && to.Type.Identifier != "abandoned")) { return true; } @@ -461,5 +522,28 @@ namespace Barotrauma { DisposeProjectSpecific(); } + + /// + /// Returns all mission types that can be selected e.g. in the server lobby, excluding any special, hidden ones like EndMission + /// (the mission at the end of the campaign) + /// + public static IEnumerable GetAllMultiplayerSelectableMissionTypes() + { + List missionTypes = new List(); + foreach (var missionPrefab in Prefabs) + { + if (missionPrefab.Commonness <= 0.0f) { continue; } + if (missionPrefab.SingleplayerOnly) { continue; } + if (HiddenMissionTypes.Contains(missionPrefab.Type)) + { + continue; + } + if (!missionTypes.Contains(missionPrefab.Type)) + { + missionTypes.Add(missionPrefab.Type); + } + } + return missionTypes.OrderBy(t => t.Value); + } } } diff --git a/Barotrauma/BarotraumaShared/SharedSource/Events/Missions/PirateMission.cs b/Barotrauma/BarotraumaShared/SharedSource/Events/Missions/PirateMission.cs index 1819a821b..6331311af 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>(); // Update the last sighting periodically so that the players can find the pirate sub even if they have lost the track of it. private readonly float pirateSightingUpdateFrequency = 30; @@ -68,7 +67,7 @@ namespace Barotrauma } } - public override int GetBaseReward(Submarine sub) + public override float GetBaseReward(Submarine sub) { return alternateReward; } @@ -92,18 +91,29 @@ 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); + factionIdentifier = prefab.ConfigElement.GetAttributeIdentifier("faction", Identifier.Empty); + //make sure all referenced character types are defined foreach (XElement characterElement in characterConfig.Elements()) { - var characterId = characterElement.GetAttributeString("typeidentifier", string.Empty); - var characterTypeElement = characterTypeConfig.Elements().FirstOrDefault(e => e.GetAttributeString("typeidentifier", string.Empty) == characterId); + Identifier typeId = characterElement.GetAttributeIdentifier("typeidentifier", Identifier.Empty); + if (typeId.IsEmpty) + { + if (characterElement.GetAttributeIdentifier("identifier", Identifier.Empty).IsEmpty) + { + DebugConsole.ThrowError($"Error in mission \"{prefab.Identifier}\". Character element with neither a typeidentifier or identifier ({characterElement.ToString()}).", + contentPackage: Prefab.ContentPackage); + } + continue; + } + var characterTypeElement = characterTypeConfig.Elements().FirstOrDefault(e => + e.GetAttributeIdentifier("typeidentifier", Identifier.Empty) == typeId); if (characterTypeElement == null) { - DebugConsole.ThrowError($"Error in mission \"{prefab.Identifier}\". Could not find a character type element for the character \"{characterId}\".", + DebugConsole.ThrowError($"Error in mission \"{prefab.Identifier}\". Could not find a character type element for the character \"{typeId}\".", contentPackage: Prefab.ContentPackage); } } @@ -114,7 +124,7 @@ namespace Barotrauma { Identifier characterIdentifier = characterElement.GetAttributeIdentifier("identifier", Identifier.Empty); Identifier characterFrom = characterElement.GetAttributeIdentifier("from", Identifier.Empty); - HumanPrefab humanPrefab = NPCSet.Get(characterFrom, characterIdentifier); + HumanPrefab humanPrefab = NPCSet.Get(characterFrom, characterIdentifier, contentPackageToLogInError: Prefab.ContentPackage); if (humanPrefab == null) { DebugConsole.ThrowError($"Error in mission \"{prefab.Identifier}\". Character prefab \"{characterIdentifier}\" not found in the NPC set \"{characterFrom}\".", @@ -143,37 +153,47 @@ namespace Barotrauma levelData = level; missionDifficulty = level?.Difficulty ?? 0; - XElement submarineConfig = GetRandomDifficultyModifiedElement(submarineTypeConfig, missionDifficulty, ShipRandomnessModifier); - alternateReward = submarineConfig.GetAttributeInt("alternatereward", Reward); - factionIdentifier = submarineConfig.GetAttributeIdentifier("faction", Identifier.Empty); + //no specific sub configured, choose a random one + if (submarineTypeConfig == null) + { + submarineInfo = GetRandomDifficultyModifiedSubmarine(missionDifficulty, ShipRandomnessModifier); + alternateReward = (int)submarineInfo.EnemySubmarineInfo.Reward; + } + else + { + XElement submarineConfig = GetRandomDifficultyModifiedElement(submarineTypeConfig, missionDifficulty, ShipRandomnessModifier); + alternateReward = submarineConfig.GetAttributeInt("alternatereward", Reward); + factionIdentifier = submarineConfig.GetAttributeIdentifier("faction", factionIdentifier); + + ContentPath submarinePath = submarineConfig.GetAttributeContentPath("path", Prefab.ContentPackage); + if (submarinePath.IsNullOrEmpty()) + { + DebugConsole.ThrowError($"No path used for submarine for the pirate mission \"{Prefab.Identifier}\"!", + contentPackage: Prefab.ContentPackage); + return; + } + + BaseSubFile contentFile = + GetSubFile(submarinePath) ?? + GetSubFile(submarinePath); + BaseSubFile GetSubFile(ContentPath path) where T : BaseSubFile + { + return ContentPackageManager.EnabledPackages.All.SelectMany(p => p.GetFiles()).FirstOrDefault(f => f.Path == submarinePath); + } + + if (contentFile == null) + { + DebugConsole.ThrowError($"No submarine file found from the path {submarinePath}!", + contentPackage: Prefab.ContentPackage); + return; + } + + submarineInfo = new SubmarineInfo(contentFile.Path.Value); + } string rewardText = $"‖color:gui.orange‖{string.Format(System.Globalization.CultureInfo.InvariantCulture, "{0:N0}", alternateReward)}‖end‖"; if (descriptionWithoutReward != null) { description = descriptionWithoutReward.Replace("[reward]", rewardText); } - ContentPath submarinePath = submarineConfig.GetAttributeContentPath("path", Prefab.ContentPackage); - if (submarinePath.IsNullOrEmpty()) - { - DebugConsole.ThrowError($"No path used for submarine for the pirate mission \"{Prefab.Identifier}\"!", - contentPackage: Prefab.ContentPackage); - return; - } - - BaseSubFile contentFile = - GetSubFile(submarinePath) ?? - GetSubFile(submarinePath); - BaseSubFile GetSubFile(ContentPath path) where T : BaseSubFile - { - return ContentPackageManager.EnabledPackages.All.SelectMany(p => p.GetFiles()).FirstOrDefault(f => f.Path == submarinePath); - } - - if (contentFile == null) - { - DebugConsole.ThrowError($"No submarine file found from the path {submarinePath}!", - contentPackage: Prefab.ContentPackage); - return; - } - - submarineInfo = new SubmarineInfo(contentFile.Path.Value); } private static float GetDifficultyModifiedValue(float preferredDifficulty, float levelDifficulty, float randomnessModifier, Random rand) @@ -185,6 +205,33 @@ namespace Barotrauma return Math.Max((int)Math.Round(minAmount + (maxAmount - minAmount) * (levelDifficulty + MathHelper.Lerp(-RandomnessModifier, RandomnessModifier, (float)rand.NextDouble())) / MaxDifficulty), minAmount); } + private SubmarineInfo GetRandomDifficultyModifiedSubmarine(float levelDifficulty, float randomnessModifier) + { + Random rand = new MTRandom(ToolBox.StringToInt(levelData.Seed)); + // look for the saved submarine that is closest to our difficulty, with some randomness + SubmarineInfo bestSubmarine = null; + float bestValue = float.MaxValue; + var submarineInfos = SubmarineInfo.SavedSubmarines.Where(i => i.IsEnemySubmarine); + foreach (SubmarineInfo submarineInfo in submarineInfos) + { + if (!Prefab.Tags.Any(t => submarineInfo.EnemySubmarineInfo.MissionTags.Contains(t))) { continue; } + float applicabilityValue = GetDifficultyModifiedValue(submarineInfo.EnemySubmarineInfo.PreferredDifficulty, levelDifficulty, randomnessModifier, rand); + if (applicabilityValue < bestValue) + { + bestSubmarine = submarineInfo; + bestValue = applicabilityValue; + } + } + + if (bestSubmarine == null) + { + DebugConsole.ThrowError("No EnemySubmarine found that matches the mission's tags!"); + return SubmarineInfo.SavedSubmarines.First(i => i.IsEnemySubmarine); + } + + return bestSubmarine; + } + private XElement GetRandomDifficultyModifiedElement(XElement parentElement, float levelDifficulty, float randomnessModifier) { Random rand = new MTRandom(ToolBox.StringToInt(levelData.Seed)); @@ -300,30 +347,54 @@ namespace Barotrauma bool commanderAssigned = false; foreach (ContentXElement element in characterConfig.Elements()) { + //there's two ways to define the characters in pirate missions + //1. "the normal way", referring to a human prefab + Identifier humanPrefabId = element.GetAttributeIdentifier("identifier", Identifier.Empty); + //2. the strange way it was initially implemented and the way the vanilla missions work: using a reference to a "character type" in the mission, which refers to a human prefab + Identifier characterTypeId = element.GetAttributeIdentifier("typeidentifier", Identifier.Empty); + + int minAmount = element.GetAttributeInt("minamount", 0); + int maxAmount = element.GetAttributeInt("maxamount", 0); // it is possible to get more than the "max" amount of characters if the modified difficulty is high enough; this is intentional // if necessary, another "hard max" value could be used to clamp the value for performance/gameplay concerns - int amountCreated = GetDifficultyModifiedAmount(element.GetAttributeInt("minamount", 0), element.GetAttributeInt("maxamount", 0), enemyCreationDifficulty, rand); - var characterId = element.GetAttributeString("typeidentifier", string.Empty); + int amountCreated = minAmount == 0 && maxAmount == 0 ? + //default to 1 character if amount is not defined + 1 : + //otherwise choose a value between min and max based on difficulty + GetDifficultyModifiedAmount(minAmount, maxAmount, enemyCreationDifficulty, rand); + for (int i = 0; i < amountCreated; i++) { - XElement characterType = characterTypeConfig.Elements().Where(e => e.GetAttributeString("typeidentifier", string.Empty) == characterId).FirstOrDefault(); - - if (characterType == null) + HumanPrefab humanPrefab = null; + bool isCommander = false; + if (!characterTypeId.IsEmpty) { - DebugConsole.ThrowError($"No character types defined in CharacterTypes for a declared type identifier in mission \"{Prefab.Identifier}\".", - contentPackage: element.ContentPackage); - return; + XElement characterType = characterTypeConfig.Elements().Where(e => e.GetAttributeIdentifier("typeidentifier", Identifier.Empty) == characterTypeId).FirstOrDefault(); + if (characterType == null) + { + DebugConsole.ThrowError($"No character types defined in CharacterTypes for a declared type identifier in mission \"{Prefab.Identifier}\".", + contentPackage: element.ContentPackage); + return; + } + XElement variantElement = GetRandomDifficultyModifiedElement(characterType, enemyCreationDifficulty, RandomnessModifier); + humanPrefab = GetHumanPrefabFromElement(variantElement); + isCommander = variantElement.GetAttributeBool("iscommander", false); + } + else if (!humanPrefabId.IsEmpty) + { + humanPrefab = GetHumanPrefabFromElement(element); + isCommander = element.GetAttributeBool("iscommander", false); } - XElement variantElement = GetRandomDifficultyModifiedElement(characterType, enemyCreationDifficulty, RandomnessModifier); - - var humanPrefab = GetHumanPrefabFromElement(variantElement); if (humanPrefab == null) { continue; } Character spawnedCharacter = CreateHuman(humanPrefab, characters, characterItems, enemySub, CharacterTeamType.None, null); + if (element.GetAttribute("color") != null) + { + spawnedCharacter.UniqueNameColor = element.GetAttributeColor("color", Color.Red); + } if (!commanderAssigned) { - bool isCommander = variantElement.GetAttributeBool("iscommander", false); if (isCommander && spawnedCharacter.AIController is HumanAIController humanAIController) { humanAIController.InitShipCommandManager(); @@ -335,6 +406,15 @@ namespace Barotrauma } } + foreach (var subElement in element.Elements()) + { + if (subElement.NameAsIdentifier() == "statuseffect") + { + var newEffect = StatusEffect.Load(subElement, parentDebugName: Prefab.Name.Value); + newEffect?.Apply(newEffect.type, 1.0f, spawnedCharacter, spawnedCharacter); + } + } + foreach (Item item in spawnedCharacter.Inventory.AllItems) { if (item?.GetComponent() != null) @@ -395,10 +475,7 @@ namespace Barotrauma } #endif enemySub.SetPosition(spawnPos); - if (!IsClient) - { - InitPirateShip(); - } + InitPirateShip(); // flipping the sub on the frame it is moved into place must be done after it's been moved, or it breaks item connections in the submarine // creating the pirates has to be done after the sub has been flipped, or it seems to break the AI pathing diff --git a/Barotrauma/BarotraumaShared/SharedSource/Events/Missions/SalvageMission.cs b/Barotrauma/BarotraumaShared/SharedSource/Events/Missions/SalvageMission.cs index f4152f676..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()) { @@ -311,7 +343,11 @@ namespace Barotrauma targets.Add(target); foreach (ContentXElement subElement in chosenElement.Elements()) { - LoadTarget(subElement, parentTarget: target); + if (subElement.NameAsIdentifier() == "target" || + subElement.NameAsIdentifier() == "chooserandom") + { + LoadTarget(subElement, parentTarget: target); + } } } } @@ -337,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; @@ -376,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) @@ -460,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); } } @@ -534,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) { @@ -567,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; } @@ -617,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); @@ -641,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 { @@ -675,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/Missions/ScanMission.cs b/Barotrauma/BarotraumaShared/SharedSource/Events/Missions/ScanMission.cs index 8ee06a07d..288e37b92 100644 --- a/Barotrauma/BarotraumaShared/SharedSource/Events/Missions/ScanMission.cs +++ b/Barotrauma/BarotraumaShared/SharedSource/Events/Missions/ScanMission.cs @@ -1,3 +1,4 @@ +using System; using Barotrauma.Extensions; using Barotrauma.Items.Components; using Barotrauma.RuinGeneration; @@ -20,23 +21,14 @@ namespace Barotrauma private readonly Dictionary scanTargets = new Dictionary(); private readonly HashSet newTargetsScanned = new HashSet(); private readonly float minTargetDistance; - - + private Ruin TargetRuin { get; set; } - private bool AllTargetsScanned - { - get - { - return scanTargets.Any() && scanTargets.All(kvp => kvp.Value); - } - } - public override IEnumerable<(LocalizedString Label, Vector2 Position)> SonarLabels { get { - if (State > 0 || scanTargets.None()) + if (AllTargetsScanned()) { return Enumerable.Empty<(LocalizedString Label, Vector2 Position)>(); } @@ -234,24 +226,19 @@ namespace Barotrauma protected override void UpdateMissionSpecific(float deltaTime) { if (IsClient) { return; } - switch (State) - { - case 0: - if (AllTargetsScanned) - { - State = 1; - } - break; - } + // Allow the state to be set higher with MissionStateAction, but not lower. + State = Math.Max(State, scanTargets.Count(kvp => kvp.Value)); } - - protected override bool DetermineCompleted() => State > 0; + + private bool AllTargetsScanned() => State >= targetsToScan; + + protected override bool DetermineCompleted() => AllTargetsScanned(); protected override void EndMissionSpecific(bool completed) { foreach (var scanner in scanners) { - if (scanner.Item != null && !scanner.Item.Removed) + if (scanner.Item is { Removed: false }) { scanner.OnScanStarted -= OnScanStarted; scanner.OnScanCompleted -= OnScanCompleted; @@ -259,7 +246,7 @@ namespace Barotrauma } } Reset(); - failed = !completed && state > 0; + failed = !completed; } } } \ No newline at end of file diff --git a/Barotrauma/BarotraumaShared/SharedSource/Events/MonsterEvent.cs b/Barotrauma/BarotraumaShared/SharedSource/Events/MonsterEvent.cs index e2c58bb74..d9cd9964a 100644 --- a/Barotrauma/BarotraumaShared/SharedSource/Events/MonsterEvent.cs +++ b/Barotrauma/BarotraumaShared/SharedSource/Events/MonsterEvent.cs @@ -72,7 +72,9 @@ namespace Barotrauma /// Maximum number of the specific type of monster in the entire level. Can be used to prevent the event from spawning more monsters if there's /// already enough of that type of monster, e.g. spawned by another event or by a mission. /// - public readonly int MaxAmountPerLevel = int.MaxValue; + public readonly int MaxAmountPerLevel; + + private readonly float? overridePlayDeadProbability; public IReadOnlyList Monsters => monsters; public Vector2? SpawnPos => spawnPos; @@ -137,6 +139,11 @@ namespace Barotrauma scatter = Math.Clamp(prefab.ConfigElement.GetAttributeFloat("scatter", 500), 0, 3000); delayBetweenSpawns = prefab.ConfigElement.GetAttributeFloat("delaybetweenspawns", 0.1f); resetTime = prefab.ConfigElement.GetAttributeFloat("resettime", 0); + float playDeadProbability = prefab.ConfigElement.GetAttributeFloat("playdeadprobability", -1f); + if (playDeadProbability >= 0) + { + overridePlayDeadProbability = playDeadProbability; + } if (GameMain.NetworkMember != null) { @@ -153,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() @@ -175,6 +182,18 @@ namespace Barotrauma protected override void InitEventSpecific(EventSet parentSet) { + // apply pvp stun resistance (reduce stun amount via resist multiplier) + if (GameMain.NetworkMember is { } networkMember && GameMain.GameSession?.GameMode is PvPMode && !networkMember.ServerSettings.PvPSpawnMonsters) + { + if (GameSettings.CurrentConfig.VerboseLogging) + { + DebugConsole.NewMessage($"PvP setting: disabling monster event ({SpeciesName})", Color.Yellow); + } + + disallowed = true; + return; + } + if (parentSet != null && resetTime == 0) { // Use the parent reset time only if there's no reset time defined for the event. @@ -200,13 +219,9 @@ namespace Barotrauma disallowed = true; continue; } - if (GameMain.GameSession.IsCurrentLocationRadiated()) + if (overridePlayDeadProbability.HasValue) { - 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.EvaluatePlayDeadProbability(overridePlayDeadProbability); } createdCharacter.DisabledByEvent = true; monsters.Add(createdCharacter); @@ -284,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) @@ -299,7 +321,7 @@ namespace Barotrauma { if (sub.Info.Type != SubmarineType.Player && sub.Info.Type != SubmarineType.EnemySubmarine && - sub != GameMain.NetworkMember?.RespawnManager?.RespawnShuttle) + !sub.IsRespawnShuttle) { continue; } @@ -606,7 +628,7 @@ namespace Barotrauma bool anyInAbyss = false; foreach (Submarine submarine in Submarine.Loaded) { - if (submarine.Info.Type != SubmarineType.Player || submarine == GameMain.NetworkMember?.RespawnManager?.RespawnShuttle) { continue; } + if (submarine.Info.Type != SubmarineType.Player || submarine.IsRespawnShuttle) { continue; } if (submarine.WorldPosition.Y < 0) { anyInAbyss = true; @@ -722,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/Events/ScriptedEvent.cs b/Barotrauma/BarotraumaShared/SharedSource/Events/ScriptedEvent.cs index 68218dc1b..f9918224d 100644 --- a/Barotrauma/BarotraumaShared/SharedSource/Events/ScriptedEvent.cs +++ b/Barotrauma/BarotraumaShared/SharedSource/Events/ScriptedEvent.cs @@ -37,9 +37,11 @@ namespace Barotrauma public readonly OnRoundEndAction OnRoundEndAction; - private readonly string[] requiredDestinationTypes; + private readonly Identifier[] requiredDestinationTypes; public readonly bool RequireBeaconStation; + public readonly Identifier RequiredDestinationFaction; + public int CurrentActionIndex { get; private set; } public List Actions { get; } = new List(); public Dictionary> Targets { get; } = new Dictionary>(); @@ -78,17 +80,84 @@ namespace Barotrauma contentPackage: prefab.ContentPackage); } - requiredDestinationTypes = prefab.ConfigElement.GetAttributeStringArray("requireddestinationtypes", null); + requiredDestinationTypes = prefab.ConfigElement.GetAttributeIdentifierArray("requireddestinationtypes", Array.Empty()); RequireBeaconStation = prefab.ConfigElement.GetAttributeBool("requirebeaconstation", false); + RequiredDestinationFaction = prefab.ConfigElement.GetAttributeIdentifier(nameof(RequiredDestinationFaction), Identifier.Empty); + + var allActionsWithIndent = GetAllActions(); + var allActions = allActionsWithIndent.Select(a => a.action); + + //attempt to check if the event has ConversationActions with options that don't close the prompt and don't lead to any follow-up conversation + foreach (var action in allActions) + { + if (action is ConversationAction conversationAction && conversationAction.Options.Any()) + { + int thisActionIndex = allActionsWithIndent.FindIndex(a => a.action == action); + int thisIndentationLevel = allActionsWithIndent[thisActionIndex].indent; + bool isLast = true; + + //go through all the actions after this one + foreach (var actionWithIndent in allActionsWithIndent.Skip(thisActionIndex + 1)) + { + //if it's an action with the same indentation level, it means it's a ConversationAction coming after this one + if (actionWithIndent.action is ConversationAction && actionWithIndent.indent == thisIndentationLevel) + { + isLast = false; + break; + } + //if the indentation level went back down, we've already searched everything inside this ConversationAction + if (actionWithIndent.indent < thisIndentationLevel) { break; } + } + if (isLast) + { + foreach (var option in conversationAction.Options) + { + if (!conversationAction.GetEndingOptions().Contains(conversationAction.Options.IndexOf(option)) && + option.Actions.None(a => + a is ConversationAction || HasConversationSubAction(a) || + /* if there's a goto action explicitly set to end the conversation, assume it's intentional*/ + a is GoTo { EndConversation: false })) + { + DebugConsole.AddWarning($"Potential error in event \"{prefab.Identifier}\": {nameof(ConversationAction)} ({conversationAction.Text}) has an option ({option.Text}) that doesn't end the conversation, but could not find any follow-ups to the conversation."); + } + } + } + } + + static bool HasConversationSubAction(EventAction action) + { + foreach (var subAction in action.GetSubActions()) + { + if (subAction is ConversationAction) { return true; } + if (HasConversationSubAction(subAction)) { return true; } + } + return false; + } + } + + foreach (var label in allActions.OfType