Core/PrototypeGame.cs
using Sandbox.Network;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
namespace Prototype;
public sealed partial class PrototypeGame : Component, Component.INetworkListener
{
public static PrototypeGame Instance { get; private set; }
[Property] public bool ReviewChaserInGame { get; set; }
[Property] public bool ReviewGunnerInGame { get; set; }
[Property] public bool ReviewChargerInGame { get; set; }
[Property] public bool StartLocalChaserTest { get; set; }
[Sync( SyncFlags.FromHost )] public RunPhase Phase { get; private set; } = RunPhase.Lobby;
[Sync( SyncFlags.FromHost )] public int Seed { get; private set; }
[Sync( SyncFlags.FromHost )] public int RoomNumber { get; private set; }
[Sync( SyncFlags.FromHost )] public int EnemiesAlive { get; private set; }
[Sync( SyncFlags.FromHost )] public int Level { get; private set; } = 1;
[Sync( SyncFlags.FromHost )] public int Score { get; private set; }
[Sync( SyncFlags.FromHost )] public int RunKills { get; private set; }
[Sync(SyncFlags.FromHost)] public float RunSeconds { get; private set; }
[Sync(SyncFlags.FromHost)] public float FinalRunSeconds { get; private set; }
public string Status { get; private set; } = "Ready";
public int MaxPlayers => PartyLobbyView.Capacity;
[Sync( SyncFlags.FromHost )] public NetDictionary<string, string> PartyRooms { get; set; } = new();
[Sync( SyncFlags.FromHost )] public NetDictionary<string, string> PlayerParties { get; set; } = new();
public bool IsOnline => Networking.IsActive && !string.IsNullOrEmpty(ServerPartyId);
public bool IsHost => !Networking.IsActive || Networking.IsHost;
public ProceduralDungeon Dungeon => _dungeon;
private int _generatedSeed;
[Sync(SyncFlags.FromHost)] private string _healedRoom { get; set; } = "";
public bool WorldReady => Seed != 0 && _generatedSeed == Seed && _dungeon.Rooms.Count > 0;
[Sync( SyncFlags.FromHost )] public string ActivePartyId { get; private set; } = "";
[Sync( SyncFlags.FromHost )] public string RunMemberIds { get; private set; } = "";
[Sync(SyncFlags.FromHost)] public string DepartedRunIds { get; private set; } = "";
public bool IsRunMenuOpen { get; set; }
public bool IsRunMember( Guid id ) => RunMemberIds.Split( ',' ).Contains( id.ToString() ) && !DepartedRunIds.Split(',').Contains(id.ToString());
public bool IsRunPlayer( FpsPlayer player ) => player.IsValid() && (!Networking.IsActive || IsRunMember( player.ParticipantId ));
public IEnumerable<FpsPlayer> RunPlayers => Scene.GetAll<FpsPlayer>().Where( IsRunPlayer );
public bool LocalInRun => !Networking.IsActive || (Connection.Local is not null && IsRunMember( Connection.Local.Id ));
public RunPhase LocalPhase => LocalInRun ? Phase : RunPhase.Lobby;
public FpsPlayer LocalPlayer => Scene.GetAll<FpsPlayer>().FirstOrDefault( p => Networking.IsActive ? Connection.Local is not null && p.ParticipantId == Connection.Local.Id && p.GameObject.Network.OwnerId == Connection.Local.Id : !p.IsProxy );
public IReadOnlyList<Connection> Players => Connection.All;
public string LocalPartyId => LocalConnectionId is { Length: > 0 } id && PlayerParties.TryGetValue( id, out var party ) ? party : "";
public IEnumerable<PartyLobbyView> AllPartyRooms => PartyRooms.Select( pair => PartyLobbyView.TryDecode( pair.Key, pair.Value, out var room, out _ ) ? room : default ).Where( room => !string.IsNullOrEmpty( room.Id ) );
public IEnumerable<PartyLobbyView> WaitingPartyRooms => AllPartyRooms.Where( room => !room.Started && !PrivateLobbies.ContainsKey(room.Id) );
private ProceduralDungeon _dungeon;
private readonly List<EnemyBrain> _enemies = new();
private readonly List<FpsPlayer> _players = new();
[Sync] private GameObject _rewardChest { get; set; }
[Sync(SyncFlags.FromHost)] private bool _pendingChest { get; set; }
[Sync(SyncFlags.FromHost)] private Vector3 _bossDeathPosition { get; set; }
private float _rewardRetryAt;
[Sync] private GameObject _portal { get; set; }
private readonly HashSet<GameObject> _portalEntrants = new();
[Sync] public int PortalEntered { get; private set; }
[Sync] public int PortalRequired { get; private set; }
[Sync] private bool _buffChosen { get; set; }
[Sync] private bool _chestOpened { get; set; }
public bool RewardAvailable => Phase == RunPhase.Reward && _chestOpened && LocalInRun && LocalPlayer?.Vitality?.IsDead == false && LocalPlayer?.Buffs is { } buffs && buffs.OfferedSeed == Seed && buffs.ClaimedSeed != Seed;
public bool AllRewardsChosen => RunPlayers.Where(p => p.Vitality?.IsDead == false).All(p => p.Buffs?.ClaimedSeed == Seed);
public bool ChestReady => Phase == RunPhase.Reward && !_chestOpened && _rewardChest.IsValid();
public bool PortalReady => Phase == RunPhase.Reward && _buffChosen && _portal.IsValid();
public bool HasNearbyRunInteraction => LocalPlayer is { } runner && runner.Vitality?.IsDead == false
&& ((ChestReady && runner.WorldPosition.Distance(_rewardChest.WorldPosition) < 145f)
|| (PortalReady && runner.WorldPosition.Distance(_portal.WorldPosition) < 145f));
public Vector3 RewardChestPosition => _rewardChest.IsValid() ? _rewardChest.WorldPosition : Vector3.Zero;
public Vector3 PortalPosition => _portal.IsValid() ? _portal.WorldPosition : Vector3.Zero;
protected override void OnAwake()
{
Instance = this;
_dungeon = Components.GetOrCreate<ProceduralDungeon>();
}
protected override void OnStart()
{
GameProfile.Load();
PrototypeAudio.PreloadWeapons();
Components.Create<RunMusic>();
var ambientObject = new GameObject( true, "Ruin Ambient Fill" );
ambientObject.SetParent( GameObject, true );
ambientObject.Components.Create<AmbientLight>().Color = new Color( .14f, .17f, .22f );
var moonObject = new GameObject( true, "Moonlight" );
moonObject.WorldRotation = Rotation.From( new Angles( 48f, -32f, -18f ) );
moonObject.SetParent( GameObject, true );
var moon = moonObject.Components.Create<DirectionalLight>();
moon.LightColor = new Color( .34f, .48f, .72f ) * 2.1f;
moon.SkyColor = new Color( .05f, .08f, .13f );
moon.Shadows = true;
moon.ContactShadows = true;
moon.ShadowCascadeCount = 3;
if ( !Networking.IsActive ) SpawnPlayer( null );
}
public bool AcceptConnection(Connection channel, ref string reason)
{
if (channel == Connection.Local) return true;
if (Phase != RunPhase.Lobby) { reason = "This run has already started."; return false; }
if (string.IsNullOrEmpty(ServerPartyId))
{
// Debug instances need the scene snapshot to load their own solo menu.
// They are never added to the team or spawned as participants.
if (Application.IsEditor) return true;
reason = "This is a solo game."; return false;
}
if (!PartyLobbyView.HasConnectionSlot(Connection.All.Select(c => c.Id.ToString()), channel.Id.ToString())) { reason = "Lobby is full."; return false; }
if (!PartyRooms.TryGetValue(ServerPartyId, out var encoded) || !PartyLobbyView.TryDecode(ServerPartyId, encoded, out var room, out var expected))
{ reason = "Lobby is not ready yet."; return false; }
if (room.Locked && channel.GetUserData("dasher_join_key") != expected)
{ reason = "Incorrect lobby password."; return false; }
return true;
}
public void OnConnected(Connection connection)
{
if (IsHost && Phase == RunPhase.Lobby) LobbyCountdown.Clear();
}
public void OnActive(Connection connection)
{
if (!IsHost || connection is null) return;
Log.Info($"[Dasher lobby] Participant active: {connection.Id}; phase={Phase}; team={ServerPartyId}");
if (Phase == RunPhase.Lobby) AddServerMember(connection);
else if (IsRunMember(connection.Id)) SpawnPlayer(connection);
else connection.Kick("This run has already started.");
}
public void OnDisconnected( Connection connection )
{
if (!IsHost || connection is null) return;
if (Phase != RunPhase.Lobby && IsRunMember(connection.Id)) DepartRun(connection.Id);
else LeavePartyInternal(connection.Id.ToString());
foreach (var pawn in Scene.GetAll<FpsPlayer>().Where(p => p.ParticipantId == connection.Id).ToArray()) pawn.GameObject.Destroy();
}
public void OnBecameHost(Connection previousHost)
{
_enemies.Clear(); _enemies.AddRange(Scene.GetAll<EnemyBrain>());
_publishedLobbyState = "";
_dungeon.ApplyRemoteRoomState(RoomNumber, EnemiesAlive == 0);
_portalEntrants.Clear(); PortalEntered = 0;
if (previousHost is not null) OnDisconnected(previousHost);
// Readiness must be confirmed again after a host changes in the waiting room.
if (Phase == RunPhase.Lobby) { LobbyReady.Clear(); LobbyCountdown.Clear(); }
Status = "Host migrated";
}
private void SpawnPlayer( Connection owner )
{
if ( !IsHost ) return;
if ( Phase != RunPhase.Lobby && owner is not null && !IsRunMember( owner.Id ) ) return;
if ( owner is not null && Scene.GetAll<FpsPlayer>().Any( p => p.GameObject.Network.OwnerId == owner.Id ) ) return;
var index = Scene.GetAll<FpsPlayer>().Count();
var go = new GameObject( true, owner is null ? "Local Runner" : owner.DisplayName );
go.WorldPosition = (WorldReady ? _dungeon.CurrentCenter : Vector3.Zero) + new Vector3( index % 4 * 70f, 0, 8f );
go.Tags.Add( "player" );
go.Components.Create<HealthComponent>();
go.Components.Create<WeaponController>();
go.Components.Create<DasherBodyVisual>();
go.Components.Create<RunBuffs>();
var voice = go.Components.Create<PartyVoice>();
voice.Mode = Voice.ActivateMode.Manual;
voice.PushToTalkInput = "Voice";
voice.WorldspacePlayback = false;
voice.Loopback = false;
voice.LipSync = false;
go.Components.Create<PartyVoiceInput>();
var player = go.Components.Create<FpsPlayer>(); player.PlayerName = owner?.DisplayName ?? "Solo Runner"; player.ParticipantId = owner?.Id ?? Guid.Empty;
_players.Add( player );
if ( owner is not null ) go.NetworkSpawn( owner );
}
[Sync(SyncFlags.FromHost)] public NetDictionary<string,bool> PrivateLobbies { get; set; } = new();
[Sync(SyncFlags.FromHost)] public NetDictionary<string,bool> LobbyReady { get; set; } = new();
[Sync(SyncFlags.FromHost)] public NetDictionary<string,float> LobbyCountdown { get; set; } = new();
public bool ReadyFor(string id) => LobbyReady.TryGetValue(id, out var ready) && ready;
public float CountdownFor(string id) => LobbyCountdown.TryGetValue(id, out var end) ? MathF.Max(0,end-Time.Now) : -1;
public void ToggleLobbyReady()
{
if (SessionBusy) return;
if (Networking.IsActive && Networking.IsHost && string.IsNullOrEmpty(ServerPartyId))
{
_ = ReadyInSoloPreview();
return;
}
ToggleLobbyReadyHost();
}
[Rpc.Host]
private void ToggleLobbyReadyHost() => ToggleLobbyReadyInternal(Rpc.Caller ?? Connection.Local);
private void ToggleLobbyReadyInternal(Connection caller)
{
if (caller is null || Phase != RunPhase.Lobby || SessionBusy) return;
var id=caller.Id.ToString();
if (!PlayerParties.ContainsKey(id))
{
if (Networking.IsActive) return;
var roomId=Guid.NewGuid().ToString("N");
PartyRooms[roomId]=PartyLobbyView.Encode(new PartyLobbyView(roomId,caller.DisplayName+"'s Solo Run",id,false,false,new[]{id}), "");
PlayerParties[id]=roomId; PrivateLobbies[roomId]=true;
}
LobbyReady[id]=!ReadyFor(id);
LobbyCountdown.Remove(PlayerParties[id]);
if (!Networking.IsActive && !ReadyFor(id)) LeavePartyInternal(id);
}
private void UpdateLobbyCountdowns()
{
foreach(var room in AllPartyRooms.Where(r=>!r.Started).ToArray())
{
if(room.Members.Count==0 || !room.Members.All(ReadyFor) || (Networking.IsActive && Connection.All.Any(c => !room.Members.Contains(c.Id.ToString())))) { LobbyCountdown.Remove(room.Id); continue; }
if(!LobbyCountdown.TryGetValue(room.Id,out var end)) { LobbyCountdown[room.Id]=Time.Now+5f; continue; }
if(Time.Now<end) continue;
ActivePartyId=room.Id; RunMemberIds=string.Join(",",room.Members);
PartyLobbyView.TryDecode(room.Id,PartyRooms[room.Id],out _,out var password);
PartyRooms[room.Id]=PartyLobbyView.Encode(room with {Started=true},password);
LobbyCountdown.Clear(); LobbyReady.Clear(); BeginRunInternal(); break;
}
}
public void StartSolo() => StartSoloRun();
[Rpc.Host]
private void StartSoloRun()
{
if ( Phase != RunPhase.Lobby || Networking.IsActive || SessionBusy ) return;
var caller = Rpc.Caller ?? Connection.Local;
if ( caller is null || Phase != RunPhase.Lobby ) return;
LeavePartyInternal( caller.Id.ToString() );
var id = Guid.NewGuid().ToString( "N" );
var owner = caller.Id.ToString();
var room = new PartyLobbyView( id, $"{caller.DisplayName}'s Solo Run", owner, false, true, new[] { owner } );
PartyRooms[id] = PartyLobbyView.Encode( room, "" ); PlayerParties[owner] = id;
ActivePartyId = id; RunMemberIds = owner;
BeginRunInternal();
}
public void HostLobby() => HostLobby( $"{Connection.Local?.DisplayName ?? "Runner"}'s Run", "" );
public void HostLobby(string lobbyName, string password)
{
var name = string.IsNullOrWhiteSpace(lobbyName) ? "Dasher Co-op" : lobbyName.Trim();
name = name.Replace("\u001f", " ");
_ = CreateServerLobby(name[..Math.Min(name.Length, 32)], password ?? "");
}
public void JoinParty(string roomId, string password)
{
var lobby = ServerLobbies.FirstOrDefault(l => l.Id == roomId);
if (!string.IsNullOrEmpty(lobby.ServerAddress)) _ = ConnectToServer(lobby, password);
}
public static string PasswordToken( string value )
{
unchecked
{
uint hash = 2166136261;
foreach ( var character in value ?? "" ) { hash ^= character; hash *= 16777619; }
return hash.ToString( "x8" );
}
}
public async Task QuickJoin()
{
await RefreshServerLobbies();
var room = ServerLobbies.FirstOrDefault(l => !l.Locked && !l.IsFull && !l.Started && l.Id != ServerPartyId);
if (!string.IsNullOrEmpty(room.Id)) JoinParty(room.Id, ""); else Status = "No open lobby found";
}
public void LeaveLobby()
{
if (Phase != RunPhase.Lobby || SessionBusy) return;
_ = ReturnToSolo();
}
private void LeavePartyInternal( string playerId )
{
LobbyReady.Remove(playerId);
if ( !PlayerParties.TryGetValue( playerId, out var roomId ) ) return;
PlayerParties.Remove( playerId );
LobbyCountdown.Remove(roomId);
if ( !PartyRooms.TryGetValue( roomId, out var encoded ) || !PartyLobbyView.TryDecode( roomId, encoded, out var room, out var password ) ) return;
var members = room.Members.Where( id => id != playerId ).ToArray();
if ( members.Length == 0 ) { PartyRooms.Remove( roomId ); PrivateLobbies.Remove(roomId); return; }
var owner = room.OwnerId == playerId ? members[0] : room.OwnerId;
PartyRooms[roomId] = PartyLobbyView.Encode( room with { OwnerId = owner, Members = members }, password );
}
private string LocalConnectionId => Connection.Local?.Id.ToString() ?? "";
[Rpc.Host]
public void BeginRun()
{
if ( Phase != RunPhase.Lobby ) return;
var caller = Rpc.Caller ?? Connection.Local;
if ( caller is null || !PlayerParties.TryGetValue( caller.Id.ToString(), out var roomId ) || !PartyRooms.TryGetValue( roomId, out var encoded ) || !PartyLobbyView.TryDecode( roomId, encoded, out var room, out var password ) || room.OwnerId != caller.Id.ToString() ) return;
if (!room.Members.All(ReadyFor) || !LobbyCountdown.TryGetValue(roomId, out var deadline) || Time.Now < deadline) return;
ActivePartyId = roomId;
RunMemberIds = string.Join( ",", room.Members.Distinct().Take( MaxPlayers ) );
PartyRooms[roomId] = PartyLobbyView.Encode( room with { Started = true }, password );
BeginRunInternal();
}
[Sync(SyncFlags.FromHost)] public string CoinRunId { get; private set; } = "";
[Sync(SyncFlags.FromHost)] public NetDictionary<string, int> RunCoinTotals { get; set; } = new();
public int CoinsFor(Guid id) => RunCoinTotals.TryGetValue(id.ToString(), out var total) ? total : 0;
public int LocalRunCoins => CoinsFor(Networking.IsActive ? Connection.Local?.Id ?? Guid.Empty : Guid.Empty);
private void AwardTeamCoins(int amount)
{
foreach (var runner in RunPlayers.Where(p => p.Vitality?.IsDead == false))
{
var id = runner.ParticipantId;
var total = CoinsFor(id) + amount;
RunCoinTotals[id.ToString()] = total;
runner.ReceiveCoins(CoinRunId, total);
}
}
[Sync(SyncFlags.FromHost)] public string RunTeamNames { get; private set; } = "";
[Sync(SyncFlags.FromHost)] public long RunRankingOwner { get; private set; }
private void BeginRunInternal()
{
if ( Phase != RunPhase.Lobby ) return;
RematchVotes.Clear();
DepartedRunIds = "";
if ( Networking.IsActive )
{
foreach ( var outsider in Scene.GetAll<FpsPlayer>().Where( p => !IsRunPlayer( p ) ).ToArray() ) outsider.GameObject.Destroy();
foreach ( var connection in Connection.All.Where( c => IsRunMember( c.Id ) ) ) SpawnPlayer( connection );
}
foreach(var runner in RunPlayers) { runner.Buffs?.ResetHost(); runner.Weapon?.ResetHostMagazine(); }
RunTeamNames = Networking.IsActive
? string.Join(" + ", Connection.All.Where(c => IsRunMember(c.Id)).OrderBy(c => c.Id).Select(c => c.DisplayName))
: Connection.Local?.DisplayName ?? "Runner";
CoinRunId = Guid.NewGuid().ToString(); RunCoinTotals.Clear();
Level = 1; Score = 0; RunKills = 0; RunSeconds = 0f; FinalRunSeconds = 0f;
StartLevel( true );
}
private void StartLevel( bool freshRun )
{
_portalEntrants.Clear(); PortalEntered = 0; PortalRequired = 0;
foreach ( var enemy in Scene.GetAll<EnemyBrain>().ToArray() ) enemy.GameObject.Destroy();
foreach ( var projectile in Scene.GetAll<Projectile>().ToArray() ) projectile.GameObject.Destroy();
Seed = Game.Random.Int( 100000, 999999 );
Phase = RunPhase.Generating; RoomNumber = 0;
_dungeon.Generate( Seed, 7 );
_generatedSeed = Seed;
foreach ( var player in RunPlayers )
{
player.TeleportForLevel( new Vector3( Game.Random.Float( -100, 100 ), Game.Random.Float( -100, 100 ), 8f ), Seed, freshRun );
// TeleportForLevel applies the owning player's purchased health.
// Never overwrite it here with the unupgraded 100 HP baseline.
}
_buffChosen = false;
Phase = RunPhase.Combat; SpawnWave(); Status = $"Level {Level} · Seed {Seed}";
}
protected override void OnUpdate()
{
UpdateServerSession();
if (_returningToSolo || SessionBusy) return;
GameProfile.UpdateAudio(Time.Delta);
GameProfile.RetryPendingRuns();
var localRunner = LocalPlayer;
if ( !IsRunMenuOpen && LocalInRun && ChestReady && localRunner?.Vitality?.IsDead == false && _rewardChest.IsValid()
&& localRunner.WorldPosition.Distance( _rewardChest.WorldPosition ) < 145f && Input.Pressed( "Use" ) ) OpenRewardChest( Seed );
if ( !IsRunMenuOpen && LocalInRun && PortalReady && localRunner?.Vitality?.IsDead == false
&& localRunner.WorldPosition.Distance( _portal.WorldPosition ) < 145f && Input.Pressed( "Use" ) )
ConfirmPortal( Seed );
if ( !IsHost )
{
// Static geometry is local; synced enemies alone do not give clients a floor.
if ( Seed != 0 && Phase != RunPhase.Lobby && _generatedSeed != Seed )
{
_dungeon.Generate( Seed, 7 );
_generatedSeed = Seed;
}
if ( WorldReady ) _dungeon.ApplyRemoteRoomState( RoomNumber, EnemiesAlive == 0 );
return;
}
if (Phase == RunPhase.Defeat) TryStartRematch();
if (Phase == RunPhase.Lobby) UpdateLobbyCountdowns();
if ( StartLocalChaserTest && !Networking.IsActive )
{
StartLocalChaserTest = false;
ReviewChaserInGame = true;
BeginRunInternal();
}
_dungeon.UpdateEntranceGate( RunPlayers.Where( p => p.Vitality?.IsDead == false ).Select( p => p.WorldPosition ) );
if ( Phase is RunPhase.Combat or RunPhase.Reward ) RunSeconds += Time.Delta;
// Remote Health sync does not invoke the local Died delegate. Evaluate the
// party after replicated health arrives, not only on the host player's death.
if ( Phase is RunPhase.Combat or RunPhase.Reward )
{
var runners = RunPlayers.ToArray();
if ( runners.Length > 0 && runners.All( p => p.Vitality?.IsDead == true ) ) NotifyPlayerDeath( runners[0] );
}
if ( Phase == RunPhase.Reward )
{
// Also publish a chest created by the previous code in an ongoing run.
if ( Networking.IsActive && _rewardChest.IsValid() && !_rewardChest.Network.Active ) _rewardChest.NetworkSpawn();
if ( _pendingChest && Time.Now >= _rewardRetryAt ) SpawnBossReward( _bossDeathPosition );
if ( PortalReady )
{
var living = RunPlayers.Where( p => p.Vitality?.IsDead == false ).ToArray();
PortalRequired = living.Length;
PortalEntered = living.Count( p => _portalEntrants.Contains( p.GameObject ) );
if ( PortalRequired > 0 && PortalEntered == PortalRequired && AllRewardsChosen ) AdvanceLevel();
}
return;
}
if ( Phase != RunPhase.Combat ) return;
if ( EnemiesAlive == 0 )
{
var healKey = $"{CoinRunId}:{Seed}:{RoomNumber}";
if (_healedRoom != healKey) { _healedRoom=healKey; foreach(var p in RunPlayers) p.Buffs?.RoomCleared(Seed,RoomNumber); }
var livingPlayers = RunPlayers
.Where( player => player.Vitality?.IsDead == false )
.Select( player => player.WorldPosition )
.ToList();
if ( _dungeon.TryAdvance( livingPlayers ) )
{
RoomNumber = _dungeon.ActiveRoom;
SpawnWave();
}
}
}
[Rpc.Host]
private void ConfirmPortal( int runSeed )
{
if ( !PortalReady || Seed != runSeed ) return;
var caller = Rpc.Caller ?? Connection.Local;
var runner = Networking.IsActive
? RunPlayers.FirstOrDefault( p => caller is not null && p.GameObject.Network.OwnerId == caller.Id )
: LocalPlayer;
if ( runner?.Vitality?.IsDead != false || runner.WorldPosition.Distance( _portal.WorldPosition ) >= 145f ) return;
_portalEntrants.Add( runner.GameObject );
var living = RunPlayers.Where( p => p.Vitality?.IsDead == false ).ToArray();
PortalRequired = living.Length;
PortalEntered = living.Count( p => _portalEntrants.Contains( p.GameObject ) );
if ( PortalRequired > 0 && PortalEntered == PortalRequired && AllRewardsChosen ) AdvanceLevel();
}
private void SpawnWave()
{
_enemies.Clear();
var isBoss = RoomNumber == _dungeon.Rooms.Count - 1;
var count = isBoss ? 1 : 4 + RoomNumber * 2 + (Level - 1) * 2;
for ( var i = 0; i < count; i++ )
{
var role = isBoss ? EnemyRole.Boss : (EnemyRole)(i % 3);
var spec = PrototypeCatalog.Enemy( role, RoomNumber );
// Progression increases combat stats, never the visual model scale.
var depthHealth = MathF.Pow(1.4f, Level - 1);
var depthDamage = MathF.Pow(1.22f, Level - 1);
spec = spec with { Health = spec.Health * (isBoss ? 2.2f : 1.4f) * depthHealth, Damage = spec.Damage * 1.5f * depthDamage, AttackCooldown = spec.AttackCooldown * .85f };
var position = FindEnemySpawn( spec, i, count );
// Keep gameplay/collision on an unscaled root. The visible body is a child;
// otherwise the root scale also multiplies its collider and decorations.
var go = new GameObject( true, role.ToString() );
go.WorldPosition = position;
if ( !RuinVisualFactory.CreateEnemyModel( go, role, spec.Color, 27f * spec.Scale + 2f ) )
{
PrototypeFactory.Disc( "Body", position, 28f * spec.Scale, 66f * spec.Scale, spec.Color, go );
RuinVisualFactory.DecorateEnemy( go, role, spec.Color, spec.Scale );
}
go.Tags.Add( "enemy" );
// The imported enemies are tall humanoids. A sphere only covered their
// ankles/pelvis and made shots through the torso miss, so collision follows
// the full visible silhouette with a vertical capsule.
var collider = go.Components.Create<CapsuleCollider>();
collider.Radius = 20f * spec.Scale;
// Root is radius+2 above the floor. Keep the capsule's lower hemisphere
// tangent to that floor instead of burying its centre below it.
collider.Start = Vector3.Down * (7f * spec.Scale + 2f);
collider.End = Vector3.Up * ((role == EnemyRole.Boss ? 120f : 78f) * spec.Scale);
collider.IsTrigger = false;
go.Components.Create<HealthComponent>();
var brain = go.Components.Create<EnemyBrain>(); brain.Configure( spec );
if ( role == EnemyRole.Chaser ) go.Components.Create<ChaserGameplayPreview>();
if ( role == EnemyRole.Gunner ) go.Components.Create<GunnerGameplayPreview>();
if ( role == EnemyRole.Charger ) go.Components.Create<ChargerGameplayPreview>();
if ( role == EnemyRole.Boss ) go.Components.Create<BossGameplayPreview>();
_enemies.Add( brain );
if ( Networking.IsActive ) go.NetworkSpawn();
}
EnemiesAlive = count;
if ( count > 0 ) _dungeon.CloseEntrance();
}
private Vector3 FindEnemySpawn( EnemySpec spec, int index, int count )
{
var radius = 30f * spec.Scale;
var players = RunPlayers.Where( p => p.Vitality?.IsDead == false ).ToArray();
for ( var attempt = 0; attempt < 64; attempt++ )
{
var angle = (index * 360f / Math.Max( 1, count )) + attempt * 137.5f;
var distance = Game.Random.Float( 520f, 900f );
var candidate = _dungeon.CurrentCenter + Rotation.FromYaw( angle ).Forward * distance;
candidate = candidate.WithZ( radius + 2f );
if ( players.Any( player => player.WorldPosition.WithZ( 0f ).Distance( candidate.WithZ( 0f ) ) < 450f ) )
continue;
if ( !_dungeon.IsClearForSpawn( candidate, radius ) ) continue;
// Validate against the actual scene as well as generated bounds. This catches
// gates and any authored collider that is not represented in the map data.
var physicalCheck = Scene.Trace.Sphere( radius + 35f, candidate + Vector3.Up * 40f, candidate + Vector3.Up * 41f )
.WithoutTags( "player", "enemy", "projectile" ).Run();
if ( physicalCheck.Hit ) continue;
if ( _enemies.Any( enemy => enemy.IsValid && enemy.WorldPosition.WithZ( 0f ).Distance( candidate.WithZ( 0f ) ) < radius + 70f ) )
continue;
var occupied = Scene.Trace.Sphere( radius, candidate, candidate + Vector3.Up * 1f )
.WithoutTags( "projectile", "enemy" ).Run();
if ( occupied.Hit ) continue;
var floor = Scene.Trace.Ray( candidate, candidate + Vector3.Down * (radius + 24f) )
.WithoutTags( "projectile", "enemy", "player" ).Run();
if ( !floor.Hit ) continue;
return candidate.WithZ( floor.HitPosition.z + radius + 2f );
}
// The generator keeps the centre clear, so this deterministic fallback is
// still valid if all randomized perimeter candidates were rejected.
var fallbackAngle = index * 360f / Math.Max( 1, count );
return _dungeon.CurrentCenter + Rotation.FromYaw( fallbackAngle ).Forward * 620f + Vector3.Up * (radius + 2f);
}
public void NotifyEnemyDeath( EnemyBrain enemy )
{
if ( !IsHost || Phase != RunPhase.Combat ) return;
var deathPosition = enemy.WorldPosition;
var wasBoss = enemy.Spec.Role == EnemyRole.Boss;
if ( wasBoss ) _bossDeathPosition = deathPosition;
_enemies.Remove( enemy ); EnemiesAlive = Math.Max( 0, EnemiesAlive - 1 );
RunKills++;
AwardTeamCoins(wasBoss ? 80 * Level : 4 + Level);
Score += wasBoss ? 1000 * Level : 100 + RoomNumber * 25 + (Level - 1) * 50;
if ( EnemiesAlive != 0 ) return;
foreach(var p in RunPlayers) p.Buffs?.RoomCleared(Seed,RoomNumber);
if ( RoomNumber == 6 ) SpawnBossReward( _bossDeathPosition );
else
{
_dungeon.OpenExit();
Phase = RunPhase.RoomClear;
Status = "Room clear — exit gate opened";
Phase = RunPhase.Combat;
}
}
private void SpawnBossReward( Vector3 position )
{
if ( !IsHost || RoomNumber != 6 || _dungeon.Rooms.Count <= 6 ) return;
Phase = RunPhase.Reward;
if ( !TryFindRewardPosition( position, 120f, 150f, false, out var safePosition ) )
{
_pendingChest = true; _rewardRetryAt = Time.Now + 1f;
Status = "Move away from the reward area";
return;
}
_pendingChest = false;
_buffChosen = false;
_chestOpened = false;
_rewardChest?.Destroy(); _portal?.Destroy();
_rewardChest = new GameObject( true, "Boss Reward Chest" );
_rewardChest.WorldPosition = safePosition;
var chestCollider = _rewardChest.Components.Create<BoxCollider>();
chestCollider.Scale = new Vector3( 80f, 58f, 52f );
chestCollider.Center = Vector3.Up * 26f;
if ( !RuinVisualFactory.UseImportedModel( _rewardChest, "models/prototype/reward_chest.vmdl", new Color(1f,.62f,.12f) ) ) RuinVisualFactory.DecorateChest( _rewardChest );
if ( Networking.IsActive ) _rewardChest.NetworkSpawn();
Status = "Boss defeated — choose a reward from the chest";
}
private FpsPlayer RequestingRunner()
{
if ( !Networking.IsActive ) return LocalPlayer;
var caller = Rpc.Caller ?? Connection.Local;
return RunPlayers.FirstOrDefault( p => caller is not null && p.GameObject.Network.OwnerId == caller.Id );
}
[Rpc.Host]
private void OpenRewardChest( int seed )
{
var runner = RequestingRunner();
if ( Seed != seed || !ChestReady || runner?.Vitality?.IsDead != false || !_rewardChest.IsValid()
|| runner.WorldPosition.Distance( _rewardChest.WorldPosition ) >= 145f ) return;
_chestOpened = true;
foreach(var player in RunPlayers.Where(p => p.Vitality?.IsDead == false)) player.Buffs?.Roll(Seed);
Status = "Each teammate chooses one reward for this run";
}
[Rpc.Host]
public void ClaimBuff( string buff, int offeredSeed )
{
var runner = RequestingRunner();
if (offeredSeed != Seed || runner?.Vitality?.IsDead != false || Phase != RunPhase.Reward || !_chestOpened || runner.Buffs is not { } choices || choices.OfferedSeed != Seed || choices.ClaimedSeed == Seed || !choices.Choices.Any(b => b.Id == buff)) return;
var preferred = (_rewardChest.IsValid() ? _rewardChest.WorldPosition : _dungeon.CurrentCenter) + Vector3.Forward * 320f;
var portalPosition = Vector3.Zero;
if ( !_buffChosen && !TryFindRewardPosition( preferred, 160f, 300f, true, out portalPosition ) )
{
Status = "No clear portal position — move away and choose again";
return;
}
choices.Ranks[buff] = choices.Rank(buff)+1;
choices.ClaimedSeed = Seed;
choices.Grant(buff,Seed);
if (_buffChosen) return;
_buffChosen = true;
PrototypeAudio.Play( "chest_open", _rewardChest.IsValid() ? _rewardChest.WorldPosition : _dungeon.CurrentCenter );
if ( _rewardChest.IsValid() ) _rewardChest.Components.Get<ModelRenderer>()?.Tint = new Color( .25f, .3f, .34f );
_portal = new GameObject( true, "Next Level Portal" );
_portal.WorldPosition = portalPosition;
if ( !RuinVisualFactory.UseImportedModel( _portal, "models/prototype/level_portal.vmdl", new Color(.25f,.45f,1f) ) ) RuinVisualFactory.DecoratePortal( _portal );
else
{
var glow = _portal.Components.Create<PointLight>();
glow.LightColor = new Color(.12f,.42f,1f) * 3f;
glow.Radius = 310f;
glow.Shadows = false;
}
if ( Networking.IsActive ) _portal.NetworkSpawn();
_portalEntrants.Clear(); PortalEntered = 0;
PortalRequired = RunPlayers.Count( p => p.Vitality?.IsDead == false );
PrototypeAudio.Play( "portal_open", portalPosition );
Status = "Reward acquired — enter the portal and press E";
}
private bool TryFindRewardPosition( Vector3 preferred, float radius, float height, bool avoidChest, out Vector3 result )
{
result = default;
const int rewardRoom = 6;
if ( _dungeon.Rooms.Count <= rewardRoom ) return false;
var rewardCenter = _dungeon.Rooms[rewardRoom].World( ProceduralDungeon.RoomSpacing );
var candidates = new System.Collections.Generic.List<Vector3> { preferred.WithZ(0) };
for ( var x = -1000; x <= 1000; x += 100 )
for ( var y = -1000; y <= 1000; y += 100 )
candidates.Add( rewardCenter + new Vector3( x, y, 0 ) );
foreach ( var point in candidates.OrderBy( p => p.Distance( preferred ) ) )
{
if ( !_dungeon.IsClearForSpawn( point, radius + 45f, rewardRoom ) ) continue;
if ( avoidChest && _rewardChest.IsValid() && point.Distance( _rewardChest.WorldPosition ) < radius + 170f ) continue;
var floor = Scene.Trace.Ray( point + Vector3.Up * 30f, point + Vector3.Down * 50f ).WithoutTags( "enemy", "player", "projectile" ).Run();
if ( !floor.Hit || floor.Normal.z < .9f ) continue;
var basePoint = point.WithZ( floor.HitPosition.z + 1f );
var occupied = Scene.Trace.Box( new Vector3( radius * 2, radius * 2, height ), basePoint + Vector3.Up * (height * .5f + 1), basePoint + Vector3.Up * (height * .5f + 2) )
.WithoutTags( "enemy", "projectile" ).Run();
if ( occupied.Hit ) continue;
bool supported = true;
foreach ( var offset in new[] { Vector3.Forward, Vector3.Backward, Vector3.Left, Vector3.Right } )
{
var edge = basePoint + offset * radius;
var support = Scene.Trace.Ray( edge + Vector3.Up * 10, edge + Vector3.Down * 10 ).WithoutTags( "enemy", "player", "projectile" ).Run();
if ( !support.Hit || System.MathF.Abs( support.HitPosition.z - floor.HitPosition.z ) > 3 ) { supported = false; break; }
}
if ( !supported ) continue;
result = basePoint; return true;
}
result = default; return false;
}
private void AdvanceLevel()
{
_rewardChest?.Destroy(); _portal?.Destroy();
Level++;
StartLevel( false );
}
public void NotifyPlayerDeath( FpsPlayer player, bool abandoned = false )
{
if ( !IsHost || Phase is RunPhase.Lobby or RunPhase.Defeat ) return;
var remaining = RunPlayers.ToArray();
// Missing/transferring pawns are not proof that the team has died.
var expected = Networking.IsActive ? Connection.All.Count(c => IsRunMember(c.Id)) : 1;
if ( abandoned || (remaining.Length > 0 && remaining.Length == expected && remaining.All(p => p.Vitality?.IsDead == true)) )
{
FinalRunSeconds = RunSeconds;
Phase = RunPhase.Defeat; Status = "Team eliminated";
var teamSize = Math.Clamp( Networking.IsActive ? Math.Max( 1, RunMemberIds.Split( ',', StringSplitOptions.RemoveEmptyEntries ).Length ) : 1, 1, 4 );
var category = new[] { "Solo", "Duo", "Trio", "Squad" }[teamSize - 1];
RunRankingOwner = Networking.IsActive
? Connection.All.Where(c => RunMemberIds.Split(',').Contains(c.Id.ToString())).OrderByDescending(c => IsRunMember(c.Id)).ThenBy(c => c.Id).Select(c => (long)c.SteamId).FirstOrDefault()
: (long)Sandbox.Game.SteamId;
PublishRunResult(category, Score, RunKills, Math.Max(0, Level - 1), RunSeconds, RunRankingOwner, RunTeamNames, CoinRunId);
}
}
[Rpc.Broadcast]
private void PublishRunResult(string category, int score, int kills, int levels, float seconds, long rankingOwner, string teamNames, string runId)
{
if (Rpc.Caller is not null && !Rpc.Caller.IsHost) return;
if (Networking.IsActive && (Connection.Local is null || !RunMemberIds.Split(',').Contains(Connection.Local.Id.ToString()))) return;
GameProfile.AddRun(new RunRecord { RunId = runId, PlayerName = teamNames, Category = category, Score = score, Kills = kills, Levels = levels, Seconds = seconds, FinishedAt = DateTimeOffset.UtcNow.ToUnixTimeSeconds() }, (long)Sandbox.Game.SteamId == rankingOwner);
}
[Sync(SyncFlags.FromHost)] public NetDictionary<string, string> RematchVotes { get; set; } = new();
private string[] RematchParticipants => Networking.IsActive
? Connection.All.Where(c => IsRunMember(c.Id)).Select(c => c.Id.ToString()).Distinct().ToArray()
: new[] { Guid.Empty.ToString() };
public int RematchRequired => RematchParticipants.Length;
public int RematchReady => RematchParticipants.Count(id => RematchVotes.TryGetValue(id, out var run) && run == CoinRunId);
public bool RematchConfirmed => RematchVotes.TryGetValue((Networking.IsActive ? Connection.Local?.Id ?? Guid.Empty : Guid.Empty).ToString(), out var run) && run == CoinRunId;
public string RematchButtonText => $"{(RematchConfirmed ? "READY" : "One More Round")} {RematchReady} / {RematchRequired}";
public void OneMoreRound()
{
if (Networking.IsActive) RestartPartyRun(CoinRunId);
else ConfirmRematch(CoinRunId, Guid.Empty);
}
[Rpc.Host]
private void RestartPartyRun(string finishedRunId)
{
var caller = Rpc.Caller ?? Connection.Local;
if (caller is null || !IsRunMember(caller.Id)) return;
ConfirmRematch(finishedRunId, caller.Id);
}
private void ConfirmRematch(string finishedRunId, Guid participant)
{
if (!IsHost || Phase != RunPhase.Defeat || finishedRunId != CoinRunId) return;
RematchVotes[participant.ToString()] = finishedRunId;
TryStartRematch();
}
private void TryStartRematch()
{
if (!IsHost || Phase != RunPhase.Defeat) return;
var participants = RematchParticipants;
if (participants.Length == 0 || !participants.All(id => RematchVotes.TryGetValue(id, out var run) && run == CoinRunId)) return;
if (Networking.IsActive) RunMemberIds = string.Join(",", participants);
CleanupRun();
LobbyReady.Clear(); LobbyCountdown.Clear();
// Lobby is only the internal entry condition; start immediately in this tick.
Phase = RunPhase.Lobby;
BeginRunInternal();
}
public void LeaveRun() => AbandonRun(CoinRunId);
[Rpc.Host]
private void AbandonRun(string runId)
{
var caller = Rpc.Caller ?? Connection.Local;
if (runId != CoinRunId || Phase == RunPhase.Lobby) return;
if (Networking.IsActive && (caller is null || !IsRunMember(caller.Id))) return;
var id = caller?.Id ?? Guid.Empty;
SettleDeparture(id, runId, CoinsFor(Networking.IsActive ? id : Guid.Empty));
DepartRun(id);
}
private void DepartRun(Guid id)
{
if (Networking.IsActive)
{
DepartedRunIds = string.Join(",", DepartedRunIds.Split(',', StringSplitOptions.RemoveEmptyEntries).Append(id.ToString()).Distinct());
LeavePartyInternal(id.ToString());
foreach (var player in Scene.GetAll<FpsPlayer>().Where(p => p.ParticipantId == id).ToArray())
player.GameObject.Destroy();
}
if (!Networking.IsActive || !Connection.All.Any(c => IsRunMember(c.Id)))
{
NotifyPlayerDeath(null, true);
CleanupRun();
Phase = RunPhase.Lobby;
ActivePartyId = "";
}
else NotifyPlayerDeath(null);
}
[Rpc.Broadcast]
private void SettleDeparture(Guid playerId, string runId, int earned)
{
if (Rpc.Caller is not null && !Rpc.Caller.IsHost) return;
if (Networking.IsActive && Connection.Local?.Id != playerId) return;
GameProfile.BankCoins(runId, earned);
IsRunMenuOpen = false;
_ = ReturnToSolo();
}
public void Restart() => ReturnToLobby();
[Rpc.Host]
private void ReturnToLobby()
{
// Any teammate can return the defeated party; a delayed click must not
// terminate a new run that has already started.
if ( Phase != RunPhase.Defeat || (Networking.IsActive && (Rpc.Caller is null || !IsRunMember( Rpc.Caller.Id ))) ) return;
ReturnPartyToWaiting();
}
private void ReturnPartyToWaiting()
{
RematchVotes.Clear();
LobbyReady.Clear();
LobbyCountdown.Clear();
CleanupRun();
foreach ( var pair in PartyRooms.ToArray() )
{
if ( pair.Key == ActivePartyId && PartyLobbyView.TryDecode( pair.Key, pair.Value, out var room, out var password ) && room.Started )
PartyRooms[pair.Key] = PartyLobbyView.Encode( room with { Started = false }, password );
}
Phase = RunPhase.Lobby;
ActivePartyId = ""; RunMemberIds = "";
if (!Networking.IsActive) { PartyRooms.Clear(); PlayerParties.Clear(); PrivateLobbies.Clear(); LobbyReady.Clear(); LobbyCountdown.Clear(); }
Status = "Ready";
}
public void ToggleWeapon() => LocalPlayer?.EquipAlternate();
private void CleanupRun()
{
foreach ( var e in Scene.GetAll<EnemyBrain>().ToArray() ) e.GameObject.Destroy();
foreach ( var projectile in Scene.GetAll<Projectile>().ToArray() ) projectile.GameObject.Destroy();
_rewardChest?.Destroy(); _portal?.Destroy();
_pendingChest = false; _chestOpened = false; _buffChosen = false;
EnemiesAlive = 0;
}
private void RemoveOfflinePlayers() { foreach ( var p in Scene.GetAll<FpsPlayer>().Where( p => !p.GameObject.Network.Active ).ToArray() ) p.GameObject.Destroy(); }
private static Color PlayerColor( int i ) => new[] { new Color( .2f, .85f, 1f ), new Color( .35f, 1f, .45f ), new Color( 1f, .7f, .2f ), new Color( .8f, .35f, 1f ) }[i % 4];
protected override void OnDestroy() { if ( Instance == this ) Instance = null; }
}