first commit

This commit is contained in:
2025-07-06 00:23:46 +02:00
commit 38f50c8819
1788 changed files with 112878 additions and 0 deletions

View File

@@ -0,0 +1,72 @@
using UnityEngine;
namespace NitroxClient.MonoBehaviours;
// Shouldn't this class be named after the armscontroller?
public class AnimationController : MonoBehaviour
{
private const float SMOOTHING_SPEED = 4f;
private Animator animator;
public bool UpdatePlayerAnimations { get; set; } = true;
public Quaternion AimingRotation { get; set; }
public Vector3 Velocity { get; set; }
public Quaternion BodyRotation { get; set; }
private Vector3 smoothedVelocity = Vector3.zero;
private float smoothViewPitch;
public void Awake()
{
animator = GetComponent<Animator>();
this["is_underwater"] = true;
}
public void FixedUpdate()
{
if (UpdatePlayerAnimations)
{
Vector3 rotationCorrectedVelocity = transform.rotation.GetInverse() * Velocity;
smoothedVelocity = UWE.Utils.SlerpVector(smoothedVelocity, rotationCorrectedVelocity, Vector3.Normalize(rotationCorrectedVelocity - smoothedVelocity) * (SMOOTHING_SPEED * Time.fixedDeltaTime));
animator.SetFloat("move_speed", smoothedVelocity.magnitude);
animator.SetFloat("move_speed_x", smoothedVelocity.x);
animator.SetFloat("move_speed_y", smoothedVelocity.y);
animator.SetFloat("move_speed_z", smoothedVelocity.z);
float viewPitch = AimingRotation.eulerAngles.x;
if (viewPitch > 180f)
{
viewPitch -= 360f;
}
viewPitch = -viewPitch;
smoothViewPitch = Mathf.Lerp(smoothViewPitch, viewPitch, 4f * Time.fixedDeltaTime);
animator.SetFloat("view_pitch", smoothViewPitch);
}
}
public bool this[string name]
{
get => animator.GetBool(name);
set => animator.SetBool(name, value);
}
internal void SetFloat(string name, float value)
{
animator.SetFloat(name, value);
}
internal void SetFloat(int id, float value)
{
animator.SetFloat(id, value);
}
public void Reset()
{
animator.Rebind();
animator.Update(0f);
}
}

View File

@@ -0,0 +1,42 @@
using NitroxClient.GameLogic;
using NitroxModel.Core;
using UnityEngine;
namespace NitroxClient.MonoBehaviours
{
public class AnimationSender : MonoBehaviour
{
private LocalPlayer localPlayer;
AnimChangeState lastUnderwaterState = AnimChangeState.UNSET;
public void Awake()
{
localPlayer = NitroxServiceLocator.LocateService<LocalPlayer>();
}
public void Update()
{
AnimChangeState underwaterState = (AnimChangeState)(Player.main.IsUnderwater() ? 1 : 0);
if (lastUnderwaterState != underwaterState)
{
localPlayer.AnimationChange(AnimChangeType.UNDERWATER, underwaterState);
lastUnderwaterState = underwaterState;
}
}
}
public enum AnimChangeState
{
OFF,
ON,
UNSET
}
public enum AnimChangeType
{
UNDERWATER,
BENCH,
INFECTION_REVEAL
}
}

View File

@@ -0,0 +1,121 @@
using System.Collections.Generic;
using NitroxClient.Communication;
using NitroxClient.GameLogic;
using NitroxModel.DataStructures;
using NitroxModel.Packets;
using NitroxModel_Subnautica.DataStructures;
using UnityEngine;
namespace NitroxClient.MonoBehaviours;
public class BaseLeakManager : MonoBehaviour
{
private Dictionary<Int3, NitroxId> idByRelativeCell;
private Base @base;
private BaseHullStrength baseHullStrength;
private NitroxId baseId;
public void Awake()
{
if (!TryGetComponent(out baseHullStrength))
{
Log.Error($"Tried adding a {nameof(BaseLeakManager)} to a GameObject that isn't a base, deleting it.");
Destroy(this);
return;
}
@base = baseHullStrength.baseComp;
@base.TryGetNitroxId(out baseId);
idByRelativeCell = new();
}
/// <summary>
/// Either creates or updates existing leaks by modifying the base cell's health.
/// Also registers the leak's id for further use.
/// </summary>
public void EnsureLeak(Int3 relativeCell, NitroxId cellId, float health)
{
Int3 absoluteCell = Absolute(relativeCell);
Transform cellObject = @base.GetCellObject(absoluteCell);
if (!cellObject)
{
return;
}
idByRelativeCell[relativeCell] = cellId;
if (cellObject.TryGetComponent(out LiveMixin cellLiveMixin))
{
// Health goes from 0 to 100
float deltaHealth = health - cellLiveMixin.health;
if (Mathf.Abs(deltaHealth) > 1)
{
// Useful part of BaseHullStrength.CrushDamageUpdate
this.Resolve<LiveMixinManager>().SyncRemoteHealth(cellLiveMixin, health, cellObject.position, DamageType.Pressure);
// Only play noise if the leak lost health
if (deltaHealth >= 0)
{
return;
}
// Spawning multiple leaks would result in a big sounds when loading the game
if (Multiplayer.Main && Multiplayer.Main.InitialSyncCompleted)
{
// Code from BaseHullStrength.CrushDamageUpdate
int num = 0;
if (baseHullStrength.totalStrength <= -3f)
{
num = 2;
}
else if (baseHullStrength.totalStrength <= -2f)
{
num = 1;
}
if (baseHullStrength.crushSounds[num] != null)
{
using (PacketSuppressor<FMODAssetPacket>.Suppress())
{
Utils.PlayFMODAsset(baseHullStrength.crushSounds[num], cellObject, 20f);
}
}
ErrorMessage.AddMessage(Language.main.GetFormat("BaseHullStrDamageDetected", baseHullStrength.totalStrength));
}
}
}
}
public void HealLeakToMax(Int3 relativeCell)
{
Transform cellObject = @base.GetCellObject(Absolute(relativeCell));
if (cellObject && cellObject.TryGetComponent(out LiveMixin liveMixin))
{
this.Resolve<LiveMixinManager>().SyncRemoteHealth(liveMixin, liveMixin.maxHealth);
idByRelativeCell.Remove(relativeCell);
}
}
public Int3 Absolute(Int3 relativeCell)
{
return relativeCell + @base.anchor;
}
public Int3 Relative(Int3 absoluteCell)
{
return absoluteCell - @base.anchor;
}
public LeakRepaired RemoveLeakByAbsoluteCell(Int3 absoluteCell)
{
Int3 relativeCell = Relative(absoluteCell);
if (idByRelativeCell.TryGetValue(relativeCell, out NitroxId cellId))
{
idByRelativeCell.Remove(relativeCell);
return new(baseId, cellId, relativeCell.ToDto());
}
return null;
}
public bool TryGetAbsoluteCellId(Int3 absoluteCell, out NitroxId cellId)
{
return idByRelativeCell.TryGetValue(Relative(absoluteCell), out cellId);
}
}

View File

@@ -0,0 +1,156 @@
using System.Collections.Generic;
using NitroxClient.GameLogic;
using UnityEngine;
namespace NitroxClient.MonoBehaviours.CinematicController;
public class MultiplayerCinematicController : MonoBehaviour
{
private readonly Dictionary<ushort, RemotePlayerCinematicController> controllerByPlayerId = new();
/// <summary>
/// MCCs with the same Animator to reset state if needed.
/// </summary>
private readonly List<MultiplayerCinematicController> multiplayerControllerSameAnimator = new();
private CinematicControllerPrefab controllerPrefab;
private PlayerCinematicController playerController;
public void CallStartCinematicMode(RemotePlayer player)
{
if (!playerController.cinematicModeActive) // Check if local player is occupying the animator.
{
GetController(player).StartCinematicMode(player);
}
}
public void CallCinematicModeEnd(RemotePlayer player)
{
if (!playerController.cinematicModeActive) // Check if local player is occupying the animator.
{
GetController(player).OnPlayerCinematicModeEnd();
}
}
public void CallAllCinematicModeEnd()
{
foreach (RemotePlayerCinematicController remoteController in controllerByPlayerId.Values)
{
remoteController.EndCinematicMode(true);
}
foreach (MultiplayerCinematicController controller in multiplayerControllerSameAnimator)
{
foreach (RemotePlayerCinematicController remoteController in controller.controllerByPlayerId.Values)
{
remoteController.EndCinematicMode(true);
}
}
}
private RemotePlayerCinematicController GetController(RemotePlayer player)
{
if (controllerByPlayerId.TryGetValue(player.PlayerId, out RemotePlayerCinematicController controller))
{
return controller;
}
player.PlayerDisconnectEvent.AddHandler(gameObject, OnPlayerDisconnect);
controller = CreateNewControllerForPlayer();
controllerByPlayerId.Add(player.PlayerId, controller);
return controller;
}
public void OnPlayerDisconnect(RemotePlayer player)
{
if (controllerByPlayerId.TryGetValue(player.PlayerId, out RemotePlayerCinematicController controller))
{
Destroy(controller);
controllerByPlayerId.Remove(player.PlayerId);
}
}
private RemotePlayerCinematicController CreateNewControllerForPlayer()
{
RemotePlayerCinematicController controller = gameObject.AddComponent<RemotePlayerCinematicController>();
controllerPrefab.PopulateRemoteController(controller);
return controller;
}
public void AddOtherControllers(IEnumerable<MultiplayerCinematicController> otherControllers)
{
foreach (MultiplayerCinematicController controller in otherControllers)
{
if (controller.playerController.animator == playerController.animator)
{
multiplayerControllerSameAnimator.Add(controller);
}
}
}
public static MultiplayerCinematicController Initialize(PlayerCinematicController playerController)
{
MultiplayerCinematicController mcp = playerController.gameObject.AddComponent<MultiplayerCinematicController>();
mcp.controllerPrefab = new CinematicControllerPrefab(playerController);
mcp.playerController = playerController;
return mcp;
}
}
public readonly struct CinematicControllerPrefab
{
private readonly Transform animatedTransform;
private readonly Transform endTransform;
private readonly bool onlyUseEndTransformInVr;
private readonly bool playInVr;
private readonly string playerViewAnimationName;
private readonly string playerViewInterpolateAnimParam;
private readonly string animParam;
private readonly string interpolateAnimParam;
private readonly float interpolationTime;
private readonly float interpolationTimeOut;
private readonly string receiversAnimParam;
private readonly GameObject[] animParamReceivers;
private readonly bool interpolateDuringAnimation;
private readonly Animator animator;
// Currently we don't sync playerController.informGameObject but no problem could be found while testing.
public CinematicControllerPrefab(PlayerCinematicController playerController)
{
animatedTransform = playerController.animatedTransform;
endTransform = playerController.endTransform;
onlyUseEndTransformInVr = playerController.onlyUseEndTransformInVr;
playInVr = playerController.playInVr;
playerViewAnimationName = playerController.playerViewAnimationName;
playerViewInterpolateAnimParam = playerController.playerViewInterpolateAnimParam;
animParam = playerController.animParam;
interpolateAnimParam = playerController.interpolateAnimParam;
interpolationTime = playerController.interpolationTime;
interpolationTimeOut = playerController.interpolationTimeOut;
receiversAnimParam = playerController.receiversAnimParam;
animParamReceivers = playerController.animParamReceivers;
interpolateDuringAnimation = playerController.interpolateDuringAnimation;
animator = playerController.animator;
}
public void PopulateRemoteController(RemotePlayerCinematicController remoteController)
{
remoteController.animatedTransform = animatedTransform;
remoteController.informGameObject = null;
remoteController.endTransform = endTransform;
remoteController.onlyUseEndTransformInVr = onlyUseEndTransformInVr;
remoteController.playInVr = playInVr;
remoteController.playerViewAnimationName = playerViewAnimationName;
remoteController.playerViewInterpolateAnimParam = playerViewInterpolateAnimParam;
remoteController.animParam = animParam;
remoteController.interpolateAnimParam = interpolateAnimParam;
remoteController.interpolationTime = interpolationTime;
remoteController.interpolationTimeOut = interpolationTimeOut;
remoteController.receiversAnimParam = receiversAnimParam;
remoteController.animParamReceivers = animParamReceivers;
remoteController.interpolateDuringAnimation = interpolateDuringAnimation;
remoteController.animator = animator;
}
}

View File

@@ -0,0 +1,81 @@
using System.Collections.Generic;
using System.Linq;
using NitroxClient.GameLogic;
using NitroxClient.Unity.Helper;
using NitroxModel.DataStructures.GameLogic;
using UnityEngine;
namespace NitroxClient.MonoBehaviours.CinematicController;
public class MultiplayerCinematicReference : MonoBehaviour
{
private readonly Dictionary<string, Dictionary<int, MultiplayerCinematicController>> controllerByKey = new();
private bool isEscapePod;
private void Start()
{
// TODO: Currently only single EscapePod is supported, therefor EscapePod.main. Can probably be removed after we use one pod per intro sequence
isEscapePod = gameObject == EscapePod.main.gameObject;
}
public void CallStartCinematicMode(string key, int identifier, RemotePlayer player)
{
if(isEscapePod && this.Resolve<LocalPlayer>().IntroCinematicMode is IntroCinematicMode.PLAYING or IntroCinematicMode.SINGLEPLAYER) return;
if (!controllerByKey.TryGetValue(key, out Dictionary<int, MultiplayerCinematicController> controllers))
{
throw new KeyNotFoundException($"There was no entry for the key {key} at {gameObject.GetFullHierarchyPath()}");
}
if (!controllers.TryGetValue(identifier, out MultiplayerCinematicController controller))
{
throw new KeyNotFoundException($"There was no entry for the identifier {identifier} at {gameObject.GetFullHierarchyPath()}");
}
controller.CallStartCinematicMode(player);
}
public void CallCinematicModeEnd(string key, int identifier, RemotePlayer player)
{
if(isEscapePod && this.Resolve<LocalPlayer>().IntroCinematicMode is IntroCinematicMode.PLAYING or IntroCinematicMode.SINGLEPLAYER) return;
if (!controllerByKey.TryGetValue(key, out Dictionary<int, MultiplayerCinematicController> controllers))
{
throw new KeyNotFoundException($"There was no entry for the key {key} at {gameObject.GetFullHierarchyPath()}");
}
if (!controllers.TryGetValue(identifier, out MultiplayerCinematicController controller))
{
throw new KeyNotFoundException($"There was no entry for the identifier {identifier} at {gameObject.GetFullHierarchyPath()}");
}
controller.CallCinematicModeEnd(player);
}
public static int GetCinematicControllerIdentifier(GameObject controller, GameObject reference) => controller.gameObject.GetHierarchyPath(reference).GetHashCode();
public void AddController(PlayerCinematicController playerController)
{
MultiplayerCinematicController[] allControllers = controllerByKey.SelectMany(n => n.Value.Select(x => x.Value)).ToArray();
if (!controllerByKey.TryGetValue(playerController.playerViewAnimationName, out Dictionary<int, MultiplayerCinematicController> controllers))
{
controllers = new Dictionary<int, MultiplayerCinematicController>();
controllerByKey.Add(playerController.playerViewAnimationName, controllers);
}
int identifier = GetCinematicControllerIdentifier(playerController.gameObject, gameObject);
if (controllers.ContainsKey(identifier))
{
return;
}
MultiplayerCinematicController controller = MultiplayerCinematicController.Initialize(playerController);
controller.AddOtherControllers(allControllers);
allControllers.ForEach(x => x.AddOtherControllers(new[] { controller }));
controllers.Add(identifier, controller);
}
}

View File

@@ -0,0 +1,471 @@
using System;
using NitroxClient.GameLogic;
using UnityEngine;
using static PlayerCinematicController;
namespace NitroxClient.MonoBehaviours.CinematicController;
/// <summary>
/// Override for <see cref="PlayerCinematicController"/>
/// </summary>
public class RemotePlayerCinematicController : MonoBehaviour, IManagedUpdateBehaviour, IManagedLateUpdateBehaviour
{
[AssertNotNull] public Transform animatedTransform;
public GameObject informGameObject;
public Transform endTransform;
public bool onlyUseEndTransformInVr;
public bool playInVr;
public string playerViewAnimationName = "";
public string playerViewInterpolateAnimParam = "";
public string animParam = "cinematicMode";
public string interpolateAnimParam = "";
public float interpolationTime = 0.25f;
public float interpolationTimeOut = 0.25f;
public string receiversAnimParam = "";
public GameObject[] animParamReceivers;
public bool interpolateDuringAnimation;
public bool debug;
public Animator animator;
[NonSerialized] public bool cinematicModeActive;
private Vector3 playerFromPosition = Vector3.zero;
private Quaternion playerFromRotation = Quaternion.identity;
private bool onCinematicModeEndCall;
private float timeStateChanged;
private State _state;
private RemotePlayer player;
private bool subscribed;
private bool _animState;
public static int cinematicModeCount { get; private set; }
public static float cinematicActivityStart { get; private set; }
private State state
{
get => _state;
set
{
timeStateChanged = Time.time;
_state = value;
}
}
public bool animState
{
get => _animState;
private set
{
if (value == _animState)
{
return;
}
if (debug)
{
Debug.Log($"setting cinematic controller {gameObject.name} to: {value}");
}
_animState = value;
if (animParam.Length > 0)
{
SafeAnimator.SetBool(animator, animParam, value);
}
if (receiversAnimParam.Length > 0)
{
for (int i = 0; i < animParamReceivers.Length; i++)
{
animParamReceivers[i].GetComponent<IAnimParamReceiver>()?.ForwardAnimationParameterBool(receiversAnimParam, value);
}
}
if (playerViewAnimationName.Length > 0 && player != null)
{
Animator componentInChildren = player.Body.GetComponentInChildren<Animator>();
if (componentInChildren != null && componentInChildren.gameObject.activeInHierarchy)
{
SafeAnimator.SetBool(componentInChildren, playerViewAnimationName, value);
}
}
SetVrActiveParam();
}
}
public int managedUpdateIndex { get; set; }
public int managedLateUpdateIndex { get; set; }
public string GetProfileTag()
{
return nameof(RemotePlayerCinematicController);
}
public void SetPlayer(RemotePlayer setplayer)
{
if (subscribed && player != setplayer)
{
Subscribe(player, false);
Subscribe(setplayer, true);
}
player = setplayer;
}
public RemotePlayer GetPlayer()
{
return player;
}
private void AddToUpdateManager()
{
BehaviourUpdateUtils.Register((IManagedUpdateBehaviour)this);
BehaviourUpdateUtils.Register((IManagedLateUpdateBehaviour)this);
}
private void RemoveFromUpdateManager()
{
BehaviourUpdateUtils.Deregister((IManagedUpdateBehaviour)this);
BehaviourUpdateUtils.Deregister((IManagedLateUpdateBehaviour)this);
}
private void OnEnable()
{
AddToUpdateManager();
}
private void OnDestroy()
{
RemoveFromUpdateManager();
}
private void Start()
{
SetVrActiveParam();
}
private void SetVrActiveParam()
{
string paramaterName = "vr_active";
bool vrAnimationMode = GameOptions.GetVrAnimationMode();
if (animator != null)
{
animator.SetBool(paramaterName, vrAnimationMode);
}
foreach (GameObject animatedObject in animParamReceivers)
{
animatedObject.GetComponent<IAnimParamReceiver>()?.ForwardAnimationParameterBool(paramaterName, vrAnimationMode);
}
}
private bool UseEndTransform()
{
if (endTransform == null)
{
return false;
}
if (onlyUseEndTransformInVr)
{
return GameOptions.GetVrAnimationMode();
}
return true;
}
private void SkipCinematic(RemotePlayer player)
{
this.player = player;
if (player != null)
{
Transform component = player.Body.GetComponent<Transform>();
if (UseEndTransform())
{
component.position = endTransform.position;
component.rotation = endTransform.rotation;
}
}
if (informGameObject != null)
{
informGameObject.SendMessage(nameof(CinematicModeTriggerBase.OnPlayerCinematicModeEnd), this, SendMessageOptions.DontRequireReceiver);
}
}
public void StartCinematicMode(RemotePlayer setplayer)
{
if (debug)
{
Debug.Log($"{gameObject.name}.StartCinematicMode");
}
if (!cinematicModeActive)
{
player = null;
if (!playInVr && GameOptions.GetVrAnimationMode())
{
if (debug)
{
Debug.Log($"{gameObject.name} skip cinematic");
}
SkipCinematic(setplayer);
return;
}
animator.cullingMode = AnimatorCullingMode.AlwaysAnimate;
cinematicModeActive = true;
if (setplayer != null)
{
SetPlayer(setplayer);
Subscribe(player, true);
}
state = State.In;
if (informGameObject != null)
{
informGameObject.SendMessage(nameof(DockedVehicleHandTarget.OnPlayerCinematicModeStart), this, SendMessageOptions.DontRequireReceiver);
}
if (player != null)
{
Transform component = player.Body.GetComponent<Transform>();
playerFromPosition = component.position;
playerFromRotation = component.rotation;
if (playerViewInterpolateAnimParam.Length > 0)
{
SafeAnimator.SetBool(player.Body.GetComponentInChildren<Animator>(), playerViewInterpolateAnimParam, true);
}
}
if (interpolateAnimParam.Length > 0)
{
SafeAnimator.SetBool(animator, interpolateAnimParam, true);
}
if (interpolateDuringAnimation)
{
animState = true;
}
if (debug)
{
Debug.Log($"{gameObject.name} successfully started cinematic");
}
if (cinematicModeCount == 0)
{
cinematicActivityStart = Time.time;
}
cinematicModeCount++;
}
else if (debug)
{
Debug.Log($"{gameObject.name} cinematic already active!");
}
}
public void EndCinematicMode(bool reset = false)
{
if (cinematicModeActive)
{
if (reset) // Added by us
{
animator.Rebind();
animator.Update(0f);
}
animator.cullingMode = AnimatorCullingMode.CullCompletely;
animState = false;
state = State.None;
cinematicModeActive = false;
cinematicModeCount--;
}
}
public void OnPlayerCinematicModeEnd()
{
if (!cinematicModeActive || onCinematicModeEndCall)
{
return;
}
if (player != null)
{
UpdatePlayerPosition();
}
animState = false;
if (UseEndTransform())
{
state = State.Out;
if (player != null)
{
Transform component = player.Body.GetComponent<Transform>();
playerFromPosition = component.position;
playerFromRotation = component.rotation;
}
}
else
{
EndCinematicMode();
}
if (informGameObject != null)
{
onCinematicModeEndCall = true;
informGameObject.SendMessage(nameof(DockedVehicleHandTarget.OnPlayerCinematicModeEnd), this, SendMessageOptions.DontRequireReceiver);
onCinematicModeEndCall = false;
}
}
private void UpdatePlayerPosition()
{
Transform component = player.Body.GetComponent<Transform>();
component.position = animatedTransform.position;
component.rotation = animatedTransform.rotation;
}
public void ManagedLateUpdate()
{
if (!cinematicModeActive)
{
return;
}
float num = Time.time - timeStateChanged;
float timedOutScalar;
Transform transform = null;
if (player != null && player.Body)
{
transform = player.Body.GetComponent<Transform>();
}
bool isVrAnimationMode = !GameOptions.GetVrAnimationMode();
switch (state)
{
case State.In:
timedOutScalar = interpolationTime != 0f && isVrAnimationMode ? Mathf.Clamp01(num / interpolationTime) : 1f;
if (player != null && transform)
{
transform.position = Vector3.Lerp(playerFromPosition, animatedTransform.position, timedOutScalar);
transform.rotation = Quaternion.Slerp(playerFromRotation, animatedTransform.rotation, timedOutScalar);
}
if (timedOutScalar == 1f)
{
state = State.Update;
animState = true;
if (interpolateAnimParam.Length > 0)
{
SafeAnimator.SetBool(animator, interpolateAnimParam, false);
}
if (playerViewInterpolateAnimParam.Length > 0 && player != null)
{
SafeAnimator.SetBool(player.Body.GetComponentInChildren<Animator>(), playerViewInterpolateAnimParam, false);
}
}
break;
case State.Update:
if (player != null)
{
UpdatePlayerPosition();
}
break;
case State.Out:
timedOutScalar = interpolationTimeOut != 0f && isVrAnimationMode ? Mathf.Clamp01(num / interpolationTimeOut) : 1f;
if (player != null && transform)
{
transform.position = Vector3.Lerp(playerFromPosition, endTransform.position, timedOutScalar);
transform.rotation = Quaternion.Slerp(playerFromRotation, endTransform.rotation, timedOutScalar);
}
if (timedOutScalar == 1f)
{
EndCinematicMode();
}
break;
}
}
public bool IsCinematicModeActive()
{
return cinematicModeActive;
}
private void OnDisable()
{
RemoveFromUpdateManager();
if (subscribed)
{
Subscribe(player, false);
}
EndCinematicMode();
}
private void OnPlayerDeath(RemotePlayer player)
{
EndCinematicMode();
animator.Rebind();
}
private void Subscribe(RemotePlayer player, bool state)
{
if (player == null)
{
subscribed = false;
}
else if (subscribed != state)
{
if (state)
{
player.PlayerDeathEvent.AddHandler(gameObject, OnPlayerDeath);
}
else
{
player.PlayerDeathEvent.RemoveHandler(gameObject, OnPlayerDeath);
}
subscribed = state;
}
}
public void ManagedUpdate()
{
if (!cinematicModeActive && subscribed)
{
Subscribe(player, false);
}
}
}

View File

@@ -0,0 +1,378 @@
using NitroxClient.GameLogic;
using NitroxClient.MonoBehaviours.Cyclops;
using UnityEngine;
using UnityEngine.XR;
namespace NitroxClient.MonoBehaviours;
/// <summary>
/// A replacement for <see cref="GroundMotor"/> while Local Player is in a Cyclops.
/// </summary>
public partial class CyclopsMotor : GroundMotor
{
public GroundMotor ActualMotor { get; private set; }
public CyclopsPawn Pawn;
private Transform body;
private NitroxCyclops cyclops;
private SubRoot sub;
private Transform realAxis;
private Transform virtualAxis;
private WorldForces worldForces;
public Vector3 Up => virtualAxis.up;
public float DeltaTime => Time.fixedDeltaTime;
private Vector3 verticalVelocity;
private Vector3 latestVelocity;
public new void Awake()
{
controller = GetComponent<CharacterController>();
controller.enabled = false;
controller.stepOffset = controllerSetup.stepOffset;
controller.slopeLimit = controllerSetup.slopeLimit;
rb = GetComponent<Rigidbody>();
playerController = GetComponent<PlayerController>();
worldForces = GetComponent<WorldForces>();
body = Player.mainObject.transform.Find("body");
}
public override void SetEnabled(bool enabled)
{
base.SetEnabled(enabled);
Setup(enabled);
}
public void Initialize(GroundMotor reference)
{
ActualMotor = reference;
movement = reference.movement;
jumping = reference.jumping;
movingPlatform = reference.movingPlatform;
sliding = reference.sliding;
controllerSetup = reference.controllerSetup;
floatingModeSetup = reference.floatingModeSetup;
allowMidAirJumping = reference.allowMidAirJumping;
minWindSpeedToAffectMovement = reference.minWindSpeedToAffectMovement;
percentWindDampeningOnGround = reference.percentWindDampeningOnGround;
percentWindDampeningInAir = reference.percentWindDampeningInAir;
floatingModeEnabled = reference.floatingModeEnabled;
forwardMaxSpeed = reference.forwardMaxSpeed;
backwardMaxSpeed = reference.backwardMaxSpeed;
strafeMaxSpeed = reference.strafeMaxSpeed;
verticalMaxSpeed = reference.verticalMaxSpeed;
climbSpeed = reference.climbSpeed;
gravity = reference.gravity;
forwardSprintModifier = reference.forwardSprintModifier;
strafeSprintModifier = reference.strafeSprintModifier;
groundAcceleration = reference.groundAcceleration;
airAcceleration = reference.airAcceleration;
jumpHeight = reference.jumpHeight;
SetEnabled(false);
RecalculateConstants();
}
public void SetCyclops(NitroxCyclops cyclops, SubRoot subRoot, CyclopsPawn pawn)
{
this.cyclops = cyclops;
sub = subRoot;
realAxis = sub.subAxis;
virtualAxis = cyclops.Virtual.axis;
Pawn = pawn;
}
public void Setup(bool enabled)
{
verticalVelocity = Vector3.zero;
latestVelocity = Vector3.zero;
if (enabled)
{
rb.isKinematic = false;
Player.wantInterpolate = false;
rb.detectCollisions = false;
worldForces.lockInterpolation = true;
worldForces.enabled = false;
controller.detectCollisions = false;
Player.mainCollider.isTrigger = true;
UWE.Utils.EnterPhysicsSyncSection();
}
else
{
rb.isKinematic = true;
Player.wantInterpolate = true;
worldForces.lockInterpolation = false;
rb.detectCollisions = true;
worldForces.enabled = true;
controller.detectCollisions = true;
Player.mainCollider.isTrigger = false;
UWE.Utils.ExitPhysicsSyncSection();
}
Pawn?.SetReference();
}
public override Vector3 UpdateMove()
{
if (!canControl)
{
return Vector3.zero;
}
// Compute movements velocities based on inputs and previous movement
Position = Pawn.Position;
Center = cyclops.Virtual.transform.TransformVector(Pawn.Controller.center);
Pawn.Handle.transform.localRotation = body.localRotation;
sprinting = false;
verticalVelocity += CalculateVerticalVelocity();
Vector3 horizontalVelocity = CalculateInputVelocity();
// movement.velocity gives velocity info for the animations and footsteps
movement.velocity = Move(horizontalVelocity);
return movement.velocity;
}
/// <summary>
/// Simulates player movement on its pawn and update the grounded state
/// </summary>
/// <remarks>
/// Adapted from <see cref="GroundMotor.UpdateFunction"/>
/// </remarks>
/// <returns>Pawn's local velocity</returns>
public Vector3 Move(Vector3 horizontalVelocity)
{
Vector3 beforePosition = Pawn.Position;
Vector3 velocity = new(horizontalVelocity.x, verticalVelocity.y, horizontalVelocity.z);
Vector3 movementThisFrame = velocity * DeltaTime;
float step = Mathf.Max(Pawn.Controller.stepOffset, Mathf.Sqrt(movementThisFrame.x * movementThisFrame.x + movementThisFrame.z * movementThisFrame.z));
if (grounded)
{
movementThisFrame -= step * Up;
}
Collision = Pawn.Controller.Move(movementThisFrame);
float verticalDot = Vector3.Dot(verticalVelocity, Up);
bool previouslyGrounded = grounded;
CheckGrounded(Collision, verticalDot <= 0f);
Vector3 velocityXZ = velocity._X0Z();
Vector3 instantVelocity = (Pawn.Position - beforePosition) / DeltaTime;
if (instantVelocity.sqrMagnitude <= 0.2f)
{
instantVelocity = velocity;
}
if (instantVelocity.y > 0f || Collision == CollisionFlags.None)
{
instantVelocity.y = velocity.y;
}
latestVelocity = instantVelocity;
Vector3 instantVelocityXZ = instantVelocity._X0Z();
if (velocityXZ == Vector3.zero)
{
latestVelocity = latestVelocity._0Y0();
}
else
{
float deviation = Vector3.Dot(instantVelocityXZ, velocityXZ) / velocityXZ.sqrMagnitude;
latestVelocity = velocityXZ * Mathf.Clamp01(deviation) + latestVelocity.y * Up;
}
if (latestVelocity.y < velocity.y - 0.001)
{
if (latestVelocity.y < 0f)
{
latestVelocity.y = velocity.y;
}
else
{
jumping.holdingJumpButton = false;
}
}
if (grounded)
{
verticalVelocity = Vector3.zero;
if (!previouslyGrounded)
{
jumping.jumping = false;
// Prefilled data is made to not hurt the player at any time when colliding with cyclops, but only to play the noise
SendMessage(nameof(Player.OnLand), new MovementCollisionData
{
impactVelocity = Vector3.one,
surfaceType = VFXSurfaceTypes.metal
}, SendMessageOptions.DontRequireReceiver);
}
}
// If player is no longer grounded after move
else if (previouslyGrounded)
{
SendMessage("OnFall", SendMessageOptions.DontRequireReceiver);
Pawn.Handle.transform.localPosition += step * Up;
}
return cyclops.transform.rotation * latestVelocity;
}
/// <summary>
/// Calculates vertical velocity variation based on the grounded state.
/// Code adapted from <see cref="GroundMotor.ApplyGravityAndJumping"/>.
/// </summary>
public Vector3 CalculateVerticalVelocity()
{
if (!jumpPressed)
{
jumping.holdingJumpButton = false;
jumping.lastButtonDownTime = -100f;
}
if (jumpPressed && (jumping.lastButtonDownTime < 0f || flyCheatEnabled))
{
jumping.lastButtonDownTime = Time.time;
}
Vector3 verticalMove = Vector3.zero;
if (!grounded)
{
verticalMove = -gravity * Up * DeltaTime;
verticalMove.y = Mathf.Max(verticalMove.y, -movement.maxFallSpeed);
}
if (grounded || allowMidAirJumping || flyCheatEnabled)
{
if (Time.time - jumping.lastButtonDownTime < 0.2)
{
grounded = false;
jumping.jumping = true;
jumping.lastStartTime = Time.time;
jumping.lastButtonDownTime = -100f;
jumping.holdingJumpButton = true;
Vector3 jumpDirection = Vector3.Slerp(Up, groundNormal, TooSteep() ? jumping.steepPerpAmount : jumping.perpAmount);
verticalMove = jumpDirection * CalculateJumpVerticalSpeed(jumping.baseHeight);
SendMessage("OnJump", SendMessageOptions.DontRequireReceiver);
}
else
{
jumping.holdingJumpButton = false;
}
}
return verticalMove;
}
/// <summary>
/// Calculates instantaneous horizontal velocity from input for the <see cref="Pawn"/> object.
/// Code adapted from <see cref="GroundMotor.ApplyInputVelocityChange"/>.
/// </summary>
public Vector3 CalculateInputVelocity()
{
// Project the movement input to the right rotation
float moveMinMagnitude = Mathf.Min(1f, movementInputDirection.magnitude);
// We rotate the input in the right basis
Vector3 input = movementInputDirection._X0Z();
Transform forwardRef = Pawn.Handle.transform;
Vector3 projectedForward = Vector3.ProjectOnPlane(forwardRef.forward, Up).normalized;
Vector3 projectedRight = Vector3.ProjectOnPlane(forwardRef.right, Up).normalized;
Vector3 moveDirection = (projectedForward * input.z + projectedRight * input.x).normalized;
Vector3 velocity;
// Manage sliding on slopes
if (grounded && TooSteep())
{
velocity = GetSlidingDirection();
Vector3 moveProjectedOnSlope = Vector3.Project(movementInputDirection, velocity);
velocity += moveProjectedOnSlope * sliding.speedControl + (movementInputDirection - moveProjectedOnSlope) * sliding.sidewaysControl;
velocity *= sliding.slidingSpeed;
}
else
{
// Apply speed modifiers
float modifier = 1f;
if (sprintPressed && grounded)
{
float z = movementInputDirection.z;
if (z > 0f)
{
modifier *= forwardSprintModifier;
}
else if (z == 0f)
{
modifier *= strafeSprintModifier;
}
sprinting = true;
}
velocity = moveDirection * forwardMaxSpeed * modifier * moveMinMagnitude;
}
if (XRSettings.enabled)
{
velocity *= VROptions.groundMoveScale;
}
if (grounded)
{
velocity = AdjustGroundVelocityToNormal(velocity, groundNormal);
}
else
{
latestVelocity.y = 0f;
}
float maxSpeed = GetMaxAcceleration(grounded) * DeltaTime;
Vector3 difference = velocity - latestVelocity;
if (difference.sqrMagnitude > maxSpeed * maxSpeed)
{
difference = difference.normalized * maxSpeed;
}
latestVelocity += difference;
if (grounded)
{
latestVelocity.y = Mathf.Min(latestVelocity.y, 0f);
}
return latestVelocity;
}
private new Vector3 GetSlidingDirection()
{
return Vector3.ProjectOnPlane(groundNormal, Up).normalized;
}
private new bool TooSteep()
{
float dotUp = Vector3.Dot(groundNormal, Up);
return dotUp <= Mathf.Cos(controller.slopeLimit * Mathf.Deg2Rad);
}
public void ToggleCyclopsMotor(bool toggled)
{
GroundMotor groundMotor = toggled ? this : ActualMotor;
Player.main.playerController.SetEnabled(false);
Player.main.groundMotor = groundMotor;
Player.main.footStepSounds.groundMoveable = groundMotor;
Player.main.groundMotor = groundMotor;
Player.main.playerController.groundController = groundMotor;
if (Player.main.playerController.activeController is GroundMotor)
{
Player.main.playerController.activeController = groundMotor;
}
// SetMotorMode sets some important variables in the motor abstract class PlayerMotor
Player.main.playerController.SetMotorMode(Player.MotorMode.Walk);
Player.main.playerController.SetEnabled(true);
}
}

