Game/GameBootstrap.cs
using Sandbox.Network;

namespace Monolith;

/// <summary>
/// Starts (or joins) the session and builds this client's local rig. The rig is deliberately
/// NOT a networked object in v1: the only thing that has to be shared is the monolith, and
/// keeping players local removes the whole prefab and ownership surface. Avatars come later.
/// </summary>
public sealed class GameBootstrap : Component, Component.INetworkListener
{
	[Property] public bool StartServer { get; set; } = true;

	private GameObject rig;
	private bool placed;

	protected override void OnStart()
	{
		LogBuildStamp();

		if ( StartServer && !Networking.IsActive )
		{
			Networking.CreateLobby( new LobbyConfig
			{
				MaxPlayers = 32,
				Privacy = LobbyPrivacy.Public,
				Name = "MONOLITH",
			} );
		}

		CreateLocalRig();
	}

	// NAMES ARE NOT LOGGED.
	//
	// Streamer Mode gives a player a generic anonymous name precisely so it is not shown, and
	// neither `Connection.Name` nor `Connection.DisplayName` is documented as honouring it:
	// DisplayName is described only as the name "with any potential nicknames or naughty words
	// filtered out", which is a profanity filter, not anonymity.
	//
	// Since the console gets screenshotted and shared, and the join line carries no debugging
	// value that the count does not, the safe reading is to print neither. Nothing downstream
	// needs a name: the leaderboard is the only place one is displayed, and it uses the
	// DisplayName the service itself returns.

	public void OnActive( Connection channel )
	{
		Log.Info( $"A player joined. ({Connection.All.Count} connected)" );
	}

	public void OnDisconnected( Connection channel )
	{
		Log.Info( $"A player left. ({Connection.All.Count} connected)" );
	}

	/// <summary>
	/// Prints the tuning values the RUNNING build actually has.
	///
	/// A clean s&amp;box compile is silent: the log records failures and nothing else. That makes
	/// "no errors in the log" indistinguishable from "nothing compiled", and this session has
	/// repeatedly reported the first while the second was true, including several times when the
	/// editor was not even running. There was no way to tell from the outside.
	///
	/// This is that way. Grep the log for `[build]` and compare against the source: if the values
	/// match, the code you are looking at is the code that is running. If the line is missing or
	/// stale, nothing you just changed is live, whatever the absence of errors suggests.
	/// </summary>
	private static void LogBuildStamp()
	{
		Log.Info( $"[build] MONOLITH | spotter lock {Tuning.SpotterLockSeconds}s "
			+ $"cone {Tuning.SpotterViewCone} | sentinel arm {Tuning.SentinelArmSeconds}s "
			+ $"range {Tuning.SentinelRange} | maxproj {Tuning.MaxProjectileCount} "
			+ $"visible {Tuning.MaxVisibleProjectiles} | pixel {Tuning.RetroPixelScale} "
			+ $"| stage growth {Tuning.StageSizeGrowth}" );

		// The systems added most recently, for the same reason the line exists at all: a stamp
		// that does not mention the thing you just changed cannot tell you whether it is live.
		Log.Info( $"[build] floor from stage {Tuning.FloorHazardFromStage} | "
			+ $"tile {Tuning.FloorTileSize}u | phase {Tuning.FloorPhaseSeconds}s "
			+ $"warn {Tuning.FloorWarnSeconds}s | dead {Tuning.FloorDeadChance:P0} "
			+ $"raised {Tuning.FloorDeadHeight}u | rocket {Tuning.RocketJumpForce} "
			+ $"blast {Tuning.RocketJumpBlastFraction:P0} | crawlers {Tuning.CrawlerMaxCount}" );

		Log.Info( $"[build] look: ao {Tuning.VoxelAoStrength} | strata {Tuning.StrataBands}x"
			+ $"{Tuning.StrataThickness} contrast {Tuning.StrataContrast} | "
			+ $"shrapnel {Tuning.ShrapnelMaxPerBurst}/burst | "
			+ $"floor hot {Tuning.FloorHotFraction:P0} dead {Tuning.FloorDeadChance:P1}" );
	}

	// REAL clock, not the game clock. A frozen game clock is one of the things this exists to
	// detect, and a GameTimeSince here would never reach its interval in exactly that case: the
	// diagnostic would go silent precisely when it was needed.
	private TimeSince timeSinceReport = 99f;
	private int reportsLeft = 3;

