using System.Collections.Generic; using System.Collections.ObjectModel; using NitroxClient.GameLogic.HUD; using NitroxClient.GameLogic.PlayerLogic; using NitroxClient.GameLogic.PlayerLogic.PlayerModel; using NitroxClient.GameLogic.PlayerLogic.PlayerModel.Abstract; using NitroxClient.MonoBehaviours; using NitroxClient.MonoBehaviours.Cyclops; using NitroxClient.MonoBehaviours.Gui.HUD; using NitroxClient.MonoBehaviours.Vehicles; using NitroxClient.Unity.Helper; using NitroxModel.GameLogic.FMOD; using NitroxModel.MultiplayerSession; using NitroxModel.Server; using UnityEngine; using UWE; namespace NitroxClient.GameLogic; public class RemotePlayer : INitroxPlayer { /// /// Marks and every so they can be precisely queried (e.g. by sea dragons). /// The value (5050) is determined arbitrarily and should not be used already. /// public const EcoTargetType PLAYER_ECO_TARGET_TYPE = (EcoTargetType)5050; private static readonly int animatorPlayerIn = Animator.StringToHash("player_in"); private readonly PlayerModelManager playerModelManager; private readonly PlayerVitalsManager playerVitalsManager; private readonly FMODWhitelist fmodWhitelist; public PlayerContext PlayerContext { get; } public GameObject Body { get; private set; } public GameObject PlayerModel { get; private set; } public Rigidbody RigidBody { get; private set; } public CapsuleCollider Collider { get; private set; } public ArmsController ArmsController { get; private set; } public AnimationController AnimationController { get; private set; } public ItemsContainer Inventory { get; private set; } public Transform ItemAttachPoint { get; private set; } public RemotePlayerVitals vitals { get; private set; } public ushort PlayerId => PlayerContext.PlayerId; public string PlayerName => PlayerContext.PlayerName; public PlayerSettings PlayerSettings => PlayerContext.PlayerSettings; public Vehicle Vehicle { get; private set; } public SubRoot SubRoot { get; private set; } public EscapePod EscapePod { get; private set; } public PilotingChair PilotingChair { get; private set; } public InfectedMixin InfectedMixin { get; private set; } public LiveMixin LiveMixin { get; private set; } public readonly Event PlayerDeathEvent = new(); public readonly Event PlayerDisconnectEvent = new(); public CyclopsPawn Pawn { get; set; } public RemotePlayer(PlayerContext playerContext, PlayerModelManager playerModelManager, PlayerVitalsManager playerVitalsManager, FMODWhitelist fmodWhitelist) { PlayerContext = playerContext; this.playerModelManager = playerModelManager; this.playerVitalsManager = playerVitalsManager; this.fmodWhitelist = fmodWhitelist; } public void InitializeGameObject(GameObject playerBody) { Body = playerBody; Body.name = PlayerName; RigidBody = Body.AddComponent(); RigidBody.useGravity = false; RigidBody.interpolation = RigidbodyInterpolation.Interpolate; NitroxEntity.SetNewId(Body, PlayerContext.PlayerNitroxId); // Get player PlayerModel = Body.RequireGameObject("player_view"); // Move variables to keep player animations from mirroring and for identification ArmsController = PlayerModel.GetComponent(); ArmsController.smoothSpeedUnderWater = 0; ArmsController.smoothSpeedAboveWater = 0; // ConditionRules has Player.Main based conditions from ArmsController PlayerModel.GetComponent().enabled = false; AnimationController = PlayerModel.AddComponent(); Transform inventoryTransform = new GameObject("Inventory").transform; inventoryTransform.SetParent(Body.transform); Inventory = new ItemsContainer(6, 8, inventoryTransform, $"NitroxInventoryStorage_{PlayerName}", null); ItemAttachPoint = PlayerModel.transform.Find(PlayerEquipmentConstants.ITEM_ATTACH_POINT_GAME_OBJECT_NAME); CoroutineUtils.StartCoroutineSmart(playerModelManager.AttachPing(this)); playerModelManager.BeginApplyPlayerColor(this); playerModelManager.RegisterEquipmentVisibilityHandler(PlayerModel); SetupBody(); SetupSkyAppliers(); SetupPlayerSounds(); SetupMixins(); vitals = playerVitalsManager.CreateOrFindForPlayer(this); RefreshVitalsVisibility(); PlayerDisconnectEvent.AddHandler(Body, _ => { Pawn?.Unregister(); Pawn = null; }); PlayerDeathEvent.AddHandler(Body, _ => { ResetStates(); }); } public void Attach(Transform transform, bool keepWorldTransform = false) { Body.transform.SetParent(transform); if (!keepWorldTransform) { UWE.Utils.ZeroTransform(Body); } SkyEnvironmentChanged.Broadcast(Body, transform); } public void Detach() { Body.transform.SetParent(null); SkyEnvironmentChanged.Broadcast(Body, (GameObject)null); } public void UpdatePosition(Vector3 position, Vector3 velocity, Quaternion bodyRotation, Quaternion aimingRotation) { // It might happen that we get movement packets before the body is actually initialized which is not too bad if (!Body) { return; } Body.SetActive(true); // When receiving movement packets, a player can not be controlling a vehicle (they can walk through subroots though). SetVehicle(null); SetPilotingChair(null); AnimationController.AimingRotation = aimingRotation; AnimationController.UpdatePlayerAnimations = true; AnimationController.Velocity = MovementHelper.GetCorrectedVelocity(position, velocity, Body, Time.fixedDeltaTime); // If in a subroot the position will be relative to the subroot if (SubRoot && SubRoot.isBase) { Quaternion vehicleAngle = SubRoot.transform.rotation; position = vehicleAngle * position; position += SubRoot.transform.position; bodyRotation = vehicleAngle * bodyRotation; aimingRotation = vehicleAngle * aimingRotation; } RigidBody.velocity = AnimationController.Velocity; RigidBody.angularVelocity = MovementHelper.GetCorrectedAngularVelocity(bodyRotation, Vector3.zero, Body, Time.fixedDeltaTime); } public void UpdatePositionInCyclops(Vector3 localPosition, Quaternion localRotation) { if (Pawn == null || PilotingChair) { return; } SetVehicle(null); AnimationController.AimingRotation = localRotation; AnimationController.UpdatePlayerAnimations = true; AnimationController.Velocity = (localPosition - Pawn.Handle.transform.localPosition) / Time.fixedDeltaTime; Pawn.Handle.transform.localPosition = localPosition; Pawn.Handle.transform.localRotation = localRotation; } public void SetPilotingChair(PilotingChair newPilotingChair) { if (PilotingChair != newPilotingChair) { PilotingChair = newPilotingChair; CyclopsMovementReplicator cyclopsMovementReplicator = null; // For unexpected and expected cases, for example when a player is driving a cyclops but the cyclops is destroyed if (!SubRoot) { Log.Error("Player changed PilotingChair but is not in SubRoot!"); } else { cyclopsMovementReplicator = SubRoot.GetComponent(); } if (PilotingChair) { Attach(PilotingChair.sittingPosition.transform); ArmsController.SetWorldIKTarget(PilotingChair.leftHandPlug, PilotingChair.rightHandPlug); if (cyclopsMovementReplicator) { cyclopsMovementReplicator.Enter(this); } if (SubRoot) { SkyEnvironmentChanged.Broadcast(Body, SubRoot); } AnimationController.UpdatePlayerAnimations = false; } else { SetSubRoot(SubRoot, true); ArmsController.SetWorldIKTarget(null, null); if (cyclopsMovementReplicator) { cyclopsMovementReplicator.Exit(); } } bool isKinematic = newPilotingChair; UWE.Utils.SetIsKinematicAndUpdateInterpolation(RigidBody, isKinematic, true); AnimationController["cyclops_steering"] = newPilotingChair; } } public void SetSubRoot(SubRoot newSubRoot, bool force = false) { if (SubRoot != newSubRoot || force) { // Unregister from previous cyclops Pawn?.Unregister(); Pawn = null; if (newSubRoot) { Attach(newSubRoot.transform, true); // Register in new cyclops if (newSubRoot.TryGetComponent(out NitroxCyclops nitroxCyclops)) { nitroxCyclops.OnPlayerEnter(this); } } else { Detach(); } SubRoot = newSubRoot; } } public void SetEscapePod(EscapePod newEscapePod) { if (EscapePod != newEscapePod) { if (newEscapePod) { Attach(newEscapePod.transform, true); } else { Detach(); } EscapePod = newEscapePod; } } public void SetVehicle(Vehicle newVehicle) { if (Vehicle != newVehicle) { if (Vehicle) { Vehicle.mainAnimator.SetBool(animatorPlayerIn, false); Detach(); ArmsController.SetWorldIKTarget(null, null); if (Vehicle.TryGetComponent(out VehicleMovementReplicator vehicleMovementReplicator)) { vehicleMovementReplicator.Exit(); } } if (newVehicle) { newVehicle.mainAnimator.SetBool(animatorPlayerIn, true); Attach(newVehicle.playerPosition.transform); ArmsController.SetWorldIKTarget(newVehicle.leftHandPlug, newVehicle.rightHandPlug); // From here, a basic issue can happen. // When a vehicle is docked since we joined a game and another player undocks him before the local player does, // no VehicleMovementReplicator can be found on the vehicle because they are only created when receiving SimulationOwnership packets // Therefore we need to make sure that the VehicleMovementReplicator component exists before using it switch (newVehicle) { case SeaMoth: newVehicle.gameObject.EnsureComponent().Enter(this); break; case Exosuit: newVehicle.gameObject.EnsureComponent().Enter(this); break; } AnimationController.UpdatePlayerAnimations = false; } bool isKinematic = newVehicle; UWE.Utils.SetIsKinematicAndUpdateInterpolation(RigidBody, isKinematic, true); Vehicle = newVehicle; AnimationController["in_seamoth"] = newVehicle is SeaMoth; AnimationController["in_exosuit"] = AnimationController["using_mechsuit"] = newVehicle is Exosuit; // In case we are dismissing the current seamoth to enter the cyclops through a docking, // we need to setup the player back in the cyclops if (!newVehicle && SubRoot) { SetSubRoot(SubRoot, true); } } } /// /// Drops the remote player, swimming where he is. Resets its animator. /// public void ResetStates() { SetPilotingChair(null); SetVehicle(null); SetSubRoot(null); AnimationController.UpdatePlayerAnimations = true; AnimationController.Reset(); ArmsController.SetWorldIKTarget(null, null); } public void Destroy() { Log.Info($"{PlayerName} left the game"); Log.InGame(Language.main.Get("Nitrox_PlayerLeft").Replace("{PLAYER}", PlayerName)); NitroxEntity.RemoveFrom(Body); Object.DestroyImmediate(Body); } public void UpdateAnimationAndCollider(AnimChangeType type, AnimChangeState state) { switch (type) { case AnimChangeType.UNDERWATER: AnimationController["is_underwater"] = state != AnimChangeState.OFF; break; case AnimChangeType.BENCH: AnimationController["cinematics_enabled"] = state != AnimChangeState.UNSET; AnimationController["bench_sit"] = state == AnimChangeState.ON; AnimationController["bench_stand_up"] = state == AnimChangeState.OFF; break; case AnimChangeType.INFECTION_REVEAL: AnimationController["player_infected"] = state != AnimChangeState.UNSET; break; } // Rough estimation for different collider boxes in different animation stages if (AnimationController["is_underwater"]) { Collider.center = new(0f, -0.3f, 0f); Collider.height = 0.5f; } else { Collider.center = new(0f, -0.8f, 0f); Collider.height = 1.5f; } } public void UpdateEquipmentVisibility(List equippedItems) { playerModelManager.UpdateEquipmentVisibility(new ReadOnlyCollection(equippedItems)); } /// /// Makes the RemotePlayer recognizable as an obstacle for buildings, and as a target for creatures /// private void SetupBody() { // set as a target for reapers EcoTarget sharkEcoTarget = Body.AddComponent(); sharkEcoTarget.SetTargetType(EcoTargetType.Shark); EcoTarget playerEcoTarget = Body.AddComponent(); playerEcoTarget.SetTargetType(PLAYER_ECO_TARGET_TYPE); TechTag techTag = Body.AddComponent(); techTag.type = TechType.Player; RemotePlayerIdentifier identifier = Body.AddComponent(); identifier.RemotePlayer = this; if (Player.mainCollider is CapsuleCollider refCollider) { // This layer lets us have a collider as a trigger without preventing its detection as an obstacle Body.layer = LayerID.Useable; Collider = Body.AddComponent(); Collider.center = Vector3.zero; Collider.radius = refCollider.radius; Collider.direction = refCollider.direction; Collider.contactOffset = refCollider.contactOffset; Collider.isTrigger = true; } else { Log.Warn("The main collider of the main Player couldn't be found or is not a CapsuleCollider. Collisions for the RemotePlayer won't be created"); } } /// /// Allows the remote player model to have its lighting dynamically adjusted /// private void SetupSkyAppliers() { // SkyAppliers apply the light effects of a lighting source on a set of renderers SkyApplier skyApplier = Body.AddComponent(); skyApplier.anchorSky = Skies.Auto; skyApplier.emissiveFromPower = false; skyApplier.dynamic = true; skyApplier.renderers = Body.GetComponentsInChildren(true); } /// /// Sets up all bubbles, breathing and diving sounds to be multiplayer ready /// private void SetupPlayerSounds() { GameObject remotePlayerSoundsRoot = new("RemotePlayerSounds"); remotePlayerSoundsRoot.transform.SetParent(Body.transform); FMODEmitterController emitterController = Body.AddComponent(); static void CopyEmitter(FMOD_CustomEmitter src, FMOD_CustomEmitter dst) { dst.asset = src.asset; dst.playOnAwake = src.playOnAwake; dst.stopImmediatelyOnDisable = src.stopImmediatelyOnDisable; dst.followParent = src.followParent; dst.restartOnPlay = src.restartOnPlay; } // Bubbles PlayerBreathBubbles localPlayerBubbles = Player.main.GetComponentInChildren(true); FMOD_CustomEmitter bubblesCustomEmitter = remotePlayerSoundsRoot.AddComponent(); CopyEmitter(localPlayerBubbles.bubbleSound, bubblesCustomEmitter); if (fmodWhitelist.IsWhitelisted(bubblesCustomEmitter.asset.path, out float bubblesSoundRadius)) { emitterController.AddEmitter(bubblesCustomEmitter.asset.path, bubblesCustomEmitter, bubblesSoundRadius); } else { Log.Error($"[{nameof(RemotePlayer)}] Manual created FMOD emitter for {nameof(PlayerBreathBubbles)} but linked sound is not whitelisted: ({bubblesCustomEmitter.asset.path})"); } // Breathing BreathingSound breathingSound = Player.main.GetComponentInChildren(true); FMOD_CustomEmitter breathingSoundCustomEmitter = remotePlayerSoundsRoot.AddComponent(); breathingSoundCustomEmitter.asset = breathingSound.loopingBreathingSound.asset; if (fmodWhitelist.IsWhitelisted(breathingSoundCustomEmitter.asset.path, out float breathingSoundRadius)) { emitterController.AddEmitter(breathingSoundCustomEmitter.asset.path, breathingSoundCustomEmitter, breathingSoundRadius); } else { Log.Error($"[{nameof(RemotePlayer)}] Manual created FMOD emitter for {nameof(BreathingSound)} but linked sound is not whitelisted: ({breathingSoundCustomEmitter.asset.path})"); } // Diving WaterAmbience waterAmbience = Player.main.GetComponentInChildren(true); FMOD_CustomEmitter diveStartCustomEmitter = remotePlayerSoundsRoot.AddComponent(); CopyEmitter(waterAmbience.diveStartSplash, diveStartCustomEmitter); if (fmodWhitelist.IsWhitelisted(diveStartCustomEmitter.asset.path, out float diveSoundRadius)) { emitterController.AddEmitter(diveStartCustomEmitter.asset.path, diveStartCustomEmitter, diveSoundRadius); } else { Log.Error($"[{nameof(RemotePlayer)}] Manual created FMOD emitter for {nameof(WaterAmbience)} but linked sound is not whitelisted: ({diveStartCustomEmitter.asset.path})"); } } /// /// An InfectedMixin is required for behaviours like which look for this on the target they find /// private void SetupMixins() { InfectedMixin = Body.AddComponent(); InfectedMixin.shaderKeyWord = InfectedMixin.uwe_playerinfection; Renderer renderer = PlayerModel.transform.Find("male_geo/diveSuit/diveSuit_hands_geo").GetComponent(); InfectedMixin.renderers = [renderer]; LiveMixin = Body.AddComponent(); LiveMixin.data = new() { maxHealth = 100, broadcastKillOnDeath = false }; LiveMixin.health = 100; // We set the remote player to invincible because we only want this component to be detectable but not to work LiveMixin.invincible = true; } public void UpdateHealthAndInfection(float health, float infection) { if (LiveMixin) { LiveMixin.health = health; } if (InfectedMixin) { InfectedMixin.infectedAmount = infection; InfectedMixin.UpdateInfectionShading(); } } public void SetGameMode(NitroxGameMode gameMode) { PlayerContext.GameMode = gameMode; RefreshVitalsVisibility(); } private void RefreshVitalsVisibility() { if (vitals) { bool visible = PlayerContext.GameMode != NitroxGameMode.CREATIVE; vitals.SetStatsVisible(visible); } } /// /// Adaptation of for remote players. /// NB: This doesn't check for other player's use of 'invisible' command /// public bool CanBeAttacked() { return !SubRoot && !EscapePod && PlayerContext.GameMode != NitroxGameMode.CREATIVE; } }