View File

@@ -0,0 +1,116 @@
using NitroxClient.GameLogic;
using UnityEngine;
namespace NitroxClient.MonoBehaviours;
/// <remarks>
/// Ground detection adapted from <see href="https://github.com/Unity-Technologies/Standard-Assets-Characters/blob/master/Assets/_Standard%20Assets/Characters/Scripts/Physics/OpenCharacterController.cs"/>
/// </remarks>
public partial class CyclopsMotor
{
private const float CAST_DISTANCE = 0.001f;
private const float CAST_EXTRA_DISTANCE = 0.001f;
public const QueryTriggerInteraction QuerySetting = QueryTriggerInteraction.Ignore;
public static readonly int LayerMaskExceptPlayer = ~CyclopsPawn.PLAYER_LAYER;
/// <summary>
/// Latest snapshot of the Pawn's global position. It is updated every frame before being used.
/// </summary>
public Vector3 Position;
/// <summary>
/// Latest snapshot of the globally transformed center offset. It is updated every frame before being used.
/// </summary>
public Vector3 Center;
/// <summary>
/// <see cref="CharacterController.height"/> scaled by the transform's y global scale
/// </summary>
public float Height;
/// <summary>
/// <see cref="CharacterController.radius"/> scaled by the transform's maximum global scale parameter
/// </summary>
public float Radius;
/// <summary>
/// Unscaled <see cref="CharacterController.skinWidth"/>
/// </summary>
public float SkinWidth;
/// <summary>
/// Snapshot of the latest <see cref="CollisionFlags"/> obtained when simulating movement on the pawn.
/// </summary>
private CollisionFlags Collision { get; set; }
/// <summary>
/// Checks if Pawn is grounded by up to 2 sphere casts. Updates the registered ground normal accordingly.
/// </summary>
public void CheckGrounded(CollisionFlags flags, bool cast)
{
if (cast)
{
Vector3 lowerPoint = GetLowerPoint();
grounded = false;
if (SphereCast(-Up, SkinWidth + CAST_DISTANCE, out RaycastHit hitInfo, lowerPoint, false))
{
grounded = true;
hitInfo.distance = Mathf.Max(0f, hitInfo.distance - SkinWidth);
}
if (!grounded && SphereCast(-Up, CAST_DISTANCE + CAST_EXTRA_DISTANCE, out hitInfo, lowerPoint + Up * CAST_EXTRA_DISTANCE, true))
{
grounded = true;
hitInfo.distance = Mathf.Max(0f, hitInfo.distance - SkinWidth);
}
groundNormal = hitInfo.normal;
return;
}
// Exceptional case in which movement was made on the ground but the casts failed
if (flags == CollisionFlags.Below)
{
grounded = true;
groundNormal = Up;
return;
}
grounded = false;
groundNormal = Vector3.zero;
}
public bool SphereCast(Vector3 direction, float distance, out RaycastHit hitInfo, Vector3 spherePosition, bool big)
{
float radius = big ? Radius + SkinWidth : Radius;
if (Physics.SphereCast(spherePosition, radius, direction, out hitInfo, distance + radius, LayerMaskExceptPlayer, QuerySetting))
{
return hitInfo.distance <= distance;
}
return false;
}
public Vector3 GetLowerPoint()
{
return Position + Center - Up * (Height * 0.5f - Radius);
}
public override void SetControllerHeight(float height, float cameraOffset)
{
base.SetControllerHeight(height, cameraOffset);
RecalculateConstants();
}
public override void SetControllerRadius(float radius)
{
base.SetControllerRadius(radius);
RecalculateConstants();
}
private void RecalculateConstants()
{
Vector3 scale = transform.lossyScale;
Height = controller.height * scale.y;
Radius = controller.radius * Mathf.Max(Mathf.Max(scale.x, scale.y), scale.z);
SkinWidth = controller.skinWidth;
}
}

View File

@@ -0,0 +1,177 @@
using System.Collections.Generic;
using NitroxClient.GameLogic;
using NitroxClient.GameLogic.PlayerLogic;
using NitroxClient.GameLogic.PlayerLogic.PlayerModel.Abstract;
using UnityEngine;
namespace NitroxClient.MonoBehaviours.Cyclops;
/// <summary>
/// Script responsible for managing all player movement-related interactions.
/// </summary>
public class NitroxCyclops : MonoBehaviour
{
public VirtualCyclops Virtual;
private CyclopsMotor cyclopsMotor;
private SubRoot subRoot;
private SubControl subControl;
private Rigidbody rigidbody;
private WorldForces worldForces;
private Stabilizer stabilizer;
private CharacterController controller;
private CyclopsNoiseManager cyclopsNoiseManager;
public readonly Dictionary<INitroxPlayer, CyclopsPawn> Pawns = [];
public static readonly Dictionary<NitroxCyclops, float> ScaledNoiseByCyclops = [];
public void Start()
{
cyclopsMotor = Player.mainObject.GetComponent<CyclopsMotor>();
subRoot = GetComponent<SubRoot>();
subControl = GetComponent<SubControl>();
rigidbody = GetComponent<Rigidbody>();
worldForces = GetComponent<WorldForces>();
stabilizer = GetComponent<Stabilizer>();
controller = cyclopsMotor.controller;
cyclopsNoiseManager = GetComponent<CyclopsNoiseManager>();
UWE.Utils.SetIsKinematicAndUpdateInterpolation(rigidbody, false, true);
WorkaroundColliders();
ScaledNoiseByCyclops.Add(this, 0f);
}
public void Update()
{
MaintainPawns();
// Calculation from AttackCyclops.UpdateAggression
ScaledNoiseByCyclops[this] = Mathf.Lerp(0f, 150f, cyclopsNoiseManager.GetNoisePercent());
}
public void OnDestroy()
{
ScaledNoiseByCyclops.Remove(this);
}
/// <summary>
/// Triggers required on-remove callbacks on children player objects, including the local player.
/// </summary>
public void RemoveAllPlayers()
{
// This will call OnLocalPlayerExit
if (Player.main.currentSub == subRoot)
{
Player.main.SetCurrentSub(null);
}
foreach (RemotePlayerIdentifier remotePlayerIdentifier in GetComponentsInChildren<RemotePlayerIdentifier>(true))
{
remotePlayerIdentifier.RemotePlayer.ResetStates();
OnPlayerExit(remotePlayerIdentifier.RemotePlayer);
}
}
/// <summary>
/// Parents local player to the cyclops and registers it in the current cyclops.
/// </summary>
public void OnLocalPlayerEnter()
{
Virtual = VirtualCyclops.Instance;
Virtual.SetCurrentCyclops(this);
Player.mainObject.transform.parent = subRoot.transform;
CyclopsPawn pawn = AddPawnForPlayer(this.Resolve<ILocalNitroxPlayer>());
cyclopsMotor.SetCyclops(this, subRoot, pawn);
cyclopsMotor.ToggleCyclopsMotor(true);
}
/// <summary>
/// Unregisters the local player from the current cyclops. Ensures the player is not weirdly rotated when it leaves the cyclops.
/// </summary>
public void OnLocalPlayerExit()
{
RemovePawnForPlayer(this.Resolve<ILocalNitroxPlayer>());
Player.main.transform.parent = null;
Player.main.transform.rotation = Quaternion.identity;
cyclopsMotor.ToggleCyclopsMotor(false);
cyclopsMotor.Pawn = null;
if (Virtual)
{
Virtual.SetCurrentCyclops(null);
}
}
/// <summary>
/// Registers a remote player for it to get a pawn in the current cyclops.
/// </summary>
public void OnPlayerEnter(RemotePlayer remotePlayer)
{
remotePlayer.Pawn = AddPawnForPlayer(remotePlayer);
}
/// <summary>
/// Unregisters a remote player from the current cyclops.
/// </summary>
public void OnPlayerExit(RemotePlayer remotePlayer)
{
RemovePawnForPlayer(remotePlayer);
remotePlayer.Pawn = null;
}
public void MaintainPawns()
{
foreach (CyclopsPawn pawn in Pawns.Values)
{
if (pawn.MaintainPredicate())
{
pawn.MaintainPosition();
}
}
}
public CyclopsPawn AddPawnForPlayer(INitroxPlayer player)
{
if (!Pawns.TryGetValue(player, out CyclopsPawn pawn))
{
pawn = new(player, this);
Pawns.Add(player, pawn);
}
return pawn;
}
public void RemovePawnForPlayer(INitroxPlayer player)
{
if (Pawns.TryGetValue(player, out CyclopsPawn pawn))
{
pawn.Terminate();
}
Pawns.Remove(player);
}
public void SetBroadcasting()
{
worldForces.enabled = true;
stabilizer.stabilizerEnabled = true;
}
public void SetReceiving()
{
worldForces.enabled = false;
stabilizer.stabilizerEnabled = false;
}
private void WorkaroundColliders()
{
CyclopsSubNameScreen cyclopsSubNameScreen = transform.GetComponentInChildren<CyclopsSubNameScreen>(true);
TriggerWorkaround subNameTriggerWorkaround = cyclopsSubNameScreen.gameObject.AddComponent<TriggerWorkaround>();
subNameTriggerWorkaround.Initialize(this,cyclopsSubNameScreen.animator, cyclopsSubNameScreen.ContentOn, nameof(CyclopsSubNameScreen.ContentOff), cyclopsSubNameScreen);
CyclopsLightingPanel cyclopsLightingPanel = transform.GetComponentInChildren<CyclopsLightingPanel>(true);
TriggerWorkaround lightingTriggerWorkaround = cyclopsLightingPanel.gameObject.AddComponent<TriggerWorkaround>();
lightingTriggerWorkaround.Initialize(this, cyclopsLightingPanel.uiPanel, cyclopsLightingPanel.ButtonsOn, nameof(CyclopsLightingPanel.ButtonsOff), cyclopsLightingPanel);
}
}

View File

@@ -0,0 +1,59 @@
using System;
using UnityEngine;
namespace NitroxClient.MonoBehaviours.Cyclops;
/// <summary>
/// With the changes to the Player's colliders, the cyclops doesn't detect the player entering or leaving to triggers
/// The easiest workaround is to replace proximity detection by distance checks.
/// </summary>
/// <remarks>
/// Works for <see cref="CyclopsLightingPanel"/> and <see cref="CyclopsSubNameScreen"/>.
/// </remarks>
public class TriggerWorkaround : MonoBehaviour
{
private const float DETECTION_RANGE = 5f;
private const string ANIMATOR_PARAM = "PanelActive";
private bool playerIn;
private NitroxCyclops cyclops;
private Animator animator;
private Action onEnterCallback;
private string onExitInvokeCallback;
private MonoBehaviour targetBehaviour;
public void Initialize(NitroxCyclops cyclops, Animator animator, Action onEnterCallback, string onExitInvokeCallback, MonoBehaviour targetBehaviour)
{
this.cyclops = cyclops;
this.animator = animator;
this.onEnterCallback = onEnterCallback;
this.onExitInvokeCallback = onExitInvokeCallback;
this.targetBehaviour = targetBehaviour;
}
/// <summary>
/// Code adapted from <see cref="CyclopsSubNameScreen.OnTriggerEnter"/> and <see cref="CyclopsSubNameScreen.OnTriggerExit"/>
/// </summary>
public void Update()
{
// Virtual is not null only when the local player is aboard
if (cyclops.Virtual && Vector3.Distance(Player.main.transform.position, transform.position) < DETECTION_RANGE)
{
if (!playerIn)
{
playerIn = true;
animator.SetBool(ANIMATOR_PARAM, true);
onEnterCallback();
targetBehaviour.CancelInvoke(onExitInvokeCallback);
}
return;
}
if (playerIn)
{
playerIn = false;
animator.SetBool(ANIMATOR_PARAM, false);
targetBehaviour.Invoke(onExitInvokeCallback, 0.5f);
}
}
}

View File

@@ -0,0 +1,290 @@
using System.Collections;
using System.Collections.Generic;
using System.Linq;
using NitroxClient.Communication;
using NitroxClient.GameLogic.Spawning.WorldEntities;
using NitroxModel.Packets;
using UnityEngine;
namespace NitroxClient.MonoBehaviours.Cyclops;
/// <summary>
/// Script responsible for creating a virtual counterpart of every cyclops, which will always be horizontal and motionless so that simulated movement is always clear.
/// Contains a pawn for each player entering the regular cyclops.
/// </summary>
public class VirtualCyclops : MonoBehaviour
{
public static VirtualCyclops Instance;
public const string NAME = "VirtualCyclops";
private static readonly Dictionary<TechType, GameObject> cacheColliderCopy = [];
private readonly Dictionary<string, Openable> virtualOpenableByName = [];
private readonly Dictionary<string, Openable> realOpenableByName = [];
private readonly Dictionary<GameObject, GameObject> virtualConstructableByRealGameObject = [];
public NitroxCyclops Cyclops;
public Transform axis;
private Rigidbody rigidbody;
private Vector3 InitialPosition;
private Quaternion InitialRotation;
public static void Initialize()
{
CreateVirtualCyclops();
Multiplayer.OnAfterMultiplayerEnd += Dispose;
}
public static void Dispose()
{
Destroy(Instance.gameObject);
Instance = null;
Multiplayer.OnAfterMultiplayerEnd -= Dispose;
}
/// <summary>
/// Initializes the <see cref="Prefab"/> object with reduced utility to ensure the virtual cyclops won't be eating too much performance.
/// </summary>
public static void CreateVirtualCyclops()
{
if (Instance)
{
return;
}
LightmappedPrefabs.main.RequestScenePrefab("cyclops", (cyclopsPrefab) =>
{
SubConsoleCommand.main.OnSubPrefabLoaded(cyclopsPrefab);
GameObject model = SubConsoleCommand.main.GetLastCreatedSub();
model.name = NAME;
Vector3 position = Vector3.up * 500;
Quaternion rotation = Quaternion.identity;
model.transform.position = position;
model.transform.rotation = rotation;
Instance = model.AddComponent<VirtualCyclops>();
Instance.axis = model.GetComponent<SubRoot>().subAxis;
GameObject.Destroy(model.GetComponent<EcoTarget>());
GameObject.Destroy(model.GetComponent<PingInstance>());
GameObject.Destroy(model.GetComponent<CyclopsDestructionEvent>());
GameObject.Destroy(model.GetComponent<VFXConstructing>());
Instance.InitialPosition = position;
Instance.InitialRotation = rotation;
Instance.rigidbody = Instance.GetComponent<Rigidbody>();
Instance.rigidbody.constraints = RigidbodyConstraints.FreezeAll;
model.GetComponent<WorldForces>().enabled = false;
model.GetComponent<WorldForces>().lockInterpolation = false;
model.GetComponent<Stabilizer>().stabilizerEnabled = false;
model.GetComponent<Rigidbody>().isKinematic = true;
model.GetComponent<LiveMixin>().invincible = true;
Instance.RegisterVirtualOpenables();
Instance.ToggleRenderers(false);
Instance.DisableBadComponents();
model.SetActive(true);
});
}
public static IEnumerator InitializeConstructablesCache()
{
List<TechType> constructableTechTypes = [];
CraftData.GetBuilderGroupTech(TechGroup.InteriorModules, constructableTechTypes, true);
CraftData.GetBuilderGroupTech(TechGroup.Miscellaneous, constructableTechTypes, true);
TaskResult<GameObject> result = new();
foreach (TechType techType in constructableTechTypes)
{
yield return DefaultWorldEntitySpawner.RequestPrefab(techType, result);
if (result.value && result.value.GetComponent<Constructable>())
{
// We immediately destroy the copy because we only want to cache it for now
Destroy(CreateColliderCopy(result.value, techType));
}
}
}
public void Populate()
{
foreach (Constructable constructable in Cyclops.GetComponentsInChildren<Constructable>(true))
{
ReplicateConstructable(constructable);
}
foreach (Openable openable in Cyclops.GetComponentsInChildren<Openable>(true))
{
openable.blocked = false;
ReplicateOpening(openable, openable.isOpen);
realOpenableByName.Add(openable.name, openable);
}
}
public void Depopulate()
{
foreach (GameObject virtualObject in virtualConstructableByRealGameObject.Values)
{
Destroy(virtualObject);
}
virtualConstructableByRealGameObject.Clear();
foreach (Openable openable in realOpenableByName.Values)
{
openable.blocked = false;
}
realOpenableByName.Clear();
}
public void SetCurrentCyclops(NitroxCyclops nitroxCyclops)
{
if (Cyclops)
{
Cyclops.Virtual = null;
Depopulate();
Cyclops = null;
}
Cyclops = nitroxCyclops;
if (Cyclops)
{
Populate();
}
}
public void Update()
{
transform.position = InitialPosition;
transform.rotation = InitialRotation;
}
public void ToggleRenderers(bool toggled)
{
foreach (Renderer renderer in transform.GetComponentsInChildren<Renderer>(true))
{
renderer.enabled = toggled;
}
}
private void RegisterVirtualOpenables()
{
foreach (Openable openable in transform.GetComponentsInChildren<Openable>(true))
{
virtualOpenableByName.Add(openable.name, openable);
}
}
private void DisableBadComponents()
{
CyclopsLightingPanel cyclopsLightingPanel = GetComponentInChildren<CyclopsLightingPanel>(true);
cyclopsLightingPanel.floodlightsOn = false;
cyclopsLightingPanel.lightingOn = false;
cyclopsLightingPanel.SetExternalLighting(false);
cyclopsLightingPanel.cyclopsRoot.ForceLightingState(false);
cyclopsLightingPanel.enabled = false;
Destroy(cyclopsLightingPanel);
// Disable a source of useless logs
foreach (FMOD_CustomEmitter customEmitter in GetComponentsInChildren<FMOD_CustomEmitter>(true))
{
customEmitter.enabled = false;
}
foreach (PlayerCinematicController cinematicController in GetComponentsInChildren<PlayerCinematicController>(true))
{
cinematicController.enabled = false;
}
}
public void ReplicateOpening(Openable openable, bool openState)
{
if (virtualOpenableByName.TryGetValue(openable.name, out Openable virtualOpenable))
{
using (PacketSuppressor<OpenableStateChanged>.Suppress())
{
virtualOpenable.PlayOpenAnimation(openState, virtualOpenable.animTime);
}
}
}
public void ReplicateBlock(Openable openable, bool blockState)
{
if (realOpenableByName.TryGetValue(openable.name, out Openable realOpenable))
{
realOpenable.blocked = blockState;
}
}
public void ReplicateConstructable(Constructable constructable)
{
if (virtualConstructableByRealGameObject.ContainsKey(constructable.gameObject))
{
return;
}
GameObject colliderCopy = CreateColliderCopy(constructable.gameObject, constructable.techType);
colliderCopy.transform.parent = transform;
colliderCopy.transform.CopyLocals(constructable.transform);
virtualConstructableByRealGameObject.Add(constructable.gameObject, colliderCopy);
}
/// <summary>
/// Creates an empty shell simulating the presence of modules by copying its children containing a collider.
/// </summary>
public static GameObject CreateColliderCopy(GameObject realObject, TechType techType)
{
if (cacheColliderCopy.TryGetValue(techType, out GameObject colliderCopy))
{
return GameObject.Instantiate(colliderCopy);
}
colliderCopy = new GameObject($"{realObject.name}-collidercopy");
// This will act as a prefab but will stay in the material world so we put it out of hands in the meantime
colliderCopy.transform.position = Vector3.up * 1000 + Vector3.right * 10 * cacheColliderCopy.Count;
Transform transform = realObject.transform;
Dictionary<Transform, Transform> copiedTransformByRealTransform = [];
copiedTransformByRealTransform[transform] = colliderCopy.transform;
IEnumerable<GameObject> uniqueColliderObjects = realObject.GetComponentsInChildren<Collider>(true).Select(c => c.gameObject).Distinct();
foreach (GameObject colliderObject in uniqueColliderObjects)
{
GameObject copiedColliderObject = new(colliderObject.name);
copiedColliderObject.transform.CopyLocals(colliderObject.transform);
foreach (Collider collider in colliderObject.GetComponents<Collider>())
{
collider.CopyComponent(copiedColliderObject);
}
// "child" is always a copied transform looking for its copied parent
Transform child = copiedColliderObject.transform;
// "parent" is always the real parent of the real transform corresponding to "child"
Transform parent = colliderObject.transform.parent;
while (!copiedTransformByRealTransform.ContainsKey(parent))
{
Transform copiedParent = copiedTransformByRealTransform[parent] = Instantiate(parent);
child.SetParent(copiedParent, false);
child = copiedParent;
parent = parent.parent;
}
// At the top of the tree we can simply stick the latest child to the collider
child.SetParent(colliderCopy.transform, false);
}
cacheColliderCopy.Add(techType, colliderCopy);
return GameObject.Instantiate(colliderCopy);
}
public void UnregisterConstructable(GameObject realObject)
{
if (virtualConstructableByRealGameObject.TryGetValue(realObject, out GameObject virtualConstructable))
{
Destroy(virtualConstructable);
virtualConstructableByRealGameObject.Remove(realObject);
}
}
}

View File

@@ -0,0 +1,194 @@
using System;
using System.Linq;
using DiscordGameSDKWrapper;
using NitroxClient.Communication.Abstract;
using NitroxClient.MonoBehaviours.Gui.MainMenu.ServersList;
using NitroxModel;
using NitroxModel.Core;
using NitroxModel.Packets;
using UnityEngine;
using UnityEngine.SceneManagement;
namespace NitroxClient.MonoBehaviours.Discord;
public class DiscordClient : MonoBehaviour
{
private const long CLIENT_ID = 405122994348752896;
private const int RETRY_INTERVAL = 60;
private static DiscordClient main;
private static DiscordGameSDKWrapper.Discord discord;
private static ActivityManager activityManager;
private static Activity activity;
private static bool showingWindow;
private void Awake()
{
if (main)
{
Log.Error($"[Discord] Tried to instantiate a second {nameof(DiscordClient)}");
return;
}
activity = new();
main = this;
DontDestroyOnLoad(gameObject);
Log.Info("[Discord] Starting Discord client");
StartDiscordHook();
}
private void StartDiscordHook()
{
try
{
discord = new DiscordGameSDKWrapper.Discord(CLIENT_ID, (ulong)CreateFlags.NoRequireDiscord);
discord.SetLogHook(DiscordGameSDKWrapper.LogLevel.Debug, (level, message) => Log.Write((NitroxModel.Logger.LogLevel)level, $"[Discord] {message}"));
activityManager = discord.GetActivityManager();
activityManager.RegisterSteam((uint)GameInfo.Subnautica.SteamAppId);
activityManager.OnActivityJoinRequest += ActivityJoinRequest;
activityManager.OnActivityJoin += ActivityJoin;
if (!string.IsNullOrEmpty(activity.State))
{
UpdateActivity();
}
}
catch (Exception ex)
{
DisposeAndScheduleHookRestart();
Log.ErrorOnce($"Encountered an error while starting Discord hook, will retry every {RETRY_INTERVAL} seconds: {ex.Message}");
}
}
private void OnDisable()
{
Log.Info("[Discord] Shutdown client");
discord?.Dispose();
}
private void OnDestroy()
{
if (main == this)
{
main = null;
activity = default;
}
}
private void Update()
{
try
{
discord?.RunCallbacks();
}
catch (Exception ex)
{
// Happens when Discord is closed while Nitrox has its Discord hook running (and for other reason)
DisposeAndScheduleHookRestart();
Log.ErrorOnce($"An error occured while running callbacks for Discord, will retry every {RETRY_INTERVAL} seconds: {ex.Message}");
}
}
private void DisposeAndScheduleHookRestart()
{
discord?.Dispose();
discord = null;
Invoke(nameof(StartDiscordHook), RETRY_INTERVAL);
}
private void ActivityJoin(string secret)
{
Log.Info("[Discord] Joining Server");
if (SceneManager.GetActiveScene().name != "StartScreen" || !MainMenuServerListPanel.Main)
{
Log.InGame(Language.main.Get("Nitrox_DiscordMultiplayerMenu"));
Log.Warn("[Discord] Can't join a server outside of the main-menu.");
return;
}
string[] splitSecret = secret.Split(':');
string ip = string.Join(":", splitSecret.Take(splitSecret.Length - 1));
string port = splitSecret.Last();
if(int.TryParse(port, out int portInt))
{
Log.Error($"[Discord] Port from received secret can't be parsed as int: {port}");
return;
}
MainMenuServerButton.OpenJoinServerMenuAsync(ip, portInt).ContinueWithHandleError(true);
}
private void ActivityJoinRequest(ref User user)
{
if (!showingWindow && Multiplayer.Active)
{
Log.Info($"[Discord] JoinRequest: Name:{user.Username}#{user.Discriminator} UserID:{user.Id}");
StartCoroutine(DiscordJoinRequestGui.SpawnGui(user));
showingWindow = true;
}
else
{
Log.Warn("[Discord] Request window is already active.");
}
}
public static void InitializeRPMenu()
{
activity.State = Language.main.Get("Nitrox_DiscordMainMenuState");
activity.Assets.LargeImage = "icon";
UpdateActivity();
}
public static void InitializeRPInGame(string username, int playerCount, int maxConnections)
{
activity.State = Language.main.Get("Nitrox_DiscordInGameState");
activity.Details = Language.main.Get("Nitrox_DiscordInGame").Replace("{PLAYER}", username);
activity.Timestamps.Start = 0;
activity.Party.Size.CurrentSize = playerCount;
activity.Party.Size.MaxSize = maxConnections;
UpdateActivity();
NitroxServiceLocator.LocateService<IPacketSender>().Send(new DiscordRequestIP(string.Empty));
}
public static void UpdateIpPort(string ipPort)
{
activity.Party.Id = $"NitroxPartyID:{ipPort}";
activity.Secrets.Join = ipPort;
UpdateActivity();
}
public static void UpdatePartySize(int size)
{
activity.Party.Size.CurrentSize = size;
UpdateActivity();
}
private static void UpdateActivity()
{
activityManager?.UpdateActivity(activity, (result) =>
{
if (result != Result.Ok)
{
Log.Error($"[Discord] {result}: Updating Activity failed");
}
});
}
public static void RespondJoinRequest(long userID, ActivityJoinRequestReply reply)
{
showingWindow = false;
activityManager?.SendRequestReply(userID, reply, (result) =>
{
if (result == Result.Ok)
{
Log.Info($"[Discord] Responded successfully {reply} to {userID}");
}
else
{
Log.InGame($"[Discord] {Language.main.Get("Nitrox_Failure")}");
Log.Error($"[Discord] {result}: Failed to send join response");
}
});
}
}

View File

@@ -0,0 +1,104 @@
using System.Collections;
using DiscordGameSDKWrapper;
using UnityEngine;
using UnityEngine.Networking;
using UnityEngine.UI;
using static NitroxClient.Unity.Helper.AssetBundleLoader;
namespace NitroxClient.MonoBehaviours.Discord;
public class DiscordJoinRequestGui : uGUI_InputGroup
{
private readonly WaitForSeconds expireTimeYielder = new(45);
private static DiscordJoinRequestGui instance;
private static User user;
private static Image profilePicture;
private static GameObject pressToFocus;
private static GameObject pressButtons;
public static IEnumerator SpawnGui(User requestingUser)
{
user = requestingUser;
yield return LoadUIAsset(NitroxAssetBundle.DISCORD_JOIN_REQUEST, false);
GameObject guiGameObject = (GameObject)NitroxAssetBundle.DISCORD_JOIN_REQUEST.LoadedAssets[0];
instance = guiGameObject.AddComponent<DiscordJoinRequestGui>();
profilePicture = guiGameObject.FindChild("ProfilePicture").GetComponent<Image>();
pressToFocus = guiGameObject.FindChild("PressToFocus");
Text[] texts = pressToFocus.GetComponentsInChildren<Text>();
texts[0].text = Language.main.Get("Nitrox_DiscordPressToFocus");
texts[3].text = GameInput.GetBinding(GameInput.Device.Keyboard, (GameInput.Button)46, GameInput.BindingSet.Primary);
pressToFocus.SetActive(true);
pressButtons = guiGameObject.FindChild("PressButtons");
pressButtons.SetActive(false);
Text[] buttonTexts = pressButtons.GetComponentsInChildren<Text>(true);
buttonTexts[0].text = Language.main.Get("Nitrox_DiscordAccept");
buttonTexts[1].text = Language.main.Get("Nitrox_DiscordDecline");
Button[] buttons = pressButtons.GetComponentsInChildren<Button>(true);
buttons[0].onClick.AddListener(instance.AcceptInvite);
buttons[1].onClick.AddListener(instance.DeclineInvite);
Text[] userTexts = guiGameObject.FindChild("UpperText").GetComponentsInChildren<Text>();
userTexts[0].text = $"{user.Username}#{user.Discriminator}";
userTexts[1].text = Language.main.Get("Nitrox_DiscordRequestText");
yield return LoadAvatar(user.Id, user.Avatar);
}
private void Start()
{
StartCoroutine(OnRequestExpired());
}
private void AcceptInvite() => CloseWindow(ActivityJoinRequestReply.Yes);
private void DeclineInvite() => CloseWindow(ActivityJoinRequestReply.No);
private void CloseWindow(ActivityJoinRequestReply reply)
{
DiscordClient.RespondJoinRequest(user.Id, reply);
DestroyImmediate(gameObject);
}
public static void Select()
{
if (instance)
{
pressToFocus.SetActive(false);
pressButtons.SetActive(true);
instance.Select(false);
}
}
private static IEnumerator LoadAvatar(long id, string avatarID)
{
UnityWebRequest avatarUrl = UnityWebRequestTexture.GetTexture($"https://cdn.discordapp.com/avatars/{id}/{avatarID}.png");
yield return avatarUrl.SendWebRequest();
Texture2D avatar = ((DownloadHandlerTexture)avatarUrl.downloadHandler).texture;
if (!avatar || avatar.height < 64)
{
UnityWebRequest standardAvatarUrl = UnityWebRequestTexture.GetTexture("https://discordapp.com/assets/6debd47ed13483642cf09e832ed0bc1b.png");
yield return standardAvatarUrl.SendWebRequest();
avatar = ((DownloadHandlerTexture)standardAvatarUrl.downloadHandler).texture;
}
profilePicture.sprite = Sprite.Create(avatar, new UnityEngine.Rect(0, 0, 128, 128), new Vector2(0.5f, 0.5f));
}
private IEnumerator OnRequestExpired()
{
yield return expireTimeYielder;
CloseWindow(ActivityJoinRequestReply.Ignore);
}
}

