PlayerReplicator.cs
using System.Numerics;
using Sandbox;
public interface IPlayerReplicatorEvents : ISceneEvent<IPlayerReplicatorEvents>
{
void PlayersReplicated( int newNumberOfPlayers ) { }
void PlayerSpawned( GameObject player ) { }
}
public sealed class PlayerReplicator : Component, IPlayerHealthEvent, IEnemySpawnerEvents
{
[Property]
public Material ReplicatorMaterial { get; set; }
Stack<Vector4> AvailableViewports;
public int NumberOfPlayers { get; private set; } = 1;
protected override void OnEnabled()
{
base.OnEnabled();
AvailableViewports = new();
}
IEnumerable<Vector4> Viewports( int numberOfPlayers )
{
if ( numberOfPlayers == 2 )
{
yield return new Vector4( 0, 0, 1, 0.5f );
yield return new Vector4( 0, 0.5f, 1, 0.5f );
}
else if ( numberOfPlayers == 4 )
{
yield return new Vector4( 0, 0, 0.5f, 0.5f );
yield return new Vector4( 0, 0.5f, 0.5f, 0.5f );
yield return new Vector4( 0.5f, 0, 0.5f, 0.5f );
yield return new Vector4( 0.5f, 0.5f, 0.5f, 0.5f );
}
else
{
throw new System.NotImplementedException();
}
}
public void ReplicatePlayers()
{
while ( AvailableViewports.Any() ) RespawnPlayer();
var playerObjects = Scene.GetComponentsInChildren<PlayerController>().Select( pc => pc.GameObject );
var newPlayerCount = playerObjects.Count() * 2;
var viewports = Viewports( newPlayerCount ).ToArray();
var viewportIndex = 0;
foreach ( var player in playerObjects )
{
// 180 -> 90 -> 45... but 8 players will require some other rotation
var rot = Rotation.FromYaw( 360 / newPlayerCount );
var clonePosition = player.WorldPosition.RotateAround( Vector3.Zero, rot );
var oldPlayerController = player.GetComponent<PlayerController>();
// the rotation for the new clone should be based on the eye angles, not the actual gameobject's rotation
var eyeAngles = oldPlayerController.EyeAngles;
var cloneRotation = eyeAngles * rot;
oldPlayerController.EyeAngles = eyeAngles;
// clone (replicate) the player
var newPlayer = player.Clone( clonePosition, cloneRotation );
foreach ( var sm in newPlayer.GetComponentsInChildren<SkinnedModelRenderer>() )
{
if ( !sm.GameObject.Name.Contains( "mp5" ) )
{
sm.MaterialOverride = ReplicatorMaterial;
}
}
// copy old eyeangles
var newPlayerController = newPlayer.GetComponent<PlayerController>();
newPlayerController.EyeAngles = newPlayerController.EyeAngles.WithPitch( eyeAngles.pitch ).WithRoll( eyeAngles.roll );
// move viewports (TODO: lerp?)
var oldPlayerCamera = player.GetComponentInChildren<CameraComponent>();
oldPlayerCamera.Viewport = viewports[viewportIndex++];
var oldHud = player.GetComponent<HUD>();
if (IsTopRight(oldPlayerCamera.Viewport))
{
oldHud.Viewport = new Vector4( 0.58f, 0, 0.5f, 0.5f );
}
else
{
oldHud.Viewport = oldPlayerCamera.Viewport;
}
// have to toggle it to make it refit itself properly
oldHud.Enabled = false;
oldHud.Enabled = true;
var newPlayerCamera = newPlayer.GetComponentInChildren<CameraComponent>();
newPlayerCamera.Viewport = viewports[viewportIndex++];
newPlayer.GetComponent<HUD>().Viewport = newPlayerCamera.Viewport;
// remove old viewer tag
var oldPlayerCameraControl = player.GetComponent<LocalCameraControl>();
newPlayer.Children[0].Tags.Remove( oldPlayerCameraControl.ExcludeRenderTag );
newPlayerCamera.RenderExcludeTags.Remove( oldPlayerCameraControl.ExcludeRenderTag );
IPlayerReplicatorEvents.Post( x => x.PlayerSpawned( newPlayer ) );
}
NumberOfPlayers = newPlayerCount;
IPlayerReplicatorEvents.Post( x => x.PlayersReplicated( newPlayerCount ) );
}
bool IsTopRight(Vector4 viewport)
{
return viewport.x == 0.5f && viewport.y == 0f && viewport.w == 0.5f && viewport.z == 0.5f;
}
public void RespawnPlayer()
{
if ( !AvailableViewports.Any() ) return;
var playerViewport = AvailableViewports.Pop();
var alivePlayers = Scene.GetComponentsInChildren<PlayerController>().ToList();
var playerToMirror = alivePlayers[0].GameObject;
var rot = Rotation.Identity;
if ( alivePlayers.Count == 1 )
{
rot = Rotation.FromYaw( 180 );
}
else
{
for ( var i = 1; i < 4; i++ )
{
rot = Rotation.FromYaw( 90 * i );
var desiredClonePosition = playerToMirror.WorldPosition.RotateAround( Vector3.Zero, rot );
var nearbyPlayer = alivePlayers
.Select( player => player.WorldPosition )
.Select( pos => pos.DistanceSquared( desiredClonePosition ) )
.Where( dist => dist < 1000 );
if ( !nearbyPlayer.Any() ) break;
}
}
// 180 -> 90 -> 45... but 8 players will require some other rotation
var clonePosition = playerToMirror.WorldPosition.RotateAround( Vector3.Zero, rot );
var oldPlayerController = playerToMirror.GetComponent<PlayerController>();
// the rotation for the new clone should be based on the eye angles, not the actual gameobject's rotation
var eyeAngles = oldPlayerController.EyeAngles;
var cloneRotation = eyeAngles * rot;
// clone (replicate) the player
var newPlayer = playerToMirror.Clone( clonePosition, cloneRotation );
foreach ( var sm in newPlayer.GetComponentsInChildren<SkinnedModelRenderer>() )
{
if ( !sm.GameObject.Name.Contains( "mp5" ) )
{
sm.MaterialOverride = ReplicatorMaterial;
}
}
// copy old eyeangles
var newPlayerController = newPlayer.GetComponent<PlayerController>();
newPlayerController.EyeAngles = newPlayerController.EyeAngles.WithPitch( eyeAngles.pitch ).WithRoll( eyeAngles.roll );
// set up viewport
var newPlayerCamera = newPlayer.GetComponentInChildren<CameraComponent>();
newPlayerCamera.Viewport = playerViewport;
if ( IsTopRight( newPlayerCamera.Viewport ) )
{
newPlayer.GetComponent<HUD>().Viewport = new Vector4( 0.58f, 0, 0.5f, 0.5f );
}
else
{
newPlayer.GetComponent<HUD>().Viewport = newPlayerCamera.Viewport;
}
// remove old viewer tag
var oldPlayerCameraControl = playerToMirror.GetComponent<LocalCameraControl>();
newPlayer.Children[0].Tags.Remove( oldPlayerCameraControl.ExcludeRenderTag );
newPlayerCamera.RenderExcludeTags.Remove( oldPlayerCameraControl.ExcludeRenderTag );
IPlayerReplicatorEvents.Post( x => x.PlayerSpawned( newPlayer ) );
}
/// <summary>
/// Put everyone on the same position as the first player again, fix movement desync
///
/// EyeAngles should always sync, so we only need to sync positions
/// </summary>
public void RealignPlayers()
{
if ( NumberOfPlayers == 1 ) return;
var targetPlayer = Scene.Get<PlayerController>();
List<Vector3> desiredPositions = NumberOfPlayers switch {
2 => [targetPlayer.WorldPosition.RotateAround( Vector3.Zero, Rotation.FromYaw( 180 ) )],
4 => [targetPlayer.WorldPosition.RotateAround( Vector3.Zero, Rotation.FromYaw( 90 ) ),
targetPlayer.WorldPosition.RotateAround( Vector3.Zero, Rotation.FromYaw( 180 ) ),
targetPlayer.WorldPosition.RotateAround( Vector3.Zero, Rotation.FromYaw( 270 ) )],
_ => throw new System.NotImplementedException(),
};
var otherAlivePlayers = Scene.GetAll<PlayerController>()
.Where( pl => pl != targetPlayer )
.Where( pl => pl.GetComponent<Health>().IsAlive )
.ToList();
foreach (var player in otherAlivePlayers)
{
player.WorldPosition = desiredPositions
.Aggregate( ( best, next ) => best.DistanceSquared(player.WorldPosition) < next.DistanceSquared(player.WorldPosition) ? best : next );
}
}
/// <summary>
/// Called by LocalCameraControl to re-allow respawning after a player has timed out from death.
/// </summary>
/// <param name="viewport"></param>
public void AddViewport( Vector4 viewport )
{
AvailableViewports.Push( viewport );
}
void IEnemySpawnerEvents.FinishedWave( int count )
{
RealignPlayers();
while (AvailableViewports.Any())
{
RespawnPlayer();
}
}
}