	/// <summary>
	/// Prints the gating state for the first few seconds of a session.
	/// </summary>
	/// <remarks>
	/// "Cannot shoot" and "spawned inside the shape" both have several possible causes that look
	/// identical from the outside: a stuck pause, a stuck cursor, a missing snapshot, or a rig
	/// that was never repositioned. This prints all four at once so the next launch answers the
	/// question instead of narrowing it.
	///
	/// Uses the REAL clock, not the game clock, because one of the things it exists to diagnose
	/// is the game clock being frozen.
	/// </remarks>
	private void ReportState()
	{
		if ( reportsLeft <= 0 || timeSinceReport < 1f )
			return;

		timeSinceReport = 0f;
		reportsLeft--;

		var manager = MonolithManager.Instance;
		var movement = rig.IsValid() ? rig.Components.Get<PlayerMovement>() : null;

		string where = "no movement";
		string shape = "no world";

		if ( movement.IsValid() )
			where = $"{movement.WorldPosition} floorZ {movement.FloorZ:0}";

		if ( manager.IsValid() && manager.World != null )
		{
			var b = manager.World.WorldBounds;
			shape = $"centre {b.Center} mins {b.Mins} size {b.Size}";

			if ( movement.IsValid() )
			{
				float flat = (movement.WorldPosition - b.Center).WithZ( 0f ).Length;
				shape += $" | player {flat:0}u from centre";
			}
		}

		Log.Info( $"[state] paused {GameTime.Paused} | cursor {Hud.CursorVisible} "
			+ $"| snapshot {(manager.IsValid() ? manager.SnapshotReady : false)} "
			+ $"| placed {placed} | miner {Scene.GetAllComponents<Miner>().Count()}" );
		Log.Info( $"[state] player {where}" );
		Log.Info( $"[state] shape  {shape}" );
	}

	private void CreateLocalRig()
	{
		rig = new GameObject( true, "Player Rig" );

		// Never networked: this exists only on the machine that made it.
		rig.NetworkMode = NetworkMode.Never;

		// The camera lives on its own child object so it can sit behind the body in third
		// person. The rig itself stays at the EYE position, which is the space every bit of
		// movement, aiming and muzzle code already works in, so none of that has to change.
		var cameraObject = new GameObject( true, "Camera Boom" );
		cameraObject.Parent = rig;
		cameraObject.NetworkMode = NetworkMode.Never;

		var camera = cameraObject.AddComponent<CameraComponent>();
		camera.IsMainCamera = true;
		camera.ClearFlags = ClearFlags.All;
		camera.ZNear = 4f;
		camera.ZFar = 100_000f;
		camera.FieldOfView = 75f;
		// Pure black. Devil Daggers is mostly empty screen, and everything reads because it is
		// the only lit thing in frame.
		camera.BackgroundColor = Color.Black;

		// A carved voxel mass is almost all coplanar faces, so direct lighting alone leaves it
		// reading as flat colour and you cannot tell what a click actually did. Ambient
		// occlusion is what makes craters, ledges and shafts legible.
		//
		// These are camera effects, so they belong on the camera object, not the rig.
		var ao = cameraObject.AddComponent<AmbientOcclusion>();
		// Untyped literals: these engine properties differ in type between versions, and an
		// integer literal is valid whether the property is int or float.
		//
		// Dropped from 2 to 1: at full strength, stacked with the retro colour pass, AO was
		// filling every crevice with black and the carved surface became a dark smear. It is
		// here to READ the geometry, and past a point it hides it instead.
		ao.Intensity = 1;
		ao.Radius = 90;

		// YOU CARRY LIGHT. This is how the reference stays readable while being mostly black:
		// the action is lit and the distance falls to nothing, rather than everything being
		// uniformly dim. A lamp on the rig means the rock you are actually working is always
		// bright, and it solves the readability problem without lifting the void at all.
		var lampObject = new GameObject( true, "Player Lamp" );
		lampObject.Parent = rig;
		lampObject.NetworkMode = NetworkMode.Never;
		lampObject.LocalPosition = new Vector3( 40f, 0f, 30f );

		var lamp = lampObject.AddComponent<PointLight>();
		lamp.LightColor = new Color( 1f, 0.72f, 0.42f ) * Tuning.PlayerLampBrightness;
		lamp.Radius = Tuning.PlayerLampRadius;
		lamp.Shadows = false;

		cameraObject.AddComponent<Bloom>();
		cameraObject.AddComponent<Tonemapping>();

		ApplyRetroLook( cameraObject );

		rig.AddComponent<PlayerMovement>();
		rig.AddComponent<PlayerProgress>();

		// On the rig rather than on the world, because the score is per listener: it answers to
		// what is hunting YOU, and in a shared session two players should not hear one mix.
		rig.AddComponent<MonolithMusic>();

		var avatar = rig.AddComponent<PlayerAvatar>();
		avatar.CameraObject = cameraObject;

		var miner = rig.AddComponent<Miner>();
		miner.Camera = camera;

		var hudObject = new GameObject( true, "HUD" );
		hudObject.Parent = rig;
		hudObject.NetworkMode = NetworkMode.Never;
		hudObject.AddComponent<ScreenPanel>();
		hudObject.AddComponent<Hud>();

		// Capture tools. Harmless in a shipped build: it does nothing until F9 or F10 is pressed.
		rig.AddComponent<PhotoMode>();
	}