View File

@@ -0,0 +1,114 @@
using System.Collections.Generic;
using System.Linq;
using NitroxClient.Communication.Abstract;
using NitroxClient.GameLogic;
using NitroxModel.Core;
using NitroxModel.DataStructures;
using NitroxModel.Packets;
using NitroxModel_Subnautica.DataStructures;
using UnityEngine;
using static NitroxModel.Packets.EntityTransformUpdates;
namespace NitroxClient.MonoBehaviours;
public class EntityPositionBroadcaster : MonoBehaviour
{
public static readonly float BROADCAST_INTERVAL = 0.25f;
private static HashSet<NitroxId> watchingEntityIds = new();
private static Dictionary<NitroxId, SplineTransformUpdate> splineUpdatesById = new();
private IPacketSender packetSender;
private float time;
public void Awake()
{
packetSender = NitroxServiceLocator.LocateService<IPacketSender>();
}
public void Update()
{
time += Time.deltaTime;
// Only do on a specific cadence to avoid hammering server
if (time >= BROADCAST_INTERVAL)
{
time = 0;
if (watchingEntityIds.Count > 0)
{
Dictionary<NitroxId, GameObject> nonSplineEntitiesById = NitroxEntity.GetObjectsFrom(watchingEntityIds)
.Where(item => !item.Value.GetComponent<SwimBehaviour>() &&
!item.Value.GetComponent<WalkBehaviour>())
.ToDictionary(item => item.Key, item => item.Value);
List<EntityTransformUpdate> updates = BuildUpdates(nonSplineEntitiesById);
if (updates.Count > 0)
{
packetSender.Send(new EntityTransformUpdates(updates));
}
}
}
}
private List<EntityTransformUpdate> BuildUpdates(Dictionary<NitroxId, GameObject> nonSplineEntitiesById)
{
List<EntityTransformUpdate> updates = new();
foreach (KeyValuePair<NitroxId, GameObject> gameObjectWithId in nonSplineEntitiesById)
{
if (gameObjectWithId.Value)
{
updates.Add(new RawTransformUpdate(gameObjectWithId.Key, gameObjectWithId.Value.transform.position.ToDto(), gameObjectWithId.Value.transform.rotation.ToDto()));
}
}
// Only send data for entities still simulated by the local player
updates.AddRange(splineUpdatesById.Values.Where(
splineUpdate => this.Resolve<SimulationOwnership>().HasAnyLockType(splineUpdate.Id)
));
splineUpdatesById.Clear();
return updates;
}
public static void WatchEntity(NitroxId id)
{
watchingEntityIds.Add(id);
// The game object may not exist at this very moment (due to being spawned in async). This is OK as we will
// automatically start sending updates when we finally get it in the world. This behavior will also allow us
// to resync or respawn entities while still have broadcasting enabled without doing anything extra.
if (NitroxEntity.TryGetComponentFrom(id, out RemotelyControlled remotelyControlled))
{
Object.Destroy(remotelyControlled);
}
}
public static void StopWatchingEntity(NitroxId id)
{
watchingEntityIds.Remove(id);
}
public static void RegisterSplineMovementChange(NitroxId id, GameObject gameObject, Vector3 targetPos, Vector3 targetDir, float velocity)
{
if (watchingEntityIds.Contains(id))
{
splineUpdatesById[id] = new(id, gameObject.transform.position.ToDto(), gameObject.transform.rotation.ToDto(), targetPos.ToDto(), targetDir.ToDto(), velocity);
}
}
public static void RemoveEntityMovementControl(GameObject gameObject, NitroxId entityId)
{
if (gameObject.TryGetComponent(out RemotelyControlled remotelyControlled))
{
Destroy(remotelyControlled);
}
StopWatchingEntity(entityId);
}
}

View File

@@ -0,0 +1,174 @@
using System;
using System.Collections.Generic;
using FMOD.Studio;
using FMODUnity;
using NitroxClient.GameLogic.FMOD;
using NitroxClient.Unity.Helper;
using NitroxModel.GameLogic.FMOD;
using UnityEngine;
namespace NitroxClient.MonoBehaviours;
[DisallowMultipleComponent]
public class FMODEmitterController : MonoBehaviour
{
private readonly Dictionary<string, FMOD_CustomEmitter> customEmitters = new();
private readonly Dictionary<string, Tuple<FMOD_CustomLoopingEmitter, bool, float>> loopingEmitters = new(); // Tuple<emitter, is3D, radius>
private readonly Dictionary<string, FMOD_StudioEventEmitter> studioEmitters = new();
private readonly Dictionary<string, EventInstance> eventInstances = new(); // 2D Sounds
/// <summary>
/// When <see cref="GameObject"/>s are copied their Start()/Awake() functions don't get called again.
/// So the FMOD Start patch that tried to locate a <see cref="NitroxEntity"/> won't find one and will error later.
/// This function can late register missed FMOD MonoBehaviours
/// </summary>
public void LateRegisterEmitter()
{
foreach (MonoBehaviour behaviour in gameObject.GetComponentsInChildren<MonoBehaviour>(true))
{
switch (behaviour)
{
case FMOD_CustomEmitter customEmitter when this.Resolve<FMODWhitelist>().IsWhitelisted(customEmitter.asset.path, out float maxDistance):
AddEmitter(customEmitter.asset.path, customEmitter, maxDistance);
break;
case FMOD_StudioEventEmitter studioEmitter when this.Resolve<FMODWhitelist>().IsWhitelisted(studioEmitter.asset.path, out float maxDistance):
AddEmitter(studioEmitter.asset.path, studioEmitter, maxDistance);
break;
}
}
}
public void AddEmitter(string path, FMOD_CustomEmitter customEmitter, float maxDistance)
{
if (customEmitters.ContainsKey(path))
{
return;
}
customEmitter.CacheEventInstance();
EventInstance evt = customEmitter.GetEventInstance();
evt.getDescription(out EventDescription description);
description.is3D(out bool is3D);
if (is3D)
{
evt.setProperty(EVENT_PROPERTY.MINIMUM_DISTANCE, 1f);
evt.setProperty(EVENT_PROPERTY.MAXIMUM_DISTANCE, maxDistance);
customEmitters.Add(path, customEmitter);
if (customEmitter is FMOD_CustomLoopingEmitter loopingEmitter)
{
if (loopingEmitter.assetStart && this.Resolve<FMODWhitelist>().IsWhitelisted(loopingEmitter.assetStart.path, out float radiusStart))
{
AddEmitter(loopingEmitter.assetStart.path, loopingEmitter, radiusStart);
}
if (loopingEmitter.assetStop && this.Resolve<FMODWhitelist>().IsWhitelisted(loopingEmitter.assetStop.path, out float radiusStop))
{
AddEmitter(loopingEmitter.assetStop.path, loopingEmitter, radiusStop);
}
}
}
else
{
AddEventInstance(customEmitter.asset.path, evt);
}
}
private void AddEmitter(string path, FMOD_CustomLoopingEmitter loopingEmitter, float radius)
{
if (!loopingEmitters.ContainsKey(path))
{
loopingEmitter.CacheEventInstance();
loopingEmitter.evt.getDescription(out EventDescription description);
description.is3D(out bool is3D);
loopingEmitters.Add(path, new Tuple<FMOD_CustomLoopingEmitter, bool, float>(loopingEmitter, is3D, radius));
}
}
public void AddEmitter(string path, FMOD_StudioEventEmitter studioEmitter, float maxDistance)
{
if (!customEmitters.ContainsKey(path))
{
studioEmitter.CacheEventInstance();
studioEmitters.Add(path, studioEmitter);
}
}
private void AddEventInstance(string path, EventInstance eventInstance)
{
if (!eventInstances.ContainsKey(path))
{
eventInstances.Add(path, eventInstance);
}
}
public void PlayCustomEmitter(string path) => customEmitters[path].AliveOrNull()?.Play();
public void SetParameterCustomEmitter(string path, string paramString, float value) => customEmitters[path].AliveOrNull()?.SetParameterValue(paramString, value);
public void StopCustomEmitter(string path) => customEmitters[path].AliveOrNull()?.Stop();
public void PlayStudioEmitter(string path) => studioEmitters[path].AliveOrNull()?.PlayUI();
public void StopStudioEmitter(string path, bool allowFadeout) => studioEmitters[path].AliveOrNull()?.Stop(allowFadeout);
public void PlayCustomLoopingEmitter(string path)
{
(FMOD_CustomLoopingEmitter loopingEmitter, bool is3D, float radius) = loopingEmitters[path];
EventInstance eventInstance = FMODUWE.GetEventImpl(path);
if (is3D)
{
eventInstance.set3DAttributes(loopingEmitter.transform.To3DAttributes());
eventInstance.setProperty(EVENT_PROPERTY.MINIMUM_DISTANCE, 1f);
eventInstance.setProperty(EVENT_PROPERTY.MAXIMUM_DISTANCE, radius);
}
else
{
eventInstance.setVolume(FMODSystem.CalculateVolume(loopingEmitter.transform.position, Player.main.transform.position, radius, 1f));
}
eventInstance.start();
eventInstance.release();
loopingEmitter.timeLastStopSound = Time.time;
}
public void PlayEventInstance(string path, float volume)
{
EventInstance eventInstance = eventInstances[path];
eventInstance.setVolume(volume);
eventInstance.start();
}
public void StopEventInstance(string path)
{
EventInstance eventInstance = eventInstances[path];
eventInstance.stop(FMOD.Studio.STOP_MODE.IMMEDIATE);
}
public static void PlayEventOneShot(FMODAsset asset, float radius, Vector3 origin, float volume = 1f) => PlayEventOneShot(asset.path, radius, origin, volume);
public static void PlayEventOneShot(string path, float radius, Vector3 origin, float volume = 1f)
{
EventInstance evt = FMODUWE.GetEventImpl(path);
evt.getDescription(out EventDescription description);
description.is3D(out bool is3D);
if (is3D)
{
evt.setProperty(EVENT_PROPERTY.MINIMUM_DISTANCE, 1f);
evt.setProperty(EVENT_PROPERTY.MAXIMUM_DISTANCE, radius);
evt.setVolume(volume);
}
else
{
evt.setVolume(FMODSystem.CalculateVolume(origin, Player.main.transform.position, radius, volume));
}
evt.set3DAttributes(origin.To3DAttributes());
evt.start();
evt.release();
}
}

View File

@@ -0,0 +1,147 @@
using System;
using System.Collections;
using System.Collections.Generic;
using System.Linq;
using NitroxClient.GameLogic.ChatUI;
using NitroxClient.GameLogic.Settings;
using NitroxModel.Core;
using UnityEngine;
using UnityEngine.UI;
namespace NitroxClient.MonoBehaviours.Gui.Chat
{
public class PlayerChat : uGUI_InputGroup
{
private const int LINE_CHAR_LIMIT = 255;
private const int MESSAGES_LIMIT = 64;
private const float TOGGLED_TRANSPARENCY = 0.4f;
public const float CHAT_VISIBILITY_TIME_LENGTH = 6f;
private static readonly Queue<ChatLogEntry> entries = new Queue<ChatLogEntry>();
private Image[] backgroundImages;
private CanvasGroup canvasGroup;
private InputField inputField;
private GameObject logEntryPrefab;
private PlayerChatManager playerChatManager;
private bool transparent;
public static bool IsReady { get; private set; }
public string InputText
{
get => inputField.text;
set => inputField.text = value;
}
public IEnumerator SetupChatComponents()
{
playerChatManager = NitroxServiceLocator.LocateService<PlayerChatManager>();
canvasGroup = GetComponent<CanvasGroup>();
logEntryPrefab = GameObject.Find("ChatLogEntryPrefab");
logEntryPrefab.AddComponent<PlayerChatLogItem>();
logEntryPrefab.SetActive(false);
GetComponentsInChildren<Button>()[0].onClick.AddListener(ToggleBackgroundTransparency);
GetComponentsInChildren<Button>()[1].gameObject.AddComponent<PlayerChatPinButton>();
inputField = GetComponentInChildren<InputField>();
inputField.gameObject.AddComponent<PlayerChatInputField>().InputField = inputField;
inputField.GetComponentInChildren<Button>().onClick.AddListener(playerChatManager.SendMessage);
// We pick any image that's inside the chat component to have all of their opacity lowered
backgroundImages = transform.GetComponentsInChildren<Image>();
yield return new WaitForEndOfFrame(); //Needed so Select() works on initialization
IsReady = true;
if (NitroxPrefs.SilenceChat.Value)
{
Log.InGame(Language.main.Get("Nitrox_SilencedChatNotif"));
}
}
public void WriteLogEntry(string playerName, string message, Color color)
{
if (entries.Count == MESSAGES_LIMIT)
{
Destroy(entries.Dequeue().EntryObject);
}
ChatLogEntry chatLogEntry;
GameObject chatLogEntryObject;
if (entries.Count != 0 && entries.Last().PlayerName == playerName)
{
chatLogEntry = entries.Last();
chatLogEntry.MessageText += $"{Environment.NewLine}{message}";
chatLogEntry.UpdateTime();
chatLogEntryObject = chatLogEntry.EntryObject;
}
else
{
chatLogEntry = new ChatLogEntry(playerName, SanitizeMessage(message), color);
chatLogEntryObject = Instantiate(logEntryPrefab, logEntryPrefab.transform.parent, false);
chatLogEntry.EntryObject = chatLogEntryObject;
entries.Enqueue(chatLogEntry);
}
chatLogEntryObject.GetComponent<PlayerChatLogItem>().ApplyOnPrefab(chatLogEntry);
}
public void Show()
{
PlayerChatInputField.ResetTimer();
StartCoroutine(ToggleChatFade(true));
}
public void Hide()
{
StartCoroutine(ToggleChatFade(false));
}
public void Select()
{
base.Select(true);
inputField.Select();
inputField.ActivateInputField();
}
private static string SanitizeMessage(string message)
{
message = message.Trim().TrimEnd('\n').Trim();
return message.Length < LINE_CHAR_LIMIT ? message : message.Substring(0, LINE_CHAR_LIMIT);
}
private void ToggleBackgroundTransparency()
{
float alpha = transparent ? 1f : TOGGLED_TRANSPARENCY;
transparent = !transparent;
foreach (Image backgroundImage in backgroundImages)
{
backgroundImage.CrossFadeAlpha(alpha, 0.5f, false);
}
}
private IEnumerator ToggleChatFade(bool fadeIn)
{
if (fadeIn)
{
while (canvasGroup.alpha < 1f)
{
canvasGroup.alpha += 0.01f;
yield return null;
}
}
else
{
while (canvasGroup.alpha > 0f)
{
canvasGroup.alpha -= 0.01f;
yield return null;
}
}
}
}
}

View File

@@ -0,0 +1,172 @@
using System.Collections;
using System.Collections.Generic;
using NitroxClient.GameLogic.ChatUI;
using NitroxModel.Core;
using UnityEngine;
using UnityEngine.EventSystems;
using UnityEngine.UI;
namespace NitroxClient.MonoBehaviours.Gui.Chat
{
public class PlayerChatInputField : MonoBehaviour, ISelectHandler, IDeselectHandler
{
private PlayerChatManager playerChatManager;
private bool selected;
private static float timeLeftUntilAutoClose;
public static bool FreezeTime;
public InputField InputField;
// Chat history
private const int historyLength = 32; // 2^5 messages availables :D
private List<string> sentMessages;
private int _sentMessagesIndex;
private int sentMessagesIndex
{
get { return _sentMessagesIndex; }
set
{
if (sentMessages.Count == 0)
{
// -1 is the state when there's no message sent
_sentMessagesIndex = -1;
}
else if (value < 1)
{
sentMessagesIndex = 1;
}
else if (value > sentMessages.Count)
{
_sentMessagesIndex = sentMessages.Count;
}
else
{
// normal functionning
InputField.text = sentMessages[value - 1];
_sentMessagesIndex = value;
}
}
}
private void Awake()
{
playerChatManager = NitroxServiceLocator.LocateService<PlayerChatManager>();
sentMessages = new();
sentMessagesIndex = -1;
}
public void OnSelect(BaseEventData eventData)
{
playerChatManager.SelectChat();
selected = true;
ResetTimer();
}
public void OnDeselect(BaseEventData eventData)
{
selected = false;
}
public static void ResetTimer()
{
timeLeftUntilAutoClose = PlayerChat.CHAT_VISIBILITY_TIME_LENGTH;
FreezeTime = false;
}
private void Update()
{
if (FreezeTime)
{
return;
}
if (selected)
{
if (!string.IsNullOrWhiteSpace(InputField.text))
{
if (UnityEngine.Input.GetKey(KeyCode.Return))
{
if (UnityEngine.Input.GetKey(KeyCode.LeftShift))
{
if (!InputField.text.EndsWith("\n"))
{
InputField.ActivateInputField();
InputField.text += "\n";
StartCoroutine(MoveToEndOfText());
}
}
else
{
// Detect if there's a ghost message on top of the list (one that wasn't sent but still saved)
if (sentMessagesIndex != sentMessages.Count && sentMessages.Count > 0)
{
sentMessages.RemoveAt(sentMessages.Count - 1);
}
// If the list is too long, we'll just remove the first message of the list
if (sentMessages.Count > historyLength)
{
sentMessages.RemoveAt(0);
}
sentMessages.Add(InputField.text);
_sentMessagesIndex = sentMessages.Count;
playerChatManager.SendMessage();
playerChatManager.DeselectChat(); // return to game after message sent
}
}
}
else
{
if (UnityEngine.Input.GetKey(KeyCode.Return))
{
ResetTimer();
playerChatManager.DeselectChat();
}
}
// Chat history stuff
// GetKeyDown means it's only getting executed once per press
if (UnityEngine.Input.GetKeyDown(KeyCode.UpArrow))
{
// If we're currently on the newest message, we want to save it to be able to come back to it (a ghost message)
if (sentMessagesIndex == sentMessages.Count && sentMessages.Count > 0)
{
sentMessages.Add(InputField.text);
_sentMessagesIndex = sentMessages.Count;
}
sentMessagesIndex--;
}
else if(UnityEngine.Input.GetKeyDown(KeyCode.DownArrow))
{
// We shouldn't execute this check if we're already on top of the list
if (sentMessagesIndex < sentMessages.Count)
{
sentMessagesIndex++;
// If we're back to the newest message, we can delete it from the list because it has not been sent yet
if (sentMessagesIndex == sentMessages.Count && sentMessages.Count > 0)
{
sentMessages.RemoveAt(sentMessages.Count - 1);
_sentMessagesIndex = sentMessages.Count;
}
}
}
}
else
{
timeLeftUntilAutoClose -= Time.unscaledDeltaTime;
if (timeLeftUntilAutoClose <= 0)
{
playerChatManager.HideChat();
FreezeTime = true;
}
}
}
private IEnumerator MoveToEndOfText()
{
yield return null;
InputField.MoveTextEnd(false);
}
}
}

View File

@@ -0,0 +1,35 @@
using NitroxClient.GameLogic.ChatUI;
using UnityEngine;
using UnityEngine.UI;
namespace NitroxClient.MonoBehaviours.Gui.Chat
{
public class PlayerChatLogItem : MonoBehaviour
{
private Text playerName;
private Text time;
private Text message;
private void SetupComponents()
{
Text[] textFields = gameObject.GetComponentsInChildren<Text>();
playerName = textFields[0];
time = textFields[1];
message = textFields[2];
}
public void ApplyOnPrefab(ChatLogEntry chatLogEntry)
{
if (playerName == null)
{
SetupComponents();
}
playerName.text = chatLogEntry.PlayerName;
playerName.color = chatLogEntry.PlayerColor;
time.text = chatLogEntry.Time;
message.text = chatLogEntry.MessageText;
gameObject.SetActive(true);
}
}
}

View File

@@ -0,0 +1,66 @@
using NitroxClient.GameLogic.ChatUI;
using NitroxModel.Core;
using UnityEngine;
using UnityEngine.EventSystems;
namespace NitroxClient.MonoBehaviours.Gui.Chat
{
public class PlayerChatPinButton : MonoBehaviour, IPointerDownHandler, IPointerUpHandler
{
private static PlayerChatManager playerChatManager;
private readonly Camera mainCamera = Camera.main;
private Vector2 screenRes = new Vector2(1920f, 1200f);
private Vector2 chatSize;
private Vector4 screenBorder;
private Vector2 offset;
private bool drag;
private void Awake()
{
playerChatManager = NitroxServiceLocator.LocateService<PlayerChatManager>();
chatSize = transform.parent.parent.GetComponent<RectTransform>().sizeDelta;
}
public void OnPointerDown(PointerEventData eventData)
{
screenRes.y = (screenRes.x / Screen.width) * Screen.height;
offset = GetMouseWorldPosition() - (Vector2)playerChatManager.PlayerChaTransform.localPosition;
screenBorder = new Vector4(-(screenRes.x - chatSize.x) / 2f, (screenRes.x - chatSize.x) / 2f, -(screenRes.y - chatSize.y) / 2f, (screenRes.y - chatSize.y) / 2f);
drag = true;
PlayerChatInputField.FreezeTime = true;
}
public void OnPointerUp(PointerEventData eventData)
{
drag = false;
PlayerChatInputField.FreezeTime = false;
PlayerChatInputField.ResetTimer();
}
private void Update()
{
if (drag)
{
playerChatManager.PlayerChaTransform.localPosition = GetChatPosition();
}
}
private Vector2 GetMouseWorldPosition()
{
Vector3 position = mainCamera.ScreenToViewportPoint(UnityEngine.Input.mousePosition);
position.x = (position.x - 0.5f) * screenRes.x;
position.y = (position.y - 0.5f) * screenRes.y;
return position;
}
private Vector2 GetChatPosition()
{
Vector2 position = GetMouseWorldPosition() - offset;
position.x = Mathf.Clamp(position.x, screenBorder.x, screenBorder.y);
position.y = Mathf.Clamp(position.y, screenBorder.z, screenBorder.w);
return position;
}
}
}

View File

@@ -0,0 +1,20 @@
using UnityEngine;
namespace NitroxClient.MonoBehaviours.Gui.HUD;
public class DenyOwnershipHand : MonoBehaviour
{
private void Start()
{
// Forces the message to go away after a few seconds.
Destroy(this, 2);
}
private void Update()
{
//TODO: Check if this should be Hand
HandReticle.main.SetText(HandReticle.TextType.Hand, "Nitrox_DenyOwnershipHand", true);
HandReticle.main.SetText(HandReticle.TextType.HandSubscript, string.Empty, false);
HandReticle.main.SetIcon(HandReticle.IconType.HandDeny);
}
}

View File

@@ -0,0 +1,287 @@
using System;
using NitroxClient.GameLogic;
using NitroxClient.Unity.Helper;
using TMPro;
using UnityEngine;
using UnityEngine.UI;
namespace NitroxClient.MonoBehaviours.Gui.HUD;
public class RemotePlayerVitals : MonoBehaviour
{
private static readonly Color OXYGEN_BAR_COLOR = new(0.168f, 0.666f, 0.60f, 1.0f);
private static readonly Color OXYGEN_BAR_BORDER_COLOR = new(0.227f, 0.949f, 0.969f, 1.0f);
private static readonly Color HEALTH_BAR_COLOR = new(0.859f, 0.373f, 0.251f, 1.0f);
private static readonly Color HEALTH_BAR_BORDER_COLOR = new(0.824f, 0.651f, 0.424f, 1.0f);
private static readonly Color FOOD_BAR_COLOR = new(0.965f, 0.655f, 0.149f, 1.0f);
private static readonly Color FOOD_BAR_BORDER_COLOR = new(0.957f, 0.914f, 0.251f, 1.0f);
private static readonly Color WATER_BAR_COLOR = new(0.212f, 0.663f, 0.855f, 1.0f);
private static readonly Color WATER_BAR_BORDER_COLOR = new(0.227f, 0.949f, 0.969f, 1.0f);
private Canvas canvas;
private Bar foodBar;
private Bar healthBar;
private Bar oxygenBar;
private string playerName;
private Bar waterBar;
/// <summary>
/// Creates a player vitals UI elements for the player id.
/// </summary>
/// <param name="playerId">Unique player id to create the vitals UI elements for.</param>
public static RemotePlayerVitals CreateForPlayer(RemotePlayer remotePlayer)
{
RemotePlayerVitals vitals = new GameObject("RemotePlayerVitals").AddComponent<RemotePlayerVitals>();
try
{
vitals.canvas = vitals.CreateCanvas(remotePlayer.Body.transform);
vitals.playerName = remotePlayer.PlayerName;
vitals.CreatePlayerName(vitals.canvas);
vitals.CreateStats(vitals.canvas);
} catch (Exception ex)
{
Log.Error(ex, $"Encountered an error while creating vitals for player {remotePlayer.PlayerId}, destroying them.");
Destroy(vitals.gameObject);
return null;
}
return vitals;
}
public void SetStatsVisible(bool visible)
{
oxygenBar.SetVisible(visible);
healthBar.SetVisible(visible);
foodBar.SetVisible(visible);
waterBar.SetVisible(visible);
}
public void SetOxygen(float oxygen, float maxOxygen)
{
oxygenBar.SetTargetValue(oxygen);
oxygenBar.SetMaxValue(maxOxygen);
}
public void SetHealth(float health)
{
healthBar.SetTargetValue(health);
}
public void SetFood(float food)
{
foodBar.SetTargetValue(food);
}
public void SetWater(float water)
{
waterBar.SetTargetValue(water);
}
public void LateUpdate()
{
oxygenBar.UpdateVisual();
healthBar.UpdateVisual();
foodBar.UpdateVisual();
waterBar.UpdateVisual();
// Make canvas face camera.
Camera camera = Camera.main;
if (canvas && camera)
{
canvas.transform.forward = camera.transform.forward;
}
}
private Canvas CreateCanvas(Transform playerTransform)
{
// Canvas
transform.SetParent(playerTransform, false);
transform.localPosition = new Vector3(0, 0, 0);
Canvas vitalsCanvas = gameObject.AddComponent<Canvas>();
vitalsCanvas.renderMode = RenderMode.WorldSpace;
CanvasScaler scaler = gameObject.AddComponent<CanvasScaler>();
scaler.dynamicPixelsPerUnit = 100;
return vitalsCanvas;
}
private void CreateStats(Canvas canvas)
{
// uGUI is a script at the topmost of the uGUI(Clone) object which contains the uGUI_ classes whe're looking for
uGUI uGUI = uGUI.main;
if (!uGUI)
{
throw new NullReferenceException($"[{nameof(RemotePlayerVitals)}] Couldn't find uGUI main instance when creating vitals");
}
healthBar = CreateBar(uGUI.GetComponentInChildren<uGUI_HealthBar>(true), canvas);
oxygenBar = CreateBar(uGUI.GetComponentInChildren<uGUI_OxygenBar>(true), canvas);
foodBar = CreateBar(uGUI.GetComponentInChildren<uGUI_FoodBar>(true), canvas);
waterBar = CreateBar(uGUI.GetComponentInChildren<uGUI_WaterBar>(true), canvas);
}
private Bar CreateBar<T>(T barBehaviour, Canvas canvas) where T : MonoBehaviour
{
GameObject originalBar = barBehaviour.gameObject;
GameObject cloned = Instantiate(originalBar, canvas.transform, true);
uGUI_CircularBar newBar = cloned.GetComponentInChildren<uGUI_CircularBar>(true);
newBar.texture = originalBar.GetComponentInChildren<uGUI_CircularBar>(true).texture;
newBar.overlay = originalBar.GetComponentInChildren<uGUI_CircularBar>(true).overlay;
cloned.transform.localRotation = Quaternion.identity;
// From uGUI_OxygenBar.Awake
if (cloned.TryGetComponentInChildren(out TextMeshProUGUI text, true))
{
text.enableCulling = true;
}
switch (barBehaviour)
{
case uGUI_HealthBar:
newBar.color = HEALTH_BAR_COLOR;
newBar.borderColor = HEALTH_BAR_BORDER_COLOR;
cloned.transform.localPosition = new Vector3(-0.05f, 0.33f, 0f);
cloned.transform.localScale = new Vector3(0.0014f, 0.0014f, 1f);
break;
case uGUI_OxygenBar:
newBar.color = OXYGEN_BAR_COLOR;
newBar.borderColor = OXYGEN_BAR_BORDER_COLOR;
cloned.transform.localPosition = new Vector3(0.05f, 0.33f, 0f);
cloned.transform.localScale = new Vector3(0.0006f, 0.0006f, 1f);
// PulseWave is only present for uGUI_OxygenBar
Destroy(cloned.FindChild("PulseWave"));
break;
case uGUI_FoodBar:
newBar.color = FOOD_BAR_COLOR;
newBar.borderColor = FOOD_BAR_BORDER_COLOR;
cloned.transform.localPosition = new Vector3(-0.05f, 0.255f, 0f);
cloned.transform.localScale = new Vector3(0.0014f, 0.0014f, 1f);
break;
case uGUI_WaterBar:
newBar.color = WATER_BAR_COLOR;
newBar.borderColor = WATER_BAR_BORDER_COLOR;
cloned.transform.localPosition = new Vector3(0.05f, 0.255f, 0f);
cloned.transform.localScale = new Vector3(0.0014f, 0.0014f, 1f);
break;
default:
Log.Info($"Unhandled bar type: {barBehaviour.GetType()}");
break;
}
Destroy(cloned.FindChild("PulseHalo"));
Destroy(cloned.GetComponent<T>());
cloned.SetActive(true);
return new Bar(cloned, 100f, 100f, 0.1f);
}
private void CreatePlayerName(Canvas canvas)
{
// Text
GameObject nameObject = new("RemotePlayerName");
nameObject.transform.parent = canvas.transform;
Text nameText = nameObject.AddComponent<Text>();
nameText.font = Resources.GetBuiltinResource<Font>("Arial.ttf");
nameText.text = playerName;
Transform nameTransform = nameText.transform;
nameTransform.localScale = new Vector3(0.015f, 0.015f, 1f);
nameTransform.rotation = canvas.transform.rotation;
nameText.fontSize = 14;
nameText.alignment = TextAnchor.MiddleCenter;
// Text position
RectTransform namePosition = nameObject.GetComponent<RectTransform>();
namePosition.localPosition = new Vector3(0, 0.4f, 0);
namePosition.sizeDelta = new Vector2(200, 100);
}
private void OnDestroy()
{
// Must stay optional in case the destroy originates from a broken object
oxygenBar?.Dispose();
healthBar?.Dispose();
foodBar?.Dispose();
waterBar?.Dispose();
}
private class Bar : IDisposable
{
private readonly GameObject gameObject;
private readonly uGUI_CircularBar circularBar;
private readonly TextMeshProUGUI text;
private bool isDisposed;
private float vel;
private float current;
private float target;
private float maximum;
private float smoothTime;
public Bar(GameObject gameObject, float current, float maximum, float smoothTime)
{
this.gameObject = gameObject;
this.current = current;
target = current;
this.maximum = maximum;
this.smoothTime = smoothTime;
circularBar = gameObject.GetComponentInChildren<uGUI_CircularBar>(true);
// text can be null
text = gameObject.GetComponentInChildren<TextMeshProUGUI>(true);
}
public void SetTargetValue(float value)
{
ThrowIfDisposed();
target = value;
}
public void SetMaxValue(float maxValue)
{
ThrowIfDisposed();
maximum = maxValue;
}
public void UpdateVisual()
{
ThrowIfDisposed();
// Adapted from uGUI_OxygenBar
float percentage = Mathf.Clamp01(target / maximum);
current = Mathf.SmoothDamp(current, percentage, ref vel, smoothTime);
circularBar.value = current;
if (text)
{
text.SetText(IntStringCache.GetStringForInt(Mathf.RoundToInt(target)));
}
}
public void SetVisible(bool visible)
{
gameObject.SetActive(visible);
}
public void Dispose()
{
if (isDisposed)
{
return;
}
isDisposed = true;
Destroy(gameObject);
}
private void ThrowIfDisposed()
{
if (isDisposed)
{
throw new ObjectDisposedException("Tried to update visual on a disposed player stat.");
}
}
}
}

View File

