Game/MonolithManager.cs
namespace Monolith;
/// <summary>
/// Owns the shared monolith. The host is authoritative: it applies every blast and broadcasts
/// the operation (centre plus radius), never the resulting voxel list. Clients apply the same
/// deterministic operation, so a whole wall coming down costs a handful of bytes.
///
/// All wire traffic goes through <see cref="MonolithNet"/> static RPCs, which is why this
/// component does not need to be a networked object.
/// </summary>
public sealed class MonolithManager : Component
{
public static MonolithManager Instance { get; private set; }
// Note: blast radius is uncapped by design (see Tuning). The only clamp is the world size,
// applied in HostHandleBlast.
/// <summary>Stage index to start a fresh session on. Handy for jumping straight to a shape.</summary>
[Property] public int StartStage { get; set; } = 0;
[Property] public MonolithRenderer Renderer { get; set; }
public VoxelWorld World { get; private set; }
/// <summary>Which rung of the ladder we are on. Each shape is different, not just bigger.</summary>
public int Tier { get; private set; }
/// <summary>
/// Solo climbs the ladder; Monolith is the shared rock everyone attacks. The door is open
/// from the first minute: the gate is power, not permission (see GOALS.md).
/// </summary>
public bool InMonolith { get; private set; }
public string StageName => InMonolith ? "THE MONOLITH" : Stages.NameFor( Tier );
/// <summary>Set for a few seconds after a clear, so the HUD can celebrate before the next
/// shape condenses. Purely cosmetic.</summary>
public bool StageJustCleared { get; private set; }
public string LastClearedName { get; private set; }
public long RemainingCubes => World?.SolidCount ?? 0;
public long TotalCubes => World?.InitialCount ?? 0;
/// <summary>False on a client until the host has streamed us the current voxel state.</summary>
public bool SnapshotReady { get; private set; }
private VoidGrid grid;
private FloorHazard floor;
private ShieldShell shield;
private bool clearAnnounced;
private float clearedBannerUntil;
private bool residueLogged;
private GameTimeSince timeSinceResidueScan;
private float monolithStartedAt;
/// <summary>Seconds since the current shared Monolith condensed, for the speedrun board.</summary>
public float MonolithSeconds => InMonolith ? GameTime.Now - monolithStartedAt : 0f;
/// <summary>
/// World position of what is left when only a handful of cubes remain, so the HUD can
/// point at it. Null when there is plenty left and no guidance is needed.
/// </summary>
public Vector3? ResidueCentre { get; private set; }
/// <summary>
/// Positions of what is left, once there is little enough to be worth listing. Drones read
/// this so they keep working through the tail of a stage, where a random ray finds nothing.
/// </summary>
private readonly List<Vector3Int> residueVoxels = new();
/// <summary>
/// Hands a drone one of the remaining cubes. Returns false while the shape is still full,
/// which is correct: random rays are cheaper and the list is not maintained then.
/// </summary>
public bool TryPickResidueVoxel( out Vector3Int voxel )
{
voxel = default;
if ( residueVoxels.Count == 0 )
return false;
voxel = residueVoxels[Game.Random.Int( 0, residueVoxels.Count - 1 )];
// It may have been shot since the last scan. Cheap to verify, and handing back a hole
// would waste the drone's shot on empty space.
return World != null && World.IsSolid( voxel.x, voxel.y, voxel.z );
}
/// <summary>
/// Average position of every remaining cube, and the list of them.
///
/// **This was the stall.** It used to walk every voxel in the world, which is fine for a
/// 3,000 cube ladder stage and catastrophic for the shared Monolith: 256^3 is 16.7 MILLION
/// iterations, and it ran every 0.75 seconds for as long as the residue counter was on
/// screen. That is exactly the "slows down considerably at ~200 cubes left" report, and the
/// cruel part is that it got slower the CLOSER you were to finishing.
///
/// Now it walks chunks and skips empty ones. With 200 cubes left almost every chunk is
/// empty, so the work collapses from the whole world to the two or three chunks that still
/// hold anything. Each chunk also stops early once it has found its own SolidCount, so a
/// chunk holding one cube costs one hit rather than 32,768 misses.
/// </summary>
private Vector3? FindResidueCentre()
{
residueVoxels.Clear();
if ( World == null ) return null;
long count = 0;
Vector3 sum = Vector3.Zero;
for ( int i = 0; i < World.ChunkCount; i++ )
{
var chunk = World.GetChunkByIndex( i );
if ( chunk == null || chunk.IsEmpty )
continue;
var coord = World.ChunkCoordFromIndex( i );
int baseX = coord.x * VoxelChunk.Size;
int baseY = coord.y * VoxelChunk.Size;
int baseZ = coord.z * VoxelChunk.Size;
int found = 0;
for ( int z = 0; z < VoxelChunk.Size && found < chunk.SolidCount; z++ )
for ( int y = 0; y < VoxelChunk.Size && found < chunk.SolidCount; y++ )
for ( int x = 0; x < VoxelChunk.Size && found < chunk.SolidCount; x++ )
{
if ( !chunk.Get( x, y, z ) ) continue;
found++;
int wx = baseX + x;
int wy = baseY + y;
int wz = baseZ + z;
sum += World.VoxelToWorld( wx, wy, wz );
count++;
if ( residueVoxels.Count < 512 )
residueVoxels.Add( new Vector3Int( wx, wy, wz ) );
}
}
return count == 0 ? null : sum / count;
}
// Host-side queue for streaming a snapshot to a joining client without stalling a frame.
private readonly Queue<(Connection target, int chunkIndex)> snapshotQueue = new();
private const int SnapshotChunksPerFrame = 24;
protected override void OnAwake()
{
Instance = this;
Renderer ??= Components.Get<MonolithRenderer>();
grid = Components.Get<VoidGrid>() ?? Components.Create<VoidGrid>();
floor = Components.Get<FloorHazard>() ?? Components.Create<FloorHazard>();
shield = Components.Get<ShieldShell>() ?? Components.Create<ShieldShell>();
}
protected override void OnStart()
{
if ( Networking.IsHost )
{
BuildWorld( StartStage );
SnapshotReady = true;
}
else
{
// Wait for the host's header before building anything: it tells us the size.
MonolithNet.RequestSnapshot();
}
}
protected override void OnDestroy()
{
if ( Instance == this )
Instance = null;
}
private TimeSince timeSinceFrameCheck;
/// <summary>
/// Catches long frames and prints what the world looked like when one happened.
///
/// Collapse has now hung four times with nothing in the log but a stall warning, and the
/// `[collapse]` timer proved the hang is NOT inside Collapse: it completes in under 50ms and
/// the freeze lands seconds later. Everything after that has been guesswork, and guessing has
/// cost more than instrumenting would have.
///
/// `Time.Delta` on the frame AFTER a hang reports the hang, so this names the duration and
/// dumps the state that could plausibly explain it. Whatever is actually responsible, the
/// next occurrence will say so instead of leaving another hypothesis.
/// </summary>
private void WatchFrameTime()
{
// A tenth of a second is already a visible hitch and nowhere near normal.
if ( Time.Delta < 0.1f )
return;
// Never fires twice for the same event, and stays quiet through the genuinely heavy
// first frames after a world is built.
if ( timeSinceFrameCheck < 1f )
return;
timeSinceFrameCheck = 0f;
int pending = Renderer.IsValid() ? Renderer.PendingRebuilds : -1;
var progress = PlayerProgress.Local;
Log.Warning( $"[hitch] frame took {Time.Delta * 1000f:0}ms | "
+ $"stage {Tier + 1}{(InMonolith ? " (monolith)" : "")} "
+ $"chunks {World?.ChunkCount ?? 0} solid {RemainingCubes:N0} | "
+ $"mesh backlog {pending} | "
+ $"bolts {Projectile.LiveCount} drones {MiningDrone.All.Count} "
+ $"spotters {Spotter.All.Count} sentinels {Sentinel.All.Count} "
+ $"orbs {SentinelOrb.All.Count} anchors {Anchor.All.Count} "
+ $"leeches {Leech.All.Count} barriers {OrbitalBarrier.All.Count} | "
// Parenthesised: inside an interpolation hole a bare `??` runs into the `:` that
// starts the format specifier.
+ $"proj/shot {progress?.ProjectileCount ?? 0} "
+ $"blast {(progress?.BlastRadius ?? 0f):0.0}" );
LogRenderStats();
}
/// <summary>
/// Draw-call side of a hitch. GOALS 7e: measure before changing the renderer.
///
/// The suspicion is that two GameObjects per chunk (rock and volatile) means a 512 chunk
/// Monolith is up to 1024 renderers and 1024 draws. These counters settle it rather than
/// leaving it as a plausible story, which is the mistake that cost four sessions on the
/// mesher: `DrawCalls` tracking renderer count means the split is worth removing, while
/// `ObjectsCulledByVis` near zero means nothing is being culled and the win is elsewhere.
///
/// Wrapped because `FrameStats` is a diagnostics surface, not a gameplay one, and a
/// diagnostic must never be the thing that takes the game down.
/// </summary>
private static void LogRenderStats()
{
try
{
var f = Sandbox.Diagnostics.FrameStats.Current;
// Triangles included because it is the mesher's OUTPUT, so it separates "too many
// objects" from "too much geometry per object". Greedy meshing is supposed to keep
// the second small, and if it is not, the problem is the mesh rather than the count.
Log.Warning( $"[draws] calls {f.DrawCalls} | rendered {f.ObjectsRendered} "
+ $"of {f.ObjectsTested} tested | tris {f.TrianglesRendered:N0} | "
+ $"batched {f.RenderBatchDraws} unbatchable {f.UnbatchableMaterialDraws} | "
+ $"culled vis {f.ObjectsCulledByVis} screen {f.ObjectsCulledByScreenSize}" );
}
catch ( Exception )
{
// Counter names are engine-owned. Losing the diagnostic is acceptable; losing the
// frame is not.
}
}
protected override void OnUpdate()
{
WatchFrameTime();
// Gated AFTER the watchdog on purpose. That measures wall time to catch hitches, so it
// is the one thing here that must keep counting while the world is frozen: a pause is
// not a stall, and the log should not confuse the two.
if ( GameTime.Paused )
return;
if ( StageJustCleared && GameTime.Now > clearedBannerUntil )
StageJustCleared = false;
// Runs everywhere: it is derived purely from local voxel state, and a client that
// cannot see the last few cubes is just as stuck as the host would be.
UpdateResidue();
UpdateShield();
if ( !Networking.IsHost )
return;
PumpSnapshotQueue();
// Guarded, because the rebuild happens when the broadcast comes back around and we
// must not fire a second one in the meantime. Also held while the ladder-complete
// summary is up, so an empty world cannot re-announce a clear every frame.
if ( clearAnnounced || LadderComplete
|| World == null || World.SolidCount > 0 || World.InitialCount <= 0 )
return;
clearAnnounced = true;
if ( InMonolith )
{
// Felling the shared Monolith is an event in its own right, not a rung on the
// ladder. Everyone who did a real share of it gets prestige credit.
MonolithStats.ReportMonolithTime( MonolithSeconds );
MonolithNet.MonolithFelled( World.InitialCount );
}
else
{
MonolithNet.MonolithCleared( Tier + 1 );
}
}
// ---------------------------------------------------------------- world lifecycle
public Vector3Int SizeForTier( int tier ) => Stages.SizeFor( tier );
/// <summary>
/// Remeshes everything because the set of volatile blocks changed. Only happens on an
/// Instability purchase, so a full rebuild is acceptable.
/// </summary>
public void RefreshVolatileAppearance()
{
World?.MarkAllDirty();
}
// ---------------------------------------------------------------- shields
/// <summary>
/// While shielded, DRONES do nothing. You can still mine by hand, so the answer to a shield
/// is to play, which is the entire reason it exists: it stops idling being the fastest way
/// through a stage.
///
/// Three states, deliberately separate:
/// <see cref="ShieldUp"/> the stage rolled a shield and its timer has not run out
/// <see cref="ShieldBroken"/> you shot the node, so it is briefly down
/// <see cref="ShieldActive"/> up AND not broken, which is the only state that jams drones
/// </summary>
public bool ShieldUp => shieldRolled && GameTime.Now < shieldEndsAt;
public bool ShieldBroken => ShieldUp && GameTime.Now < shieldBrokenUntil;
public bool ShieldActive => ShieldUp && !ShieldBroken;
private bool shieldRolled;
private float shieldEndsAt;
private float shieldBrokenUntil = -999f;
public float ShieldSecondsLeft
=> ShieldUp ? MathF.Max( 0f, shieldEndsAt - GameTime.Now ) : 0f;
/// <summary>Seconds of drone time left in the current break. Zero when the shield is live.</summary>
public float ShieldBreakSecondsLeft => MathF.Max( 0f, shieldBrokenUntil - GameTime.Now );
private void RollShield()
{
shieldRolled = false;
shieldBrokenUntil = -999f;
shield?.Hide();
if ( InMonolith )
return;
// Channel 3. Seeded from the stage so the shield is part of the fixed challenge rather
// than a coin flip, which is what lets the ladder speedrun board compare like with like.
InterceptorSpawner.SeedForStage( Tier, 3 );
if ( Game.Random.Float() > Tuning.ShieldChance )
return;
shieldRolled = true;
shieldEndsAt = GameTime.Now
+ Game.Random.Float( Tuning.ShieldMinSeconds, Tuning.ShieldMaxSeconds );
if ( World != null )
shield?.Show( World.WorldBounds );
Log.Info( $"Stage shielded for {shieldEndsAt - GameTime.Now:0}s. " +
"Drones are jammed. Shoot the node above the shape to drop it." );
}
/// <summary>
/// Called when a PLAYER shot strikes the node. Drops the shield for a couple of seconds and
/// then it comes back, so the stage becomes a repeating beat: break it, cash in the drone
/// window, break it again. Re-striking during a break does not stack.
/// </summary>
public void BreakShield()
{
if ( !ShieldUp || ShieldBroken )
return;
shieldBrokenUntil = GameTime.Now + Tuning.ShieldBreakSeconds;
Log.Info( $"Shield node struck. Drones free for {Tuning.ShieldBreakSeconds:0}s." );
}
private void UpdateShield()
{
if ( shieldRolled && !ShieldUp )
{
shieldRolled = false;
shield?.Hide();
}
}
/// <summary>
/// Rebuilds the current stage from scratch. Used when a Spotter completes a lock.
///
/// Deliberately scoped: it restores the SHAPE only. Dust, upgrades, cores and marks are all
/// untouched, so losing a stage costs you time and nothing else. Taking earned progression
/// away would make the whole system feel punishing rather than tense.
/// </summary>
/// <summary>
/// Takes the stage back and rebuilds it.
/// </summary>
/// <param name="reason">
/// What did it. This used to be hardcoded to the Spotter, which was true when the Spotter was
/// the only thing that could take a stage and became a lie the moment the Crawler and then the
/// lethal floor could too. It showed up immediately in a real log: "caught by the floor"
/// followed on the same millisecond by "Spotter lock complete", which is exactly the kind of
/// thing that sends you hunting a bug in the wrong system.
/// </param>
public void ResetStageProgress( string reason = "Stage progress reset" )
{
if ( World == null ) return;
Log.Info( $"{reason}. Stage progress reset." );
StageResetAt = GameTime.Now;
if ( InMonolith )
BuildMonolith();
else
BuildWorld( Tier );
NotifyMinersOfReset();
}
/// <summary>Time of the last Spotter-caused reset, for the HUD banner.</summary>
public float StageResetAt { get; private set; } = -999f;
/// <summary>Sends the player back to the bottom of the ladder. Used by Collapse.</summary>
public void RestartLadder()
{
InMonolith = false;
LadderComplete = false;
// Foresight lets a prestiged player skip the opening stages entirely.
int start = PlayerProgress.Local.IsValid() ? PlayerProgress.Local.StartingStage : 0;
BuildWorld( start );
NotifyMinersOfReset();
RepositionPlayers();
// A collapse starts a fresh speedrun attempt.
PlayerProgress.Local?.StartRunTimer();
}
/// <summary>Travel to (or back from) the shared Monolith.</summary>
public void SetMonolithMode( bool monolith )
{
if ( InMonolith == monolith )
return;
InMonolith = monolith;
LadderComplete = false;
if ( monolith )
BuildMonolith();
else
BuildWorld( Tier );
NotifyMinersOfReset();
RepositionPlayers();
}
private void NotifyMinersOfReset()
{
foreach ( var miner in Scene.GetAllComponents<Miner>() )
miner.OnMonolithReset();
}
/// <summary>Reframes the local rig on whatever shape is now in front of it.</summary>
/// <summary>
/// Stands every player on the arena floor a little back from the shape, looking at it.
/// The floor sits at the shape's base so you walk up to it rather than orbit it.
/// </summary>
private void RepositionPlayers()
{
if ( World == null ) return;
var bounds = World.WorldBounds;
float floorZ = bounds.Mins.z;
// Far enough back that the whole shape is comfortably in frame.
float distance = MathF.Max( 420f, bounds.Size.Length * 0.85f );
var offset = new Vector3( 0.35f, -1f, 0f ).Normal * distance;
var position = bounds.Center.WithZ( floorZ ) + offset;
// Look at the middle of the shape rather than its base.
var lookTarget = bounds.Center;
float extent = ArenaHalfExtent;
foreach ( var player in Scene.GetAllComponents<PlayerMovement>() )
{
player.FloorZ = floorZ;
player.ArenaCentre = bounds.Center;
player.ArenaHalfExtent = extent;
var eye = position.WithZ( floorZ + player.EyeHeight );
player.PlaceOnFloor( position, Rotation.LookAt( (lookTarget - eye).Normal ) );
}
grid?.FitTo( bounds );
// Relaid for every shape, because the arena resizes with the stage and a tile grid that
// did not follow it would put lethal squares outside the walls and none inside them.
// Tier is 0-based; FloorHazard and its tuning both talk in the stage number the player
// sees. Converting here rather than there is what stopped a threshold of 3 arming on 4.
floor?.FitTo( bounds, extent, Tier + 1, InMonolith );
// Shrapnel belongs to the shape that threw it. Carrying it across a rebuild would let a
// stage you just lost kill you again in the one you were given to replace it.
Shrapnel.ClearAll();
}
/// <summary>
/// Half-width of the walled arena for the CURRENT shape.
///
/// This has to scale, not sit at a constant. The shared Monolith is 4096 units per axis, so
/// its barriers orbit at roughly 3900 while the old fixed arena stopped you at 2600: they
/// were permanently outside the walls, unreachable, and blocked nothing you could ever fire.
/// </summary>
public float ArenaHalfExtent
{
get
{
if ( World == null )
return Tuning.ArenaMinHalfExtent;
return MathF.Max( Tuning.ArenaMinHalfExtent,
World.WorldBounds.Size.Length * Tuning.ArenaShapeMultiple );
}
}
private void BuildMonolith()
{
clearAnnounced = false;
residueLogged = false;
// THE SHARED MONOLITH IS NEVER SHIELDED.
//
// RollShield already refuses to roll one here, but it is only called from BuildWorld, so
// a shield rolled on the ladder stage you were standing on stayed active when you
// travelled: you arrived at a weeks-long communal rock with your drones jammed and no
// stage change coming to clear it. Jamming the drones is a nudge to play actively for
// thirty seconds; on the Monolith it is just a tax on the one place idle throughput is
// the entire point.
shieldRolled = false;
shieldBrokenUntil = -999f;
shield?.Hide();
if ( Renderer.IsValid() )
Renderer.HighlightResidue = false;
World = new VoxelWorld( Tuning.MonolithSize );
World.FillShape( ShapeKind.Box );
// Its own speedrun clock, independent of the solo ladder timer.
monolithStartedAt = GameTime.Now;
Renderer?.Attach( World );
RepositionPlayers();
Log.Info( $"THE MONOLITH: {World.Size.x}x{World.Size.y}x{World.Size.z}, " +
$"{World.InitialCount:N0} cubes across {World.ChunkCount} chunks." );
}
private void BuildWorld( int stage )
{
Tier = stage;
clearAnnounced = false;
residueLogged = false;
if ( Renderer.IsValid() )
Renderer.HighlightResidue = false;
var def = Stages.Get( stage );
var size = Stages.SizeFor( stage );
// A Slag reroll overrides the archetype for this stage only.
var shape = shapeOverride ?? def?.Shape ?? ShapeKind.Box;
shapeOverride = null;
World = new VoxelWorld( size );
World.FillShape( shape );
Renderer?.Attach( World );
RepositionPlayers();
RollShield();
// Reports the shape actually built, not the stage definition's. After a Slag reroll those
// differ, and logging the definition made a rerolled stage look like the cube count had
// changed on its own.
Log.Info( $"Stage {stage + 1} \"{Stages.NameFor( stage )}\" condensed: " +
$"{size.x}x{size.y}x{size.z} {shape}, {World.InitialCount:N0} cubes " +
$"across {World.ChunkCount} chunks." );
}
// ---------------------------------------------------------------- residue
/// <summary>
/// The last handful of cubes are effectively invisible: one 16 unit cube adrift in a void
/// hundreds of units across. Light them up, and log exactly where they are the first time
/// we drop below the threshold so a genuine stall can be told apart from a hunt.
/// </summary>
private void UpdateResidue()
{
if ( World == null )
return;
bool revealing = World.SolidCount > 0 && World.SolidCount <= Tuning.ResidueRevealThreshold;
if ( Renderer.IsValid() )
Renderer.HighlightResidue = revealing;
if ( !revealing )
{
ResidueCentre = null;
residueVoxels.Clear();
return;
}
// Refresh the beacon target periodically so it tracks as the last cubes get eaten.
if ( timeSinceResidueScan > 0.75f )
{
timeSinceResidueScan = 0;
ResidueCentre = FindResidueCentre();
}
if ( residueLogged )
return;
residueLogged = true;
var found = new List<Vector3Int>();
for ( int z = 0; z < World.Size.z && found.Count < 32; z++ )
for ( int y = 0; y < World.Size.y && found.Count < 32; y++ )
for ( int x = 0; x < World.Size.x && found.Count < 32; x++ )
{
if ( World.IsSolid( x, y, z ) )
found.Add( new Vector3Int( x, y, z ) );
}
Log.Info( $"[residue] SolidCount says {World.SolidCount}; scan found {found.Count} " +
$"(capped at 32). Voxels: {string.Join( ", ", found.Select( v => $"({v.x},{v.y},{v.z})" ) )}" );
if ( found.Count == 0 )
{
Log.Warning( "[residue] SolidCount is above zero but no solid voxel exists. " +
"That is a counting bug, not a findability problem." );
}
}
// ---------------------------------------------------------------- fracture
/// <summary>Spends Slag to shatter half of what remains. Routed through the host.</summary>
public void RequestFracture()
{
MonolithNet.RequestFracture( Tuning.FractureFraction );
}
public void HostHandleFracture( float fraction, Guid causedBy )
{
if ( !Networking.IsHost || World == null )
return;
// NOT int.MaxValue. Game.Random.Int is INCLUSIVE of max, so it calls Next(min, max + 1),
// and max + 1 overflows to int.MinValue: every Fracture threw
// "'minValue' cannot be greater than maxValue" and silently did nothing.
int seed = Game.Random.Int( 1, int.MaxValue - 1 );
int removed = World.Fracture( fraction, seed );
if ( removed <= 0 )
return;
MonolithNet.ApplyFracture( fraction, seed, causedBy, removed );
}
public void ClientApplyFracture( float fraction, int seed, Guid causedBy, int removed )
{
if ( World == null )
return;
if ( !Networking.IsHost )
World.Fracture( fraction, seed );
var bounds = World.WorldBounds;
BlastEffect.Spawn( Scene, bounds.Center, bounds.Size.Length * 0.4f, true );
Debris.Burst( Scene, bounds.Center, bounds.Size.Length * 0.25f, true );
if ( PlayerProgress.Local.IsValid() && Connection.Local?.Id == causedBy )
PlayerProgress.Local.AwardCubes( removed );
}
/// <summary>Rerolls the current stage into a different shape. Host authoritative.</summary>
public void RerollShape()
{
if ( !Networking.IsHost || InMonolith )
return;
shapeOverride = PickDifferentShape();
BuildWorld( Tier );
NotifyMinersOfReset();
}
private ShapeKind? shapeOverride;
private ShapeKind PickDifferentShape()
{
var current = Stages.Get( Tier )?.Shape ?? ShapeKind.Box;
var kinds = Enum.GetValues<ShapeKind>();
for ( int attempt = 0; attempt < 12; attempt++ )
{
var candidate = kinds[Game.Random.Int( 0, kinds.Length - 1 )];
if ( candidate != current )
return candidate;
}
return current;
}
/// <summary>
/// The shared Monolith fell. Each client scores its own contribution, then a fresh one
/// condenses so the server always has something to work on.
/// </summary>
public void ClientMonolithFelled( long totalCubes )
{
LastClearedName = "THE MONOLITH";
StageJustCleared = true;
clearedBannerUntil = GameTime.Now + Tuning.StageClearedPauseSeconds;
// The single biggest event in the game announced itself with a banner and no sound.
Audio.StageClear( WorldPosition );
Audio.PlayUi( Audio.StingLow, 0.6f, 0.75f );
MonolithFelledCredit = PlayerProgress.Local.IsValid()
&& PlayerProgress.Local.TryAwardMonolithCredit( totalCubes );
PlayerProgress.Local?.ResetMonolithContribution();
BuildMonolith();
NotifyMinersOfReset();
}
/// <summary>True if the local player earned credit from the last Monolith felling.</summary>
public bool MonolithFelledCredit { get; private set; }
/// <summary>
/// True once the ladder has been completed and the run is waiting on a decision.
///
/// The ladder used to just roll on: clearing stage 100 condensed stage 101, then 102, with
/// no acknowledgement that the thing the entire progression is built around had been
/// achieved. Reaching the end of a hundred stage climb has to be an EVENT, and it has to
/// stop, or the achievement is indistinguishable from the stage before it.
/// </summary>
public bool LadderComplete { get; private set; }
/// <summary>Seconds the completed run took. Frozen at completion for the summary.</summary>
public float LadderCompleteSeconds { get; private set; }
/// <summary>Dismisses the summary. Called by the HUD once a choice is made.</summary>
public void ClearLadderComplete() => LadderComplete = false;
public void ClientMonolithCleared( int nextStage )
{
LastClearedName = Stages.NameFor( Tier );
StageJustCleared = true;
clearedBannerUntil = GameTime.Now + Tuning.StageClearedPauseSeconds;
Audio.StageClear( WorldPosition );
// THE LADDER ENDS AT 100. It does not overflow into 101.
if ( nextStage >= Tuning.PrestigeStageRequirement )
{
LadderComplete = true;
LadderCompleteSeconds = PlayerProgress.Local.IsValid()
? PlayerProgress.Local.RunSeconds
: 0f;
if ( PlayerProgress.Local.IsValid() )
// PrestigeStageRequirement - 1, because ReportStage takes a 0-BASED index and
// adds one itself. Passing 100 recorded the completed ladder as stage 101: the
// start screen read "stage 101 of 100", it paid a core it had not earned, and
// it would have put 101s on a public leaderboard that cannot be cleaned up
// afterwards. The other caller passes `Tier`, which is why the convention is
// an index rather than a count.
PlayerProgress.Local.ReportStage( Tuning.PrestigeStageRequirement - 1 );
Log.Info( $"LADDER COMPLETE in {LadderCompleteSeconds:0.0}s. " +
"Awaiting Collapse or the Monolith." );
// The world is deliberately left standing. Rebuilding underneath the summary would
// put the player back to work behind their own results screen.
SnapshotReady = true;
return;
}
Log.Info( $"\"{LastClearedName}\" cleared. Next: \"{Stages.NameFor( nextStage )}\"." );
BuildWorld( nextStage );
SnapshotReady = true;
foreach ( var miner in Scene.GetAllComponents<Miner>() )
miner.OnMonolithReset();
}
// ---------------------------------------------------------------- blasts
/// <summary>Called by the local Miner. Routes through the host so everyone converges.</summary>
public void RequestBlast( Vector3Int centre, float radius, bool isCharge )
{
MonolithNet.RequestBlast( centre.x, centre.y, centre.z, radius, isCharge );
}
/// <summary>Host side: validate, apply, then mirror the exact result to everyone.</summary>
public void HostHandleBlast( int vx, int vy, int vz, float radius, bool isCharge, Guid causedBy )
{
if ( !Networking.IsHost || World == null )
return;
// Blast radius is uncapped by design, so the only limit here is the world itself:
// a sphere larger than the shape cannot remove more than the shape.
float worldMax = Math.Max( World.Size.x, Math.Max( World.Size.y, World.Size.z ) );
radius = Math.Clamp( radius, 0f, worldMax );
var centre = new Vector3Int( vx, vy, vz );
// The CENTRE may sit outside the world. A demolition charge detonating just off the
// surface, or in open air beside the shape, still has most of its sphere overlapping
// rock, and rejecting it outright is why an area weapon appeared to do nothing unless
// it was buried.
//
// `RemoveSphere` already clamps its own iteration bounds to the world, so the only thing
// needed here is to check the sphere REACHES the world at all rather than that its
// centre is inside it.
int reach = (int)MathF.Ceiling( radius );
bool touchesWorld =
centre.x + reach >= 0 && centre.x - reach < World.Size.x &&
centre.y + reach >= 0 && centre.y - reach < World.Size.y &&
centre.z + reach >= 0 && centre.z - reach < World.Size.z;
if ( !touchesWorld )
return;
int removed = World.RemoveSphere( centre, radius );
if ( removed <= 0 )
return;
MonolithNet.ApplyBlast( vx, vy, vz, radius, isCharge, causedBy, removed );
}
/// <summary>Applies a mirrored blast. The host already applied it, so it only does effects.</summary>
public void ClientApplyBlast( int vx, int vy, int vz, float radius, bool isCharge, Guid causedBy, int removed )
{
if ( World == null )
return;
var centre = new Vector3Int( vx, vy, vz );
if ( !Networking.IsHost )
World.RemoveSphere( centre, radius );
var worldPos = World.VoxelToWorld( centre.x, centre.y, centre.z );
float worldRadius = radius * Tuning.VoxelSize;
BlastEffect.Spawn( Scene, worldPos, worldRadius, isCharge );
Debris.Burst( Scene, worldPos, worldRadius, isCharge );
// Credit the player who caused it, on their own machine only.
if ( PlayerProgress.Local.IsValid() && Connection.Local?.Id == causedBy )
PlayerProgress.Local.AwardCubes( removed );
}
// ---------------------------------------------------------------- snapshots
public void HostQueueSnapshot( Connection caller )
{
if ( !Networking.IsHost || caller == null || World == null )
return;
using ( Rpc.FilterInclude( caller ) )
{
MonolithNet.SnapshotHeader( World.Size.x, World.Size.y, World.Size.z, Tier, World.InitialCount );
}
for ( int i = 0; i < World.ChunkCount; i++ )
snapshotQueue.Enqueue( (caller, i) );
}
private void PumpSnapshotQueue()
{
int budget = SnapshotChunksPerFrame;
while ( budget-- > 0 && snapshotQueue.Count > 0 )
{
var (target, index) = snapshotQueue.Dequeue();
if ( target == null )
continue;
var payload = Convert.ToBase64String( World.GetChunkByIndex( index ).Serialize() );
using ( Rpc.FilterInclude( target ) )
{
MonolithNet.SnapshotChunk( index, payload );
if ( snapshotQueue.Count == 0 )
MonolithNet.SnapshotComplete();
}
}
}
public void ClientSnapshotHeader( int sx, int sy, int sz, int tier, long initial )
{
if ( Networking.IsHost )
return;
Tier = tier;
World = new VoxelWorld( new Vector3Int( sx, sy, sz ) );
World.SetInitialCount( initial );
SnapshotReady = false;
Renderer?.Attach( World );
}
public void ClientSnapshotChunk( int index, string payload )
{
if ( Networking.IsHost || World == null )
return;
if ( index < 0 || index >= World.ChunkCount )
return;
World.GetChunkByIndex( index ).Deserialize( Convert.FromBase64String( payload ) );
World.DirtyChunks.Add( index );
}
public void ClientSnapshotComplete()
{
if ( Networking.IsHost )
return;
World?.RecountSolid();
World?.MarkAllDirty();
SnapshotReady = true;
Log.Info( "Monolith snapshot received." );
}
}