Files
Nitrox/NitroxClient/GameLogic/RemotePlayer.cs
2025-07-06 00:23:46 +02:00

568 lines
21 KiB
C#

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
{
/// <summary>
/// Marks <see cref="Player.mainObject"/> and every <see cref="Body"/> so they can be precisely queried (e.g. by sea dragons).
/// The value (5050) is determined arbitrarily and should not be used already.
/// </summary>
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<RemotePlayer> PlayerDeathEvent = new();
public readonly Event<RemotePlayer> 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>();
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>();
ArmsController.smoothSpeedUnderWater = 0;
ArmsController.smoothSpeedAboveWater = 0;
// ConditionRules has Player.Main based conditions from ArmsController
PlayerModel.GetComponent<ConditionRules>().enabled = false;
AnimationController = PlayerModel.AddComponent<AnimationController>();
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<CyclopsMovementReplicator>();
}
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<SeamothMovementReplicator>().Enter(this);
break;
case Exosuit:
newVehicle.gameObject.EnsureComponent<ExosuitMovementReplicator>().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);
}
}
}
/// <summary>
/// Drops the remote player, swimming where he is. Resets its animator.
/// </summary>
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<TechType> equippedItems)
{
playerModelManager.UpdateEquipmentVisibility(new ReadOnlyCollection<TechType>(equippedItems));
}
/// <summary>
/// Makes the RemotePlayer recognizable as an obstacle for buildings, and as a target for creatures
/// </summary>
private void SetupBody()
{
// set as a target for reapers
EcoTarget sharkEcoTarget = Body.AddComponent<EcoTarget>();
sharkEcoTarget.SetTargetType(EcoTargetType.Shark);
EcoTarget playerEcoTarget = Body.AddComponent<EcoTarget>();
playerEcoTarget.SetTargetType(PLAYER_ECO_TARGET_TYPE);
TechTag techTag = Body.AddComponent<TechTag>();
techTag.type = TechType.Player;
RemotePlayerIdentifier identifier = Body.AddComponent<RemotePlayerIdentifier>();
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<CapsuleCollider>();
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");
}
}
/// <summary>
/// Allows the remote player model to have its lighting dynamically adjusted
/// </summary>
private void SetupSkyAppliers()
{
// SkyAppliers apply the light effects of a lighting source on a set of renderers
SkyApplier skyApplier = Body.AddComponent<SkyApplier>();
skyApplier.anchorSky = Skies.Auto;
skyApplier.emissiveFromPower = false;
skyApplier.dynamic = true;
skyApplier.renderers = Body.GetComponentsInChildren<SkinnedMeshRenderer>(true);
}
/// <summary>
/// Sets up all bubbles, breathing and diving sounds to be multiplayer ready
/// </summary>
private void SetupPlayerSounds()
{
GameObject remotePlayerSoundsRoot = new("RemotePlayerSounds");
remotePlayerSoundsRoot.transform.SetParent(Body.transform);
FMODEmitterController emitterController = Body.AddComponent<FMODEmitterController>();
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<PlayerBreathBubbles>(true);
FMOD_CustomEmitter bubblesCustomEmitter = remotePlayerSoundsRoot.AddComponent<FMOD_CustomEmitter>();
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<BreathingSound>(true);
FMOD_CustomEmitter breathingSoundCustomEmitter = remotePlayerSoundsRoot.AddComponent<FMOD_CustomEmitter>();
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<WaterAmbience>(true);
FMOD_CustomEmitter diveStartCustomEmitter = remotePlayerSoundsRoot.AddComponent<FMOD_CustomEmitter>();
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})");
}
}
/// <summary>
/// An InfectedMixin is required for behaviours like <see cref="AggressiveWhenSeeTarget"/> which look for this on the target they find
/// </summary>
private void SetupMixins()
{
InfectedMixin = Body.AddComponent<InfectedMixin>();
InfectedMixin.shaderKeyWord = InfectedMixin.uwe_playerinfection;
Renderer renderer = PlayerModel.transform.Find("male_geo/diveSuit/diveSuit_hands_geo").GetComponent<Renderer>();
InfectedMixin.renderers = [renderer];
LiveMixin = Body.AddComponent<LiveMixin>();
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);
}
}
/// <summary>
/// Adaptation of <see cref="Player.CanBeAttacked"/> for remote players.
/// NB: This doesn't check for other player's use of 'invisible' command
/// </summary>
public bool CanBeAttacked()
{
return !SubRoot && !EscapePod && PlayerContext.GameMode != NitroxGameMode.CREATIVE;
}
}