@@ -0,0 +1,44 @@
using NitroxModel.Helper;
using TMPro;
using UnityEngine;
using UnityEngine.XR;
namespace NitroxClient.MonoBehaviours.Gui.MainMenu;
public static class LoadingScreenVersionText
{
private static GameObject buildWatermark => uGUI.main.overlays.transform.parent.GetComponentInChildren<uGUI_BuildWatermark>().gameObject;
private static uGUI_TextFade loadingScreenWarning;
private static uGUI_TextFade versionText;
public static void Initialize()
{
versionText = AddTextToLoadingScreen("LoadingScreenVersionText", $"\nNitrox {NitroxEnvironment.ReleasePhase} V{NitroxEnvironment.Version}");
loadingScreenWarning = AddTextToLoadingScreen("LoadingScreenWarnText", $"\n\n{Language.main.Get("Nitrox_LoadingScreenWarn")}");
}
private static uGUI_TextFade AddTextToLoadingScreen(string name, string text)
{
GameObject gameObject = Object.Instantiate(buildWatermark, buildWatermark.transform.parent);
gameObject.name = name;
Object.Destroy(gameObject.GetComponent<uGUI_BuildWatermark>());
uGUI_TextFade textFade = gameObject.AddComponent<uGUI_TextFade>();
textFade.SetAlignment(TextAlignmentOptions.TopRight);
textFade.SetColor(Color.white.WithAlpha(0.5f));
textFade.SetText(text);
textFade.FadeIn(1f, null);
return textFade;
}
public static void DisableWarningText()
{
loadingScreenWarning.FadeOut(1f, null);
if (XRSettings.enabled)
{
versionText.FadeOut(1f, null);
}
}
}

View File

@@ -0,0 +1,40 @@
using System.Collections.Generic;
using System.Linq;
using NitroxClient.MonoBehaviours.Gui.Input.KeyBindings;
using NitroxClient.MonoBehaviours.Gui.Input.KeyBindings.Actions;
using NitroxClient.Serialization;
using NitroxModel.Helper;
namespace NitroxClient.MonoBehaviours.Gui.Input
{
public class KeyBindingManager
{
public List<KeyBinding> KeyboardKeyBindings { get; }
public KeyBindingManager()
{
ClientConfig cfg = ClientConfig.Load(NitroxUser.AppDataPath);
KeyboardKeyBindings = new List<KeyBinding>
{
// new bindings should not be set to a value equivalent to a pre-existing GameInput.Button enum or another custom binding
new((int)KeyBindingValues.CHAT, "Chat", GameInput.Device.Keyboard, new ChatKeyBindingAction(), cfg.OpenChatKeybindPrimary, cfg.OpenChatKeybindSecondary),
new((int)KeyBindingValues.FOCUS_DISCORD, "Focus Discord (Alt +)", GameInput.Device.Keyboard, new DiscordFocusBindingAction(), cfg.FocusDiscordKeybindPrimary, cfg.FocusDiscordKeybindSecondary),
};
}
// Returns highest custom key binding value. If no custom key bindings, returns 0.
public int GetHighestKeyBindingValue()
{
return KeyboardKeyBindings.Select(keyBinding => (int)keyBinding.Button).DefaultIfEmpty(0).Max();
}
}
/// <summary>
/// Refers the keybinding values used for button creation in the options menu, to a more clear form
/// </summary>
public enum KeyBindingValues
{
CHAT = 45,
FOCUS_DISCORD = 46
}
}

View File

@@ -0,0 +1,17 @@
using NitroxClient.GameLogic.ChatUI;
using NitroxModel.Core;
namespace NitroxClient.MonoBehaviours.Gui.Input.KeyBindings.Actions
{
public class ChatKeyBindingAction : KeyBindingAction
{
public override void Execute()
{
// If no other UWE input field is currently active then allow chat to open.
if (FPSInputModule.current.lastGroup == null)
{
NitroxServiceLocator.LocateService<PlayerChatManager>().SelectChat();
}
}
}
}

View File

@@ -0,0 +1,15 @@
using NitroxClient.MonoBehaviours.Discord;
using UnityEngine;
namespace NitroxClient.MonoBehaviours.Gui.Input.KeyBindings.Actions;
public class DiscordFocusBindingAction : KeyBindingAction
{
public override void Execute()
{
if (UnityEngine.Input.GetKey(KeyCode.LeftAlt))
{
DiscordJoinRequestGui.Select();
}
}
}

View File

@@ -0,0 +1,7 @@
namespace NitroxClient.MonoBehaviours.Gui.Input.KeyBindings.Actions
{
public abstract class KeyBindingAction
{
public abstract void Execute();
}
}

View File

@@ -0,0 +1,14 @@
namespace NitroxClient.MonoBehaviours.Gui.Input.KeyBindings
{
public class DefaultKeyBinding
{
public string Binding { get; }
public GameInput.BindingSet BindingSet { get; }
public DefaultKeyBinding(string defaultBinding, GameInput.BindingSet defaultBindingSet)
{
Binding = defaultBinding;
BindingSet = defaultBindingSet;
}
}
}

View File

@@ -0,0 +1,26 @@
using NitroxClient.MonoBehaviours.Gui.Input.KeyBindings.Actions;
using NitroxModel.Helper;
namespace NitroxClient.MonoBehaviours.Gui.Input.KeyBindings;
public class KeyBinding
{
public GameInput.Button Button { get; }
public GameInput.Device Device { get; }
public string Label { get; }
public string PrimaryKey { get; }
public string SecondaryKey { get; }
public KeyBindingAction Action { get; }
public KeyBinding(int keyBindingValue, string buttonLabel, GameInput.Device buttonDevice, KeyBindingAction buttonAction, string primaryKey, string secondaryKey = null)
{
Validate.NotNull(primaryKey);
Button = (GameInput.Button)keyBindingValue;
Device = buttonDevice;
Label = buttonLabel;
Action = buttonAction;
PrimaryKey = primaryKey;
SecondaryKey = secondaryKey;
}
}

View File

@@ -0,0 +1,182 @@
using System;
using System.Collections;
using System.Linq;
using NitroxClient.Unity.Helper;
using TMPro;
using UnityEngine;
using UnityEngine.EventSystems;
using UnityEngine.ResourceManagement.AsyncOperations;
using UnityEngine.UI;
namespace NitroxClient.MonoBehaviours.Gui.MainMenu;
public class MainMenuNotificationPanel : MonoBehaviour, uGUI_INavigableIconGrid, uGUI_IButtonReceiver
{
public const string NAME = "MultiplayerNotification";
private static MainMenuNotificationPanel instance;
private Image loadingCircle;
private TextMeshProUGUI text;
private GameObject confirmObject;
private Button confirmButton;
private mGUI_Change_Legend_On_Select confirmButtonLegend;
private LegendButtonData[] savedLegendData;
private string returningMenuPanel;
private Action continuationAction;
public static void ShowLoading()
{
if (!instance)
{
Log.Error($"Tried to use {nameof(ShowLoading)} while {nameof(MainMenuNotificationPanel)} was not ready");
return;
}
instance.confirmObject.SetActive(false);
instance.loadingCircle.gameObject.SetActive(true);
instance.text.text = Language.main.Get("Nitrox_Loading");
uGUI_MainMenu.main.ShowPrimaryOptions(true);
MainMenuRightSide.main.OpenGroup(NAME);
instance.confirmButtonLegend.legendButtonConfiguration = [];
}
public static void ShowMessage(string message, string returningMenuPanel, Action continuationAction = null)
{
if (!instance)
{
Log.Error("Tried to use ShowMessage() while MainMenuJoinServerNotificationPanel was not ready");
return;
}
instance.text.text = message;
instance.returningMenuPanel = returningMenuPanel;
instance.continuationAction = continuationAction;
instance.confirmObject.SetActive(true);
instance.loadingCircle.gameObject.SetActive(false);
uGUI_MainMenu.main.ShowPrimaryOptions(true);
MainMenuRightSide.main.OpenGroup(NAME);
instance.confirmButtonLegend.legendButtonConfiguration = instance.savedLegendData;
}
public void Setup(GameObject savedGamesRef)
{
instance = this;
Destroy(transform.RequireGameObject("Scroll View"));
Destroy(GetComponentInChildren<TranslationLiveUpdate>());
text = GetComponentInChildren<TextMeshProUGUI>();
text.horizontalAlignment = HorizontalAlignmentOptions.Center;
text.verticalAlignment = VerticalAlignmentOptions.Top;
text.transform.localPosition = new Vector3(-375, 350, 0);
text.GetComponent<RectTransform>().sizeDelta = new Vector2(350, 280);
GameObject multiplayerButtonRef = savedGamesRef.RequireGameObject("Scroll View/Viewport/SavedGameAreaContent/NewGame");
confirmObject = Instantiate(multiplayerButtonRef, transform, false);
confirmObject.transform.localPosition = new Vector3(-200, 50, 0);
confirmObject.transform.localScale = new Vector3(1.25f, 1.25f, 1.25f);
confirmObject.transform.GetChild(0).GetComponent<RectTransform>().sizeDelta = new Vector2(200, 40);
confirmObject.GetComponentInChildren<TextMeshProUGUI>().text = Language.main.Get("Nitrox_OK");
confirmButton = confirmObject.RequireTransform("NewGameButton").GetComponent<Button>();
confirmButton.onClick = new Button.ButtonClickedEvent();
confirmButton.onClick.AddListener(() =>
{
continuationAction?.Invoke();
if (!string.IsNullOrEmpty(returningMenuPanel))
{
MainMenuRightSide.main.OpenGroup(returningMenuPanel);
}
});
confirmButtonLegend = confirmButton.GetComponent<mGUI_Change_Legend_On_Select>();
savedLegendData = confirmButtonLegend.legendButtonConfiguration.Take(1).ToArray();
GameObject loadingCircleObject = new("LoadingCircle");
loadingCircle = loadingCircleObject.AddComponent<Image>();
loadingCircleObject.transform.SetParent(transform);
loadingCircleObject.transform.localPosition = new Vector3(-200, 180, 0);
loadingCircleObject.transform.localRotation = Quaternion.identity;
loadingCircleObject.transform.localScale = Vector3.one;
}
private IEnumerator Start()
{
AsyncOperationHandle<Texture2D> request = AddressablesUtility.LoadAsync<Texture2D>("Assets/uGUI/Sources/Sprites/HUD/Progress.png");
yield return request;
Texture2D tex = request.Result;
loadingCircle.sprite = Sprite.Create(tex, new Rect(0, 0, tex.width, tex.height), new Vector2(0.5f, 0.5f));
loadingCircle.type = Image.Type.Filled;
}
private void Update()
{
if (loadingCircle)
{
loadingCircle.transform.localRotation = Quaternion.Euler(0, 0, Time.time % 360 * 250); // 250 is the speed
loadingCircle.fillAmount = Mathf.Lerp(0.05f, 0.95f, Math.Abs(Time.time % 6 - 3) * 0.333f); // Lerps t fades from 0 to 1 and back to 0
uGUI_LegendBar.ClearButtons();
}
}
bool uGUI_INavigableIconGrid.ShowSelector => false;
bool uGUI_INavigableIconGrid.EmulateRaycast => false;
bool uGUI_INavigableIconGrid.SelectItemClosestToPosition(Vector3 worldPos) => false;
uGUI_INavigableIconGrid uGUI_INavigableIconGrid.GetNavigableGridInDirection(int dirX, int dirY) => null;
Graphic uGUI_INavigableIconGrid.GetSelectedIcon() => null;
object uGUI_INavigableIconGrid.GetSelectedItem() => confirmObject ? confirmObject : null;
public bool SelectItemInDirection(int dirX, int dirY) => SelectFirstItem();
public bool SelectFirstItem()
{
if (confirmObject)
{
SelectItem(confirmObject);
return true;
}
return false;
}
public void SelectItem(object item)
{
DeselectItem();
GameObject selectedItem = item as GameObject;
if (selectedItem && selectedItem == confirmObject)
{
confirmButton.Select();
confirmButtonLegend.SyncLegendBarToGUISelection();
}
}
public void DeselectItem()
{
if (confirmObject)
{
EventSystem.current.SetSelectedGameObject(null);
confirmObject.GetComponentInChildren<uGUI_BasicColorSwap>().makeTextWhite();
}
uGUI_LegendBar.ClearButtons();
}
public bool OnButtonDown(GameInput.Button button)
{
switch (button)
{
case GameInput.Button.UISubmit:
case GameInput.Button.UICancel:
if (confirmObject.activeSelf)
{
confirmButton.onClick.Invoke();
}
return true;
default:
return false;
}
}
}

View File

@@ -0,0 +1,83 @@
using NitroxClient.MonoBehaviours.Discord;
using NitroxClient.MonoBehaviours.Gui.MainMenu.ServerJoin;
using NitroxClient.MonoBehaviours.Gui.MainMenu.ServersList;
using NitroxClient.Unity.Helper;
using TMPro;
using UnityEngine;
using UnityEngine.SceneManagement;
using UnityEngine.UI;
namespace NitroxClient.MonoBehaviours.Gui.MainMenu;
public class NitroxMainMenuModifications : MonoBehaviour
{
private MainMenuRightSide rightSide;
private void OnEnable() => SceneManager.sceneLoaded += SceneManager_sceneLoaded;
private void OnDisable() => SceneManager.sceneLoaded -= SceneManager_sceneLoaded;
private void SceneManager_sceneLoaded(Scene scene, LoadSceneMode loadMode)
{
if (scene.name == "XMenu")
{
rightSide = MainMenuRightSide.main;
MultiplayerMenuMods();
DiscordClient.InitializeRPMenu();
}
}
private void MultiplayerMenuMods()
{
GameObject startButton = GameObjectHelper.RequireGameObject("Menu canvas/Panel/MainMenu/PrimaryOptions/MenuButtons/ButtonPlay");
GameObject showLoadedMultiplayer = Instantiate(startButton, startButton.transform.parent);
showLoadedMultiplayer.name = "ButtonMultiplayer";
showLoadedMultiplayer.transform.SetSiblingIndex(3);
TextMeshProUGUI buttonText = showLoadedMultiplayer.RequireGameObject("Circle/Bar/Text").GetComponent<TextMeshProUGUI>();
buttonText.text = Language.main.Get("Nitrox_Multiplayer");
buttonText.GetComponent<TranslationLiveUpdate>().translationKey = "Nitrox_Multiplayer";
Button showLoadedMultiplayerButton = showLoadedMultiplayer.GetComponent<Button>();
showLoadedMultiplayerButton.onClick = new Button.ButtonClickedEvent();
showLoadedMultiplayerButton.onClick.AddListener(() => rightSide.OpenGroup(MainMenuServerListPanel.NAME));
GameObject savedGamesRef = rightSide.gameObject.RequireGameObject("SavedGames");
GameObject CloneMainMenuLoadPanel(string panelName, string translationKey)
{
GameObject menuPanel = Instantiate(savedGamesRef, rightSide.transform);
menuPanel.name = panelName;
Transform header = menuPanel.RequireTransform("Header");
header.GetComponent<TextMeshProUGUI>().text = Language.main.Get(translationKey);
header.GetComponent<TranslationLiveUpdate>().translationKey = translationKey;
Destroy(menuPanel.RequireGameObject("Scroll View/Viewport/SavedGameAreaContent/NewGame"));
Destroy(menuPanel.GetComponent<MainMenuLoadPanel>());
Destroy(menuPanel.GetComponentInChildren<MainMenuLoadMenu>());
rightSide.groups.Add(menuPanel.GetComponent<MainMenuGroup>());
return menuPanel;
}
GameObject serverJoinNotification = CloneMainMenuLoadPanel(MainMenuNotificationPanel.NAME, string.Empty);
serverJoinNotification.AddComponent<MainMenuNotificationPanel>().Setup(savedGamesRef);
GameObject serverJoin = CloneMainMenuLoadPanel(MainMenuJoinServerPanel.NAME, "Nitrox_JoinServer");
serverJoin.AddComponent<MainMenuJoinServerPanel>().Setup(savedGamesRef);
GameObject serverPasswordEnter = CloneMainMenuLoadPanel(MainMenuEnterPasswordPanel.NAME, "Nitrox_JoinServerPasswordHeader");
serverPasswordEnter.AddComponent<MainMenuEnterPasswordPanel>().Setup(savedGamesRef);
GameObject serverList = CloneMainMenuLoadPanel(MainMenuServerListPanel.NAME, "Nitrox_Multiplayer");
serverList.AddComponent<MainMenuServerListPanel>().Setup(savedGamesRef);
GameObject serverCreate = CloneMainMenuLoadPanel(MainMenuCreateServerPanel.NAME, "Nitrox_AddServer");
serverCreate.AddComponent<MainMenuCreateServerPanel>().Setup(savedGamesRef);
#if RELEASE
// Remove singleplayer button because SP is broken when Nitrox is injected.
// TODO: Allow SP to work and co-exist with Nitrox MP in the future
startButton.SetActive(false);
#endif
}
}

View File

@@ -0,0 +1,186 @@
using System.Collections;
using System.Diagnostics;
using System.Net;
using System.Threading.Tasks;
using NitroxClient.Communication.Abstract;
using NitroxClient.Communication.Exceptions;
using NitroxClient.Communication.MultiplayerSession;
using NitroxClient.GameLogic.PlayerLogic.PlayerPreferences;
using NitroxClient.MonoBehaviours.Gui.MainMenu.ServersList;
using NitroxModel.Core;
using NitroxModel.DataStructures.Util;
using NitroxModel.Helper;
using NitroxModel.MultiplayerSession;
using NitroxModel_Subnautica.DataStructures;
using UnityEngine;
namespace NitroxClient.MonoBehaviours.Gui.MainMenu.ServerJoin;
public static class JoinServerBackend
{
private static PlayerPreferenceManager preferencesManager;
private static PlayerPreference activePlayerPreference;
private static IMultiplayerSession multiplayerSession;
private static GameObject multiplayerClient;
private static string serverIp;
private static int serverPort;
public static void RequestSessionReservation(string playerName, Color playerColor)
{
preferencesManager.SetPreference(serverIp, new PlayerPreference(playerName, playerColor));
Optional<string> opPassword = MainMenuEnterPasswordPanel.LastEnteredPassword;
AuthenticationContext authenticationContext = new(playerName, opPassword);
multiplayerSession.RequestSessionReservation(new PlayerSettings(playerColor.ToDto()), authenticationContext);
}
private static void SessionConnectionStateChangedHandler(IMultiplayerSessionConnectionState state)
{
switch (state.CurrentStage)
{
case MultiplayerSessionConnectionStage.ESTABLISHING_SERVER_POLICY:
Log.Info("Requesting session policy info");
Log.InGame(Language.main.Get("Nitrox_RequestingSessionPolicy"));
break;
case MultiplayerSessionConnectionStage.AWAITING_RESERVATION_CREDENTIALS:
Color.RGBToHSV(activePlayerPreference.PreferredColor(), out float hue, out float saturation, out float brightness); // HSV => Hue Saturation Value, HSB => Hue Saturation Brightness
MainMenuJoinServerPanel.Instance.UpdatePlayerPanelValues(activePlayerPreference.PlayerName, new Vector3(hue, saturation, brightness));
if (multiplayerSession.SessionPolicy.RequiresServerPassword)
{
Log.Info("Waiting for server password input");
Log.InGame(Language.main.Get("Nitrox_WaitingPassword"));
MainMenuEnterPasswordPanel.ResetLastEnteredPassword();
MainMenuRightSide.main.OpenGroup(MainMenuEnterPasswordPanel.NAME);
MainMenuEnterPasswordPanel.Instance.FocusPasswordField();
break;
}
Log.Info("Waiting for user input");
Log.InGame(Language.main.Get("Nitrox_WaitingUserInput"));
MainMenuRightSide.main.OpenGroup(MainMenuJoinServerPanel.NAME);
MainMenuJoinServerPanel.Instance.FocusNameInputField();
break;
case MultiplayerSessionConnectionStage.SESSION_RESERVED:
Log.Info("Launching game");
Log.InGame(Language.main.Get("Nitrox_LaunchGame"));
multiplayerSession.ConnectionStateChanged -= SessionConnectionStateChangedHandler;
preferencesManager.Save();
StartGame();
break;
case MultiplayerSessionConnectionStage.SESSION_RESERVATION_REJECTED:
Log.Info("Reservation rejected");
Log.InGame(Language.main.Get("Nitrox_RejectedSessionPolicy"));
MultiplayerSessionReservationState reservationState = multiplayerSession.Reservation.ReservationState;
string reservationRejectionNotification = reservationState.Describe();
MainMenuNotificationPanel.ShowMessage(reservationRejectionNotification, null, () =>
{
multiplayerSession.Disconnect();
multiplayerSession.ConnectAsync(serverIp, serverPort);
});
break;
case MultiplayerSessionConnectionStage.DISCONNECTED:
Log.Info(Language.main.Get("Nitrox_DisconnectedSession"));
break;
}
}
public static async Task StartMultiplayerClientAsync(IPAddress ip, int port)
{
serverIp = ip.ToString();
serverPort = port;
NitroxServiceLocator.BeginNewLifetimeScope();
preferencesManager = NitroxServiceLocator.LocateService<PlayerPreferenceManager>();
activePlayerPreference = preferencesManager.GetPreference(serverIp);
multiplayerSession = NitroxServiceLocator.LocateService<IMultiplayerSession>();
if (!multiplayerClient)
{
multiplayerClient = new GameObject("Nitrox Multiplayer Client");
multiplayerClient.AddComponent<Multiplayer>();
multiplayerSession.ConnectionStateChanged += SessionConnectionStateChangedHandler;
}
try
{
await multiplayerSession.ConnectAsync(serverIp, serverPort);
}
catch (ClientConnectionFailedException ex)
{
Log.ErrorSensitive("Unable to contact the remote server at: {ip}:{port}", serverIp, serverPort);
string msg = $"{Language.main.Get("Nitrox_UnableToConnect")} {serverIp}:{serverPort}";
if (ip.IsLocalhost())
{
if (Process.GetProcessesByName("NitroxServer-Subnautica").Length == 0)
{
Log.Error("No server process was found while address was localhost");
msg += $"\n{Language.main.Get("Nitrox_StartServer")}";
}
else
{
Log.Error(ex);
msg += $"\n{Language.main.Get("Nitrox_FirewallInterfering")}";
}
}
Log.InGame(msg);
StopMultiplayerClient();
MainMenuNotificationPanel.ShowMessage(msg, MainMenuServerListPanel.NAME);
}
}
/// <summary>
/// This method starts a connection with the provided server but leaves handling the session negotiation for the caller.
/// </summary>
public static async Task StartDetachedMultiplayerClientAsync(IPAddress ip, int port, MultiplayerSessionConnectionStateChangedEventHandler sessionHandler)
{
multiplayerClient = new GameObject("Nitrox Multiplayer Client");
Task task = StartMultiplayerClientAsync(ip, port);
multiplayerClient.AddComponent<Multiplayer>();
multiplayerSession.ConnectionStateChanged += sessionHandler;
await task;
}
public static void StartGame()
{
#pragma warning disable CS0618 // God Damn it UWE...
Multiplayer.SubnauticaLoadingStarted();
IEnumerator startNewGame = uGUI_MainMenu.main.StartNewGame(GameMode.Survival);
#pragma warning restore CS0618 // God damn it UWE...
UWE.CoroutineHost.StartCoroutine(startNewGame);
LoadingScreenVersionText.Initialize();
}
public static void StopMultiplayerClient()
{
if (!multiplayerClient || !Multiplayer.Main)
{
return;
}
if (multiplayerSession.CurrentState.CurrentStage != MultiplayerSessionConnectionStage.DISCONNECTED)
{
multiplayerSession.Disconnect();
}
multiplayerSession.ConnectionStateChanged -= SessionConnectionStateChangedHandler;
Multiplayer.Main.StopCurrentSession();
NitroxServiceLocator.EndCurrentLifetimeScope(); //Always do this last.
Object.Destroy(multiplayerClient);
multiplayerClient = null;
}
}

View File

@@ -0,0 +1,59 @@
using NitroxClient.Unity.Helper;
using UnityEngine;
using UnityEngine.EventSystems;
using UnityEngine.UI;
namespace NitroxClient.MonoBehaviours.Gui.MainMenu.ServerJoin;
public class MainMenuColorPickerPreview : MonoBehaviour, IPointerDownHandler, IPointerUpHandler
{
private Image previewImage;
private CanvasGroup cg;
public void Init(uGUI_ColorPicker colorPicker)
{
GameObject colorPreview = new("ColorPreview");
colorPreview.transform.SetParent(colorPicker.pointer.transform);
colorPreview.transform.localPosition = new Vector3(-30, 30, 0);
colorPreview.transform.localRotation = Quaternion.identity;
colorPreview.transform.localScale = new Vector3(0.4f, 0.4f, 0.4f);
previewImage = colorPreview.AddComponent<Image>();
previewImage.sprite = CreateCircleSprite();
cg = colorPreview.AddComponent<CanvasGroup>();
cg.alpha = 0;
colorPicker.onColorChange.AddListener(OnColorPickerDrag);
}
private static Sprite CreateCircleSprite()
{
const int HALF_SIZE = 50;
const int RADIUS = 42;
Texture2D tex = new(HALF_SIZE * 2, HALF_SIZE * 2);
for (int y = -HALF_SIZE; y <= HALF_SIZE; y++)
{
for (int x = -HALF_SIZE; x <= HALF_SIZE; x++)
{
bool isInsideCircle = x * x + y * y <= RADIUS * RADIUS;
tex.SetPixel(HALF_SIZE + x, HALF_SIZE + y, isInsideCircle ? Color.white : Color.clear);
}
}
tex.Apply();
return Sprite.Create(tex, new Rect(0, 0, tex.width, tex.height), new Vector2(0.5f, 0.5f), 200);
}
private void OnColorPickerDrag(ColorChangeEventData data) => previewImage.color = data.color;
public void OnPointerDown(PointerEventData _)
{
StopAllCoroutines();
StartCoroutine(cg.ShiftAlpha(1, 0.25f, 1.5f, true));
}
public void OnPointerUp(PointerEventData _)
{
StopAllCoroutines();
StartCoroutine(cg.ShiftAlpha(0, 0.25f, 1.5f, false));
}
}

View File

@@ -0,0 +1,251 @@
using System;
using System.Collections;
using System.Linq;
using FMODUnity;
using NitroxClient.MonoBehaviours.Gui.MainMenu.ServersList;
using NitroxClient.Unity.Helper;
using NitroxModel.DataStructures.Util;
using TMPro;
using UnityEngine;
using UnityEngine.EventSystems;
using UnityEngine.UI;
namespace NitroxClient.MonoBehaviours.Gui.MainMenu.ServerJoin;
public class MainMenuEnterPasswordPanel : MonoBehaviour, uGUI_INavigableIconGrid, uGUI_IButtonReceiver
{
public const string NAME = "MultiplayerEnterPassword";
public static MainMenuEnterPasswordPanel Instance { get; private set; }
private TMP_InputField passwordInput;
private mGUI_Change_Legend_On_Select legendChange;
private GameObject selectedItem;
private GameObject[] selectableItems;
private static string lastEnteredPassword;
public static Optional<string> LastEnteredPassword => lastEnteredPassword != null ? Optional.Of(lastEnteredPassword) : Optional.Empty;
public static void ResetLastEnteredPassword() => lastEnteredPassword = null;
public void Setup(GameObject savedGamesRef)
{
Instance = this;
GameObject multiplayerButtonRef = savedGamesRef.RequireGameObject("Scroll View/Viewport/SavedGameAreaContent/NewGame");
GameObject generalTextRef = multiplayerButtonRef.GetComponentInChildren<TextMeshProUGUI>().gameObject;
GameObject inputFieldRef = GameObject.Find("/Menu canvas/Panel/MainMenu/RightSide/Home/EmailBox/InputField");
GameObject passwordInputGameObject = Instantiate(inputFieldRef, transform, false);
passwordInputGameObject.transform.localPosition = new Vector3(-160, 300, 0);
passwordInputGameObject.GetComponent<RectTransform>().sizeDelta = new Vector2(300, 40);
passwordInput = passwordInputGameObject.GetComponent<TMP_InputField>();
passwordInput.characterValidation = TMP_InputField.CharacterValidation.None;
passwordInput.onSubmit = new TMP_InputField.SubmitEvent();
passwordInput.onSubmit.AddListener(_ => OnConfirmButtonClicked());
passwordInput.placeholder.GetComponent<TranslationLiveUpdate>().translationKey = Language.main.Get("Nitrox_JoinServerPlaceholder");
GameObject passwordInputDesc = Instantiate(generalTextRef, passwordInputGameObject.transform, false);
passwordInputDesc.transform.localPosition = new Vector3(-200, 0, 0);
passwordInputDesc.GetComponent<TextMeshProUGUI>().text = Language.main.Get("Nitrox_JoinServerPassword");
GameObject confirmButton = Instantiate(multiplayerButtonRef, transform, false);
confirmButton.transform.localPosition = new Vector3(-200, 90, 0);
confirmButton.transform.GetChild(0).GetComponent<RectTransform>().sizeDelta = new Vector2(200, 40);
confirmButton.GetComponentInChildren<TextMeshProUGUI>().text = Language.main.Get("Nitrox_Confirm");
Button confirmButtonButton = confirmButton.RequireTransform("NewGameButton").GetComponent<Button>();
confirmButtonButton.onClick = new Button.ButtonClickedEvent();
confirmButtonButton.onClick.AddListener(OnConfirmButtonClicked);
GameObject backButton = Instantiate(multiplayerButtonRef, transform, false);
backButton.transform.localPosition = new Vector3(-200, 40, 0);
backButton.transform.GetChild(0).GetComponent<RectTransform>().sizeDelta = new Vector2(200, 40);
backButton.GetComponentInChildren<TextMeshProUGUI>().text = Language.main.Get("Nitrox_Cancel");
Button backButtonButton = backButton.RequireTransform("NewGameButton").GetComponent<Button>();
backButtonButton.onClick = new Button.ButtonClickedEvent();
backButtonButton.onClick.AddListener(OnCancelClick);
selectableItems = [passwordInputGameObject, confirmButton, backButton];
Destroy(transform.Find("Scroll View").gameObject);
legendChange = gameObject.AddComponent<mGUI_Change_Legend_On_Select>();
legendChange.legendButtonConfiguration = confirmButtonButton.GetComponent<mGUI_Change_Legend_On_Select>().legendButtonConfiguration.Take(1).ToArray();
}
public void FocusPasswordField()
{
StartCoroutine(Coroutine());
IEnumerator Coroutine()
{
passwordInput.Select();
EventSystem.current.SetSelectedGameObject(passwordInput.gameObject);
yield return null;
passwordInput.MoveToEndOfLine(false, true);
}
}
private void OnConfirmButtonClicked()
{
lastEnteredPassword = passwordInput.text;
MainMenuRightSide.main.OpenGroup(MainMenuJoinServerPanel.NAME);
MainMenuJoinServerPanel.Instance.FocusNameInputField();
}
private static void OnCancelClick()
{
JoinServerBackend.StopMultiplayerClient();
MainMenuRightSide.main.OpenGroup(MainMenuServerListPanel.NAME);
}
public bool OnButtonDown(GameInput.Button button)
{
switch (button)
{
case GameInput.Button.UISubmit:
OnConfirm();
return true;
case GameInput.Button.UICancel:
OnBack();
return true;
default:
return false;
}
}
public void OnConfirm()
{
if (selectedItem.TryGetComponentInChildren(out TMP_InputField inputField))
{
inputField.ActivateInputField();
}
if (selectedItem.TryGetComponentInChildren(out Button button))
{
button.onClick.Invoke();
}
}
public void OnBack()
{
passwordInput.text = string.Empty;
ResetLastEnteredPassword();
DeselectAllItems();
MainMenuRightSide.main.OpenGroup(MainMenuServerListPanel.NAME);
}
bool uGUI_INavigableIconGrid.ShowSelector => false;
bool uGUI_INavigableIconGrid.EmulateRaycast => false;
bool uGUI_INavigableIconGrid.SelectItemClosestToPosition(Vector3 worldPos) => false;
uGUI_INavigableIconGrid uGUI_INavigableIconGrid.GetNavigableGridInDirection(int dirX, int dirY) => null;
Graphic uGUI_INavigableIconGrid.GetSelectedIcon() => null;
object uGUI_INavigableIconGrid.GetSelectedItem() => selectedItem;
public void SelectItem(object item)
{
DeselectItem();
selectedItem = item as GameObject;
legendChange.SyncLegendBarToGUISelection();
if (!selectedItem)
{
return;
}
if (selectedItem.TryGetComponent(out TMP_InputField selectedInputField))
{
selectedInputField.Select();
}
else // Buttons
{
selectedItem.GetComponentInChildren<uGUI_BasicColorSwap>().makeTextBlack();
selectedItem.transform.GetChild(0).GetComponent<Image>().sprite = MainMenuServerListPanel.SelectedSprite;
}
if (!EventSystem.current.alreadySelecting)
{
EventSystem.current.SetSelectedGameObject(selectedItem);
}
RuntimeManager.PlayOneShot(MainMenuServerListPanel.HoverSound.path);
}
public void DeselectItem()
{
if (!selectedItem)
{
return;
}
if (selectedItem.TryGetComponent(out TMP_InputField selectedInputField))
{
selectedInputField.DeactivateInputField();
selectedInputField.ReleaseSelection();
}
else // Buttons
{
selectedItem.GetComponentInChildren<uGUI_BasicColorSwap>().makeTextWhite();
selectedItem.transform.GetChild(0).GetComponent<Image>().sprite = MainMenuServerListPanel.NormalSprite;
}
if (!EventSystem.current.alreadySelecting)
{
EventSystem.current.SetSelectedGameObject(null);
}
selectedItem = null;
}
public void DeselectAllItems()
{
foreach (GameObject child in selectableItems)
{
selectedItem = child;
DeselectItem();
}
}
public bool SelectFirstItem()
{
SelectItem(selectableItems[0]);
return true;
}
public bool SelectItemInDirection(int dirX, int dirY)
{
if (!selectedItem)
{
return SelectFirstItem();
}
if (dirX == dirY)
{
return false;
}
int dir = dirX + dirY > 0 ? 1 : -1;
for (int newIndex = GetSelectedIndex() + dir; newIndex >= 0 && newIndex < selectableItems.Length; newIndex += dir)
{
if (SelectItemByIndex(newIndex))
{
return true;
}
}
return false;
}
private int GetSelectedIndex() => selectedItem ? Array.IndexOf(selectableItems, selectedItem) : -1;
private bool SelectItemByIndex(int selectedIndex)
{
if (selectedIndex >= 0 && selectedIndex < selectableItems.Length)
{
SelectItem(selectableItems[selectedIndex]);
return true;
}
return false;
}
}