	/// <summary>
	/// The Devil Daggers look, as far as it can honestly be applied to what we render.
	///
	/// The research (GOALS 6d) gives four ingredients: a **320x240 render resolution**,
	/// **unfiltered textures with no anti-aliasing**, **low colour depth that is dithered**, and
	/// an **unshaded albedo shader with vertex colour faking the lighting**.
	///
	/// Three of those we can take. The fourth we deliberately cannot, and it is worth being
	/// precise about why rather than half-doing it:
	///
	/// **We keep our lighting and our ambient occlusion.** Devil Daggers can afford to be unlit
	/// because it has TEXTURES, and texture detail is what tells you the shape of a surface. Our
	/// monolith is untextured coplanar cubes. Strip the lighting and a crater, a ledge and a flat
	/// wall all become the same solid orange silhouette, and you can no longer see what your last
	/// shot did. AO is doing the job that texture does in the reference, so removing it to match
	/// the technique would lose the thing the technique was for.
	///
	/// Everything else is a post-process, which is exactly where a look like this belongs.
	/// </summary>
	private static void ApplyRetroLook( GameObject cameraObject )
	{
		// 1. THE RESOLUTION. The single biggest contributor: chunky pixels, hard edges, no AA.
		var pixelate = cameraObject.AddComponent<Pixelate>();
		pixelate.Scale = Tuning.RetroPixelScale;

		// 2. THE PALETTE. Low colour depth reads as heavy contrast and reduced saturation, which
		// is what pushes everything toward the bone-and-ember range rather than full colour.
		var colour = cameraObject.AddComponent<ColorAdjustments>();
		colour.Saturation = Tuning.RetroSaturation;
		colour.Contrast = Tuning.RetroContrast;
		colour.Brightness = Tuning.RetroBrightness;

		// 3. THE GRAIN. Standing in for dithering, which has no built-in component. Not the same
		// technique, but it does the same job: it breaks up flat areas so they stop reading as
		// clean modern gradients.
		var grain = cameraObject.AddComponent<FilmGrain>();
		grain.Intensity = Tuning.RetroGrain;
		grain.Response = 0.5f;

		// 4. THE DARKNESS. The reference is mostly black with the action lit in the middle.
		// A vignette is the cheapest way to stop our arena grid from filling the corners.
		var vignette = cameraObject.AddComponent<Vignette>();
		vignette.Intensity = Tuning.RetroVignette;
		vignette.Roundness = 1f;
		vignette.Smoothness = 1f;
		vignette.Color = Color.Black;

		// Losing a stage had NO feedback at all: the manager recorded the reset and nothing read
		// it. Added last so it can find the vignette above and borrow it for the red wash.
		cameraObject.AddComponent<StageLostEffect>();
	}

	protected override void OnUpdate()
	{
		// The pause clock is driven from here because this is the one component that is never
		// itself gated by the pause. Anything that freezes cannot be trusted to count the freeze.
		GameTime.Advance();

		ReportState();

		if ( placed || !rig.IsValid() )
			return;

		var world = MonolithManager.Instance?.World;
		if ( world == null )
			return;

		// Stand on the arena floor a little back from the shape, looking at it.
		var bounds = world.WorldBounds;
		float floorZ = bounds.Mins.z;
		float distance = MathF.Max( 420f, bounds.Size.Length * 0.85f );

		var position = bounds.Center.WithZ( floorZ )
			+ new Vector3( 0.35f, -1f, 0f ).Normal * distance;

		if ( rig.Components.TryGet<PlayerMovement>( out var movement ) )
		{
			movement.FloorZ = floorZ;
			movement.ArenaCentre = bounds.Center;

			var eye = position.WithZ( floorZ + movement.EyeHeight );
			movement.PlaceOnFloor( position, Rotation.LookAt( (bounds.Center - eye).Normal ) );
		}

		placed = true;
	}
}