View File

@@ -0,0 +1,363 @@
using System.Collections;
using System.Linq;
using System.Text.RegularExpressions;
using FMODUnity;
using NitroxClient.MonoBehaviours.Gui.MainMenu.ServersList;
using NitroxClient.Unity.Helper;
using TMPro;
using UnityEngine;
using UnityEngine.EventSystems;
using UnityEngine.ResourceManagement.AsyncOperations;
using UnityEngine.UI;
using UWE;
namespace NitroxClient.MonoBehaviours.Gui.MainMenu.ServerJoin;
public class MainMenuJoinServerPanel : MonoBehaviour, uGUI_INavigableIconGrid, uGUI_IButtonReceiver, uGUI_IScrollReceiver, uGUI_IAdjustReceiver
{
public const string NAME = "MultiplayerJoinServer";
public static MainMenuJoinServerPanel Instance { get; private set; }
private GameObject playerSettingsPanel;
private TextMeshProUGUI header;
private uGUI_ColorPicker colorPicker;
private MainMenuColorPickerPreview colorPickerPreview;
private Slider saturationSlider;
private uGUI_InputField playerNameInputField;
private GameObject selectedItem;
private GameObject[] selectableItems;
public void Setup(GameObject savedGamesRef)
{
Instance = this;
Destroy(transform.RequireGameObject("Scroll View"));
Destroy(GetComponentInChildren<TranslationLiveUpdate>());
header = GetComponentInChildren<TextMeshProUGUI>();
CoroutineHost.StartCoroutine(AsyncSetup(savedGamesRef)); // As JoinServer waits for AsyncSetup to be completed we can't use normal Unity IEnumerator Start()
}
private IEnumerator AsyncSetup(GameObject savedGamesRef)
{
AsyncOperationHandle<GameObject> request = AddressablesUtility.LoadAsync<GameObject>("Assets/Prefabs/Base/GeneratorPieces/BaseMoonpoolUpgradeConsole.prefab");
yield return request;
GameObject colorPickerPanelPrototype = request.Result.RequireGameObject("EditScreen/Active");
RectTransform parent = GetComponent<RectTransform>();
GameObject newGameButtonRef = savedGamesRef.RequireGameObject("Scroll View/Viewport/SavedGameAreaContent/NewGame/NewGameButton");
LegendButtonData[] defaultLegend = newGameButtonRef.GetComponent<mGUI_Change_Legend_On_Select>().legendButtonConfiguration.Take(1).ToArray();
//Create a clone of the RocketBase color picker panel.
playerSettingsPanel = Instantiate(colorPickerPanelPrototype, parent);
//Prepares name input field
GameObject inputField = playerSettingsPanel.RequireGameObject("InputField");
inputField.transform.SetParent(parent);
inputField.transform.localPosition = new Vector3(-200, 310, 0);
inputField.transform.localScale = Vector3.one;
inputField.AddComponent<mGUI_Change_Legend_On_Select>().legendButtonConfiguration = defaultLegend;
playerNameInputField = inputField.GetComponent<uGUI_InputField>();
((TextMeshProUGUI)playerNameInputField.placeholder).text = Language.main.Get("Nitrox_EnterName");
playerNameInputField.textComponent.fontSizeMin = 17;
playerNameInputField.textComponent.fontSizeMax = 21;
playerNameInputField.textComponent.GetComponent<RectTransform>().sizeDelta = new Vector2(-20, 42);
playerNameInputField.characterLimit = 25; // See this.OnJoinClick()
playerNameInputField.onFocusSelectAll = false;
playerNameInputField.onSubmit.AddListener(_ => OnJoinClick());
playerNameInputField.onSubmit.AddListener(_ => DeselectAllItems());
playerNameInputField.ActivateInputField();
//Prepares player color picker
GameObject colorPickerObject = playerSettingsPanel.RequireGameObject("ColorPicker");
colorPickerObject.transform.SetParent(parent);
colorPickerObject.transform.localPosition = new Vector3(-268, 175, 0);
colorPickerObject.transform.localScale = new Vector3(1.1f, 0.75f, 1);
colorPicker = colorPickerObject.GetComponentInChildren<uGUI_ColorPicker>();
colorPicker.pointer.localScale = new Vector3(1f, 1.46f, 1);
saturationSlider = colorPicker.saturationSlider;
saturationSlider.transform.localPosition = new Vector3(197, 0, 0);
colorPickerPreview = colorPicker.gameObject.AddComponent<MainMenuColorPickerPreview>();
colorPickerPreview.Init(colorPicker);
GameObject buttonLeft = Instantiate(newGameButtonRef, parent);
buttonLeft.GetComponent<RectTransform>().sizeDelta = new Vector2(160, 45);
buttonLeft.GetComponent<mGUI_Change_Legend_On_Select>().legendButtonConfiguration = defaultLegend;
GameObject buttonRight = Instantiate(buttonLeft, parent);
//Prepares cancel button
buttonLeft.transform.SetParent(parent);
buttonLeft.transform.localPosition = new Vector3(-285, 40, 0);
buttonLeft.GetComponentInChildren<TextMeshProUGUI>().text = Language.main.Get("Nitrox_Cancel");
Button cancelButton = buttonLeft.GetComponent<Button>();
cancelButton.onClick = new Button.ButtonClickedEvent();
cancelButton.onClick.AddListener(OnCancelClick);
cancelButton.onClick.AddListener(DeselectAllItems);
//Prepares join button
buttonRight.transform.localPosition = new Vector3(-115, 40, 0);
buttonRight.GetComponentInChildren<TextMeshProUGUI>().text = Language.main.Get("Nitrox_Join");
Button joinButton = buttonRight.GetComponent<Button>();
joinButton.onClick = new Button.ButtonClickedEvent();
joinButton.onClick.AddListener(OnJoinClick);
joinButton.onClick.AddListener(DeselectAllItems);
selectableItems = [inputField, colorPicker.gameObject, saturationSlider.gameObject, buttonLeft, buttonRight];
Destroy(playerSettingsPanel);
}
private void OnJoinClick()
{
string playerName = playerNameInputField.text;
//https://regex101.com/r/eTWiEs/2/
if (!Regex.IsMatch(playerName, "^[a-zA-Z0-9._-]{3,25}$"))
{
MainMenuNotificationPanel.ShowMessage(Language.main.Get("Nitrox_InvalidUserName"), NAME);
return;
}
JoinServerBackend.RequestSessionReservation(playerName, colorPicker.currentColor);
}
private static void OnCancelClick()
{
JoinServerBackend.StopMultiplayerClient();
MainMenuRightSide.main.OpenGroup(MainMenuServerListPanel.NAME);
}
public void UpdatePanelValues(string serverName) => header.text = $" {Language.main.Get("Nitrox_JoinServer")} {serverName}";
public void UpdatePlayerPanelValues(string playerName, Vector3 hsb)
{
playerNameInputField.text = playerName;
colorPicker.SetHSB(hsb);
}
public void FocusNameInputField()
{
StartCoroutine(Coroutine());
IEnumerator Coroutine()
{
SelectFirstItem();
yield return new WaitForEndOfFrame();
playerNameInputField.MoveToEndOfLine(false, true);
}
}
public bool OnButtonDown(GameInput.Button button)
{
if (button != GameInput.Button.UISubmit || !selectedItem)
{
return false;
}
if (selectedItem.TryGetComponentInChildren(out TMP_InputField inputField))
{
inputField.Select();
inputField.ActivateInputField();
}
else if (selectedItem.TryGetComponentInChildren(out Button buttonComponent))
{
buttonComponent.onClick.Invoke();
}
return true;
}
public bool OnScroll(float scrollDelta, float speedMultiplier)
{
if (EventSystem.current != null &&
EventSystem.current.currentSelectedGameObject == selectedItem &&
selectedItem.TryGetComponent(out Slider slider))
{
slider.value += scrollDelta * speedMultiplier * 0.01f;
return true;
}
return false;
}
public bool OnAdjust(Vector2 adjustDelta)
{
if (selectedItem && selectedItem.TryGetComponent(out uGUI_ColorPicker selectedColorPicker))
{
return selectedColorPicker.OnAdjust(adjustDelta);
}
return false;
}
object uGUI_INavigableIconGrid.GetSelectedItem() => selectedItem;
bool uGUI_INavigableIconGrid.ShowSelector => false;
bool uGUI_INavigableIconGrid.EmulateRaycast => false;
bool uGUI_INavigableIconGrid.SelectItemClosestToPosition(Vector3 worldPos) => false;
uGUI_INavigableIconGrid uGUI_INavigableIconGrid.GetNavigableGridInDirection(int dirX, int dirY) => null;
Graphic uGUI_INavigableIconGrid.GetSelectedIcon() => null;
public void SelectItem(object item)
{
DeselectItem();
selectedItem = item as GameObject;
if (!selectedItem)
{
return;
}
if (selectedItem.TryGetComponent(out mGUI_Change_Legend_On_Select changeLegend))
{
changeLegend.SyncLegendBarToGUISelection();
}
else
{
uGUI_LegendBar.ClearButtons();
}
if (selectedItem == selectableItems[1])
{
colorPicker.pointer.GetComponent<Image>().color = Color.cyan;
if (GameInput.GetPrimaryDevice() == GameInput.Device.Controller)
{
colorPickerPreview.OnPointerDown(null);
}
}
else if (selectedItem == selectableItems[3] || selectedItem == selectableItems[4])
{
selectedItem.GetComponentInChildren<uGUI_BasicColorSwap>().makeTextBlack();
}
if (selectedItem.TryGetComponentInChildren(out Selectable selectable))
{
selectable.Select();
}
if (!EventSystem.current.alreadySelecting)
{
EventSystem.current.SetSelectedGameObject(selectedItem);
}
RuntimeManager.PlayOneShot(MainMenuServerListPanel.HoverSound.path);
}
public void DeselectItem()
{
if (!selectedItem)
{
return;
}
if (selectedItem.TryGetComponent(out TMP_InputField selectedInputField))
{
//This line need to be before selectedInputField.ReleaseSelection() as it will call this method recursive leading to NRE
selectedInputField.DeactivateInputField();
selectedInputField.ReleaseSelection();
}
else if (selectedItem.TryGetComponent(out uGUI_ColorPicker selectedColorPicker))
{
Image colorPickerPointer = selectedColorPicker.pointer.GetComponent<Image>();
if (colorPickerPointer.color != Color.white &&
GameInput.GetPrimaryDevice() == GameInput.Device.Controller)
{
colorPickerPreview.OnPointerUp(null);
}
colorPickerPointer.color = Color.white;
}
else if (selectedItem.TryGetComponentInChildren(out uGUI_BasicColorSwap colorSwap))
{
colorSwap.makeTextWhite();
}
if (!EventSystem.current.alreadySelecting)
{
EventSystem.current.SetSelectedGameObject(null);
}
selectedItem = null;
}
public void DeselectAllItems()
{
foreach (GameObject item in selectableItems)
{
selectedItem = item;
DeselectItem();
}
}
public bool SelectFirstItem()
{
SelectItem(selectableItems[0]);
return true;
}
public bool SelectItemInDirection(int dirX, int dirY)
{
if (!selectedItem)
{
return SelectFirstItem();
}
if (selectedItem == selectableItems[0]) //Name input
{
switch (dirY)
{
case < 0:
SelectItem(selectableItems[^2]);
return true;
case > 0:
SelectItem(selectableItems[1]);
return true;
}
}
if (selectedItem == selectableItems[1] || selectedItem == selectableItems[2]) // ColorPicker and SaturationSlider
{
switch (dirY)
{
case < 0:
SelectItem(selectableItems[0]);
return true;
case > 0:
SelectItem(selectableItems[3]);
return true;
}
if (dirX != 0)
{
int direction = selectedItem == selectableItems[1] ? 0 : 1;
direction = (direction + dirX) % 2;
SelectItem(selectableItems[1 + direction]);
return true;
}
}
if (selectedItem == selectableItems[3] || selectedItem == selectableItems[4]) // CancelButton and ConfirmButton
{
switch (dirY)
{
case < 0:
SelectItem(selectableItems[1]);
return true;
case > 0:
SelectItem(selectableItems[0]);
return true;
}
if (dirX != 0)
{
int direction = selectedItem == selectableItems[3] ? 0 : 1;
direction = (direction + dirX) % 2;
SelectItem(selectableItems[3 + direction]);
return true;
}
}
return false;
}
}

View File

@@ -0,0 +1,292 @@
using System;
using System.Collections;
using System.Linq;
using FMODUnity;
using NitroxClient.Unity.Helper;
using NitroxModel.Serialization;
using TMPro;
using UnityEngine;
using UnityEngine.EventSystems;
using UnityEngine.UI;
namespace NitroxClient.MonoBehaviours.Gui.MainMenu.ServersList;
public class MainMenuCreateServerPanel : MonoBehaviour, uGUI_INavigableIconGrid, uGUI_IButtonReceiver
{
public const string NAME = "MultiplayerCreateServer";
private const string DEFAULT_PORT = "11000";
private TMP_InputField serverNameInput, serverAddressInput, serverPortInput;
private mGUI_Change_Legend_On_Select legendChange;
private GameObject selectedItem;
private GameObject[] selectableItems;
public void Setup(GameObject savedGamesRef)
{
GameObject multiplayerButtonRef = savedGamesRef.RequireGameObject("Scroll View/Viewport/SavedGameAreaContent/NewGame");
GameObject generalTextRef = multiplayerButtonRef.GetComponentInChildren<TextMeshProUGUI>().gameObject;
GameObject inputFieldRef = GameObject.Find("/Menu canvas/Panel/MainMenu/RightSide/Home/EmailBox/InputField");
GameObject inputFieldBlueprint = Instantiate(inputFieldRef, transform, false);
inputFieldBlueprint.GetComponent<RectTransform>().sizeDelta = new Vector2(300, 40);
TMP_InputField inputFieldBlueprintInput = inputFieldBlueprint.GetComponent<TMP_InputField>();
inputFieldBlueprintInput.characterValidation = TMP_InputField.CharacterValidation.None;
inputFieldBlueprintInput.onSubmit = new TMP_InputField.SubmitEvent();
inputFieldBlueprintInput.onSubmit.AddListener(_ => { SelectItemInDirection(0, 1); });
GameObject serverName = Instantiate(inputFieldBlueprint, transform, false);
serverName.transform.localPosition = new Vector3(-160, 300, 0);
serverNameInput = serverName.GetComponent<TMP_InputField>();
serverNameInput.placeholder.GetComponent<TranslationLiveUpdate>().translationKey = Language.main.Get("Nitrox_AddServer_NamePlaceholder");
GameObject serverNameDesc = Instantiate(generalTextRef, serverName.transform, false);
serverNameDesc.transform.localPosition = new Vector3(-200, 0, 0);
serverNameDesc.GetComponent<TextMeshProUGUI>().text = Language.main.Get("Nitrox_AddServer_NameDescription");
GameObject serverAddress = Instantiate(inputFieldBlueprint, transform, false);
serverAddress.transform.localPosition = new Vector3(-160, 225, 0);
serverAddressInput = serverAddress.GetComponent<TMP_InputField>();
serverAddressInput.placeholder.GetComponent<TranslationLiveUpdate>().translationKey = Language.main.Get("Nitrox_AddServer_AddressPlaceholder");
GameObject serverAddressDesc = Instantiate(generalTextRef, serverAddress.transform, false);
serverAddressDesc.transform.localPosition = new Vector3(-200, 0, 0);
serverAddressDesc.GetComponent<TextMeshProUGUI>().text = Language.main.Get("Nitrox_AddServer_AddressDescription");
GameObject serverPort = Instantiate(inputFieldBlueprint, transform, false);
serverPort.transform.localPosition = new Vector3(-160, 150, 0);
serverPortInput = serverPort.GetComponent<TMP_InputField>();
serverPortInput.characterValidation = TMP_InputField.CharacterValidation.Integer;
serverPortInput.placeholder.GetComponent<TranslationLiveUpdate>().translationKey = Language.main.Get("Nitrox_AddServer_PortPlaceholder");
serverPortInput.text = DEFAULT_PORT;
GameObject serverPortDesc = Instantiate(generalTextRef, serverPort.transform, false);
serverPortDesc.transform.localPosition = new Vector3(-200, 0, 0);
serverPortDesc.GetComponent<TextMeshProUGUI>().text = Language.main.Get("Nitrox_AddServer_PortDescription");
GameObject confirmButton = Instantiate(multiplayerButtonRef, transform, false);
confirmButton.transform.localPosition = new Vector3(-200, 90, 0);
confirmButton.transform.GetChild(0).GetComponent<RectTransform>().sizeDelta = new Vector2(200, 40);
confirmButton.GetComponentInChildren<TextMeshProUGUI>().text = Language.main.Get("Nitrox_AddServer_Confirm");
Button confirmButtonButton = confirmButton.RequireTransform("NewGameButton").GetComponent<Button>();
confirmButtonButton.onClick = new Button.ButtonClickedEvent();
confirmButtonButton.onClick.AddListener(SaveServer);
GameObject backButton = Instantiate(multiplayerButtonRef, transform, false);
backButton.transform.localPosition = new Vector3(-200, 40, 0);
backButton.transform.GetChild(0).GetComponent<RectTransform>().sizeDelta = new Vector2(200, 40);
backButton.GetComponentInChildren<TextMeshProUGUI>().text = Language.main.Get("Nitrox_Cancel");
Button backButtonButton = backButton.RequireTransform("NewGameButton").GetComponent<Button>();
backButtonButton.onClick = new Button.ButtonClickedEvent();
backButtonButton.onClick.AddListener(OnBack);
selectableItems = [serverName, serverAddress, serverPort, confirmButton, backButton];
Destroy(inputFieldBlueprint);
Destroy(transform.Find("Scroll View").gameObject);
legendChange = gameObject.AddComponent<mGUI_Change_Legend_On_Select>();
legendChange.legendButtonConfiguration = confirmButtonButton.GetComponent<mGUI_Change_Legend_On_Select>().legendButtonConfiguration.Take(2).ToArray();
}
private void SaveServer()
{
string serverNameText = serverNameInput.text.Trim();
string serverHostText = serverAddressInput.text.Trim();
string serverPortText = serverPortInput.text.Trim();
if (string.IsNullOrWhiteSpace(serverNameText) ||
string.IsNullOrWhiteSpace(serverHostText) ||
string.IsNullOrWhiteSpace(serverPortText) ||
!int.TryParse(serverPortText, out int serverPort))
{
Log.InGame(Language.main.Get("Nitrox_AddServer_InvalidInput"));
return;
}
GameObject newEntry = MainMenuServerListPanel.Main.CreateServerButton(serverNameText, serverHostText, serverPort);
ServerList.Instance.Add(new ServerList.Entry(serverNameText, serverHostText, serverPort));
ServerList.Instance.Save();
OnBack();
MainMenuServerListPanel.Main.StartCoroutine(DelayedScrollToNewEntry());
Log.InGame(Language.main.Get("Nitrox_AddServer_CreatedSuccessful"));
return;
IEnumerator DelayedScrollToNewEntry()
{
yield return new WaitForEndOfFrame();
UIUtils.ScrollToShowItemInCenter(newEntry.transform);
}
}
public bool OnButtonDown(GameInput.Button button)
{
switch (button)
{
case GameInput.Button.UISubmit:
OnConfirm();
return true;
case GameInput.Button.UICancel:
OnBack();
return true;
default:
return false;
}
}
private void Update()
{
if (GameInput.GetKeyDown(KeyCode.Tab))
{
if (GameInput.GetKey(KeyCode.LeftShift))
{
SelectItemInDirection(-1, 0);
}
else
{
SelectItemInDirection(1, 0);
}
}
else if (selectedItem && GameInput.GetKeyDown(KeyCode.Return))
{
OnConfirm();
}
}
public void OnBack()
{
serverNameInput.text = string.Empty;
serverAddressInput.text = string.Empty;
serverPortInput.text = string.Empty;
DeselectAllItems();
MainMenuRightSide.main.OpenGroup(MainMenuServerListPanel.NAME);
}
public void OnConfirm()
{
if (selectedItem.TryGetComponentInChildren(out TMP_InputField inputField))
{
inputField.ActivateInputField();
}
if (selectedItem.TryGetComponentInChildren(out Button button))
{
button.onClick.Invoke();
}
}
object uGUI_INavigableIconGrid.GetSelectedItem() => selectedItem;
bool uGUI_INavigableIconGrid.ShowSelector => false;
bool uGUI_INavigableIconGrid.EmulateRaycast => false;
bool uGUI_INavigableIconGrid.SelectItemClosestToPosition(Vector3 worldPos) => false;
uGUI_INavigableIconGrid uGUI_INavigableIconGrid.GetNavigableGridInDirection(int dirX, int dirY) => null;
Graphic uGUI_INavigableIconGrid.GetSelectedIcon() => null;
public void SelectItem(object item)
{
DeselectItem();
selectedItem = item as GameObject;
legendChange.SyncLegendBarToGUISelection();
if (!selectedItem)
{
return;
}
if (selectedItem.TryGetComponent(out TMP_InputField selectedInputField))
{
selectedInputField.Select();
}
else // Button
{
selectedItem.transform.GetChild(0).GetComponent<Image>().sprite = MainMenuServerListPanel.SelectedSprite;
selectedItem.GetComponentInChildren<uGUI_BasicColorSwap>().makeTextBlack();
}
if (!EventSystem.current.alreadySelecting)
{
EventSystem.current.SetSelectedGameObject(selectedItem);
}
RuntimeManager.PlayOneShot(MainMenuServerListPanel.HoverSound.path);
}
public void DeselectItem()
{
if (!selectedItem)
{
return;
}
if (selectedItem.TryGetComponent(out TMP_InputField selectedInputField))
{
selectedInputField.DeactivateInputField();
selectedInputField.ReleaseSelection();
}
else // Button
{
selectedItem.transform.GetChild(0).GetComponent<Image>().sprite = MainMenuServerListPanel.NormalSprite;
selectedItem.transform.GetChild(0).GetComponent<uGUI_BasicColorSwap>().makeTextWhite();
}
if (!EventSystem.current.alreadySelecting)
{
EventSystem.current.SetSelectedGameObject(null);
}
selectedItem = null;
}
public void DeselectAllItems()
{
foreach (GameObject child in selectableItems)
{
selectedItem = child;
DeselectItem();
}
}
public bool SelectFirstItem()
{
SelectItem(selectableItems[0]);
return true;
}
public bool SelectItemInDirection(int dirX, int dirY)
{
if (!selectedItem)
{
return SelectFirstItem();
}
if (dirX == dirY)
{
return false;
}
int dir = dirX + dirY > 0 ? 1 : -1;
for (int newIndex = GetSelectedIndex() + dir; newIndex >= 0 && newIndex < selectableItems.Length; newIndex += dir)
{
if (SelectItemByIndex(newIndex))
{
return true;
}
}
return false;
}
private int GetSelectedIndex() => selectedItem ? Array.IndexOf(selectableItems, selectedItem) : -1;
private bool SelectItemByIndex(int selectedIndex)
{
if (selectedIndex >= 0 && selectedIndex < selectableItems.Length)
{
SelectItem(selectableItems[selectedIndex]);
return true;
}
return false;
}
}

View File

@@ -0,0 +1,21 @@
namespace NitroxClient.MonoBehaviours.Gui.MainMenu.ServersList;
public class MainMenuDeleteServer : uGUI_NavigableControlGrid, uGUI_IButtonReceiver
{
public MainMenuServerButton serverButton;
private void Start() => interGridNavigation = new uGUI_InterGridNavigation();
public bool OnButtonDown(GameInput.Button button)
{
if (button != GameInput.Button.UICancel)
{
return false;
}
OnBack();
return true;
}
public void OnBack() => serverButton.CancelDelete();
}

View File

@@ -0,0 +1,220 @@
using System;
using System.Net;
using System.Net.Sockets;
using System.Text;
using System.Threading.Tasks;
using NitroxClient.GameLogic.Settings;
using NitroxClient.MonoBehaviours.Gui.MainMenu.ServerJoin;
using NitroxClient.Unity.Helper;
using NitroxModel.Serialization;
using TMPro;
using UnityEngine;
using UnityEngine.UI;
namespace NitroxClient.MonoBehaviours.Gui.MainMenu.ServersList;
public class MainMenuServerButton : MonoBehaviour
{
private static MainMenuLoadButton loadButtonRef;
private static LegendButtonData[] confirmButtonLegendData;
private static GameObject deleteButtonRef;
private CanvasGroup loadCg;
private CanvasGroup deleteCg;
private Button cancelDeleteButton;
private string joinIp;
private int joinPort;
private string joinServerName;
public static void Setup(MainMenuLoadButton _loadButtonRef)
{
loadButtonRef = _loadButtonRef;
confirmButtonLegendData = _loadButtonRef.GetComponent<mGUI_Change_Legend_On_Select>().legendButtonConfiguration;
deleteButtonRef = _loadButtonRef.deleteButton;
}
public void Init(string serverName, string ip, int port, bool isReadOnly)
{
joinIp = ip;
joinPort = port;
joinServerName = serverName;
Transform loadTransform = this.RequireTransform("Load");
loadCg = loadTransform.gameObject.AddComponent<CanvasGroup>();
Transform newGameButtonTransform = loadTransform.RequireTransform("NewGameButton");
TextMeshProUGUI tmp = newGameButtonTransform.RequireTransform("Text").GetComponent<TextMeshProUGUI>();
Destroy(tmp.GetComponent<TranslationLiveUpdate>());
StringBuilder buttonText = new(Language.main.Get("Nitrox_ConnectTo"));
buttonText.Append(" <b>").Append(serverName).AppendLine("</b>");
if (NitroxPrefs.HideIp.Value)
{
buttonText.AppendLine("***.***.***.***:*****");
}
else
{
buttonText.Append(ip[^Math.Min(ip.Length, 25)..]).Append(':').Append(port);
}
tmp.text = buttonText.ToString();
Button multiplayerJoinButton = newGameButtonTransform.GetComponent<Button>();
multiplayerJoinButton.onClick = new Button.ButtonClickedEvent();
multiplayerJoinButton.onClick.AddListener(() => _ = OnJoinButtonClicked());
gameObject.GetComponent<mGUI_Change_Legend_On_Select>().legendButtonConfiguration = confirmButtonLegendData;
// We don't want servers that are discovered automatically to be deleted
if (isReadOnly)
{
Destroy(transform.Find("Delete").gameObject);
return;
}
GameObject delete = Instantiate(deleteButtonRef, loadTransform, false);
Button deleteButtonButton = delete.GetComponent<Button>();
deleteButtonButton.onClick = new Button.ButtonClickedEvent();
deleteButtonButton.onClick.AddListener(RequestDelete);
Transform deleteTransform = this.RequireTransform("Delete");
Destroy(deleteTransform.GetComponent<MainMenuDeleteGame>());
Destroy(deleteTransform.GetComponent<TranslationLiveUpdate>());
deleteCg = deleteTransform.GetComponent<CanvasGroup>();
cancelDeleteButton = deleteTransform.RequireTransform("DeleteCancelButton").GetComponent<Button>();
cancelDeleteButton.onClick = new Button.ButtonClickedEvent();
cancelDeleteButton.onClick.AddListener(CancelDelete);
Button confirmDeleteButton = deleteTransform.RequireTransform("DeleteConfirmButton").GetComponent<Button>();
confirmDeleteButton.onClick = new Button.ButtonClickedEvent();
confirmDeleteButton.onClick.AddListener(Delete);
deleteTransform.gameObject.AddComponent<MainMenuDeleteServer>().serverButton = this;
TextMeshProUGUI warningTmp = deleteTransform.RequireTransform("DeleteWarningText").GetComponent<TextMeshProUGUI>();
warningTmp.text = Language.main.Get("Nitrox_ServerEntry_DeleteWarning");
}
public void RequestDelete()
{
uGUI_MainMenu.main.OnRightSideOpened(deleteCg.gameObject);
uGUI_LegendBar.ClearButtons();
uGUI_LegendBar.ChangeButton(0, uGUI.FormatButton(GameInput.Button.UICancel, gamePadOnly: true), Language.main.GetFormat("Back"));
uGUI_LegendBar.ChangeButton(1, uGUI.FormatButton(GameInput.Button.UISubmit, gamePadOnly: true), Language.main.GetFormat("ItemSelectorSelect"));
StartCoroutine(loadButtonRef.ShiftAlpha(loadCg, 0.0f, loadButtonRef.animTime, loadButtonRef.alphaPower, false));
StartCoroutine(loadButtonRef.ShiftAlpha(deleteCg, 1f, loadButtonRef.animTime, loadButtonRef.alphaPower, true, cancelDeleteButton));
StartCoroutine(loadButtonRef.ShiftPos(loadCg, MainMenuLoadButton.target.left, MainMenuLoadButton.target.centre, loadButtonRef.animTime, loadButtonRef.posPower));
StartCoroutine(loadButtonRef.ShiftPos(deleteCg, MainMenuLoadButton.target.centre, MainMenuLoadButton.target.right, loadButtonRef.animTime, loadButtonRef.posPower));
}
public void CancelDelete()
{
MainMenuRightSide.main.OpenGroup(MainMenuServerListPanel.NAME);
if (GameInput.IsPrimaryDeviceGamepad())
MainMenuServerListPanel.Main.SelectItemByIndex(MainMenuServerListPanel.Main.GetSelectedIndex());
StartCoroutine(loadButtonRef.ShiftAlpha(loadCg, 1f, loadButtonRef.animTime, loadButtonRef.alphaPower, true));
StartCoroutine(loadButtonRef.ShiftAlpha(deleteCg, 0.0f, loadButtonRef.animTime, loadButtonRef.alphaPower, false));
StartCoroutine(loadButtonRef.ShiftPos(loadCg, MainMenuLoadButton.target.centre, MainMenuLoadButton.target.left, loadButtonRef.animTime, loadButtonRef.posPower));
StartCoroutine(loadButtonRef.ShiftPos(deleteCg, MainMenuLoadButton.target.right, MainMenuLoadButton.target.centre, loadButtonRef.animTime, loadButtonRef.posPower));
}
public void ResetLoadDeleteView()
{
loadCg.alpha = 1;
loadCg.interactable = loadCg.blocksRaycasts = true;
RectTransform loadTransform = loadCg.GetComponent<RectTransform>();
float loadPosX = loadTransform.sizeDelta.x * 0.5f;
loadTransform.localPosition = new Vector3(loadPosX, loadTransform.localPosition.y, 0);
if (deleteCg) // Read only server entries
{
RectTransform deleteTransform = deleteCg.GetComponent<RectTransform>();
float deletePosX = deleteTransform.sizeDelta.x * 0.5f;
deleteTransform.localPosition = new Vector3(deletePosX, deleteTransform.localPosition.y, 0);
deleteCg.alpha = 0;
deleteCg.interactable = deleteCg.blocksRaycasts = false;
}
}
public void Delete()
{
MainMenuRightSide.main.OpenGroup(MainMenuServerListPanel.NAME);
int scrollIndex = MainMenuServerListPanel.Main.GetSelectedIndex();
if (GameInput.IsPrimaryDeviceGamepad() && !MainMenuServerListPanel.Main.SelectItemInYDirection(scrollIndex, 1))
{
MainMenuServerListPanel.Main.SelectItemInYDirection(scrollIndex, -1);
}
StartCoroutine(loadButtonRef.ShiftPos(deleteCg, MainMenuLoadButton.target.left, MainMenuLoadButton.target.centre, loadButtonRef.animTime, loadButtonRef.posPower));
StartCoroutine(loadButtonRef.ShiftAlpha(deleteCg, 0.0f, loadButtonRef.animTime, loadButtonRef.alphaPower, false));
ServerList.Instance.RemoveAt(transform.GetSiblingIndex() - 1);
ServerList.Instance.Save();
Destroy(gameObject);
}
public async Task OnJoinButtonClicked()
{
if (MainMenuServerListPanel.Main.IsJoining)
{
return; // Do not attempt to join multiple servers.
}
MainMenuServerListPanel.Main.IsJoining = true;
MainMenuServerListPanel.Main.DeselectAllItems();
await OpenJoinServerMenuAsync(joinIp, joinPort).ContinueWith(_ => { MainMenuServerListPanel.Main.IsJoining = false; });
MainMenuJoinServerPanel.Instance.UpdatePanelValues(joinServerName);
}
public static async Task OpenJoinServerMenuAsync(string serverIp, int serverPort)
{
if (!MainMenuServerListPanel.Main)
{
Log.Error("MainMenuServerListPanel is not instantiated although OpenJoinServerMenuAsync is called.");
return;
}
IPEndPoint endpoint = ResolveIPEndPoint(serverIp, serverPort);
if (endpoint == null)
{
Log.InGame($"{Language.main.Get("Nitrox_UnableToConnect")}: {serverIp}:{serverPort}");
return;
}
MainMenuNotificationPanel.ShowLoading();
await JoinServerBackend.StartMultiplayerClientAsync(endpoint.Address, endpoint.Port);
}
private static IPEndPoint ResolveIPEndPoint(string serverIp, int serverPort)
{
UriHostNameType hostType = Uri.CheckHostName(serverIp);
IPAddress address;
switch (hostType)
{
case UriHostNameType.IPv4:
case UriHostNameType.IPv6:
IPAddress.TryParse(serverIp, out address);
break;
case UriHostNameType.Dns:
address = ResolveHostName(serverIp, serverPort);
break;
default:
return null;
}
return address != null ? new IPEndPoint(address, serverPort) : null;
static IPAddress ResolveHostName(string hostname, int serverPort)
{
try
{
IPHostEntry hostEntry = Dns.GetHostEntry(hostname);
return hostEntry.AddressList[0];
}
catch (SocketException ex)
{
Log.ErrorSensitive(ex, "Unable to resolve the address {hostname}:{serverPort}", hostname, serverPort);
return null;
}
}
}
}

View File

@@ -0,0 +1,355 @@
using System.Collections;
using System.Collections.Generic;
using System.Linq;
using System.Net;
using System.Text;
using System.Threading.Tasks;
using FMODUnity;
using NitroxClient.Communication;
using NitroxClient.GameLogic.Settings;
using NitroxClient.Unity.Helper;
using NitroxModel;
using NitroxModel.Serialization;
using TMPro;
using UnityEngine;
using UnityEngine.EventSystems;
using UnityEngine.UI;
using UWE;
namespace NitroxClient.MonoBehaviours.Gui.MainMenu.ServersList;
public class MainMenuServerListPanel : MonoBehaviour, uGUI_INavigableIconGrid, uGUI_IButtonReceiver
{
public const string NAME = "MultiplayerServerList";
public static MainMenuServerListPanel Main;
public static Sprite NormalSprite;
public static Sprite SelectedSprite;
public static FMODAsset HoverSound;
private GameObject multiplayerNewServerButtonRef;
private GameObject multiplayerServerButtonRef;
private Transform serverAreaContent;
private GameObject selectedServerItem;
private ScrollRect scrollRect;
private GameObject scrollBar;
public bool IsJoining { get; set; }
public void Setup(GameObject savedGamesRef)
{
Main = this;
MainMenuLoadMenu loadMenu = savedGamesRef.GetComponentInChildren<MainMenuLoadMenu>();
NormalSprite = loadMenu.normalSprite;
SelectedSprite = loadMenu.selectedSprite;
HoverSound = loadMenu.hoverSound;
multiplayerNewServerButtonRef = savedGamesRef.RequireGameObject("Scroll View/Viewport/SavedGameAreaContent/NewGame");
serverAreaContent = transform.RequireTransform("Scroll View/Viewport/SavedGameAreaContent");
serverAreaContent.gameObject.name = "ServerAreaContent";
serverAreaContent.GetComponent<GridLayoutGroup>().spacing = new Vector2(0, 5);
scrollRect = transform.RequireGameObject("Scroll View").GetComponent<ScrollRect>();
scrollBar = scrollRect.RequireGameObject("Scrollbar");
multiplayerServerButtonRef = savedGamesRef.GetComponent<MainMenuLoadPanel>().saveInstance;
MainMenuServerButton.Setup(multiplayerServerButtonRef.GetComponent<MainMenuLoadButton>());
RefreshServerEntries();
}
public bool OnButtonDown(GameInput.Button button)
{
switch (button)
{
case GameInput.Button.UISubmit:
OnConfirm();
return true;
case GameInput.Button.UICancel:
OnBack();
return true;
case GameInput.Button.UIClear:
OnClear();
return true;
default:
return false;
}
}
public void OnBack()
{
DeselectAllItems();
MainMenuRightSide.main.OpenGroup("Home");
}
public void OnClear()
{
if (selectedServerItem && selectedServerItem.TryGetComponent(out MainMenuServerButton serverButton))
{
serverButton.RequestDelete();
}
}
public void OnConfirm()
{
if (!selectedServerItem)
{
return;
}
if (selectedServerItem.gameObject.name == "NewServer")
{
DeselectAllItems();
MainMenuRightSide.main.OpenGroup(MainMenuCreateServerPanel.NAME);
}
else if (selectedServerItem.TryGetComponent(out MainMenuServerButton serverButton))
{
_ = serverButton.OnJoinButtonClicked().ContinueWithHandleError(Log.Error);
}
}
object uGUI_INavigableIconGrid.GetSelectedItem() => selectedServerItem;
bool uGUI_INavigableIconGrid.ShowSelector => false;
bool uGUI_INavigableIconGrid.EmulateRaycast => false;
bool uGUI_INavigableIconGrid.SelectItemClosestToPosition(Vector3 worldPos) => false;
uGUI_INavigableIconGrid uGUI_INavigableIconGrid.GetNavigableGridInDirection(int dirX, int dirY) => null;
Graphic uGUI_INavigableIconGrid.GetSelectedIcon() => null;
public void SelectItem(object item)
{
DeselectItem();
selectedServerItem = item as GameObject;
if (!selectedServerItem)
{
return;
}
if (selectedServerItem.TryGetComponentInChildren(out mGUI_Change_Legend_On_Select componentInChildren))
{
componentInChildren.SyncLegendBarToGUISelection();
}
if (selectedServerItem == serverAreaContent.GetChild(0).gameObject) // Server Create Button
{
selectedServerItem.transform.Find("NewGameButton").GetComponent<Image>().sprite = SelectedSprite;
}
else
{
selectedServerItem.transform.Find("Load/NewGameButton").GetComponent<Image>().sprite = SelectedSprite;
}
selectedServerItem.GetComponentInChildren<uGUI_BasicColorSwap>();
UIUtils.ScrollToShowItemInCenter(selectedServerItem.transform);
RuntimeManager.PlayOneShot(HoverSound.path);
}
public void DeselectItem()
{
if (!selectedServerItem)
{
return;
}
if (selectedServerItem == serverAreaContent.GetChild(0).gameObject) // Server Create Button
{
selectedServerItem.transform.Find("NewGameButton").GetComponent<Image>().sprite = NormalSprite;
}
else
{
selectedServerItem.transform.Find("Load/NewGameButton").GetComponent<Image>().sprite = NormalSprite;
}
selectedServerItem.GetComponentInChildren<uGUI_BasicColorSwap>().makeTextWhite();
selectedServerItem = null;
}
public void DeselectAllItems()
{
// Create ServerEntry button
serverAreaContent.GetChild(0).Find("NewGameButton").GetComponent<Image>().sprite = NormalSprite;
serverAreaContent.GetChild(0).GetComponentInChildren<uGUI_BasicColorSwap>().makeTextWhite();
// Server buttons
for (int i = 1; i < serverAreaContent.childCount; i++)
{
Transform child = serverAreaContent.GetChild(i);
child.Find("Load/NewGameButton").GetComponent<Image>().sprite = NormalSprite;
child.GetComponentInChildren<uGUI_BasicColorSwap>().makeTextWhite();
child.GetComponent<MainMenuServerButton>().ResetLoadDeleteView();
}
}
public bool SelectFirstItem()
{
MainMenuServerButton firstServerObject = serverAreaContent.GetComponentInChildren<MainMenuServerButton>();
if (firstServerObject)
{
SelectItem(firstServerObject.gameObject);
return true;
}
Transform serverCreationButton = serverAreaContent.GetChild(0);
if (serverCreationButton && serverCreationButton.name == "NewServer")
{
SelectItem(serverCreationButton.gameObject);
return true;
}
return false;
}
public bool SelectItemInDirection(int dirX, int dirY)
{
if (selectedServerItem)
{
return dirY != 0 && SelectItemInYDirection(GetSelectedIndex(), dirY);
}
return SelectFirstItem();
}
public int GetSelectedIndex() => selectedServerItem ? selectedServerItem.transform.GetSiblingIndex() : -1;
public bool SelectItemInYDirection(int selectedIndex, int dirY)
{
int dir = dirY > 0 ? 1 : -1;
for (int newIndex = selectedIndex + dir; newIndex >= 0 && newIndex < serverAreaContent.childCount; newIndex += dir)
{
if (SelectItemByIndex(newIndex))
{
return true;
}
}
return false;
}
public bool SelectItemByIndex(int selectedIndex)
{
if (selectedIndex < serverAreaContent.childCount && selectedIndex >= 0)
{
SelectItem(serverAreaContent.GetChild(selectedIndex).gameObject);
return true;
}
return false;
}
private void LoadSavedServers()
{
ServerList.Refresh();
foreach (ServerList.Entry entry in ServerList.Instance.Entries)
{
CreateServerButton(entry.Name, entry.Address, entry.Port);
}
}
private IEnumerator FindLANServers()
{
void LateAddButton(IPEndPoint serverEndPoint)
{
if (!ServerList.Instance.Entries.Any(e => e.Address == serverEndPoint.Address.ToString() && e.Port == serverEndPoint.Port))
{
Log.Info($"Adding LAN server: {serverEndPoint}");
// Add ServerList entry to keep indices in sync with servers UI, to enable removal by index
ServerList.Instance.Add(new ServerList.Entry("LAN Server", serverEndPoint.Address, serverEndPoint.Port, false));
CreateServerButton("LAN Server", serverEndPoint.Address.ToString(), serverEndPoint.Port, true);
}
}
using Task<IEnumerable<IPEndPoint>> searchTask = LANBroadcastClient.SearchAsync();
while (!searchTask.IsCompleted)
{
while (LANBroadcastClient.DiscoveredServers.TryDequeue(out IPEndPoint endPoint))
{
LateAddButton(endPoint);
}
yield return null;
}
while (LANBroadcastClient.DiscoveredServers.TryDequeue(out IPEndPoint endPoint))
{
LateAddButton(endPoint);
}
ServerList.Instance.Save();
}
public GameObject CreateServerButton(string serverName, string address, int port, bool isReadOnly = false)
{
GameObject multiplayerButtonInst = Instantiate(multiplayerServerButtonRef, serverAreaContent, false);
multiplayerButtonInst.name = $"NitroxServer_{serverAreaContent.childCount - 2}";
DestroyImmediate(multiplayerButtonInst.RequireGameObject("Load")); // Needs to be deleted before MainMenuServerButton.Init() below
Destroy(multiplayerButtonInst.GetComponent<MainMenuLoadButton>());
GameObject multiplayerLoadButtonInst = Instantiate(multiplayerNewServerButtonRef, multiplayerButtonInst.transform, false);
multiplayerLoadButtonInst.name = "Load";
MainMenuServerButton serverButton = multiplayerButtonInst.AddComponent<MainMenuServerButton>();
serverButton.Init(serverName, address, port, isReadOnly);
scrollBar.SetActive(serverAreaContent.childCount >= 4);
foreach (EventTrigger eventTrigger in multiplayerButtonInst.GetComponentsInChildren<EventTrigger>(true))
{
ForwardTriggerScrollToScrollRect(eventTrigger);
}
return multiplayerButtonInst;
}
private void CreateAddServerButton()
{
GameObject multiplayerButtonInst = Instantiate(multiplayerNewServerButtonRef, serverAreaContent, false);
multiplayerButtonInst.name = "NewServer"; // "NewServer" is important, see OnConfirm()
TextMeshProUGUI txt = multiplayerButtonInst.RequireTransform("NewGameButton/Text").GetComponent<TextMeshProUGUI>();
txt.text = "Nitrox_AddServer";
txt.fontSize *= 1.5f;
txt.fontStyle = FontStyles.Bold;
Button multiplayerButtonButton = multiplayerButtonInst.RequireTransform("NewGameButton").GetComponent<Button>();
multiplayerButtonButton.onClick = new Button.ButtonClickedEvent();
multiplayerButtonButton.onClick.AddListener(OpenAddServerGroup);
ForwardTriggerScrollToScrollRect(multiplayerButtonButton.GetComponent<EventTrigger>());
}
private void ForwardTriggerScrollToScrollRect(EventTrigger eventTrigger)
{
eventTrigger.triggers.RemoveAll(trigger => trigger.eventID == EventTriggerType.Scroll);
EventTrigger.TriggerEvent callback = new();
callback.AddListener(x => scrollRect.Scroll(((PointerEventData)x).scrollDelta.y, 5f));
eventTrigger.triggers.Add(new EventTrigger.Entry
{
eventID = EventTriggerType.Scroll,
callback = callback
});
}
public void OpenAddServerGroup()
{
DeselectAllItems();
MainMenuRightSide.main.OpenGroup(MainMenuCreateServerPanel.NAME);
}
public void RefreshServerEntries()
{
if (!serverAreaContent)
{
return;
}
foreach (Transform child in serverAreaContent)
{
Destroy(child.gameObject);
}
CreateAddServerButton();
LoadSavedServers();
CoroutineHost.StartCoroutine(FindLANServers());
}
}

View File

@@ -0,0 +1,36 @@
using System;
namespace NitroxClient.MonoBehaviours.Gui.Modals;
public class ConfirmModal : Modal
{
private Action yesCallback;
public ConfirmModal() : base(yesButtonText: "Confirm", hideNoButton: false, noButtonText: "Cancel", isAvoidable: true, transparency: 0.93f)
{ }
public void Show(string actionText, Action yesCallback)
{
ModalText = actionText;
this.yesCallback = yesCallback;
Show();
}
public override void ClickYes()
{
yesCallback?.Invoke();
Hide();
OnDeselect();
}
public override void ClickNo()
{
Hide();
OnDeselect();
}
public override void OnDeselect()
{
yesCallback = null;
}
}

View File

@@ -0,0 +1,27 @@
using System.Collections;
namespace NitroxClient.MonoBehaviours.Gui.Modals;
public class InfoModal : Modal
{
public InfoModal() : base(yesButtonText: "Ok", isAvoidable: false, transparency: 0.93f, height: 400f)
{ }
public void Show(string actionText)
{
ModalText = actionText;
Show();
}
public override void ClickYes()
{
Hide();
OnDeselect();
}
public IEnumerator ShowAsync(string actionText)
{
ModalText = actionText;
yield return ShowAsync();
}
}

View File

@@ -0,0 +1,20 @@
namespace NitroxClient.MonoBehaviours.Gui.Modals;
public class KickedModal : Modal
{
// When disconnected from the server, we don't want to keep playing
public KickedModal() : base(yesButtonText: "OK", freezeGame: true, transparency: 1.0f)
{
}
public void Show(string reason)
{
ModalText = reason;
Show();
}
public override void ClickYes()
{
IngameMenu.main.QuitGame(false);
}
}

View File

@@ -0,0 +1,16 @@
namespace NitroxClient.MonoBehaviours.Gui.Modals;
/// <summary>
/// Extends the IngameMenu with a disconnect popup.
/// </summary>
public class LostConnectionModal : Modal
{
public LostConnectionModal() : base(yesButtonText: "OK", modalText: Language.main.Get("Nitrox_LostConnection"), freezeGame: true, transparency: 1.0f)
{
}
public override void ClickYes()
{
IngameMenu.main.QuitGame(false);
}
}

View File

@@ -0,0 +1,218 @@
using System;
using System.Collections;
using System.Collections.Generic;
using TMPro;
using UnityEngine;
using UnityEngine.UI;
using UWE;
namespace NitroxClient.MonoBehaviours.Gui.Modals;
/// <summary>
/// Base class for Modal components, which are dialog boxes that appear in the middle of the screen
/// </summary>
public abstract class Modal
{
/// <summary>
/// Get a Modal by its type at any time (static)
/// </summary>
public static Dictionary<Type, Modal> Modals = new();
/// <summary>
/// Current modal that is visible on the screen
/// </summary>
public static Modal CurrentModal;
private GameObject modalSubWindow;
private TextMeshProUGUI text;
// All the properties that will be overriden by new instances that inherit this class
public string SubWindowName { get; init; }
public string ModalText { get; set; }
/// <summary>
/// Makes it possible to dismiss the modal by clicking outside of the modal or pressing escape (default false).
/// </summary>
public bool IsAvoidable { get; init; }
public bool HideNoButton { get; init; }
public string YesButtonText { get; init; }
public string NoButtonText { get; init; }
public bool FreezeGame { get; init; }
public float Transparency { get; init; }
public float Height { get; init; }
// Is useful for calling IngameMenu::OnDeselect() from a modal class (in Hide() for example)
public bool IsAvoidableBypass = false;
public Modal(string yesButtonText = "YES", bool hideNoButton = true, string noButtonText = "NO", string modalText = "", bool isAvoidable = false, bool freezeGame = false, float transparency = 0.392f, float height = 195f)
{
Type type = GetType();
if (Modals.ContainsKey(type))
{
throw new NotSupportedException($"You cannot set two modals to have the same Type");
}
SubWindowName = GetType().Name;
YesButtonText = yesButtonText;
HideNoButton = hideNoButton;
NoButtonText = noButtonText;
ModalText = modalText;
IsAvoidable = isAvoidable;
FreezeGame = freezeGame;
Transparency = transparency; // 0.392 is the default transparency for Subnautica's modal
Height = height;
Log.Debug($"Registered Modal {SubWindowName} of type {type}");
Modals.Add(type, this);
}
/// <summary>
/// Adds the Modal to the screen
/// </summary>
public void Show()
{
CoroutineHost.StartCoroutine(ShowAsync());
}
public IEnumerator ShowAsync()
{
CurrentModal?.Hide();
CurrentModal = this;
yield return ShowImplementation();
}
/// <summary>
/// Removes the Modal from the screen
/// </summary>
public void Hide()
{
CurrentModal = null;
if (FreezeGame)
{
FreezeTime.End(FreezeTime.Id.Quit);
}
if (IsAvoidable)
{
IngameMenu.main.OnDeselect();
}
else
{
IsAvoidableBypass = true;
IngameMenu.main.OnDeselect();
IsAvoidableBypass = false;
}
}
/// <summary>
/// Called when this modal is deselected (only when pressing outside of the modal)
/// </summary>
public virtual void OnDeselect() { }
/// <summary>
/// This creates the modal when showing it for the first time, you can't modify it afterwards
/// </summary>
private void InitSubWindow()
{
if (!IngameMenu.main)
{
throw new NotSupportedException($"Cannot show ingame subwindow {SubWindowName} because the ingame window does not exist.");
}
if (!modalSubWindow)
{
GameObject derivedSubWindow = IngameMenu.main.transform.Find("QuitConfirmation").gameObject;
modalSubWindow = UnityEngine.Object.Instantiate(derivedSubWindow, IngameMenu.main.transform, false);
modalSubWindow.name = SubWindowName;
// Styling.
RectTransform main = modalSubWindow.GetComponent<RectTransform>();
main.sizeDelta = new Vector2(700, Height);
RectTransform messageTransform = modalSubWindow.FindChild("Header").GetComponent<RectTransform>();
messageTransform.sizeDelta = new Vector2(700, Height);
messageTransform.anchoredPosition = new Vector2(0, 50 - Height / 2);
}
modalSubWindow.GetComponent<Image>().color = Color.white.WithAlpha(Transparency);
// Will happen either it's initialized or not
UpdateModal();
}
/// <summary>
/// Update the modal with informations that may change from one Show() to another
/// </summary>
private void UpdateModal()
{
text = modalSubWindow.FindChild("Header").GetComponent<TextMeshProUGUI>();
text.text = ModalText;
GameObject buttonYesObject = modalSubWindow.FindChild("ButtonYes");
GameObject buttonNoObject = modalSubWindow.FindChild("ButtonNo");
Button yesButton = buttonYesObject.GetComponent<Button>();
// We need to reinitialize onClick to avoid keeping Persisted Events (which are set manually inside Unity's Editor)
yesButton.onClick = new Button.ButtonClickedEvent();
yesButton.onClick.AddListener(ClickYes);
buttonYesObject.GetComponentInChildren<TextMeshProUGUI>().text = YesButtonText;
RectTransform yesButtonTransform = buttonYesObject.GetComponent<RectTransform>();
yesButtonTransform.anchoredPosition = new Vector2(yesButtonTransform.anchoredPosition.x, 50f - Height);
// TODO: fix yes and no button positions
if (HideNoButton)
{
UnityEngine.Object.Destroy(buttonNoObject);
buttonYesObject.transform.position = new Vector3(modalSubWindow.transform.position.x / 2, buttonYesObject.transform.position.y, buttonYesObject.transform.position.z); // Center Button
return;
}
if (buttonNoObject)
{
Button noButton = buttonNoObject.GetComponent<Button>();
noButton.onClick = new Button.ButtonClickedEvent();
noButton.onClick.AddListener(ClickNo);
buttonNoObject.GetComponentInChildren<TextMeshProUGUI>().text = NoButtonText;
RectTransform noButtonTransform = buttonNoObject.GetComponent<RectTransform>();
noButtonTransform.anchoredPosition = new Vector2(noButtonTransform.anchoredPosition.x, 50f - Height);
}
}
public virtual void ClickYes() { }
public virtual void ClickNo() { }
private IEnumerator ShowImplementation()
{
// Execute frame-by-frame to allow UI scripts to initialize.
InitSubWindow();
yield return new WaitForEndOfFrame();
// Equivalent of IngameMenu.main.Open() but without minding for the freeze
IngameMenu.main.gameObject.SetActive(true);
IngameMenu.main.Select();
yield return new WaitForEndOfFrame();
IngameMenu.main.ChangeSubscreen(SubWindowName);
yield return new WaitForEndOfFrame();
if (FreezeGame)
{
FreezeTime.Begin(FreezeTime.Id.Quit);
}
}
/// <summary>
/// Lets you get any existing Modal by its Type
/// </summary>
/// <typeparam name="T">The type of the modal to get</typeparam>
/// <returns>An existing instance of the modal if it already exists, else, a new one</returns>
public static T Get<T>() where T : Modal
{
if (Modals.TryGetValue(typeof(T), out Modal modal))
{
return (T)modal;
}
// No need to add entry in dictionary as it's done in constructor
return (T)Activator.CreateInstance(typeof(T));
}
}

View File

@@ -0,0 +1,13 @@
namespace NitroxClient.MonoBehaviours.Gui.Modals;
public class ServerStoppedModal : Modal
{
public ServerStoppedModal() : base(yesButtonText: "OK", modalText: Language.main.Get("Nitrox_ServerStopped"), freezeGame: true, transparency: 1.0f)
{
}
public override void ClickYes()
{
IngameMenu.main.QuitGame(false);
}
}

View File

@@ -0,0 +1,178 @@
using System.Collections;
using NitroxClient.GameLogic;
using NitroxModel.Core;
using UnityEngine;
namespace NitroxClient.MonoBehaviours;
public class IntroCinematicUpdater : MonoBehaviour
{
public static RemotePlayer Partner;
private static Transform modelRoot;
private static readonly Transform playerTransform = Player.main.transform;
private static readonly Quaternion rotateOtherSide = Quaternion.Euler(0, 180, 0);
private Transform introEndTarget;
private Vector3 introRemoteEndPosition;
private Transform[] seatPartsLeft;
private Transform[] seatPartsRight;
private Transform seatArmRestLeft;
private Transform seatArmRestRight;
private SkinnedMeshRenderer[] seatBarRendererRight = [];
private SkinnedMeshRenderer remotePlayerHeadRenderer;
private GameObject remotePlayerCustomHead;
private SkinnedMeshRenderer[] remoteRenders = [];
public void Awake()
{
if (Partner == null)
{
Log.Error($"[{nameof(IntroCinematicUpdater)}.Awake()]: Partner was null. Disabling MonoBehaviour.");
enabled = false;
return;
}
modelRoot = EscapePod.main.transform.Find("models/Life_Pod_damaged_03/root");
Transform seatLeft = modelRoot.Find("life_pod_seat_01_left_damaged_jnt1");
Transform seatRight = modelRoot.Find("life_pod_seat_01_right_damaged_jnt1");
seatPartsLeft = new[]
{
seatLeft.Find("life_pod_seat_01_left_damaged_jnt2"),
seatLeft.Find("life_pod_seat_01_left_damaged_jnt3")
};
seatPartsRight = new[]
{
seatRight.Find("life_pod_seat_01_right_damaged_jnt2"),
seatRight.Find("life_pod_seat_01_right_damaged_jnt3")
};
seatArmRestLeft = modelRoot.Find("life_pod_seat_01_left_damaged_jnt4/life_pod_seat_01_left_damaged_jnt5");
seatArmRestRight = modelRoot.Find("life_pod_seat_01_right_damaged_jnt4/life_pod_seat_01_right_damaged_jnt5");
seatBarRendererRight = modelRoot.parent.Find("lifepod_damaged_03_geo/life_pod_seat_01_R").GetComponentsInChildren<SkinnedMeshRenderer>();
remoteRenders = Partner.PlayerModel.GetComponentsInChildren<SkinnedMeshRenderer>();
remotePlayerHeadRenderer = Partner.PlayerModel.transform.Find("male_geo/diveSuit/diveSuit_head_geo").GetComponent<SkinnedMeshRenderer>();
remotePlayerCustomHead = Instantiate(new GameObject("diveSuit_custom_head_geo"), Partner.PlayerModel.transform.Find("export_skeleton/head_rig"));
remotePlayerCustomHead.transform.localPosition = new Vector3(1.0941f, -0.1163f, -1.2107f);
remotePlayerCustomHead.transform.localRotation = new Quaternion(-0.05750692f, -0.5272675f, -0.6740523f, -0.5141357f);
MeshFilter mesh = remotePlayerCustomHead.AddComponent<MeshFilter>();
MeshRenderer meshRenderer = remotePlayerCustomHead.AddComponent<MeshRenderer>();
mesh.mesh = remotePlayerHeadRenderer.sharedMesh;
meshRenderer.materials = remotePlayerHeadRenderer.materials;
remotePlayerCustomHead.SetActive(false);
bool isLeftPlayer = NitroxServiceLocator.Cache<LocalPlayer>.Value.PlayerId < Partner.PlayerId;
introEndTarget = EscapePod.main.transform.Find("EscapePodCinematics/intro_end");
Vector3 introEndDiff = isLeftPlayer ? new Vector3(0, 0, 0.3f) : new Vector3(0, 0, -0.3f);
introRemoteEndPosition = introEndTarget.position - introEndDiff;
introEndTarget.position += introEndDiff;
}
private Vector3 remotePlayerDestinationPos;
private Quaternion remotePlayerDestinationRot;
private bool shouldDisplaceLocalPlayer;
private float localPlayerDisplacementTime;
private bool shouldDisplaceRemotePlayer;
private Vector3 remotePlayerDisplacement = Vector3.zero;
private IEnumerator Start()
{
remoteRenders.ForEach(smr => smr.enabled = false);
seatBarRendererRight.ForEach(smr => smr.updateWhenOffscreen = true);
yield return new WaitForSecondsRealtime(11f); //Local player in seat
remoteRenders.ForEach(smr => smr.enabled = true);
yield return new WaitForSecondsRealtime(33.45f - 11f); //Remote Player deadly hit => disable original head
remotePlayerHeadRenderer.enabled = false;
yield return new WaitForSecondsRealtime(33.58f - 33.45f); //Remote Player visible => enable custom head
remotePlayerCustomHead.SetActive(true);
yield return new WaitForSecondsRealtime(40f - 33.58f); //Seatbelt appears => enable position sync & switch to original head
remotePlayerCustomHead.SetActive(false);
remotePlayerHeadRenderer.enabled = true;
Destroy(remotePlayerCustomHead);
yield return new WaitForSecondsRealtime(50.5f - 40f); //Slowly move remote player backwards
shouldDisplaceRemotePlayer = true;
yield return new WaitForSecondsRealtime(50.72f - 50.5f); //Disable and move remote player
shouldDisplaceRemotePlayer = false;
remotePlayerDisplacement = Vector3.zero;
remoteRenders.ForEach(smr => smr.enabled = false);
yield return new WaitForSecondsRealtime(53f - 50.72f); //Slowly move local player to modified cin-end
shouldDisplaceLocalPlayer = true;
}
private void LateUpdate()
{
Vector3 modelRootPos = modelRoot.position;
Quaternion modelRootRot = modelRoot.rotation;
// Coping the global rotation, inverting the y and adding 180° to it
Quaternion InverseRotateAroundEscapePod(Quaternion from)
{
Quaternion offset = Quaternion.Inverse(modelRootRot) * from;
return modelRootRot * rotateOtherSide * new Quaternion(-offset.x, offset.y, offset.z, -offset.w);
}
// Mirror animate the remote like the local player
Vector3 posDiff = playerTransform.position - modelRootPos;
remotePlayerDestinationPos = modelRootPos + new Vector3(posDiff.x, posDiff.y, -posDiff.z) + remotePlayerDisplacement;
remotePlayerDestinationRot = InverseRotateAroundEscapePod(playerTransform.rotation);
transform.SetPositionAndRotation(remotePlayerDestinationPos, remotePlayerDestinationRot);
// Mirror animate the seat parts and stop it before the animation ends so the SN animator resets our manipulations
if (!shouldDisplaceLocalPlayer)
{
for (int i = 0; i < seatPartsLeft.Length; i++)
{
seatPartsRight[i].localRotation = seatPartsLeft[i].localRotation;
}
seatArmRestRight.localPosition = -seatArmRestLeft.localPosition;
}
if (shouldDisplaceRemotePlayer) remotePlayerDisplacement += new Vector3(0, 0, 0.05f);
if (shouldDisplaceLocalPlayer)
{
Vector3 introEndTargetPos = introEndTarget.position;
playerTransform.position = Vector3.Lerp(playerTransform.position, introEndTargetPos, localPlayerDisplacementTime);
MainCameraControl.main.transform.position = Vector3.Lerp(MainCameraControl.main.transform.position, introEndTargetPos, localPlayerDisplacementTime);
localPlayerDisplacementTime += 0.01f;
}
}
private void OnDestroy()
{
foreach (SkinnedMeshRenderer renderer in remoteRenders)
{
if (renderer)
{
renderer.enabled = true;
}
}
foreach (SkinnedMeshRenderer renderer in seatBarRendererRight)
{
if (renderer)
{
renderer.updateWhenOffscreen = false;
}
}
transform.position = introRemoteEndPosition;
}
}

View File

@@ -0,0 +1,178 @@
using System.Collections;
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
using NitroxClient.GameLogic;
using NitroxClient.Unity.Helper;
using NitroxModel.DataStructures;
using NitroxModel.DataStructures.GameLogic.Entities;
using NitroxModel.DataStructures.GameLogic.Entities.Bases;
using NitroxModel.DataStructures.Util;
using NitroxModel_Subnautica.DataStructures;
using UnityEngine;
namespace NitroxClient.MonoBehaviours;
/// <summary>
/// Saves the moonpools NitroxEntity of a <see cref="Base"/> to assign them back after each <see cref="Base.RebuildGeometry"/>.
/// </summary>
/// <remarks>
/// To recognize pieces even after the base is rebuilt, we use the base anchor (<see cref="Base.anchor"/>) to get an absolute cell value.
/// </remarks>
public class MoonpoolManager : MonoBehaviour
{
private Entities entities;
private Base @base;
private NitroxId baseId;
private Dictionary<Int3, MoonpoolEntity> moonpoolsByCell;
public MoonpoolEntity LatestRegisteredMoonpool { get; private set; }
public void Awake()
{
entities = this.Resolve<Entities>();
if (!TryGetComponent(out @base))
{
Log.Error($"Tried adding a {nameof(MoonpoolManager)} to a GameObject that isn't a bases, deleting it.");
Destroy(this);
return;
}
@base.TryGetNitroxId(out baseId);
moonpoolsByCell = new();
@base.onPostRebuildGeometry += OnPostRebuildGeometry;
}
public void OnDestroy()
{
@base.onPostRebuildGeometry -= OnPostRebuildGeometry;
}
public void LateAssignNitroxEntity(NitroxId baseId)
{
this.baseId = baseId;
NitroxId nextId = baseId.Increment(); // To be recognizable, we need it to be deterministic
foreach (MoonpoolEntity moonpoolEntity in moonpoolsByCell.Values)
{
moonpoolEntity.ParentId = baseId;
moonpoolEntity.Id = nextId;
nextId = nextId.Increment();
}
}
public void OnPostRebuildGeometry(Base _)
{
foreach (KeyValuePair<Int3, MoonpoolEntity> moonpoolEntry in moonpoolsByCell)
{
AssignNitroxEntityToMoonpool(moonpoolEntry.Key, moonpoolEntry.Value.Id);
}
}
private void AssignNitroxEntityToMoonpool(Int3 absoluteCell, NitroxId moonpoolId, TaskResult<Optional<GameObject>> result = null)
{
Int3 relativeCell = Relative(absoluteCell);
Transform baseCellTransform = @base.GetCellObject(relativeCell);
if (!baseCellTransform)
{
Log.Warn($"[{nameof(AssignNitroxEntityToMoonpool)}] CellObject not found for RelativeCell: {relativeCell}, AbsoluteCell: {absoluteCell}");
return;
}
if (baseCellTransform.TryGetComponentInChildren(out VehicleDockingBay vehicleDockingBay, true))
{
result?.Set(vehicleDockingBay.gameObject);
NitroxEntity.SetNewId(vehicleDockingBay.gameObject, moonpoolId);
}
}
public Optional<GameObject> RegisterMoonpool(Transform constructableTransform, NitroxId moonpoolId)
{
return RegisterMoonpool(Absolute(constructableTransform.position), moonpoolId);
}
public Optional<GameObject> RegisterMoonpool(Int3 absoluteCell, NitroxId moonpoolId)
{
moonpoolsByCell[absoluteCell] = new(moonpoolId, baseId, absoluteCell.ToDto());
TaskResult<Optional<GameObject>> resultObject = new();
AssignNitroxEntityToMoonpool(absoluteCell, moonpoolId, resultObject);
LatestRegisteredMoonpool = moonpoolsByCell[absoluteCell];
return resultObject.Get();
}
public NitroxId DeregisterMoonpool(Transform constructableTransform)
{
Int3 absoluteCell = Absolute(constructableTransform.position);
if (moonpoolsByCell.TryGetValue(absoluteCell, out MoonpoolEntity moonpoolEntity))
{
moonpoolsByCell.Remove(absoluteCell);
return moonpoolEntity.Id;
}
return null;
}
public void LoadMoonpools(IEnumerable<MoonpoolEntity> moonpoolEntities)
{
moonpoolsByCell.Clear();
foreach (MoonpoolEntity moonpoolEntity in moonpoolEntities)
{
moonpoolsByCell[moonpoolEntity.Cell.ToUnity()] = moonpoolEntity;
}
}
public IEnumerator SpawnVehicles()
{
foreach (MoonpoolEntity moonpoolEntity in moonpoolsByCell.Values)
{
VehicleWorldEntity moonpoolVehicleEntity = moonpoolEntity.ChildEntities.OfType<VehicleWorldEntity>().FirstOrFallback(null);
if (moonpoolVehicleEntity != null)
{
yield return entities.SpawnEntityAsync(moonpoolVehicleEntity);
}
}
}
private Int3 Absolute(Vector3 position)
{
return Absolute(@base.WorldToGrid(position));
}
private Int3 Absolute(Int3 baseCell)
{
return baseCell - @base.GetAnchor();
}
private Int3 Relative(Int3 baseCell)
{
return baseCell + @base.GetAnchor();
}
public List<MoonpoolEntity> GetSavedMoonpools()
{
return moonpoolsByCell.Values.ToList();
}
public Dictionary<NitroxId, NitroxInt3> GetMoonpoolsUpdate()
{
return moonpoolsByCell.ToDictionary(entry => entry.Value.Id, entry => entry.Key.ToDto());
}
[Conditional("DEBUG")]
public void PrintDebug()
{
Log.Debug($"MoonpoolManager's registered moonpools (anchor: {@base.GetAnchor()}):");
foreach (MoonpoolEntity moonpoolEntity in moonpoolsByCell.Values)
{
Int3 absoluteCell = moonpoolEntity.Cell.ToUnity();
Int3 baseCell = Relative(moonpoolEntity.Cell.ToUnity());
Log.Debug($"AbsoluteCell: {absoluteCell}, BaseCell: {baseCell}, id: {moonpoolEntity.Id}");
}
}
public static IEnumerator RestoreMoonpools(IEnumerable<MoonpoolEntity> moonpoolEntities, Base @base)
{
MoonpoolManager moonpoolManager = @base.gameObject.EnsureComponent<MoonpoolManager>();
moonpoolManager.LoadMoonpools(moonpoolEntities);
moonpoolManager.OnPostRebuildGeometry(@base);
yield return moonpoolManager.SpawnVehicles();
}
}

View File

@@ -0,0 +1,108 @@
using System.Collections.Generic;
using NitroxClient.Communication.Abstract;
using NitroxClient.GameLogic;
using NitroxClient.MonoBehaviours.Vehicles;
using NitroxModel.DataStructures;
using NitroxModel.Packets;
using UnityEngine;
namespace NitroxClient.MonoBehaviours;
public class MovementBroadcaster : MonoBehaviour
{
public const int BROADCAST_FREQUENCY = 30;
public const float BROADCAST_PERIOD = 1f / BROADCAST_FREQUENCY;
public static MovementBroadcaster Instance;
public Dictionary<NitroxId, MovementReplicator> Replicators = [];
private readonly Dictionary<NitroxId, WatchedEntry> watchedEntries = [];
private float latestBroadcastTime;
public void Start()
{
if (Instance)
{
Log.Error($"There's already a {nameof(MovementBroadcaster)} Instance alive, destroying the new one.");
Destroy(this);
return;
}
Instance = this;
}
public void OnDestroy()
{
Instance = null;
}
public void Update()
{
float currentTime = (float)this.Resolve<TimeManager>().RealTimeElapsed;
if (currentTime < latestBroadcastTime + BROADCAST_PERIOD)
{
return;
}
latestBroadcastTime = currentTime;
BroadcastLocalData(currentTime);
}
public void BroadcastLocalData(float time)
{
List<MovementData> data = [];
List<NitroxId> watchedIds = [..watchedEntries.Keys];
for (int i = watchedIds.Count - 1; i >= 0; i--)
{
NitroxId entryId = watchedIds[i];
WatchedEntry entry = watchedEntries[entryId];
if (entry.ShouldBroadcastMovement())
{
data.Add(entry.GetMovementData(entryId));
entry.OnBroadcastPosition();
}
}
if (data.Count > 0)
{
this.Resolve<IPacketSender>().Send(new VehicleMovements(data, time));
}
}
public static void RegisterWatched(GameObject gameObject, NitroxId entityId)
{
if (!Instance)
{
return;
}
if (!Instance.watchedEntries.ContainsKey(entityId))
{
Instance.watchedEntries.Add(entityId, new(entityId, gameObject.transform));
}
}
public static void UnregisterWatched(NitroxId entityId)
{
if (Instance)
{
Instance.watchedEntries.Remove(entityId);
}
}
public static void RegisterReplicator(MovementReplicator movementReplicator)
{
if (Instance)
{
Instance.Replicators.Add(movementReplicator.objectId, movementReplicator);
}
}
public static void UnregisterReplicator(MovementReplicator movementReplicator)
{
if (Instance)
{
Instance.Replicators.Remove(movementReplicator.objectId);
}
}
}

View File

@@ -0,0 +1,212 @@
using System.Collections.Generic;
using NitroxClient.GameLogic;
using NitroxClient.GameLogic.Settings;
using NitroxClient.MonoBehaviours.Cyclops;
using NitroxClient.MonoBehaviours.Vehicles;
using NitroxModel.DataStructures;
using NitroxModel.Packets;
using NitroxModel_Subnautica.DataStructures;
using UnityEngine;
namespace NitroxClient.MonoBehaviours;
public abstract class MovementReplicator : MonoBehaviour
{
public const float INTERPOLATION_TIME = 4 * MovementBroadcaster.BROADCAST_PERIOD;
public const float SNAPSHOT_EXPIRATION_TIME = 5f * INTERPOLATION_TIME;
private readonly LinkedList<Snapshot> buffer = new();
/// <summary>
/// To ensure a smooth experience, we need a max allowed latency value which should top the incoming latencies at all times.
/// Big increments and any decrements of this value will likely cause stutter, so we try to avoid changing this value too much.
/// But it is required that after a lag spike, we eventually lower down that value, which is done periodically <see cref="NitroxPrefs.LatencyUpdatePeriod"/>.
/// </summary>
public float maxAllowedLatency;
private float latestLatencyBumpTime;
private float maxLatencyDetectedRecently;
/// <summary>
/// When encountering a latency bump, we must expect worse happening right after, so we add this margin to our new <see cref="maxAllowedLatency"/>.
/// After each periodical latency update (<see cref="LatencyUpdatePeriod"/>), we only want to lower the latency if it's way smaller than the current variable latency.
/// The safety threshold is defined by this value.
/// </summary>
private float SafetyLatencyMargin => NitroxPrefs.SafetyLatencyMargin.Value;
private float LatencyUpdatePeriod => NitroxPrefs.LatencyUpdatePeriod.Value;
private Rigidbody rigidbody;
public NitroxId objectId { get; private set; }
/// <summary>
/// Current time must be based on real time to avoid effects from time changes/speed.
/// </summary>
private float CurrentTime => (float)this.Resolve<TimeManager>().RealTimeElapsed;
public void AddSnapshot(MovementData movementData, float time)
{
float currentTime = CurrentTime;
float latency = currentTime - time;
if (latency > maxAllowedLatency)
{
maxAllowedLatency = latency + SafetyLatencyMargin;
latestLatencyBumpTime = currentTime;
maxLatencyDetectedRecently = 0;
}
else
{
maxLatencyDetectedRecently = Mathf.Max(latency, maxLatencyDetectedRecently);
if (currentTime - latestLatencyBumpTime >= LatencyUpdatePeriod)
{
if (maxLatencyDetectedRecently < maxAllowedLatency - 2 * SafetyLatencyMargin)
{
maxAllowedLatency = maxLatencyDetectedRecently + SafetyLatencyMargin; // regular gameplay latency variation
}
latestLatencyBumpTime = currentTime;
maxLatencyDetectedRecently = 0;
}
}
float occurrenceTime = time + INTERPOLATION_TIME + maxAllowedLatency;
// Cleaning any previous value change that would occur later than the newly received snapshot
while (buffer.Last != null && buffer.Last.Value.IsSnapshotNewer(occurrenceTime))
{
buffer.RemoveLast();
}
buffer.AddLast(new Snapshot(movementData, occurrenceTime));
}
public void ClearBuffer() => buffer.Clear();
public void Start()
{
if (!gameObject.TryGetNitroxId(out NitroxId _objectId))
{
Log.Error($"Can't start a {nameof(MovementReplicator)} on {name} because it doesn't have an attached: {nameof(NitroxEntity)}");
Destroy(this);
return;
}
objectId = _objectId;
rigidbody = GetComponent<Rigidbody>();
if (gameObject.TryGetComponent(out NitroxCyclops nitroxCyclops))
{
nitroxCyclops.SetReceiving();
}
else
{
if (gameObject.TryGetComponent(out WorldForces worldForces))
{
worldForces.enabled = false;
}
rigidbody.isKinematic = false;
}
MovementBroadcaster.RegisterReplicator(this);
}
public void OnDestroy()
{
if (gameObject.TryGetComponent(out NitroxCyclops nitroxCyclops))
{
nitroxCyclops.SetBroadcasting();
}
else
{
if (gameObject.TryGetComponent(out WorldForces worldForces))
{
worldForces.enabled = true;
}
}
MovementBroadcaster.UnregisterReplicator(this);
}
public void Update()
{
if (buffer.Count == 0)
{
return;
}
float currentTime = CurrentTime;
// Sorting out expired nodes
while (buffer.First != null && buffer.First.Value.IsExpired(currentTime))
{
buffer.RemoveFirst();
}
LinkedListNode<Snapshot> firstNode = buffer.First;
if (firstNode == null)
{
return;
}
// Current node is not useable yet
if (firstNode.Value.IsSnapshotNewer(currentTime))
{
return;
}
// Purging the next nodes if they should have already happened (we still have an expiration margin for the first node so it's fine)
while (firstNode.Next != null && !firstNode.Next.Value.IsSnapshotNewer(currentTime))
{
firstNode = firstNode.Next;
buffer.RemoveFirst();
}
LinkedListNode<Snapshot> nextNode = firstNode.Next;
// Current node is fine but there's no next node (waiting for it without dropping current)
if (nextNode == null)
{
return;
}
// Interpolation
MovementData prevData = firstNode.Value.Data;
MovementData nextData = nextNode.Value.Data;
float t = (currentTime - firstNode.Value.Time) / (nextNode.Value.Time - firstNode.Value.Time);
transform.position = Vector3.Lerp(prevData.Position.ToUnity(), nextData.Position.ToUnity(), t);
transform.rotation = Quaternion.Lerp(prevData.Rotation.ToUnity(), nextData.Rotation.ToUnity(), t);
ApplyNewMovementData(nextData);
// TODO: fix remote players being able to go through the object (ex: cyclops)
}
public abstract void ApplyNewMovementData(MovementData newMovementData);
public record struct Snapshot(MovementData Data, float Time)
{
public bool IsSnapshotNewer(float currentTime) => currentTime < Time;
public bool IsExpired(float currentTime) => currentTime > Time + SNAPSHOT_EXPIRATION_TIME;
}
public static MovementReplicator AddReplicatorToObject(GameObject gameObject)
{
if (gameObject.GetComponent<SeaMoth>())
{
return gameObject.AddComponent<SeamothMovementReplicator>();
}
if (gameObject.GetComponent<Exosuit>())
{
return gameObject.AddComponent<ExosuitMovementReplicator>();
}
if (gameObject.GetComponent<SubControl>())
{
return gameObject.AddComponent<CyclopsMovementReplicator>();
}
return gameObject.AddComponent<MovementReplicator>();
}
}

View File

@@ -0,0 +1,233 @@
using System;
using System.Collections;
using System.Collections.Generic;
using NitroxClient.Communication;
using NitroxClient.Communication.Abstract;
using NitroxClient.Communication.MultiplayerSession;
using NitroxClient.Communication.Packets.Processors.Abstract;
using NitroxClient.GameLogic;
using NitroxClient.GameLogic.Bases;
using NitroxClient.GameLogic.ChatUI;
using NitroxClient.GameLogic.PlayerLogic.PlayerModel.Abstract;
using NitroxClient.GameLogic.PlayerLogic.PlayerModel.ColorSwap;
using NitroxClient.MonoBehaviours.Cyclops;
using NitroxClient.MonoBehaviours.Discord;
using NitroxClient.MonoBehaviours.Gui.MainMenu;
using NitroxClient.MonoBehaviours.Gui.MainMenu.ServerJoin;
using NitroxModel.Core;
using NitroxModel.Packets;
using NitroxModel.Packets.Processors.Abstract;
using UnityEngine;
using UnityEngine.SceneManagement;
using UWE;
namespace NitroxClient.MonoBehaviours
{
public class Multiplayer : MonoBehaviour
{
public static Multiplayer Main;
private readonly Dictionary<Type, PacketProcessor> packetProcessorCache = new();
private IClient client;
private IMultiplayerSession multiplayerSession;
private PacketReceiver packetReceiver;
private ThrottledPacketSender throttledPacketSender;
private GameLogic.Terrain terrain;
public bool InitialSyncCompleted { get; set; }
/// <summary>
/// True if multiplayer is loaded and client is connected to a server.
/// </summary>
public static bool Active => Main && Main.multiplayerSession.Client.IsConnected;
/// <summary>
/// True if multiplayer is loaded and player has successfully joined a server.
/// </summary>
public static bool Joined => Main && Main.multiplayerSession.CurrentState.CurrentStage == MultiplayerSessionConnectionStage.SESSION_JOINED;
public void Awake()
{
NitroxServiceLocator.LifetimeScopeEnded += (_, _) => packetProcessorCache.Clear();
client = NitroxServiceLocator.LocateService<IClient>();
multiplayerSession = NitroxServiceLocator.LocateService<IMultiplayerSession>();
packetReceiver = NitroxServiceLocator.LocateService<PacketReceiver>();
throttledPacketSender = NitroxServiceLocator.LocateService<ThrottledPacketSender>();
terrain = NitroxServiceLocator.LocateService<GameLogic.Terrain>();
Main = this;
DontDestroyOnLoad(gameObject);
Log.Info("Multiplayer client loaded…");
Log.InGame(Language.main.Get("Nitrox_MultiplayerLoaded"));
}
public void Update()
{
client.PollEvents();
if (multiplayerSession.CurrentState.CurrentStage != MultiplayerSessionConnectionStage.DISCONNECTED)
{
ProcessPackets();
throttledPacketSender.Update();
// Loading up shouldn't be bothered by entities spawning in the surroundings
if (multiplayerSession.CurrentState.CurrentStage == MultiplayerSessionConnectionStage.SESSION_JOINED &&
InitialSyncCompleted)
{
terrain.UpdateVisibility();
}
}
}
public static event Action OnLoadingComplete;
public static event Action OnBeforeMultiplayerStart;
public static event Action OnAfterMultiplayerEnd;
public static void SubnauticaLoadingStarted()
{
OnBeforeMultiplayerStart?.Invoke();
}
public static void SubnauticaLoadingCompleted()
{
if (Active)
{
Main.InitialSyncCompleted = false;
Main.StartCoroutine(LoadAsync());
}
else
{
SetLoadingComplete();
OnLoadingComplete?.Invoke();
}
}
public static IEnumerator LoadAsync()
{
WaitScreen.ManualWaitItem worldSettleItem = WaitScreen.Add(Language.main.Get("Nitrox_WorldSettling"));
yield return new WaitUntil(() => LargeWorldStreamer.main != null &&
LargeWorldStreamer.main.land != null &&
LargeWorldStreamer.main.IsReady() &&
LargeWorldStreamer.main.IsWorldSettled());
WaitScreen.Remove(worldSettleItem);
WaitScreen.ManualWaitItem item = WaitScreen.Add(Language.main.Get("Nitrox_JoiningSession"));
yield return Main.StartCoroutine(Main.StartSession());
WaitScreen.Remove(item);
yield return new WaitUntil(() => Main.InitialSyncCompleted);
SetLoadingComplete();
OnLoadingComplete?.Invoke();
}
public void ProcessPackets()
{
static PacketProcessor ResolveProcessor(Packet packet, Dictionary<Type, PacketProcessor> processorCache)
{
Type packetType = packet.GetType();
if (processorCache.TryGetValue(packetType, out PacketProcessor processor))
{
return processor;
}
try
{
Type packetProcessorType = typeof(ClientPacketProcessor<>).MakeGenericType(packetType);
return processorCache[packetType] = (PacketProcessor)NitroxServiceLocator.LocateService(packetProcessorType);
}
catch (Exception ex)
{
Log.Error(ex, $"Failed to find packet processor for packet {packet}");
}
return null;
}
packetReceiver.ConsumePackets(static (packet, processorCache) =>
{
try
{
ResolveProcessor(packet, processorCache)?.ProcessPacket(packet, null);
}
catch (Exception ex)
{
Log.Error(ex, $"Error while processing packet {packet}");
}
}, packetProcessorCache);
}
public IEnumerator StartSession()
{
yield return StartCoroutine(InitializeLocalPlayerState());
multiplayerSession.JoinSession();
InitMonoBehaviours();
Utils.SetContinueMode(true);
SceneManager.sceneLoaded += SceneManager_sceneLoaded;
}
public void InitMonoBehaviours()
{
// Gameplay.
gameObject.AddComponent<AnimationSender>();
gameObject.AddComponent<PlayerMovementBroadcaster>();
gameObject.AddComponent<PlayerDeathBroadcaster>();
gameObject.AddComponent<PlayerStatsBroadcaster>();
gameObject.AddComponent<EntityPositionBroadcaster>();
gameObject.AddComponent<BuildingHandler>();
gameObject.AddComponent<MovementBroadcaster>();
VirtualCyclops.Initialize();
}
public void StopCurrentSession()
{
SceneManager.sceneLoaded -= SceneManager_sceneLoaded;
OnAfterMultiplayerEnd?.Invoke();
}
private static void SetLoadingComplete()
{
WaitScreen.main.isWaiting = false;
WaitScreen.main.stageProgress.Clear();
FreezeTime.End(FreezeTime.Id.WaitScreen);
WaitScreen.main.items.Clear();
PlayerManager remotePlayerManager = NitroxServiceLocator.LocateService<PlayerManager>();
LoadingScreenVersionText.DisableWarningText();
DiscordClient.InitializeRPInGame(Main.multiplayerSession.AuthenticationContext.Username, remotePlayerManager.GetTotalPlayerCount(), Main.multiplayerSession.SessionPolicy.MaxConnections);
CoroutineHost.StartCoroutine(NitroxServiceLocator.LocateService<PlayerChatManager>().LoadChatKeyHint());
}
private IEnumerator InitializeLocalPlayerState()
{
ILocalNitroxPlayer localPlayer = NitroxServiceLocator.LocateService<ILocalNitroxPlayer>();
IEnumerable<IColorSwapManager> colorSwapManagers = NitroxServiceLocator.LocateService<IEnumerable<IColorSwapManager>>();
// This is used to init the lazy GameObject in order to create a real default Body Prototype for other players
GameObject body = localPlayer.BodyPrototype;
Log.Info($"Init body prototype {body.name}");
ColorSwapAsyncOperation swapOperation = new ColorSwapAsyncOperation(localPlayer, colorSwapManagers).BeginColorSwap();
yield return new WaitUntil(() => swapOperation.IsColorSwapComplete());
swapOperation.ApplySwappedColors();
// UWE developers added noisy logging for non-whitelisted components during serialization.
// We add NitroxEntiy in here to avoid a large amount of log spam.
ProtobufSerializer.componentWhitelist.Add(nameof(NitroxEntity));
}
private void SceneManager_sceneLoaded(Scene scene, LoadSceneMode loadMode)
{
if (scene.name == "XMenu")
{
// If we just disconnected from a multiplayer session, then we need to kill the connection here.
// Maybe a better place for this, but here works in a pinch.
JoinServerBackend.StopMultiplayerClient();
SceneCleaner.Open();
}
}
}
}

View File

@@ -0,0 +1,83 @@
using System;
using NitroxClient.GameLogic;
using NitroxClient.Unity.Smoothing;
using UnityEngine;
namespace NitroxClient.MonoBehaviours
{
public abstract class MultiplayerVehicleControl : MonoBehaviour
{
private Rigidbody rigidbody;
protected readonly SmoothParameter SmoothYaw = new SmoothParameter();
protected readonly SmoothParameter SmoothPitch = new SmoothParameter();
protected readonly SmoothVector SmoothLeftArm = new SmoothVector();
protected readonly SmoothVector SmoothRightArm = new SmoothVector();
protected SmoothVector SmoothPosition;
protected SmoothVector SmoothVelocity;
protected SmoothRotation SmoothRotation;
protected SmoothVector SmoothAngularVelocity;
protected Action<float> WheelYawSetter;
protected Action<float> WheelPitchSetter;
protected virtual void Awake()
{
rigidbody = gameObject.GetComponent<Rigidbody>();
// For now, we assume the set position and rotation is equal to the server one.
// Default velocities are probably empty, but set them anyway.
SmoothPosition = new SmoothVector(gameObject.transform.position);
SmoothVelocity = new SmoothVector(rigidbody.velocity);
SmoothRotation = new SmoothRotation(gameObject.transform.rotation);
SmoothAngularVelocity = new SmoothVector(rigidbody.angularVelocity);
}
protected virtual void FixedUpdate()
{
SmoothYaw.FixedUpdate();
SmoothPitch.FixedUpdate();
SmoothPosition.FixedUpdate();
SmoothVelocity.FixedUpdate();
rigidbody.velocity = MovementHelper.GetCorrectedVelocity(SmoothPosition.Current, SmoothVelocity.Current, gameObject, Time.fixedDeltaTime);
SmoothRotation.FixedUpdate();
SmoothAngularVelocity.FixedUpdate();
rigidbody.angularVelocity = MovementHelper.GetCorrectedAngularVelocity(SmoothRotation.Current, SmoothAngularVelocity.Current, gameObject, Time.fixedDeltaTime);
WheelYawSetter(SmoothYaw.SmoothValue);
WheelPitchSetter(SmoothPitch.SmoothValue);
}
internal void SetPositionVelocityRotation(Vector3 remotePosition, Vector3 remoteVelocity, Quaternion remoteRotation, Vector3 remoteAngularVelocity)
{
gameObject.SetActive(true);
SmoothPosition.Target = remotePosition;
SmoothVelocity.Target = remoteVelocity;
SmoothRotation.Target = remoteRotation;
SmoothAngularVelocity.Target = remoteAngularVelocity;
}
internal virtual void SetSteeringWheel(float yaw, float pitch)
{
SmoothYaw.Target = yaw;
SmoothPitch.Target = pitch;
}
internal virtual void SetArmPositions(Vector3 leftArmPosition, Vector3 rightArmPosition)
{
SmoothLeftArm.Target = leftArmPosition;
SmoothRightArm.Target = rightArmPosition;
}
internal virtual void Enter()
{
enabled = true;
}
public virtual void Exit()
{
enabled = false;
}
internal abstract void SetThrottle(bool isOn);
}
}

View File

@@ -0,0 +1,48 @@
using System.Diagnostics;
using NitroxClient.MonoBehaviours.Discord;
using NitroxClient.MonoBehaviours.Gui.MainMenu;
using UnityEngine;
namespace NitroxClient.MonoBehaviours;
public class NitroxBootstrapper : MonoBehaviour
{
internal static NitroxBootstrapper Instance;
private void Awake()
{
DontDestroyOnLoad(gameObject);
Instance = this;
gameObject.AddComponent<SceneCleanerPreserve>();
gameObject.AddComponent<NitroxMainMenuModifications>();
gameObject.AddComponent<DiscordClient>();
#if DEBUG
EnableDeveloperFeatures();
CreateDebugger();
#endif
// This is very important, see Application_runInBackground_Patch.cs
Application.runInBackground = true;
Log.Info($"Unity run in background set to \"{Application.runInBackground}\"");
// Also very important for similar reasons
MiscSettings.pdaPause = false;
}
#if DEBUG
private static void EnableDeveloperFeatures()
{
Log.Info("Enabling Subnautica developer console");
PlatformUtils.SetDevToolsEnabled(true);
}
private void CreateDebugger()
{
Log.Info("Enabling Nitrox debugger");
GameObject debugger = new();
debugger.name = "Debug manager";
debugger.AddComponent<NitroxDebugManager>();
debugger.transform.SetParent(transform);
}
#endif
}

View File

@@ -0,0 +1,183 @@
#if DEBUG
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.Linq;
using NitroxClient.Debuggers;
using NitroxModel.Core;
using UnityEngine;
using UnityEngine.SceneManagement;
namespace NitroxClient.MonoBehaviours;
[ExcludeFromCodeCoverage]
public class NitroxDebugManager : MonoBehaviour
{
private const KeyCode ENABLE_DEBUGGER_HOTKEY = KeyCode.F7;
private readonly HashSet<BaseDebugger> prevActiveDebuggers = [];
private List<BaseDebugger> debuggers;
private bool showDebuggerList;
private bool isDebugging;
private Rect windowRect;
private void Awake()
{
debuggers = NitroxServiceLocator.LocateServicePreLifetime<IEnumerable<BaseDebugger>>().ToList();
}
public static void ToggleCursor()
{
UWE.Utils.lockCursor = !UWE.Utils.lockCursor;
}
public void OnGUI()
{
if (!isDebugging)
{
return;
}
// Main window to display all available debuggers.
windowRect = GUILayout.Window(GUIUtility.GetControlID(FocusType.Keyboard), windowRect, DoWindow, "Nitrox debugging");
// Render debugger windows if they are enabled.
foreach (BaseDebugger debugger in debuggers)
{
debugger.OnGUI();
}
}
public void Update()
{
if (Input.GetKeyDown(ENABLE_DEBUGGER_HOTKEY))
{
ToggleDebugging();
}
if (isDebugging)
{
if (Input.GetKeyDown(KeyCode.C) && Input.GetKey(KeyCode.LeftControl))
{
ToggleCursor();
}
CheckDebuggerHotkeys();
foreach (BaseDebugger debugger in debuggers.Where(debugger => debugger.Enabled))
{
debugger.Update();
}
}
}
public void ToggleDebugging()
{
isDebugging = !isDebugging;
if (isDebugging)
{
UWE.Utils.PushLockCursor(false);
ShowDebuggers();
}
else
{
UWE.Utils.PopLockCursor();
HideDebuggers();
foreach (BaseDebugger baseDebugger in debuggers)
{
baseDebugger.ResetWindowPosition();
}
}
}
private void DoWindow(int windowId)
{
using (new GUILayout.VerticalScope())
{
using (new GUILayout.HorizontalScope())
{
if (GUILayout.Button("Toggle cursor (CTRL+C)"))
{
ToggleCursor();
}
if (GUILayout.Button("Show / Hide", GUILayout.Width(100)))
{
showDebuggerList = !showDebuggerList;
windowRect = default;
}
}
if (showDebuggerList)
{
foreach (BaseDebugger debugger in debuggers)
{
debugger.Enabled = GUILayout.Toggle(debugger.Enabled, $"{debugger.DebuggerName} debugger ({debugger.HotkeyString})");
}
}
}
}
private void CheckDebuggerHotkeys()
{
foreach (BaseDebugger debugger in debuggers)
{
if (Input.GetKeyDown(debugger.Hotkey) && Input.GetKey(KeyCode.LeftControl) == debugger.HotkeyControlRequired && Input.GetKey(KeyCode.LeftShift) == debugger.HotkeyShiftRequired && Input.GetKey(KeyCode.LeftAlt) == debugger.HotkeyAltRequired)
{
debugger.Enabled = !debugger.Enabled;
}
}
}
private void HideDebuggers()
{
foreach (BaseDebugger debugger in GetComponents<BaseDebugger>())
{
if (debugger.Enabled)
{
prevActiveDebuggers.Add(debugger);
}
debugger.Enabled = false;
}
}
private void ShowDebuggers()
{
foreach (BaseDebugger debugger in prevActiveDebuggers)
{
debugger.Enabled = true;
}
prevActiveDebuggers.Clear();
}
private void OnEnable()
{
SceneManager.sceneLoaded += SceneManager_sceneLoaded;
SceneManager.sceneUnloaded += SceneManager_sceneUnloaded;
SceneManager.activeSceneChanged += SceneManager_activeSceneChanged;
}
private void OnDisable()
{
SceneManager.sceneLoaded -= SceneManager_sceneLoaded;
SceneManager.sceneUnloaded -= SceneManager_sceneUnloaded;
SceneManager.activeSceneChanged -= SceneManager_activeSceneChanged;
}
private static void SceneManager_sceneLoaded(Scene scene, LoadSceneMode loadMode)
{
Log.Debug($"Scene {scene.name} loaded as {loadMode}");
}
private static void SceneManager_sceneUnloaded(Scene scene)
{
Log.Debug($"Scene {scene.name} unloaded.");
}
private static void SceneManager_activeSceneChanged(Scene fromScene, Scene toScene)
{
Log.Debug($"Active scene changed from {fromScene.name} to {toScene.name}");
}
}
#endif

View File

@@ -0,0 +1,174 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Runtime.Serialization;
using NitroxModel.DataStructures;
using NitroxModel.DataStructures.Util;
using NitroxModel.Helper;
using ProtoBuf;
using UnityEngine;
namespace NitroxClient.MonoBehaviours
{
[Serializable]
[DataContract]
[ProtoContract] // REQUIRED as the game serializes/deserializes phasing entities in batches when moving around the map.
public class NitroxEntity : MonoBehaviour, IProtoTreeEventListener
{
private static readonly Dictionary<NitroxId, GameObject> gameObjectsById = new();
[DataMember(Order = 1)]
[ProtoMember(1)]
public NitroxId Id;
private NitroxEntity() // Default for Proto
{
}
public static IEnumerable<KeyValuePair<NitroxId, GameObject>> GetGameObjects()
{
return gameObjectsById;
}
public static GameObject RequireObjectFrom(NitroxId id)
{
Optional<GameObject> gameObject = GetObjectFrom(id);
Validate.IsPresent(gameObject, $"Game object required from id: {id}");
return gameObject.Value;
}
public static Optional<GameObject> GetObjectFrom(NitroxId id)
{
if (id == null)
{
return Optional.Empty;
}
if (!gameObjectsById.TryGetValue(id, out GameObject gameObject))
{
return Optional.Empty;
}
// Nullable incase game object is marked as destroyed
return Optional.OfNullable(gameObject);
}
public static Dictionary<NitroxId, GameObject> GetObjectsFrom(HashSet<NitroxId> ids)
{
return ids.Select(id => new KeyValuePair<NitroxId, GameObject>(id, gameObjectsById.GetOrDefault(id, null)))
.Where(keyValue => keyValue.Value)
.ToDictionary(kv => kv.Key, kv => kv.Value);
}
public static bool TryGetObjectFrom(NitroxId id, out GameObject gameObject)
{
gameObject = null;
return id != null && gameObjectsById.TryGetValue(id, out gameObject) && gameObject;
}
public static bool TryGetComponentFrom<T>(NitroxId id, out T component)
{
component = default;
return id != null && gameObjectsById.TryGetValue(id, out GameObject gameObject) && gameObject &&
gameObject.TryGetComponent(out component);
}
public static void SetNewId(GameObject gameObject, NitroxId id)
{
Validate.IsTrue(gameObject);
Validate.NotNull(id);
if (gameObject.TryGetComponent(out NitroxEntity entity))
{
gameObjectsById.Remove(entity.Id);
UniqueIdentifier.identifiers.Remove(entity.Id.ToString());
}
else
{
entity = gameObject.AddComponent<NitroxEntity>();
}
entity.Id = id;
gameObjectsById[id] = gameObject;
if (gameObject.TryGetComponent(out UniqueIdentifier uniqueIdentifier))
{
// To avoid unrequired error spams, we do the id setting manually
// If the current UID was already registered, we unregister it
if (!string.IsNullOrEmpty(uniqueIdentifier.id))
{
UniqueIdentifier.identifiers.Remove(uniqueIdentifier.id);
}
uniqueIdentifier.id = id.ToString();
UniqueIdentifier.identifiers[id.ToString()] = uniqueIdentifier;
}
}
public static NitroxId GenerateNewId(GameObject gameObject)
{
Validate.IsTrue(gameObject);
NitroxId id = new();
SetNewId(gameObject, id);
return id;
}
public static NitroxId GetIdOrGenerateNew(GameObject gameObject)
{
Validate.IsTrue(gameObject);
if (gameObject.TryGetComponent(out NitroxEntity entity))
{
return entity.Id;
}
NitroxId id = new();
SetNewId(gameObject, id);
return id;
}
public static void RemoveFrom(GameObject gameObject)
{
if (gameObject.TryGetComponent(out NitroxEntity entity) && entity.Id != null)
{
gameObjectsById.Remove(entity.Id);
DestroyImmediate(entity);
}
}
/// <summary>
/// Removes the <see cref="NitroxEntity"/> from the global directory and set's its <see cref="Id"/> to null.
/// </summary>
public void Remove()
{
if (Id != null)
{
gameObjectsById.Remove(Id);
Id = null;
if (gameObject.TryGetComponent(out UniqueIdentifier uniqueIdentifier))
{
uniqueIdentifier.Unregister();
uniqueIdentifier.id = null;
}
}
}
public void Start()
{
// Just in case this object comes to life via serialization
if (Id != null)
{
gameObjectsById[Id] = gameObject;
}
}
public void OnProtoSerializeObjectTree(ProtobufSerializer _)
{
}
public void OnProtoDeserializeObjectTree(ProtobufSerializer _)
{
gameObjectsById[Id] = gameObject;
}
}
}

View File

@@ -0,0 +1,55 @@
using NitroxModel.DataStructures.GameLogic.Entities;
using UnityEngine;
namespace NitroxClient.MonoBehaviours;
/// <summary>
/// <see cref="Geyser"/> works with <see cref="MonoBehaviour.InvokeRepeating"/> which is based on <see cref="Time"/>.
/// Therefore, to ensure client freeze (and other things modifying the local unity's time) don't disturb the precise geyser's erupt schedule,
/// we manage it ourselves in a synced way.
/// </summary>
public class NitroxGeyser : MonoBehaviour
{
private Geyser geyser;
private float lastEruptTime;
private float eruptInterval;
public void Initialize(GeyserWorldEntity geyserEntity, Geyser geyser)
{
if (!DayNightCycle.main)
{
Log.Error($"Can't initialize {nameof(NitroxGeyser)} without {nameof(DayNightCycle)} being initialized");
Destroy(this);
return;
}
this.geyser = geyser;
eruptInterval = geyser.eruptionInterval + geyserEntity.RandomIntervalVarianceMultiplier * geyser.eruptionIntervalVariance;
float timePassed = DayNightCycle.main.timePassedAsFloat;
float timeSinceLastErupt = (timePassed - geyserEntity.StartEruptTime) % eruptInterval;
lastEruptTime = timePassed - timeSinceLastErupt;
geyser.CancelInvoke(nameof(Geyser.Erupt));
}
public void Update()
{
if (!DayNightCycle.main)
{
return;
}
int eruptOccurrences = (int)((DayNightCycle.main.timePassedAsFloat - lastEruptTime) / eruptInterval);
if (eruptOccurrences > 0)
{
lastEruptTime += eruptOccurrences * eruptInterval;
if (geyser.erupting)
{
geyser.CancelInvoke(nameof(Geyser.EndErupt));
geyser.EndErupt();
}
geyser.Erupt();
}
}
}

View File

@@ -0,0 +1,30 @@
using NitroxClient.Communication.Abstract;
using NitroxModel.DataStructures;
using NitroxModel.Packets;
using UnityEngine;
namespace NitroxClient.MonoBehaviours;
/// <summary>
/// Entities might move slightly out of the loaded zone, in which case the server thinks that they're in another cell
/// (because the cell is only determined by the entity's position). Thus we need to be able to know when this entity is unloaded
/// and broadcast this event so the server can switch the ownership from it.
/// </summary>
public class OutOfCellEntity : MonoBehaviour
{
private NitroxId entityId;
public void Init(NitroxId nitroxId)
{
if (entityId == null)
{
this.Resolve<IPacketSender>().Send(new PlayerSeeOutOfCellEntity(nitroxId));
}
entityId = nitroxId;
}
public void OnDestroy()
{
this.Resolve<IPacketSender>().Send(new PlayerUnseeOutOfCellEntity(entityId));
}
}

View File

@@ -0,0 +1,71 @@
using System;
using NitroxClient.Unity.Helper;
using UnityEngine;
namespace NitroxClient.MonoBehaviours.Overrides
{
public class MultiplayerBench : Bench
{
private Side side;
public static MultiplayerBench FromBench(Bench origin, GameObject target, Side side, GameObject animatorRoot)
{
Animator animator = animatorRoot.GetComponent<Animator>();
Transform playerTarget = animatorRoot.transform.Find("root/cine_loc/player_target");
Transform playerOutTarget = animatorRoot.transform.Find("out_target");
MultiplayerBench bench = target.AddComponent<MultiplayerBench>();
bench.frontObstacleCheck = origin.frontObstacleCheck;
bench.backObstacleCheck = origin.backObstacleCheck;
bench.frontAnimRotation = origin.frontAnimRotation;
bench.backAnimRotation = origin.backAnimRotation;
bench.checkDistance = origin.checkDistance;
bench.handText = origin.handText;
bench.triggerType = origin.triggerType;
bench.volumeTriggerType = origin.volumeTriggerType;
bench.standUpCinematicController = origin.standUpCinematicController;
bench.cinematicController = origin.cinematicController;
bench.onCinematicStart = new CinematicModeEvent();
bench.onCinematicEnd = new CinematicModeEvent();
bench.side = side;
bench.animator = animator;
bench.playerTarget = playerTarget;
bench.cinematicController.animatedTransform = playerTarget;
bench.cinematicController.animator = animator;
bench.cinematicController.informGameObject = target;
bench.standUpCinematicController.animatedTransform = playerTarget;
bench.standUpCinematicController.endTransform = playerOutTarget;
bench.standUpCinematicController.animator = animator;
bench.standUpCinematicController.informGameObject = target;
return bench;
}
public override void OnHandClick(GUIHand hand)
{
// Prevent users from sitting on a not fully-constructed bench
if (gameObject.TryGetComponentInParent(out Constructable constructable, true) && !constructable.constructed)
{
return;
}
standUpCinematicController.transform.localPosition = side switch
{
Side.LEFT => new Vector3(-0.75f, 0.082f, 0),
Side.CENTER => new Vector3(0, 0.082f, 0),
Side.RIGHT => new Vector3(0.75f, 0.082f, 0),
_ => throw new ArgumentOutOfRangeException()
};
base.OnHandClick(hand);
}
public enum Side
{
LEFT,
CENTER,
RIGHT
}
}
}

View File

@@ -0,0 +1,26 @@
using NitroxClient.GameLogic;
using UnityEngine;
namespace NitroxClient.MonoBehaviours;
public class PlayerDeathBroadcaster : MonoBehaviour
{
private LocalPlayer localPlayer;
public void Awake()
{
localPlayer = this.Resolve<LocalPlayer>();
Player.main.playerDeathEvent.AddHandler(this, PlayerDeath);
}
private void PlayerDeath(Player player)
{
localPlayer.BroadcastDeath(player.transform.position);
}
public void OnDestroy()
{
Player.main.playerDeathEvent.RemoveHandler(this, PlayerDeath);
}
}

View File

@@ -0,0 +1,74 @@
using NitroxClient.Communication.Abstract;
using NitroxClient.GameLogic;
using NitroxModel.Packets;
using NitroxModel_Subnautica.DataStructures;
using UnityEngine;
namespace NitroxClient.MonoBehaviours;
public class PlayerMovementBroadcaster : MonoBehaviour
{
private LocalPlayer localPlayer;
public void Awake()
{
localPlayer = this.Resolve<LocalPlayer>();
}
public void Update()
{
// Freecam does disable main camera control
// But it's also disabled when driving the cyclops through a cyclops camera (content.activeSelf is only true when controlling through a cyclops camera)
if (!MainCameraControl.main.isActiveAndEnabled &&
!uGUI_CameraCyclops.main.content.activeSelf)
{
return;
}
if (BroadcastPlayerInCyclopsMovement())
{
return;
}
if (Player.main.isPiloting)
{
return;
}
Vector3 currentPosition = Player.main.transform.position;
Vector3 playerVelocity = Player.main.playerController.velocity;
// IDEA: possibly only CameraRotation is of interest, because bodyrotation is extracted from that.
Quaternion bodyRotation = MainCameraControl.main.viewModel.transform.rotation;
Quaternion aimingRotation = Player.main.camRoot.GetAimingTransform().rotation;
SubRoot subRoot = Player.main.GetCurrentSub();
// If in a subroot the position will be relative to the subroot
if (subRoot)
{
// Rotate relative player position relative to the subroot (else there are problems with respawning)
Transform subRootTransform = subRoot.transform;
Quaternion undoVehicleAngle = subRootTransform.rotation.GetInverse();
currentPosition = currentPosition - subRootTransform.position;
currentPosition = undoVehicleAngle * currentPosition;
bodyRotation = undoVehicleAngle * bodyRotation;
aimingRotation = undoVehicleAngle * aimingRotation;
currentPosition = subRootTransform.TransformPoint(currentPosition);
}
localPlayer.BroadcastLocation(currentPosition, playerVelocity, bodyRotation, aimingRotation);
}
private bool BroadcastPlayerInCyclopsMovement()
{
if (!Player.main.isPiloting && Player.main.TryGetComponent(out CyclopsMotor cyclopsMotor) && cyclopsMotor.Pawn != null)
{
Transform pawnTransform = cyclopsMotor.Pawn.Handle.transform;
PlayerInCyclopsMovement packet = new(this.Resolve<LocalPlayer>().PlayerId.Value, pawnTransform.localPosition.ToDto(), pawnTransform.localRotation.ToDto());
this.Resolve<IPacketSender>().Send(packet);
return true;
}
return false;
}
}

View File

@@ -0,0 +1,42 @@
using NitroxClient.GameLogic;
using UnityEngine;
namespace NitroxClient.MonoBehaviours;
public class PlayerStatsBroadcaster : MonoBehaviour
{
private float time;
private const float BROADCAST_INTERVAL = 3f;
private LocalPlayer localPlayer;
private Survival survival;
public void Awake()
{
localPlayer = this.Resolve<LocalPlayer>();
survival = Player.main.AliveOrNull()?.GetComponent<Survival>();
if (!survival)
{
Log.Error($"Couldn't find the {nameof(Survival)} instance on the main {nameof(Player)} instance. Destroying {nameof(PlayerStatsBroadcaster)}");
Destroy(this);
}
}
public void Update()
{
time += Time.deltaTime;
// Only do on a specific cadence to avoid hammering server
if (time >= BROADCAST_INTERVAL)
{
time = 0;
float oxygen = Player.main.oxygenMgr.GetOxygenAvailable();
float maxOxygen = Player.main.oxygenMgr.GetOxygenCapacity();
float health = Player.main.liveMixin.health;
float food = survival.food;
float water = survival.water;
float infectionAmount = Player.main.infectedMixin.GetInfectedAmount();
localPlayer.BroadcastStats(oxygen, maxOxygen, health, food, water, infectionAmount);
}
}
}

View File

@@ -0,0 +1,27 @@
using System;
using System.Collections.Generic;
using UnityEngine;
namespace NitroxClient.MonoBehaviours;
public class ReferenceHolder : MonoBehaviour
{
private readonly Dictionary<Type, object> references = [];
public bool TryGetReference<T>(out T outReference)
{
if (references.TryGetValue(typeof(T), out object value) && value is T reference)
{
outReference = reference;
return true;
}
outReference = default;
return false;
}
public void AddReference<T>(T reference)
{
references[typeof(T)] = reference;
}
}

View File

@@ -0,0 +1,86 @@
using NitroxClient.GameLogic;
using NitroxClient.Unity.Smoothing;
using UnityEngine;
namespace NitroxClient.MonoBehaviours;
public class RemotelyControlled : MonoBehaviour
{
private readonly SmoothVector smoothPosition = new SmoothVector();
private readonly SmoothRotation smoothRotation = new SmoothRotation();
private SwimBehaviour swimBehaviour;
private WalkBehaviour walkBehaviour;
private Rigidbody rigidbody;
public void Awake()
{
swimBehaviour = gameObject.GetComponent<SwimBehaviour>();
walkBehaviour = gameObject.GetComponent<WalkBehaviour>();
rigidbody = gameObject.GetComponent<Rigidbody>();
}
public void FixedUpdate()
{
if (swimBehaviour || walkBehaviour || !rigidbody)
{
return;
}
smoothPosition.FixedUpdate();
smoothRotation.FixedUpdate();
rigidbody.isKinematic = false;
rigidbody.velocity = MovementHelper.GetCorrectedVelocity(smoothPosition.Current, Vector3.zero, gameObject, EntityPositionBroadcaster.BROADCAST_INTERVAL);
rigidbody.angularVelocity = MovementHelper.GetCorrectedAngularVelocity(smoothRotation.Current, Vector3.zero, gameObject, EntityPositionBroadcaster.BROADCAST_INTERVAL);
}
public void UpdateOrientation(Vector3 position, Quaternion rotation)
{
TeleportIfTooFar(position, rotation);
if (swimBehaviour)
{
swimBehaviour.SwimTo(position, 3f);
}
Transform selfTransform = transform;
// Entities can lose their swimBehavior (such as if they get killed). Keep these up-to-date incase that happens.
smoothPosition.Current = selfTransform.position;
smoothRotation.Current = selfTransform.rotation;
smoothPosition.Target = position;
smoothRotation.Target = rotation;
}
public void UpdateKnownSplineUser(Vector3 currentPosition, Quaternion currentRotation, Vector3 destination, Vector3 destinationDirection, float velocity)
{
TeleportIfTooFar(currentPosition, currentRotation);
if (swimBehaviour)
{
// First lines of SwimBehaviour.SwimToInternal
swimBehaviour.originalTargetPosition = destination;
swimBehaviour.originalTargetDirection = destinationDirection;
swimBehaviour.originalVelocity = velocity;
// Only the useful part of the methods called in SwimBehaviour.SwimToInternal
swimBehaviour.splineFollowing.GoTo(destination, destinationDirection, velocity);
}
if (walkBehaviour)
{
walkBehaviour.GoToInternal(destination, destinationDirection, velocity);
}
}
private void TeleportIfTooFar(Vector3 position, Quaternion rotation)
{
Transform selfTransform = transform;
if ((selfTransform.position - position).sqrMagnitude > 25) // Optimized 5m distance test
{
selfTransform.position = position;
selfTransform.rotation = rotation;
}
}
}

View File

@@ -0,0 +1,91 @@
using NitroxClient.GameLogic;
using NitroxModel.Packets;
using UnityEngine;
namespace NitroxClient.MonoBehaviours.Vehicles;
public class CyclopsMovementReplicator : VehicleMovementReplicator
{
protected static readonly int CYCLOPS_YAW = Animator.StringToHash("cyclops_yaw");
protected static readonly int CYCLOPS_PITCH = Animator.StringToHash("cyclops_pitch");
private SubControl subControl;
private RemotePlayer drivingPlayer;
private bool throttleApplied;
private float steeringWheelYaw;
public void Awake()
{
subControl = GetComponent<SubControl>();
}
public new void Update()
{
base.Update();
if (subControl.canAccel && throttleApplied)
{
// See SubControl.Update
var topClamp = subControl.useThrottleIndex switch
{
1 => 0.66f,
2 => 1f,
_ => 0.33f,
};
subControl.engineRPMManager.AccelerateInput(topClamp);
for (int i = 0; i < subControl.throttleHandlers.Length; i++)
{
subControl.throttleHandlers[i].OnSubAppliedThrottle();
}
}
if (Mathf.Abs(steeringWheelYaw) > 0.1f)
{
ShipSide shipSide = steeringWheelYaw > 0 ? ShipSide.Port : ShipSide.Starboard;
for (int i = 0; i < subControl.turnHandlers.Length; i++)
{
subControl.turnHandlers[i].OnSubTurn(shipSide);
}
}
}
public override void ApplyNewMovementData(MovementData newMovementData)
{
if (newMovementData is not DrivenVehicleMovementData vehicleMovementData)
{
return;
}
steeringWheelYaw = vehicleMovementData.SteeringWheelYaw;
float steeringWheelPitch = vehicleMovementData.SteeringWheelPitch;
// See SubControl.UpdateAnimation
subControl.steeringWheelYaw = steeringWheelYaw;
subControl.steeringWheelPitch = steeringWheelPitch;
if (subControl.mainAnimator)
{
subControl.mainAnimator.SetFloat(VIEW_YAW, subControl.steeringWheelYaw);
subControl.mainAnimator.SetFloat(VIEW_PITCH, subControl.steeringWheelPitch);
if (drivingPlayer != null)
{
drivingPlayer.AnimationController.SetFloat(CYCLOPS_YAW, subControl.steeringWheelYaw);
drivingPlayer.AnimationController.SetFloat(CYCLOPS_PITCH, subControl.steeringWheelPitch);
}
}
throttleApplied = vehicleMovementData.ThrottleApplied;
}
public override void Enter(RemotePlayer drivingPlayer)
{
this.drivingPlayer = drivingPlayer;
}
public override void Exit()
{
drivingPlayer = null;
throttleApplied = false;
}
}

View File

@@ -0,0 +1,134 @@
using FMOD.Studio;
using NitroxClient.GameLogic;
using NitroxClient.GameLogic.FMOD;
using NitroxModel.GameLogic.FMOD;
using NitroxModel.Packets;
using UnityEngine;
namespace NitroxClient.MonoBehaviours.Vehicles;
public class ExosuitMovementReplicator : VehicleMovementReplicator
{
private Exosuit exosuit;
public Vector3 velocity;
private float jetLoopingSoundDistance;
private float thrustPower;
private bool jetsActive;
private float timeJetsActiveChanged;
public void Awake()
{
exosuit = GetComponent<Exosuit>();
SetupSound();
}
public new void Update()
{
Vector3 positionBefore = transform.position;
base.Update();
Vector3 positionAfter = transform.position;
velocity = (positionAfter - positionBefore) / Time.deltaTime;
float volume = FMODSystem.CalculateVolume(transform.position, Player.main.transform.position, jetLoopingSoundDistance, 1f);
EventInstance soundHandle = exosuit.loopingJetSound.playing ? exosuit.loopingJetSound.evt : exosuit.loopingJetSound.evtStop;
if (soundHandle.hasHandle())
{
soundHandle.setVolume(volume);
}
// See Exosuit.Update, thrust power simulation
if (jetsActive)
{
thrustPower = Mathf.Clamp01(thrustPower - Time.deltaTime * exosuit.thrustConsumption);
exosuit.thrustIntensity += Time.deltaTime / exosuit.timeForFullVirbation;
}
else
{
float num = Time.deltaTime * exosuit.thrustConsumption * 0.7f;
if (exosuit.onGround)
{
num = Time.deltaTime * exosuit.thrustConsumption * 4f;
}
thrustPower = Mathf.Clamp01(thrustPower + num);
exosuit.thrustIntensity -= Time.deltaTime * 10f;
}
exosuit.thrustIntensity = Mathf.Clamp01(exosuit.thrustIntensity);
if (timeJetsActiveChanged + 0.3f <= Time.time)
{
if (jetsActive && thrustPower > 0f)
{
exosuit.loopingJetSound.Play();
exosuit.fxcontrol.Play(0);
exosuit.areFXPlaying = true;
}
else
{
exosuit.loopingJetSound.Stop();
exosuit.fxcontrol.Stop(0);
exosuit.areFXPlaying = false;
}
}
}
public override void ApplyNewMovementData(MovementData newMovementData)
{
if (newMovementData is not DrivenVehicleMovementData vehicleMovementData)
{
return;
}
float steeringWheelYaw = vehicleMovementData.SteeringWheelYaw;
float steeringWheelPitch = vehicleMovementData.SteeringWheelPitch;
// See Vehicle.Update (reverse operation for vehicle.steeringWheel... = ...)
exosuit.steeringWheelYaw = steeringWheelPitch / 70f;
exosuit.steeringWheelPitch = steeringWheelPitch / 45f;
if (exosuit.mainAnimator)
{
exosuit.mainAnimator.SetFloat(VIEW_YAW, steeringWheelYaw);
exosuit.mainAnimator.SetFloat(VIEW_PITCH, steeringWheelPitch);
}
// See Exosuit.jetsActive setter
if (jetsActive != vehicleMovementData.ThrottleApplied)
{
jetsActive = vehicleMovementData.ThrottleApplied;
timeJetsActiveChanged = Time.time;
}
}
private void SetupSound()
{
this.Resolve<FMODWhitelist>().TryGetSoundData(exosuit.loopingJetSound.asset.path, out SoundData jetSoundData);
jetLoopingSoundDistance = jetSoundData.Radius;
if (FMODUWE.IsInvalidParameterId(exosuit.fmodIndexSpeed))
{
exosuit.fmodIndexSpeed = exosuit.ambienceSound.GetParameterIndex("speed");
}
if (FMODUWE.IsInvalidParameterId(exosuit.fmodIndexRotate))
{
exosuit.fmodIndexRotate = exosuit.ambienceSound.GetParameterIndex("rotate");
}
}
public override void Enter(RemotePlayer remotePlayer)
{
exosuit.SetIKEnabled(true);
exosuit.thrustIntensity = 0;
}
public override void Exit()
{
exosuit.SetIKEnabled(false);
exosuit.loopingJetSound.Stop(STOP_MODE.ALLOWFADEOUT);
exosuit.fxcontrol.Stop(0);
jetsActive = false;
}
}

View File

@@ -0,0 +1,120 @@
using FMOD.Studio;
using NitroxClient.GameLogic;
using NitroxModel.GameLogic.FMOD;
using NitroxModel.Packets;
using UnityEngine;
namespace NitroxClient.MonoBehaviours.Vehicles;
public class SeamothMovementReplicator : VehicleMovementReplicator
{
private SeaMoth seaMoth;
private FMOD_CustomLoopingEmitter rpmSound;
private FMOD_CustomEmitter revSound;
private FMOD_CustomEmitter enterSeamoth;
private float radiusRpmSound;
private float radiusRevSound;
private float radiusEnterSound;
private bool throttleApplied;
public void Awake()
{
seaMoth = GetComponent<SeaMoth>();
SetupSound();
}
public new void Update()
{
base.Update();
if (throttleApplied)
{
seaMoth.engineSound.AccelerateInput(1);
}
}
public override void ApplyNewMovementData(MovementData newMovementData)
{
if (newMovementData is not DrivenVehicleMovementData vehicleMovementData)
{
return;
}
float steeringWheelYaw = vehicleMovementData.SteeringWheelYaw;
float steeringWheelPitch = vehicleMovementData.SteeringWheelPitch;
// See Vehicle.Update (reverse operation for vehicle.steeringWheel... = ...)
seaMoth.steeringWheelYaw = steeringWheelYaw / 70f;
seaMoth.steeringWheelPitch = steeringWheelPitch / 45f;
if (seaMoth.mainAnimator)
{
seaMoth.mainAnimator.SetFloat(VIEW_YAW, steeringWheelYaw);
seaMoth.mainAnimator.SetFloat(VIEW_PITCH, steeringWheelPitch);
}
// Adjusting volume for the engine Sound
float distanceToPlayer = Vector3.Distance(Player.main.transform.position, transform.position);
float volumeRpmSound = SoundHelper.CalculateVolume(distanceToPlayer, radiusRpmSound, 1f);
float volumeRevSound = SoundHelper.CalculateVolume(distanceToPlayer, radiusRevSound, 1f);
rpmSound.GetEventInstance().setVolume(volumeRpmSound);
revSound.GetEventInstance().setVolume(volumeRevSound);
throttleApplied = vehicleMovementData.ThrottleApplied;
}
private void SetupSound()
{
rpmSound = seaMoth.engineSound.engineRpmSFX;
revSound = seaMoth.engineSound.engineRevUp;
enterSeamoth = seaMoth.enterSeamoth;
rpmSound.followParent = true;
revSound.followParent = true;
this.Resolve<FMODWhitelist>().IsWhitelisted(rpmSound.asset.path, out radiusRpmSound);
this.Resolve<FMODWhitelist>().IsWhitelisted(revSound.asset.path, out radiusRevSound);
this.Resolve<FMODWhitelist>().IsWhitelisted(seaMoth.enterSeamoth.asset.path, out radiusEnterSound);
rpmSound.GetEventInstance().setProperty(EVENT_PROPERTY.MINIMUM_DISTANCE, 1f);
revSound.GetEventInstance().setProperty(EVENT_PROPERTY.MINIMUM_DISTANCE, 1f);
enterSeamoth.GetEventInstance().setProperty(EVENT_PROPERTY.MINIMUM_DISTANCE, 1f);
rpmSound.GetEventInstance().setProperty(EVENT_PROPERTY.MAXIMUM_DISTANCE, radiusRpmSound);
revSound.GetEventInstance().setProperty(EVENT_PROPERTY.MAXIMUM_DISTANCE, radiusRevSound);
enterSeamoth.GetEventInstance().setProperty(EVENT_PROPERTY.MAXIMUM_DISTANCE, radiusEnterSound);
if (FMODUWE.IsInvalidParameterId(seaMoth.fmodIndexSpeed))
{
seaMoth.fmodIndexSpeed = seaMoth.ambienceSound.GetParameterIndex("speed");
}
}
public override void Enter(RemotePlayer remotePlayer)
{
seaMoth.bubbles.Play();
if (enterSeamoth)
{
// After first run, this sound will still be in "playing" mode so we need to release it by hand
enterSeamoth.Stop();
enterSeamoth.ReleaseEvent();
enterSeamoth.CacheEventInstance();
float distanceToPlayer = Vector3.Distance(Player.main.transform.position, transform.position);
float sound = SoundHelper.CalculateVolume(distanceToPlayer, radiusEnterSound, 1f);
enterSeamoth.evt.setVolume(sound);
enterSeamoth.Play();
}
}
public override void Exit()
{
seaMoth.bubbles.Stop();
throttleApplied = false;
}
}

View File

@@ -0,0 +1,13 @@
using NitroxClient.GameLogic;
using UnityEngine;
namespace NitroxClient.MonoBehaviours.Vehicles;
public abstract class VehicleMovementReplicator : MovementReplicator
{
protected static readonly int VIEW_YAW = Animator.StringToHash("view_yaw");
protected static readonly int VIEW_PITCH = Animator.StringToHash("view_pitch");
public abstract void Enter(RemotePlayer remotePlayer);
public abstract void Exit();
}

View File

@@ -0,0 +1,147 @@
using NitroxModel.DataStructures;
using NitroxModel.Packets;
using NitroxModel_Subnautica.DataStructures;
using UnityEngine;
namespace NitroxClient.MonoBehaviours.Vehicles;
public class WatchedEntry
{
/// <remarks>
/// In unity position units. Refer to <see cref="ShouldBroadcastMovement"/> for use infos.
/// </remarks>
private const float MINIMAL_MOVEMENT_TRESHOLD = 0.05f;
/// <remarks>
/// In degrees (°). Refer to <see cref="ShouldBroadcastMovement"/> for use infos.
/// </remarks>
private const float MINIMAL_ROTATION_TRESHOLD = 0.05f;
/// <remarks>
/// In seconds. Refer to <see cref="ShouldBroadcastMovement"/> for use infos.
/// </remarks>
private const float MAX_TIME_WITHOUT_BROADCAST = 5f;
/// <inheritdoc cref="MAX_TIME_WITHOUT_BROADCAST"/>
private const float SAFETY_BROADCAST_WINDOW = 0.2f;
private readonly NitroxId Id;
private readonly Transform transform;
private readonly Vehicle vehicle;
private readonly SubControl subControl;
private float latestBroadcastTime;
private Vector3 latestLocalPositionSent;
private Quaternion latestLocalRotationSent;
public WatchedEntry(NitroxId Id, Transform transform)
{
this.Id = Id;
this.transform = transform;
vehicle = transform.GetComponent<Vehicle>();
subControl = transform.GetComponent<SubControl>();
}
private bool IsDrivenVehicle()
{
return vehicle && Player.main.currentMountedVehicle == vehicle;
}
private bool IsDrivenCyclops()
{
return subControl && Player.main.currentSub == subControl.sub && Player.main.mode == Player.Mode.Piloting;
}
public MovementData GetMovementData(NitroxId id)
{
// Packets should be filled with more data if the vehicle is being driven by the local player
if (IsDrivenVehicle())
{
// Those two values are set between -1 and 1 so we can easily scale them up while still in range for sbyte
sbyte steeringWheelYaw = (sbyte)(Mathf.Clamp(vehicle.steeringWheelYaw, -1, 1) * 70f);
sbyte steeringWheelPitch = (sbyte)(Mathf.Clamp(vehicle.steeringWheelPitch, -1, 1) * 45f);
bool throttleApplied = false;
Vector3 input = AvatarInputHandler.main.IsEnabled() ? GameInput.GetMoveDirection() : Vector3.zero;
// See SeaMoth.UpdateSounds
if (vehicle is SeaMoth)
{
throttleApplied = input.magnitude > 0f;
}
// See Exosuit.Update
else if (vehicle is Exosuit)
{
throttleApplied = input.y > 0f;
}
return new DrivenVehicleMovementData(id, transform.position.ToDto(), transform.rotation.ToDto(), steeringWheelYaw, steeringWheelPitch, throttleApplied);
}
if (IsDrivenCyclops())
{
// Cyclop steering wheel's yaw and pitch are between -90 and 90 so they're already in range for sbyte
sbyte steeringWheelYaw = (sbyte)Mathf.Clamp(subControl.steeringWheelYaw, -90, 90);
sbyte steeringWheelPitch = (sbyte)Mathf.Clamp(subControl.steeringWheelPitch, -90, 90);
// See SubControl.Update
bool throttleApplied = subControl.throttle.magnitude > 0.0001f;
return new DrivenVehicleMovementData(id, transform.position.ToDto(), transform.rotation.ToDto(), steeringWheelYaw, steeringWheelPitch, throttleApplied);
}
// Normal case in which the vehicule isn't driven by the local player
return new SimpleMovementData(id, transform.position.ToDto(), transform.rotation.ToDto());
}
public void OnBroadcastPosition()
{
latestLocalPositionSent = transform.localPosition;
latestLocalRotationSent = transform.localRotation;
}
private bool HasVehicleMoved()
{
return Vector3.Distance(latestLocalPositionSent, transform.localPosition) > MINIMAL_MOVEMENT_TRESHOLD ||
Quaternion.Angle(latestLocalRotationSent, transform.localRotation) > MINIMAL_ROTATION_TRESHOLD;
}
/// <summary>
/// Rate limiter which prevents all non-moving vehicles from sending too many packets following some rules:
/// - the driven vehicle is not rate limited
/// - position changes less than <see cref="MINIMAL_MOVEMENT_TRESHOLD"/> are ignored
/// - rotation changes less than <see cref="MINIMAL_ROTATION_TRESHOLD"/> are ignored
/// - every period of <see cref="MAX_TIME_WITHOUT_BROADCAST"/>, there's a <see cref="SAFETY_BROADCAST_WINDOW"/>
/// during which movements packets are sent to avoid any packet drop's bad effect, regardless of <see cref="HasVehicleMoved"/>
/// </summary>
/// <remarks>
/// <see cref="latestBroadcastTime"/> is not updated during the <see cref="SAFETY_BROADCAST_WINDOW"/> so we can recognize this window
/// </remarks>
public bool ShouldBroadcastMovement()
{
// Watched entry validity check (e.g. for vehicle death)
if (!transform)
{
MovementBroadcaster.UnregisterWatched(Id);
return false;
}
float deltaTimeSinceBroadcast = DayNightCycle.main.timePassedAsFloat - latestBroadcastTime;
if (IsDrivenCyclops() || IsDrivenVehicle() || deltaTimeSinceBroadcast < 0 || HasVehicleMoved())
{
// As long as the vehicle has moved, we can reset the broadcast timer
latestBroadcastTime = DayNightCycle.main.timePassedAsFloat;
return true;
}
if (deltaTimeSinceBroadcast > MAX_TIME_WITHOUT_BROADCAST)
{
if (deltaTimeSinceBroadcast > MAX_TIME_WITHOUT_BROADCAST + SAFETY_BROADCAST_WINDOW)
{
// only reset the broadcast timer after the safety window has elapsed
latestBroadcastTime = DayNightCycle.main.timePassedAsFloat;
}
return true;
}
return false;
}
}