s&box Package Code Search

Search C# source code, UI razor templates, shaders, and configs across s&box packages.

Showing code results for query: * (59 total matches found)
facepunch.diamondgrinder / DiamondDamage.cs
Game game
using System;
using System.Collections.Generic;
using static Diamonds.DiamondBoard;

namespace Diamonds;

/// <summary>One damage wave per collision, propagated only through pre-existing face bonds.</summary>
public static class DiamondDamage
{
	const float Tolerance = 0.02f;
	public const float SlideDamageSteps = 3;
	public readonly record struct Contact( float Lane, float Y, int ColorIndex );
	public readonly record struct FaceContact( float StartLane, float StartY, float EndLane, float EndY, int ColorIndex );

	public static Dictionary<int, Corner> Targets( IReadOnlyList<Diamond> cells,
		IReadOnlyList<DiamondMatching.CellMove> moving, IReadOnlyList<IReadOnlyList<int>> bonds,
		float velocityX, float velocityY, List<Contact> contacts = null, List<FaceContact> faceContacts = null )
	{
		var hit = new Dictionary<int, Corner>();
		if ( velocityY <= 0 ) return hit;
		// Bottom-to-top points damage only on straight vertical falls. Diagonal
		// slides must qualify through their full leading faces, even at high speed.
		if ( velocityX == 0 && velocityY > DamageSpeedThreshold )
			hit = PointTargets( cells, moving, contacts );
		SlideFaceTargets( cells, moving, hit, faceContacts );
		var movingIndices = new HashSet<int>();
		foreach ( var move in moving ) movingIndices.Add( move.Index );
		var damaged = new Dictionary<int, Corner>( hit );
		foreach ( var group in bonds )
		{
			foreach ( int index in group )
			{
				if ( !hit.TryGetValue( index, out var corner ) ) continue;
				// Indirect fractures follow the initiating strike. Direct contacts retain
				// their own corner, including simultaneous hits on the same bonded group.
				foreach ( int member in group ) if ( !movingIndices.Contains( member ) ) damaged.TryAdd( member, corner );
				break;
			}
		}
		return damaged;
	}

	// SettlementMove spans one uninterrupted slide, already coalesced across lattice
	// steps. A stop, turn, group change or replan starts a new From, so distance from
	// an earlier slide can never survive a landing or the destruction of a support.
	static void SlideFaceTargets( IReadOnlyList<Diamond> cells, IReadOnlyList<DiamondMatching.CellMove> moving,
		Dictionary<int, Corner> hit, List<FaceContact> contacts )
	{
		var movingIndices = new HashSet<int>();
		foreach ( var move in moving ) movingIndices.Add( move.Index );
		var seen = new HashSet<(int, int)>();
		foreach ( var move in moving )
		{
			float lanes = move.To.Lane - move.From.Lane;
			float dy = move.To.Y - move.From.Y;
			if ( lanes == 0 || dy <= 0 || MathF.Abs( dy - MathF.Abs( lanes ) * Height * 0.5f ) > Tolerance ||
				MathF.Abs( lanes ) < SlideDamageSteps ) continue;
			int direction = Math.Sign( lanes );
			for ( int other = 0; other < cells.Count; other++ )
			{
				if ( movingIndices.Contains( other ) || cells[other].Health <= 0 ) continue;
				var receiver = cells[other];
				// Only the lower face facing into the slide closes onto a full opposing
				// face. The supporting slope is tangential; tips and partial faces don't count.
				if ( MathF.Abs( receiver.Lane - move.To.Lane - direction ) * Width * 0.5f >= Tolerance ||
					MathF.Abs( receiver.Y - move.To.Y - Height * 0.5f ) >= Tolerance ) continue;
				hit.TryAdd( other, Corner.Top );
				if ( contacts is not null && seen.Add( (other, direction) ) )
					contacts.Add( new( receiver.Lane, receiver.Y - Height * 0.5f,
						receiver.Lane - direction, receiver.Y, receiver.ColorIndex ) );
			}
		}
	}

	/// <summary>Exposed point contacts for a downward preview, regardless of speed, including floor peaks.</summary>
	public static IReadOnlyList<Contact> PointContacts( IReadOnlyList<Diamond> cells, IReadOnlyList<DiamondMatching.CellMove> moving,
		bool fillCornerPockets = true )
	{
		var contacts = new List<Contact>();
		PointTargets( cells, moving, contacts, includeFloor: true, fillCornerPockets: fillCornerPockets );
		return contacts;
	}

	// Caller-owned storage: previews reuse this without sharing state with real damage waves.
	internal sealed class PointContactScratch
	{
		public readonly Dictionary<int, Corner> Hit = new();
		public readonly HashSet<int> Moving = new();
		public readonly HashSet<(int, Corner)> Corners = new();
	}

	internal static void CopyPointContacts( IReadOnlyList<Diamond> cells, IReadOnlyList<DiamondMatching.CellMove> moving,
		List<Contact> contacts, PointContactScratch scratch, bool fillCornerPockets = true )
	{
		contacts.Clear();
		PointTargets( cells, moving, contacts, includeFloor: true, scratch: scratch, fillCornerPockets: fillCornerPockets );
	}

	/// <summary>New exposed tip collisions for cosmetic sparks, including harmless side tips.</summary>
	public static IReadOnlyList<Contact> SparkContacts( IReadOnlyList<Diamond> cells, IReadOnlyList<DiamondMatching.CellMove> moving,
		bool fillCornerPockets = true )
	{
		var contacts = new List<Contact>( PointContacts( cells, moving, fillCornerPockets ) );
		var movingIndices = new HashSet<int>( moving.Select( m => m.Index ) );
		foreach ( var move in moving )
		for ( int other = 0; other < cells.Count; other++ )
		{
			if ( movingIndices.Contains( other ) || cells[other].Health <= 0 ) continue;
			var receiver = cells[other];
			float dx = (receiver.Lane - move.To.Lane) * Width * 0.5f;
			if ( MathF.Abs( MathF.Abs( dx ) - Width ) > Tolerance || MathF.Abs( receiver.Y - move.To.Y ) > Tolerance ) continue;
			int side = Math.Sign( dx );
			if ( (move.To.Lane - move.From.Lane) * side <= 0 ||
				!ExposedPoint( cells, move.Index, side, 0, movingIndices ) ||
				!ExposedPoint( cells, other, -side, 0, movingIndices ) ) continue;
			var contact = new Contact( (move.To.Lane + receiver.Lane) * 0.5f, receiver.Y, receiver.ColorIndex );
			if ( !contacts.Contains( contact ) ) contacts.Add( contact );
		}
		return contacts;
	}

	static Dictionary<int, Corner> PointTargets( IReadOnlyList<Diamond> cells,
		IReadOnlyList<DiamondMatching.CellMove> moving,
		List<Contact> contacts, bool includeFloor = false, PointContactScratch scratch = null, bool fillCornerPockets = true )
	{
		var hit = scratch?.Hit ?? new Dictionary<int, Corner>();
		var movingIndices = scratch?.Moving ?? new HashSet<int>();
		var contactCorners = scratch?.Corners ?? new HashSet<(int, Corner)>();
		hit.Clear(); movingIndices.Clear(); contactCorners.Clear();
		for ( int i = 0; i < moving.Count; i++ ) movingIndices.Add( moving[i].Index );
		for ( int i = 0; i < moving.Count; i++ )
		{
			var move = moving[i];
			var cell = move.To;
			if ( cell.Y <= move.From.Y ) continue;
			// Odd lanes sit over upward floor peaks; even lanes nest between two faces.
			if ( includeFloor && MathF.Abs( cell.Lane % 2 - 1 ) * Width * 0.5f < Tolerance &&
				MathF.Abs( cell.Y - FloorY( cell.Lane ) ) < Tolerance &&
				ExposedPoint( cells, move.Index, 0, 1, movingIndices ) && ExposedFloorPeak( cells, cell, movingIndices, fillCornerPockets ) &&
				contactCorners.Add( (move.Index, Corner.Bottom) ) )
				contacts.Add( new( cell.Lane, cell.Y + Height * 0.5f, cell.ColorIndex ) );
			// Only receiving diamonds take damage; the floor cannot receive gem damage.
			for ( int other = 0; other < cells.Count; other++ )
			{
				if ( movingIndices.Contains( other ) ) continue;
				var receiver = cells[other];
				// Only a moving bottom tip striking a receiving top tip can damage.
				// Side tips meeting at the end of a downhill slide are harmless.
				if ( !BottomToTopContact( cell, receiver ) || BottomToTopContact( move.From, receiver, 0.0001f ) ) continue;
				if ( !ExposedPoint( cells, move.Index, 0, 1, movingIndices ) ||
					!ExposedPoint( cells, other, 0, -1, movingIndices ) ) continue;
				const Corner corner = Corner.Top;
				hit.TryAdd( other, corner );
				if ( contacts is not null && contactCorners.Add( (other, corner) ) )
					contacts.Add( new( (cell.Lane + receiver.Lane) * 0.5f, (cell.Y + receiver.Y) * 0.5f, receiver.ColorIndex ) );
			}
		}
		return hit;
	}

	// Either face adjoining the struck point can extend it into a smooth edge.
	// Check the whole physical assembly, including other colors, but exclude the
	// opposite collision side: new contacts must not hide the points being tested.
	static bool ExposedPoint( IReadOnlyList<Diamond> cells, int index, int pointX, int pointY, HashSet<int> moving )
	{
		var cell = cells[index];
		bool isMoving = moving.Contains( index );
		for ( int other = 0; other < cells.Count; other++ )
		{
			if ( other == index || moving.Contains( other ) != isMoving || cells[other].Health <= 0 ) continue;
			float dx = (cells[other].Lane - cell.Lane) * Width * 0.5f;
			float dy = cells[other].Y - cell.Y;
			if ( MathF.Abs( MathF.Abs( dx ) - Width * 0.5f ) >= Tolerance ||
				MathF.Abs( MathF.Abs( dy ) - Height * 0.5f ) >= Tolerance ) continue;
			if ( (pointX != 0 && Math.Sign( dx ) == pointX) ||
				(pointY != 0 && Math.Sign( dy ) == pointY) ) return false;
		}
		return true;
	}

	// A peak flanked by a resting gem or filled corner slope is part of a smooth
	// edge: the mover's lower face lands flush on that neighbor's upper face.
	static bool ExposedFloorPeak( IReadOnlyList<Diamond> cells, Diamond cell, HashSet<int> moving, bool fillCornerPockets )
	{
		for ( int other = 0; other < cells.Count; other++ )
			if ( !moving.Contains( other ) && cells[other].Health > 0 && FlanksPeak( cells[other] ) ) return false;
		// Matches DiamondBoard.CornerFills without allocating an iterator per preview frame.
		return !fillCornerPockets || (!FlanksPeak( new( 0, BoardHeight - Height * 0.5f, 0 ) ) &&
			!FlanksPeak( new( LaneCount - 1, BoardHeight - Height * 0.5f, 0 ) ));
		bool FlanksPeak( Diamond flank ) => MathF.Abs( MathF.Abs( flank.Lane - cell.Lane ) * Width * 0.5f - Width * 0.5f ) < Tolerance &&
			MathF.Abs( flank.Y - cell.Y - Height * 0.5f ) < Tolerance;
	}

	static bool BottomToTopContact( Diamond mover, Diamond receiver, float tolerance = Tolerance )
	{
		float dx = MathF.Abs( mover.Lane - receiver.Lane ) * Width * 0.5f;
		return dx < tolerance && MathF.Abs( receiver.Y - mover.Y - Height ) < tolerance;
	}

	public static bool Apply( List<Diamond> cells, IReadOnlyDictionary<int, Corner> targets )
	{
		bool destroyed = false;
		foreach ( var target in targets )
		{
			int index = target.Key;
			cells[index] = cells[index] with
			{
				Health = Math.Max( 0, cells[index].Health - 1 ),
				FirstCrack = cells[index].Health == MaxHealth ? target.Value : cells[index].FirstCrack
			};
			destroyed |= cells[index].Health == 0;
		}
		return destroyed;
	}
}
facepunch.diamondgrinder / DiamondDebris.cs
Game game
using System;
using System.Collections.Generic;
using Sandbox;
using static Diamonds.DiamondBoard;

namespace Diamonds;

/// <summary>Box2D debris in Painter coordinates (+Y down). Only crushed shards award points.</summary>
public sealed partial class DiamondDebris
{
	public sealed class Particle
	{
		internal PhysicsBody Body;
		internal float Age, BlinkTime = -1;
		internal float MaxAge;
		internal float ShineDelay, ShinePeriod = 3.5f;
		public int ShineDirection { get; internal set; } = 1;
		// The debris age stops on pause, including while the physics body sleeps.
		public float ShineProgress => IsSpark || Age < ShineDelay ? -1 : (Age - ShineDelay) % ShinePeriod / 0.32f;
		internal Transform LastClearTransform;
		internal bool HasClearTransform;
		public bool IsSpark { get; internal set; }
		public float Opacity => IsSpark ? MathF.Pow( Math.Clamp( 1 - Age / MaxAge, 0, 1 ), 1.4f ) : 1;
		public float Radius { get; internal set; }
		public int ColorIndex { get; internal set; }
		public float Brightness { get; internal set; }
		public Vector2[] Vertices { get; internal set; }
		public Vector2 Position
		{
			get
			{
				// Read the native transform once for both coordinates.
				var position = Body.Position;
				return new( position.x, position.y );
			}
		}
		public float Angle => Body.Rotation.Yaw();
		public bool Visible => IsSpark ? Age < MaxAge : BlinkTime < 0 || (int)(BlinkTime / (BlinkTime < 0.45f ? 0.12f : 0.06f)) % 2 == 0;
	}

	const int MaxShards = 320;
	const int MaxSparks = 960;
	public const float MaxSparkSpeed = 8000;
	const float BlinkDuration = 0.9f;
	const int PhysicsRevision = 10;
	int physicsRevision;
	int settledVersion, activeSpawnVersion;
	readonly Random random;
	public DiamondDebris( int? seed = null ) => random = seed.HasValue ? new Random( seed.Value ) : new Random();
	readonly List<Particle> particles = new();
	readonly List<PhysicsBody> settledBodies = new();
	readonly List<PhysicsBody> activeBodies = new();
	readonly List<Diamond> cells = new();
	readonly Surface surface = new() { Friction = 0.65f, Elasticity = 0.24f, Density = 1000, RollingResistance = 0.2f };
	readonly Surface sparkSurface = new() { Friction = 0.65f, Elasticity = 0.96f, Density = 1000, RollingResistance = 0.2f };
	public float SparkForceMultiplier { get; set; } = 4;
	public float SparkLifetimeMultiplier { get; set; } = 4;
	public float SparkGravityMin { get; set; } = 0.75f;
	public float SparkGravityMax { get; set; } = 1.6f;
	public float ShardLifetimeMin { get; set; } = 38;
	public float ShardLifetimeMax { get; set; } = 42;
	public float SparkBounciness
	{
		get => sparkSurface.Elasticity;
		set
		{
			float elasticity = Math.Clamp( value, 0, 1 );
			if ( sparkSurface.Elasticity == elasticity ) return;
			sparkSurface.Elasticity = elasticity;
			// Box2D copies surface values into each fixture when assigned.
			foreach ( var particle in particles )
			{
				if ( !particle.IsSpark ) continue;
				foreach ( var shape in particle.Body.Shapes ) shape.Surface = sparkSurface;
			}
		}
	}
	Scene physicsScene;
	DiamondBoard currentBoard;
	bool FillCornerPockets => currentBoard?.FillCornerPockets ?? true;
	int resetVersion;
	long lastFlash;
	long lastFaceFlash;
	long lastShatter;
	long lastCornerSpark;
	public int CrushedThisFrame { get; private set; }
	public readonly record struct CrushAward( Vector2 Position, int ColorIndex, long Points );
	readonly List<CrushAward> crushAwards = new();
	public IReadOnlyList<CrushAward> CrushAwards => crushAwards;
	public IReadOnlyList<Particle> Particles => particles;
#if STANDALONE
#endif
	PhysicsWorld World => physicsScene.PhysicsWorld;

	static bool prepared;
	/// <summary>Compile and initialize particle/physics paths before play, in an isolated disposable world.</summary>
	public static void Prepare()
	{
		if ( prepared ) return;
		var probe = new DiamondDebris( 0 );
		try
		{
			probe.CreateWorld();
			// Exercise both collision tags, native fixtures and the filter callback.
			// The probe owns its randomness and never publishes particles or awards.
			probe.EmitShatter( new Diamond( 16, FloorY( 16 ), 0 ) );
			probe.AddSpark( new Vector2( LaneX( 16 ), FloorY( 16 ) ), Vector2.Zero, 1, 0 );
			probe.AddSpark( new Vector2( LaneX( 16 ), FloorY( 16 ) ), Vector2.Zero, 1, 0 );
			probe.World.Step( 1 / 480f, 1 );
			prepared = true;
		}
		finally { probe.Clear(); }
	}

	public void Clear()
	{
		physicsScene?.Destroy();
		physicsScene = null;
		particles.Clear();
		smoke.Clear();
		shatterSunbursts.Clear();
		crushSunbursts.Clear();
		settledBodies.Clear();
		activeBodies.Clear();
		cells.Clear();
		crushDiamonds.Clear();
		crushFaces.Clear();
		currentBoard = null;
		lastFlash = 0;
		lastFaceFlash = 0;
		lastShatter = 0;
		lastCornerSpark = 0;
		CrushedThisFrame = 0;
		crushAwards.Clear();
	}

	void CreateWorld()
	{
		// A private scene selects the engine's Box2D backend. It isn't added to the
		// game scene hierarchy or ticked; we step only its physics world below.
		physicsScene = new Scene { PhysicsMode = ScenePhysicsMode.Physics2D };
		physicsRevision = PhysicsRevision;
		World.Gravity = new Vector3( 0, 780, 0 );
		World.SleepingEnabled = true;
		var rules = new Sandbox.Physics.CollisionRules();
		rules.Pairs[("spark", "spark")] = Sandbox.Physics.CollisionRules.Result.Ignore;
		rules.Pairs[("shard", "spark")] = Sandbox.Physics.CollisionRules.Result.Ignore;
		rules.Serialize(); // Validate and rebuild the Box2D runtime tag filter.
		World.CollisionRules = rules;
		var floor = World.CreateBody();
		floor.BodyType = PhysicsBodyType.Static;
		for ( int i = 0; i < Valleys * 2; i++ )
		{
			float x = i * Width * 0.5f, nextX = x + Width * 0.5f;
			float y = BoardHeight - (i % 2 == 0 ? Height * 0.5f : 0);
			float nextY = BoardHeight - (i % 2 == 0 ? 0 : Height * 0.5f);
			floor.AddHullShape( Vector3.Zero, Rotation.Identity, new List<Vector3>
			{
				new( x, y, 0 ), new( nextX, nextY, 0 ), new( nextX, BoardHeight + 32, 0 ), new( x, BoardHeight + 32, 0 )
			} ).Surface = surface;
		}
		floor.AddBoxShape( new Vector3( -32, BoardHeight * 0.5f, 0 ), Rotation.Identity, new Vector3( 32, BoardHeight * 0.5f + 64, 1 ) ).Surface = surface;
		floor.AddBoxShape( new Vector3( BoardWidth + 32, BoardHeight * 0.5f, 0 ), Rotation.Identity, new Vector3( 32, BoardHeight * 0.5f + 64, 1 ) ).Surface = surface;
		floor.AddBoxShape( new Vector3( BoardWidth * 0.5f, -32, 0 ), Rotation.Identity, new Vector3( BoardWidth * 0.5f + 64, 32, 1 ) ).Surface = surface;
		foreach ( var tooth in SideWallTeeth() )
		{
			float x = LaneX( tooth.Lane ), inward = tooth.Lane < 0 ? 1 : -1;
			floor.AddHullShape( Vector3.Zero, Rotation.Identity, new List<Vector3>
			{
				new( x, tooth.Y - Height * 0.5f, 0 ), new( x + inward * Width * 0.5f, tooth.Y, 0 ),
				new( x, tooth.Y + Height * 0.5f, 0 )
			} ).Surface = surface;
		}
		foreach ( var corner in CornerFills( FillCornerPockets ) )
		{
			float x = LaneX( corner.Lane ), y = corner.Y;
			floor.AddHullShape( Vector3.Zero, Rotation.Identity, new List<Vector3>
			{
				new( x, y - Height * 0.5f, 0 ), new( x + Width * 0.5f, y, 0 ),
				new( x, y + Height * 0.5f, 0 ), new( x - Width * 0.5f, y, 0 )
			} ).Surface = surface;
		}
		floor.EnableTouch = false;
	}

	PhysicsBody CreateDiamond( Diamond cell )
	{
		var body = World.CreateBody();
		body.BodyType = PhysicsBodyType.Keyframed;
		body.Position = new Vector3( LaneX( cell.Lane ), cell.Y, 0 );
		// Convex fixtures tile the complete piece, preserving concave notches and
		// holes rather than filling them with a convex hull of the whole assembly.
		body.AddHullShape( Vector3.Zero, Rotation.Identity, new List<Vector3>
		{
			new( 0, -Height * 0.5f, 0 ), new( Width * 0.5f, 0, 0 ),
			new( 0, Height * 0.5f, 0 ), new( -Width * 0.5f, 0, 0 )
		} ).Surface = surface;
		body.EnableTouch = false;
		return body;
	}

	void SyncBodies( List<PhysicsBody> bodies, IReadOnlyList<Diamond> diamonds, float delta, bool rebuild = false )
	{
#if STANDALONE
#endif
		// A lethal landing can add exactly as many cells as it removes. Indices
		// then refer to different gems despite an unchanged count; never animate
		// those replacement colliders across the board from their former owners.
		if ( rebuild || bodies.Count != diamonds.Count )
		{
			foreach ( var body in bodies ) body.Remove();
			bodies.Clear();
			foreach ( var cell in diamonds ) bodies.Add( CreateDiamond( cell ) );
		}
		for ( int i = 0; i < diamonds.Count; i++ )
		{
			var cell = diamonds[i];
			var target = new Vector3( LaneX( cell.Lane ), cell.Y, 0 );
			// Keyframed velocities carry/push debris along with animated supports.
			var body = bodies[i];
			if ( (body.Position - target).LengthSquared > 0.0001f ) body.Move( new Transform( target ), delta );
			else if ( body.Velocity != Vector3.Zero ) body.Velocity = Vector3.Zero;
		}
	}

	public void Update( DiamondBoard board, float delta )
	{
#if STANDALONE
#endif
		CrushedThisFrame = 0;
		crushAwards.Clear();
		if ( board != currentBoard || board.ResetVersion != resetVersion || physicsRevision != PhysicsRevision )
		{
			Clear();
			currentBoard = board;
			resetVersion = board.ResetVersion;
		}
		if ( board.Paused || delta <= 0 ) return;
		delta = Math.Clamp( delta, 0, 0.1f );
		if ( physicsScene is null ) CreateWorld();
		SyncBodies( settledBodies, board.Settled, delta, settledVersion != board.SettledVersion );
		settledVersion = board.SettledVersion;
		cells.Clear();
		if ( !board.GameOver && !board.IsResolving ) CopyCellsOf( board.Active, cells );
		SyncBodies( activeBodies, cells, delta, activeSpawnVersion != board.SpawnVersion );
		activeSpawnVersion = board.SpawnVersion;
		cells.AddRange( board.Settled );
		UpdateShatterSunbursts( delta );
		EmitSlidingSparks( board, delta );
		foreach ( var flash in board.DamageFlashes )
		{
			if ( flash.Id <= lastFlash ) continue;
			Emit( flash, board.GameOver );
			lastFlash = flash.Id;
		}
		foreach ( var contact in board.CornerSparks )
		{
			if ( contact.Id <= lastCornerSpark ) continue;
			bool gentle = contact.Speed <= DamageSpeedThreshold;
			EmitCorner( contact.Contact, gentle ? random.Next( 3, 6 ) : SparkCount( 1 ),
				gentle ? Range( 0.18f, 0.28f ) : SparkStrength( 1 ) );
			lastCornerSpark = contact.Id;
		}
		foreach ( var burst in board.ShatterBursts )
		{
			if ( burst.Id <= lastShatter ) continue;
			if ( !board.GameOver ) EmitShatter( burst.Cell );
			EmitShatterEffects( burst.Cell, board.GameOver );
			lastShatter = burst.Id;
		}
		foreach ( var flash in board.FaceFlashes )
		{
			if ( flash.Id <= lastFaceFlash ) continue;
			EmitFaceSparks( flash );
			lastFaceFlash = flash.Id;
		}
		// Keep solver substeps at most 1/480s: softened contacts at 1/240s can
		// sleep with visibly compressed shards in a stack pocket. Outer steps
		// still batch geometry checks at 60 Hz, or 120 Hz for fast closing gems.
		bool fastGems = settledBodies.Any( b => b.Velocity.LengthSquared > 480 * 480 ) ||
			activeBodies.Any( b => b.Velocity.LengthSquared > 480 * 480 );
		int steps = Math.Max( 1, (int)MathF.Ceiling( delta * (fastGems ? 120 : 60) ) );
		int solverSubsteps = Math.Max( 4, (int)MathF.Ceiling( delta / steps * 480 ) );
#if STANDALONE
#endif
		UpdateSmoke( delta );
		for ( int step = 0; step < steps; step++ )
		{
			CacheCrushDiamonds();
			CrushTrappedChips( delta / steps );
			ConstrainParticles();
			{
#if STANDALONE
#endif
				World.Step( delta / steps, solverSubsteps );
			}
			ConstrainParticles();
			CacheCrushDiamonds();
			CrushEmbeddedShards();
			ResolveShardOverlaps();
		}
#if STANDALONE
#endif
		for ( int i = particles.Count - 1; i >= 0; i-- )
		{
			var chip = particles[i];
			chip.Age += delta;
			// Shards blink out once their own sampled lifetime passes; sparks fade out by age.
			if ( !chip.IsSpark && chip.BlinkTime < 0 && chip.Age > chip.MaxAge ) chip.BlinkTime = 0;
			if ( chip.BlinkTime >= 0 ) chip.BlinkTime += delta;
			if ( (chip.IsSpark && chip.Age >= chip.MaxAge) || chip.BlinkTime >= BlinkDuration || chip.Position.y > BoardHeight + 64 )
			{
				if ( chip.BlinkTime >= BlinkDuration && chip.Position.y <= BoardHeight ) EmitSmoke( chip, false );
				chip.Body.Remove();
				particles.RemoveAt( i );
			}
		}
	}

	float Range( float min, float max ) => min + (max - min) * (float)random.NextDouble();

	bool HasRoom( Vector2 position, float radius )
	{
		if ( position.x < radius || position.x > BoardWidth - radius || position.y < radius ) return false;
		float wall = SideWallInset( position.y, FillCornerPockets ) + radius * MathF.Sqrt( 1 + Width * Width / (Height * Height) );
		if ( position.x < wall || position.x > BoardWidth - wall ) return false;
		float floor = BoardHeight - Height * 0.5f * MathF.Abs( 1 - position.x / (Width * 0.5f) % 2 );
		if ( position.y > floor - radius * MathF.Sqrt( 1 + Height * Height / (Width * Width) ) ) return false;
		foreach ( var cell in cells )
		{
			float dx = MathF.Abs( position.x - LaneX( cell.Lane ) );
			float dy = MathF.Abs( position.y - cell.Y );
			// Expanded face half-planes, conservative at the tips for a round chip.
			if ( dx / (Width * 0.5f) + dy / (Height * 0.5f) <
				1 + radius * MathF.Sqrt( 4 / (Width * Width) + 4 / (Height * Height) ) ) return false;
		}
		return true;
	}

	void Emit( DamageFlash flash, bool gameOver )
	{
		EmitCorner( new( flash.Lane, flash.Y, flash.ColorIndex ),
			gameOver ? random.Next( 3, 6 ) : SparkCount( flash.DamageCount ), SparkStrength( flash.DamageCount ) );
	}

	void EmitCorner( DiamondDamage.Contact contact, int count, float strength )
	{
#if STANDALONE
#endif
		var origin = new Vector2( LaneX( contact.Lane ), contact.Y );
		for ( int i = 0; i < count; i++ )
		{
			float radius = Range( 0.7f, 1.8f );
			Vector2 direction = default, position = origin;
			bool found = false;
			// Separate just outside the struck tips, so Box2D doesn't eject a chip
			// from inside the receiving or incoming diamond with an arbitrary impulse.
			for ( int attempt = 0; attempt < 24 && !found; attempt++ )
			{
				float angle = Range( -MathF.PI, MathF.PI );
				direction = new Vector2( MathF.Cos( angle ), MathF.Sin( angle ) );
				for ( float distance = radius + 1; distance <= 12; distance += 2 )
				{
					position = origin + direction * distance;
					if ( HasRoom( position, radius + 0.5f ) ) { found = true; break; }
				}
			}
			if ( !found ) continue;
			var velocity = new Vector2( direction.x * Range( 100, 260 ), direction.y * Range( 90, 220 ) - 130 ) * strength;
			AddSpark( position, velocity, radius, contact.ColorIndex );
		}
	}

	int SparkCount( int damageCount )
	{
		// Quiet single hits; broad bonded damage gets a much denser shower.
		float count = 10 + 6 * MathF.Pow( Math.Clamp( damageCount, 1, 32 ) - 1, 1.5f );
		return Math.Clamp( (int)MathF.Round( count * Range( 0.9f, 1.1f ) ), 8, 240 );
	}

	float SparkStrength( int damageCount ) =>
		(1 + 0.22f * Math.Min( 12, Math.Max( 0, damageCount - 1 ) )) * Range( 0.95f, 1.05f );

	void EmitShatter( Diamond cell )
	{
		var center = new Vector2( LaneX( cell.Lane ), cell.Y );
		// Each horizontal half is already almost equilateral (56 x 52 x 52).
		// Quarter it through its edge midpoints, then inset each triangle about
		// its centroid so the eight fragments start with breathing room.
		foreach ( int side in new[] { -1, 1 } )
		{
			var tip = new Vector2( 0, side * Height / 2 );
			var left = new Vector2( -Width / 2, 0 );
			var right = new Vector2( Width / 2, 0 );
			var leftMid = (tip + left) / 2;
			var rightMid = (tip + right) / 2;
			AddShard( tip, leftMid, rightMid );
			AddShard( left, Vector2.Zero, leftMid );
			AddShard( right, rightMid, Vector2.Zero );
			AddShard( Vector2.Zero, rightMid, leftMid );
		}

		void AddShard( Vector2 a, Vector2 b, Vector2 c )
		{
			var offset = (a + b + c) / 3;
			float scale = Range( 0.66f, 0.78f );
			Vector2[] vertices = [(a - offset) * scale, (b - offset) * scale, (c - offset) * scale];
			var outward = offset.Normal;
			float angle = Range( -0.25f, 0.25f );
			var direction = outward * MathF.Cos( angle ) + new Vector2( -outward.y, outward.x ) * MathF.Sin( angle );
			AddParticle( center + offset, direction * Range( 600, 900 ), vertices, cell.ColorIndex, false );
		}
	}

	void EmitFaceSparks( FaceFlash flash )
	{
		var face = flash.Contact;
		var start = new Vector2( LaneX( face.StartLane ), face.StartY );
		var end = new Vector2( LaneX( face.EndLane ), face.EndY );
		var tangent = (end - start).Normal;
		// A full face seam is sealed by two gems. Escape around its endpoints,
		// searching outside both bodies instead of spawning inside either one.
		int count = SparkCount( flash.DamageCount );
		float strength = SparkStrength( flash.DamageCount );
		for ( int i = 0; i < count; i++ )
		{
			float radius = Range( 0.7f, 1.8f );
			bool atStart = i % 2 == 0;
			var origin = atStart ? start : end;
			var outward = tangent * (atStart ? -1 : 1);
			for ( int attempt = 0; attempt < 24; attempt++ )
			{
				float angle = Range( -1.2f, 1.2f );
				var direction = outward * MathF.Cos( angle ) + new Vector2( -outward.y, outward.x ) * MathF.Sin( angle );
				var position = origin + direction * Range( 3, 14 );
				if ( !HasRoom( position, radius + 0.5f ) ) continue;
				AddSpark( position, direction * Range( 130, 310 ) * strength, radius, face.ColorIndex );
				break;
			}
		}
	}

	void AddSpark( Vector2 position, Vector2 velocity, float radius, int colorIndex )
	{
		// Three quarters triangular splinters; the remaining chips are squares.
		bool triangle = random.NextDouble() < 0.76;
		float w = radius * Range( 0.65f, 1 ), h = radius;
		Vector2[] vertices = triangle
			? [new( -w, h * 0.65f ), new( w, h * 0.65f ), new( Range( -0.35f, 0.35f ) * w, -h )]
			: [new( -w, -w ), new( w, -w ), new( w, w ), new( -w, w )];
		AddParticle( position, velocity * MathF.Max( 0, SparkForceMultiplier ), vertices, colorIndex, true );
	}

	void AddParticle( Vector2 position, Vector2 velocity, Vector2[] vertices, int colorIndex, bool spark )
	{
		// Sparks cannot evict the long-lived, score-bearing shards.
		int count = 0, oldest = -1;
		for ( int i = 0; i < particles.Count; i++ )
		{
			if ( particles[i].IsSpark != spark ) continue;
			if ( oldest < 0 ) oldest = i;
			count++;
		}
		if ( count >= (spark ? MaxSparks : MaxShards) )
		{
			particles[oldest].Body.Remove();
			particles.RemoveAt( oldest );
		}
		var body = World.CreateBody();
		body.BodyType = PhysicsBodyType.Dynamic;
		body.Position = new Vector3( position.x, position.y, 0 );
		float radius = 0;
		for ( int i = 0; i < vertices.Length; i++ ) radius = MathF.Max( radius, vertices[i].Length );
		// Sub-unit spark polygons fall below Box2D's hull-welding tolerance.
		// Use a tiny round fixture; full-size shards retain their exact triangles.
		PhysicsShape shape;
		if ( spark ) shape = body.AddSphereShape( Vector3.Zero, radius );
		else
		{
			var hull = new List<Vector3>( vertices.Length );
			for ( int i = 0; i < vertices.Length; i++ ) hull.Add( new( vertices[i].x, vertices[i].y, 0 ) );
			shape = body.AddHullShape( Vector3.Zero, Rotation.Identity, hull );
		}
		shape.Surface = spark ? sparkSurface : surface;
		// Debris uses geometric crush checks, not engine contact callbacks.
		shape.EnableTouch = false;
		shape.Tags.Add( spark ? "spark" : "shard" );
		body.Rotation = spark ? Rotation.FromYaw( Range( 0, 360 ) ) : Rotation.Identity;
		if ( spark && velocity.LengthSquared > MaxSparkSpeed * MaxSparkSpeed ) velocity = velocity.Normal * MaxSparkSpeed;
		body.Velocity = new Vector3( velocity.x, velocity.y, 0 );
		body.AngularVelocity = new Vector3( 0, 0, spark ? Range( -620, 620 ) : Range( -160, 160 ) );
		// Each spark samples its own weight so a burst fans into varied arcs.
		if ( spark ) body.GravityScale = Range( MathF.Max( 0, SparkGravityMin ), MathF.Max( SparkGravityMin, SparkGravityMax ) );
		body.LinearDamping = 0.35f;
		body.AngularDamping = 1.8f;
		body.EnhancedCcd = true;
		body.AutoSleep = true;
		body.SleepThreshold = 8;
		particles.Add( new Particle { Body = body, Radius = radius, Vertices = vertices, ColorIndex = colorIndex,
			IsSpark = spark, Brightness = spark ? Range( 0.5f, 0.8f ) : Range( 0.15f, 0.4f ),
			MaxAge = spark ? Range( 0.5f, 0.9f ) * MathF.Max( 0.1f, SparkLifetimeMultiplier ) : Range( MathF.Max( 0, ShardLifetimeMin ), MathF.Max( ShardLifetimeMin, ShardLifetimeMax ) ),
			ShineDelay = spark ? 0 : Range( 0.5f, 3 ), ShinePeriod = spark ? 3.5f : Range( 2.5f, 4.5f ),
			ShineDirection = spark || random.Next( 2 ) == 0 ? 1 : -1 } );
	}
}
facepunch.diamondgrinder / DiamondPanel.Strokes.cs
Game game
using System;
using Sandbox;

namespace Diamonds;

public sealed partial class DiamondPanel
{
	// Used only by editor comparisons; never exposed as a gameplay setting.
	static bool drawingReferenceStrokes = false;

	// Small mitered strips stay on Painter's inline polygon path instead of allocating
	// a stroke primitive tree. This is restricted to the simple gem/fragment geometry.
	static void DrawGemStroke( Painter p, ReadOnlySpan<Vector2> points, Color color, float width, bool closed )
	{
		if ( drawingReferenceStrokes || (!closed && points.Length > 4) )
		{
			p.Fill = Fill.None;
			p.Stroke = Stroke.Solid( color, width );
			if ( closed ) p.Polygon( points ); else p.Line( points );
			return;
		}
		Span<Vector2> offsets = stackalloc Vector2[points.Length];
		for ( int i = 0; i < points.Length; i++ )
		{
			var incoming = (points[i] - points[(i + points.Length - 1) % points.Length]).Normal;
			var outgoing = (points[(i + 1) % points.Length] - points[i]).Normal;
			if ( !closed && i == 0 ) incoming = outgoing;
			if ( !closed && i == points.Length - 1 ) outgoing = incoming;
			var n0 = new Vector2( -incoming.y, incoming.x );
			var n1 = new Vector2( -outgoing.y, outgoing.x );
			float denominator = 1 + Vector2.Dot( n0, n1 );
			var miter = denominator > 0.00001f ? (n0 + n1) / denominator : n0;
			// Match the default four-width miter limit; extreme shard tips use the original path.
			if ( denominator <= 0.00001f || miter.Length > 4 )
			{
				p.Fill = Fill.None; p.Stroke = Stroke.Solid( color, width );
				if ( closed ) p.Polygon( points ); else p.Line( points );
				return;
			}
			offsets[i] = miter * (width * 0.5f);
		}
		p.Stroke = Stroke.None;
		p.Fill = color;
		if ( !closed )
		{
			// A single polygon keeps crack joins seamless. Longer cracks retain Painter's
			// stroke path rather than splitting them across independently antialiased edges.
			Span<Vector2> polygon = stackalloc Vector2[points.Length * 2];
			for ( int i = 0; i < points.Length; i++ )
			{
				polygon[i] = points[i] + offsets[i];
				polygon[polygon.Length - 1 - i] = points[i] - offsets[i];
			}
			p.Polygon( polygon );
			return;
		}
		for ( int i = 0; i < points.Length; i++ )
		{
			int next = (i + 1) % points.Length;
			p.Quad( points[i] + offsets[i], points[next] + offsets[next],
				points[next] - offsets[next], points[i] - offsets[i] );
		}
	}
}
facepunch.diamondgrinder / DiamondDebris.Shatter.cs
Game game
using System;
using System.Collections.Generic;
using Sandbox;
using static Diamonds.DiamondBoard;

namespace Diamonds;

public sealed partial class DiamondDebris
{
	public sealed class ShatterSunburst
	{
		public Vector2 Position { get; internal set; }
		public float Age { get; internal set; }
		public float Lifetime { get; internal set; }
		public float Scale { get; internal set; }
		public float Rotation { get; internal set; }
		public float Brightness { get; internal set; }
		public int ColorIndex { get; internal set; }
		internal Vector2 Velocity;
	}

	readonly List<ShatterSunburst> shatterSunbursts = new();
	public IReadOnlyList<ShatterSunburst> ShatterSunbursts => shatterSunbursts;
	readonly List<ShatterSunburst> crushSunbursts = new();
	public IReadOnlyList<ShatterSunburst> CrushSunbursts => crushSunbursts;
	public const float CrushBurstDuration = 0.25f;
	public const float CrushBurstScale = 0.6f;

	void EmitCrushSunburst( Particle shard )
	{
		if ( crushSunbursts.Count >= MaxShards ) crushSunbursts.RemoveAt( 0 );
		crushSunbursts.Add( new ShatterSunburst
		{
			Position = shard.Position, ColorIndex = shard.ColorIndex,
			Lifetime = CrushBurstDuration, Scale = CrushBurstScale,
			Rotation = Range( -MathF.PI, MathF.PI ), Brightness = 0.4f
		} );
	}

	void UpdateShatterSunbursts( float delta )
	{
		UpdateSunbursts( shatterSunbursts, delta );
		UpdateSunbursts( crushSunbursts, delta );
	}

	static void UpdateSunbursts( List<ShatterSunburst> bursts, float delta )
	{
		for ( int i = bursts.Count - 1; i >= 0; i-- )
		{
			var burst = bursts[i];
			burst.Age += delta;
			if ( burst.Age >= burst.Lifetime ) { bursts.RemoveAt( i ); continue; }
			burst.Position += burst.Velocity * delta;
		}
	}

	void EmitShatterEffects( Diamond cell, bool gameOver )
	{
		var center = new Vector2( LaneX( cell.Lane ), cell.Y );
		for ( int i = 0; i < 3; i++ )
		{
			if ( shatterSunbursts.Count >= 192 ) shatterSunbursts.RemoveAt( 0 );
			float angle = Range( -MathF.PI, MathF.PI );
			var outward = new Vector2( MathF.Cos( angle ), MathF.Sin( angle ) );
			shatterSunbursts.Add( new ShatterSunburst
			{
				Position = center + outward * (i == 0 ? 0 : Range( 8, 18 )),
				Velocity = outward * (i == 0 ? 0 : Range( 35, 75 )),
				Lifetime = Range( 0.35f, 0.5f ), Scale = i == 0 ? 1.3f : Range( 0.5f, 0.8f ),
				Rotation = Range( -MathF.PI, MathF.PI ), Brightness = Range( 0.4f, 0.6f ), ColorIndex = cell.ColorIndex
			} );
		}
		int count = gameOver ? random.Next( 9, 15 ) : random.Next( 28, 43 );
		for ( int i = 0; i < count; i++ )
		{
			float angle = Range( -MathF.PI, MathF.PI );
			var outward = new Vector2( MathF.Cos( angle ), MathF.Sin( angle ) );
			float radius = Range( 0.8f, 1.9f );
			var position = center + outward * Range( 2, 18 );
			if ( !HasRoom( position, radius + 0.5f ) ) continue;
			// The vanished gem leaves an open pocket. Launch from inside it so
			// nearby surviving gems naturally block and deflect the explosion.
			AddSpark( position, outward * Range( 450, 950 ), radius, cell.ColorIndex );
		}
	}
}
facepunch.diamondgrinder / DiamondOutline.cs
Game game
using System;
using System.Collections.Generic;
using static Diamonds.DiamondBoard;

namespace Diamonds;

/// <summary>Exposed perimeter edges of actual bonded groups, in board coordinates.</summary>
public static class DiamondOutline
{
	public readonly record struct Edge( float X1, float Y1, float X2, float Y2, int ColorIndex );

	/// <summary>Whether a traced perimeter can be drawn as a closed polygon.</summary>
	public static bool IsClosed( IReadOnlyList<Edge> contour ) => contour.Count >= 3
		&& MathF.Abs( contour[^1].X2 - contour[0].X1 ) <= 0.01f
		&& MathF.Abs( contour[^1].Y2 - contour[0].Y1 ) <= 0.01f;

	/// <summary>Order one group's perimeter into contours, including holes.
	/// Animated positions can leave open chains; use IsClosed before closing a path.
	/// At a shared tip, follow the rightmost turn so touching loops stay separate.</summary>
	public static List<List<Edge>> Contours( IReadOnlyList<Edge> edges )
	{
		var contours = new List<List<Edge>>();
		var used = new bool[edges.Count];
		for ( int start = 0; start < edges.Count; start++ )
		{
			if ( used[start] ) continue;
			var contour = new List<Edge>();
			int current = start;
			while ( current >= 0 && !used[current] )
			{
				var edge = edges[current];
				used[current] = true;
				contour.Add( edge );
				int next = -1;
				float bestTurn = float.NegativeInfinity;
				for ( int candidate = 0; candidate < edges.Count; candidate++ )
				{
					var other = edges[candidate];
					if ( (used[candidate] && candidate != start) || other.ColorIndex != edge.ColorIndex ||
						MathF.Abs( other.X1 - edge.X2 ) > 0.01f || MathF.Abs( other.Y1 - edge.Y2 ) > 0.01f ) continue;
					float dx = edge.X2 - edge.X1, dy = edge.Y2 - edge.Y1;
					float ox = other.X2 - other.X1, oy = other.Y2 - other.Y1;
					float turn = MathF.Atan2( dx * oy - dy * ox, dx * ox + dy * oy );
					if ( turn > bestTurn ) { next = candidate; bestTurn = turn; }
				}
				current = next;
			}
			contours.Add( contour );
		}
		return contours;
	}

	public static List<Edge> Build( IReadOnlyList<Diamond> cells, IReadOnlyList<IReadOnlyList<int>> groups )
	{
		var edges = new List<Edge>();
		foreach ( var group in groups )
		foreach ( int index in group )
		{
			var cell = cells[index];
			int shared = 0;
			foreach ( int otherIndex in group )
			{
				if ( index == otherIndex ) continue;
				var other = cells[otherIndex];
				if ( cell.ColorIndex != other.ColorIndex || !DiamondMatching.Touching( cell, other ) ) continue;
				int side = other.Lane > cell.Lane ? (other.Y < cell.Y ? 0 : 1) : (other.Y > cell.Y ? 2 : 3);
				shared |= 1 << side;
			}
			float x = LaneX( cell.Lane ), y = cell.Y;
			float w = Width * 0.5f, h = Height * 0.5f;
			if ( (shared & 1) == 0 ) edges.Add( new( x, y - h, x + w, y, cell.ColorIndex ) );
			if ( (shared & 2) == 0 ) edges.Add( new( x + w, y, x, y + h, cell.ColorIndex ) );
			if ( (shared & 4) == 0 ) edges.Add( new( x, y + h, x - w, y, cell.ColorIndex ) );
			if ( (shared & 8) == 0 ) edges.Add( new( x - w, y, x, y - h, cell.ColorIndex ) );
		}
		return edges;
	}
}
facepunch.diamondgrinder / DiamondPanel.Score.cs
Game game
using System;
using Sandbox;

namespace Diamonds;

public sealed partial class DiamondPanel
{
	readonly DiamondScoreLayout fallbackScoreLayout = new();
	static Color ScoreColor( float phase, int step = 1 )
	{
		int index = ((int)phase * step) % Palette.Length;
		float blend = phase - MathF.Floor( phase );
		blend = blend * blend * (3 - 2 * blend);
		return Color.Lerp( Palette[index], Palette[(index + step) % Palette.Length], blend );
	}

	void DrawScore( Painter p, float center )
	{
		var effects = ScoreEffects;
		var origin = new Vector2( Left, Top + boardOffsetY );
		var target = new Vector2( center, NextPreviewCenterY );
		if ( effects is not null ) effects.Target = target - origin;
		var color = ScoreColor( effects?.ColorPhase ?? 0 );
		var layout = effects?.Layout ?? fallbackScoreLayout;
		// Use the actual letterbox, including extra space on wide viewports.
		// Keep 16 units clear on each side; never shrink the digits to fit.
		layout.MaxWidth = (Left - center) * 2 - 32;
		layout.Update( effects?.DisplayedScore ?? Board.Score );
		float scoreScale = effects?.Scale ?? 1;
		for ( int i = 0; i < layout.Rows.Count; i++ )
			DrawScoreNumber( p, layout.Rows[i].Text, target + new Vector2( 0, i * DiamondScoreLayout.RowSpacing * scoreScale ),
				DiamondScoreLayout.FontSize * scoreScale, color );
		if ( effects is null ) return;
		foreach ( var pickup in effects.Pickups )
		{
			var position = origin + pickup.Position;
			var tint = Palette[pickup.ColorIndex % Palette.Length];
			float launch = Math.Clamp( pickup.Age / 0.12f, 0, 1 );
			float pop = 1 + MathF.Sin( launch * MathF.PI ) * 0.28f;
			float scale = MathF.Min( 2, DiamondScoreEffects.ValueScale( pickup.Points ) * pop );
			float pickupSize = DiamondScoreEffects.PickupFontSize * scale * (1 - pickup.PullProgress * 0.45f);
			if ( pickup.PullProgress > 0 )
			{
				var tail = pickup.Velocity.Normal * MathF.Min( 45, pickup.Velocity.Length * 0.018f );
				p.Stroke = Stroke.Solid( tint.WithAlpha( 0.24f ), 2 );
				p.Line( position - tail, position );
			}
			DrawScoreNumber( p, $"+{pickup.Points}", position, pickupSize, tint );
		}
		foreach ( var spark in effects.Sparks )
		{
			float fade = 1 - spark.Age / spark.Lifetime;
			var position = origin + spark.Position;
			var tint = Color.Lerp( Palette[spark.ColorIndex % Palette.Length], Color.White, 0.55f );
			p.Stroke = Stroke.Solid( tint.WithAlpha( fade * fade ), 1.8f * fade );
			p.Line( position - spark.Velocity * 0.024f, position );
		}
	}

	/// <summary>Draw at <paramref name="center"/>; <paramref name="align"/> 0 places the text's left edge there instead.</summary>
	static void DrawScoreNumber( Painter p, string value, Vector2 center, float fontSize, Color color, float align = 0.5f, bool outline = true )
	{
		using var scope = p.Scope();
		p.Translate( center );
		p.Scale( fontSize / DiamondScoreEffects.PickupFontSize );
		p.TextStyle = new TextStyle( DiamondScoreEffects.ScoreFont, DiamondScoreEffects.PickupFontSize, color )
		{
			Alignment = TextFlag.Center,
			Outline = new TextRendering.Outline { Enabled = outline, Color = Color.Black.WithAlpha( 0.8f ), Size = 1.5f }
		};
		// Keep layout at a fixed size; Painter transforms animate the cached glyphs.
		var bounds = p.MeasureText( value ) + new Vector2( 4 );
		p.Text( value, new Rect( new Vector2( -bounds.x * align, -bounds.y * 0.5f ), bounds ) );
	}

	// Advances at PickupFontSize, measured on first use.
	static float[] timeAdvances;

	/// <summary>
	/// A character's cell width in a time. Enixe's numerals are proportional only because 1 is narrow,
	/// so every digit takes the widest numeral's cell and a ticking time keeps its width and positions.
	/// </summary>
	static float TimeAdvance( char c )
	{
		if ( c >= 128 ) return DiamondScoreEffects.MeasureNumber( c.ToString() ).x;
		if ( timeAdvances is null )
		{
			float digit = 0;
			for ( char d = '0'; d <= '9'; d++ ) digit = MathF.Max( digit, DiamondScoreEffects.MeasureNumber( d.ToString() ).x );
			// Don't keep widths measured before the font is available.
			if ( digit <= 0 ) return 0;
			timeAdvances = new float[128];
			for ( char d = '0'; d <= '9'; d++ ) timeAdvances[d] = digit;
		}
		if ( timeAdvances[c] <= 0 ) timeAdvances[c] = DiamondScoreEffects.MeasureNumber( c.ToString() ).x;
		return timeAdvances[c];
	}

	static float MeasureTimeNumber( string value, float fontSize )
	{
		float width = 0;
		foreach ( char c in value ) width += TimeAdvance( c );
		return width * fontSize / DiamondScoreEffects.PickupFontSize;
	}

	/// <summary>Draw a time like <see cref="DrawScoreNumber"/>, but with each glyph centered in a fixed-width cell.</summary>
	static void DrawTimeNumber( Painter p, string value, Vector2 center, float fontSize, Color color, float align = 0.5f, bool outline = true )
	{
		float scale = fontSize / DiamondScoreEffects.PickupFontSize;
		float x = center.x - MeasureTimeNumber( value, fontSize ) * align;
		foreach ( char c in value )
		{
			float advance = TimeAdvance( c ) * scale;
			DrawScoreNumber( p, c.ToString(), new Vector2( x + advance * 0.5f, center.y ), fontSize, color, outline: outline );
			x += advance;
		}
	}
}
facepunch.diamondgrinder / DiamondPanel.Title.cs
Game game
using System;
using Sandbox;

namespace Diamonds;

public sealed partial class DiamondPanel
{
	bool titleScreen;
	public bool TitleScreen
	{
		get => titleScreen;
		set
		{
			if ( titleScreen == value ) return;
			titleScreen = value;
			// Every visit to the title starts with PLAY selected.
			titleSelection = 0;
			if ( titleButtonBlend is not null ) Array.Clear( titleButtonBlend );
			TitleAnimationTime = 0;
			titleColorTime = 0;
			titleSparkAccumulator = 0;
			titleEffects.Clear();
			titleDye.Clear();
			StopTitleAudio();
		}
	}
	public Action PlayRequested { get; set; }
	public Action QuitRequested { get; set; }
	public float TitleAnimationTime { get; private set; }
	const float TitleSlideDuration = 0.72f;
	const float TitleShineDuration = 0.24f;
	const float TitleAngle = -51;
	float titleColorTime;
	float titleSparkAccumulator;
	readonly DiamondTitleEffects titleEffects = new();
	readonly DiamondMenuDye titleDye = new();
	Texture titleDyeTexture;
	Color32[] titleDyePixels;
	DyeTextureStamp titleDyeTextureStamp;
	// Only the editor comparison tools enable the retained rectangle reference.
	bool titleDyeReferenceTiles = false;
	public DiamondTitleEffects TitleEffects => titleEffects;
	public DiamondMenuDye TitleDye => titleDye;
	readonly DiamondSlideAudio titleSlideAudio = new();
	SoundHandle titleImpact;
	// Stacked below the title's lowest glyph (y ~650), ending above the canvas bottom.
	static Rect TitleMenuButton( int index ) => new( (CanvasWidth - 360) * 0.5f, 688 + index * 74, 360, 64 );
	static Rect PlayButton => TitleMenuButton( 0 );
	static Rect LeaderboardButton => TitleMenuButton( 1 );
	static Rect InstructionsButton => TitleMenuButton( 2 );
	static Rect QuitButton => TitleMenuButton( 3 );
	// Refilled on use: hotload keeps static values and leaves new fields null.
	Rect[] titleButtons;
	/// <summary>Solid walls for the menu dye and sparks.</summary>
	Rect[] TitleButtons
	{
		get
		{
			titleButtons ??= new Rect[4];
			for ( int i = 0; i < titleButtons.Length; i++ ) titleButtons[i] = TitleMenuButton( i );
			return titleButtons;
		}
	}
	Vector2 TitleShakeOffset => new Vector2( -0.6293f, 0.7771f ) *
		(TitleShake( TitleAnimationTime - TitleSlideDuration ) + TitleShake( TitleAnimationTime - TitleSlideDuration * 2 ));

	public void StopTitleAudio()
	{
		titleSlideAudio.Stop();
		titleImpact?.Stop();
		titleImpact = null;
	}

	public void UpdateTitleScreen( float delta )
	{
		if ( !TitleScreen ) { StopTitleAudio(); return; }
		UpdateTitleButtonRainbow( MathF.Max( 0, delta ) );
		if ( ModalOpen ) { StopTitleAudio(); return; }
		delta = MathF.Max( 0, delta );
		float previous = TitleAnimationTime;
		// Substep cosmetic physics and emission together, including frames spanning a landing.
		float remaining = MathF.Min( delta, MathF.Max( 0, 4.5f - previous ) );
		while ( remaining > 0 )
		{
			float step = MathF.Min( remaining, 1f / 120 );
			float before = TitleAnimationTime;
			TitleAnimationTime = MathF.Min( 4.5f, before + step );
			titleEffects.Update( step, TitleViewport, TitleButtons );
			UpdateTitleDye( step );
			for ( int word = 0; word < 2; word++ )
			{
				float start = word * TitleSlideDuration, hit = start + TitleSlideDuration;
				float sliding = MathF.Max( 0, MathF.Min( TitleAnimationTime, hit ) - MathF.Max( before, start ) );
				titleSparkAccumulator += sliding * 190;
				while ( titleSparkAccumulator >= 1 )
				{
					titleSparkAccumulator--;
					float speed = MathF.Pow( Math.Clamp( (TitleAnimationTime - start) / TitleSlideDuration, 0, 1 ), 1.8f );
					var local = new Vector2( titleEffects.Range( -345, 335 ), 49 );
					var position = TitleWordPoint( word, local, TitleAnimationTime );
					if ( TitleViewport.IsInside( position ) ) titleEffects.EmitSlide( position, TitleWordColor( word, titleColorTime + TitleAnimationTime - previous ), speed );
				}
				if ( before < hit && TitleAnimationTime >= hit )
				{
					var color = TitleWordColor( word, titleColorTime + hit - previous );
					if ( word == 0 ) titleEffects.EmitImpact( TitleContactPoint( word ), color );
					else titleEffects.EmitFaceImpact(
						TitleWordPoint( 1, DiamondTitleGlyphs.Words[1][0][9], hit ),
						TitleWordPoint( 1, DiamondTitleGlyphs.Words[1][0][10], hit ), color );
				}
			}
			remaining -= step;
		}
		float idle = delta - (TitleAnimationTime - previous);
		if ( idle > 0.0001f ) UpdateTitleDye( idle );
		titleColorTime = (titleColorTime + delta) % 100;
		for ( int word = 1; word <= 2; word++ )
		{
			float landing = TitleSlideDuration * word;
			if ( previous >= landing || TitleAnimationTime < landing ) continue;
			titleSlideAudio.Stop();
			if ( IsMuted ) continue;
			titleImpact?.Stop();
			titleImpact = Sound.Play( "sfx/diamond_impact.sound" );
			if ( titleImpact is null ) continue;
			titleImpact.SpacialBlend = 0;
			titleImpact.Volume *= (word == 1 ? 0.72f : 0.85f) * DiamondSettings.EffectsGain;
			titleImpact.Pitch *= word == 1 ? 0.92f : 0.80f;
		}
		if ( IsMuted ) { StopTitleAudio(); return; }
		if ( TitleAnimationTime < TitleSlideDuration * 2 )
		{
			float progress = (TitleAnimationTime % TitleSlideDuration) / TitleSlideDuration;
			// The derivative of the power-2.8 slide drives the same foley as gameplay.
			titleSlideAudio.Update( MathF.Pow( progress, 1.8f ), delta );
		}
		else titleSlideAudio.Stop();
	}

	public void HandleTitleClick( Vector2 mouse, Vector2 size )
	{
		if ( !TitleScreen || ModalOpen || TransitionPending ) return;
		float scale = MathF.Min( size.x / CanvasWidth, size.y / CanvasHeight );
		if ( scale <= 0 ) return;
		var point = (mouse - (size - new Vector2( CanvasWidth, CanvasHeight ) * scale) * 0.5f) / scale;
		point -= TitleShakeOffset;
		for ( int i = 0; i < TitleButtonCount; i++ )
			if ( TitleMenuButton( i ).IsInside( point ) ) { ActivateTitleButton( i ); return; }
	}

	void DrawTitleScreen( Painter p, float scale )
	{
		using var scope = p.Scope();
		p.Translate( TitleShakeOffset );
		DrawTitleDye( p );
		DrawTitleWord( p, "DIAMOND", 0 );
		DrawTitleWord( p, "GRINDER", 1 );
		var size = Box.Rect.Size;
		var mouse = (MousePosition - (size - new Vector2( CanvasWidth, CanvasHeight ) * scale) * 0.5f) / scale;
		mouse -= TitleShakeOffset;
		// The fluid layer is blurred, so cover its filtered fringe inside the solid buttons.
		p.Stroke = Stroke.None;
		p.Fill = Color.Parse( "#0b1017" ).Value;
		foreach ( var button in TitleButtons ) p.Rect( button );
		// The selection, not the mouse, lights title buttons; hovering one selects it.
		DrawTitleButton( p, PlayButton, "PLAY", mouse, scale, selected: titleSelection == 0, labelColor: TitleButtonLabel( 0 ) );
		DrawTitleButton( p, LeaderboardButton, "LEADERBOARDS", mouse, scale, selected: titleSelection == 1, labelColor: TitleButtonLabel( 1 ) );
		DrawTitleButton( p, InstructionsButton, "INSTRUCTIONS", mouse, scale, selected: titleSelection == 2, labelColor: TitleButtonLabel( 2 ) );
		DrawTitleButton( p, QuitButton, "QUIT", mouse, scale, danger: true, selected: titleSelection == 3, labelColor: TitleButtonLabel( 3 ) );
		DrawTitleEffects( p );
	}

	static float TitleShake( float age ) => age < 0 || age > 0.48f ? 0
		: 11 * MathF.Exp( -age * 10 ) * MathF.Cos( age * 95 );

	static Color TitleWordColor( int word, float time )
	{
		// DIAMOND reaches pure blue exactly as it lands, so its darker face shows the shine.
		float phase = word == 0 ? 1 + (time - TitleSlideDuration) * 0.85f : 3 - time * 1.05f;
		// Wrap the reverse cycle into the palette's positive range before interpolating.
		return ScoreColor( (phase % Palette.Length + Palette.Length) % Palette.Length );
	}

	Rect TitleViewport
	{
		get
		{
			var size = Box.Rect.Size;
			float scale = MathF.Min( size.x / CanvasWidth, size.y / CanvasHeight );
			var extent = scale > 0 ? size / scale : new Vector2( CanvasWidth, CanvasHeight );
			return new Rect( (new Vector2( CanvasWidth, CanvasHeight ) - extent) * 0.5f, extent );
		}
	}

	Vector2 TitleWordPoint( int word, Vector2 local, float time )
	{
		float progress = Math.Clamp( (time - word * TitleSlideDuration) / TitleSlideDuration, 0, 1 );
		float slide = (910 + MathF.Max( 0, -TitleViewport.Top ) / 0.777146f) * (1 - MathF.Pow( progress, 2.8f ));
		// Compensate for D/G's different lowest vertices, aligning the visible tips.
		local += new Vector2( slide + (word == 0 ? -9.72f : 45), word == 0 ? -54 : 54 );
		return new Vector2( CanvasWidth * 0.5f, 374 ) + new Vector2( local.x * 0.62932f + local.y * 0.777146f, -local.x * 0.777146f + local.y * 0.62932f );
	}

	Vector2 TitleContactPoint( int word ) => TitleWordPoint( word,
		word == 0 ? new Vector2( -348.5f, 47.11f ) : new Vector2( -324.64f, 36.14f ), TitleSlideDuration * 2 );

	Vector2 TitleWordCenter( int index )
	{
		float age = TitleAnimationTime - index * TitleSlideDuration;
		float damage = Math.Clamp( (age - TitleSlideDuration) / 0.24f, 0, 1 );
		float magnitude = age < TitleSlideDuration ? 0 : 12 * (1 - damage) * (1 - damage);
		float angle = MathF.Floor( damage * 18 ) * 2.39996f + index * 1.7f;
		return TitleWordPoint( index, Vector2.Zero, TitleAnimationTime ) + new Vector2( MathF.Cos( angle ), MathF.Sin( angle ) ) * magnitude;
	}

	void UpdateTitleDye( float delta ) => titleDye.Update( delta, TitleViewport, TitleButtons,
		TitleWordCenter( 0 ), TitleWordCenter( 1 ) );

	void DrawTitleDye( Painter p )
	{
		if ( HideDye ) return;
		if ( !titleDye.Ready( TitleViewport ) ) UpdateTitleDye( 0 );
		if ( titleDyeReferenceTiles ) { DrawTitleDyeTiles( p ); return; }
		UpdateDyeTexture( titleDye, titleDye.DyeVersion, titleDye.Samples, DiamondMenuDye.Columns, DiamondMenuDye.Rows,
			Color.Parse( "#0b1017" ).Value, "diamonds-menu-dye", Math.Clamp( DyeVibrancy, 0, 1 ), ref titleDyeTexture, ref titleDyePixels, ref titleDyeTextureStamp );
		using var scope = p.Scope();
		p.Clip( TitleViewport );
		DrawDyeLayer( p, titleDyeTexture, TitleViewport, TitleViewport, DyeLayerOpacity( 1 ) );
	}

	void DrawTitleDyeTiles( Painter p )
	{
		if ( !titleDye.Ready( TitleViewport ) ) UpdateTitleDye( 0 );
		using var scope = p.Scope();
		p.Clip( TitleViewport );
		using var layer = p.BeginLayer( TitleViewport, 0.22f, new Painter.Filter { Blur = 6 } );
		var background = Color.Parse( "#0b1017" ).Value;
		p.Stroke = Stroke.None;
		for ( int y = 0; y < DiamondMenuDye.Rows; y++ )
		for ( int x = 0; x < DiamondMenuDye.Columns; x++ )
		{
			float total = 0, weight = 0;
			var rgb = Vector3.Zero;
			for ( int color = 0; color < 5; color++ )
			{
				float amount = titleDye.Sample( x, y, color );
				total += amount; weight += amount * amount;
				rgb += new Vector3( Palette[color].r, Palette[color].g, Palette[color].b ) * (amount * amount);
			}
			if ( weight <= 0 ) continue;
			float coverage = Math.Clamp( (total - 0.04f) / 0.28f, 0, 1 );
			coverage = coverage * coverage * (3 - 2 * coverage);
			rgb /= weight;
			p.Fill = Color.Lerp( background, new Color( rgb.x, rgb.y, rgb.z ), coverage );
			p.Rect( new Rect( titleDye.Position( x, y ) - titleDye.CellSize * 0.5f, titleDye.CellSize + new Vector2( 0.6f ) ) );
		}
	}

	void DrawTitleWord( Painter p, string word, int index )
	{
		float age = TitleAnimationTime - index * TitleSlideDuration;
		if ( age <= 0 ) return;
		var color = TitleWordColor( index, titleColorTime );
		using var scope = p.Scope();
		p.Translate( TitleWordCenter( index ) );
		p.Rotate( TitleAngle );
		p.TextStyle = new TextStyle( DiamondScoreEffects.ScoreFont, 100, color ) { FontWeight = 700, Alignment = TextFlag.Center };
		var bounds = p.MeasureText( word );
		if ( bounds.x <= 0 || bounds.y <= 0 ) return;
		p.Scale( 700 / bounds.x, 1.8f );
		var rect = new Rect( -(bounds + new Vector2( 4 )) * 0.5f, bounds + new Vector2( 4 ) );
		p.Text( word, rect );
		float shine = (age - TitleSlideDuration) / TitleShineDuration;
		if ( shine < 0 || shine >= 1 ) return;
		// Sweep downhill along the glyphs, clipping bright text instead of drawing over its gaps.
		float sweep = bounds.x * (0.65f - shine * 1.3f);
		// A blurred copy of a wider band spills light past the glyph edges.
		using ( p.BeginLayer( new Rect( rect.Position - new Vector2( 32 ), rect.Size + new Vector2( 64 ) ), 0.9f * (1 - shine * shine),
			new Painter.Filter { Blur = 7 } ) )
			DrawTitleShineBands( p, word, rect, bounds, color, sweep, 2.2f, 1 );
		DrawTitleShineBands( p, word, rect, bounds, color, sweep, 1, 0.94f );
	}

	static void DrawTitleShineBands( Painter p, string word, Rect rect, Vector2 bounds, Color color, float sweep, float spread, float brightness )
	{
		float width = bounds.x * 0.0065f * spread;
		for ( int band = 0; band < 25; band++ )
		{
			using var highlight = p.Scope();
			// Overlap the antialiased clip edges so the reflection has no dark seams.
			p.Clip( new Rect( sweep + (band - 12.5f) * width, -bounds.y, width + 1, bounds.y * 2 ) );
			float intensity = 1 - MathF.Abs( band - 12 ) / 13;
			p.TextStyle = new TextStyle( DiamondScoreEffects.ScoreFont, 100, Color.Lerp( color, Color.White, intensity * brightness ) )
				{ FontWeight = 700, Alignment = TextFlag.Center };
			p.Text( word, rect );
		}
	}

	void DrawTitleEffects( Painter p )
	{
		foreach ( var spark in titleEffects.Particles )
		{
			float fade = Math.Clamp( (spark.Lifetime - spark.Age) / 0.45f, 0, 1 );
			var tint = Color.Lerp( spark.Color, Color.White, 0.45f );
			p.Stroke = Stroke.Solid( tint.WithAlpha( fade * 0.25f ), spark.Radius * 3 );
			var tail = spark.Position - spark.Velocity.Normal * MathF.Min( 15, spark.Velocity.Length * 0.012f );
			p.Line( tail, spark.Position );
			p.Stroke = Stroke.Solid( tint.WithAlpha( fade ), spark.Radius );
			p.Line( tail, spark.Position );
		}
		foreach ( var burst in titleEffects.Bursts )
		{
			if ( burst.FaceEnd.HasValue )
			{
				var jolt = TitleWordCenter( 1 ) - TitleWordPoint( 1, Vector2.Zero, TitleAnimationTime );
				DrawFaceFlash( p, burst.Position + jolt, burst.FaceEnd.Value + jolt, burst.Color, burst.Age, burst.Rotation, 0.42f, 2 );
			}
			else DrawSunburst( p, burst.Position, burst.Color, burst.Age / 0.25f, burst.Rotation, 0.42f, 2.4f );
		}
	}

	// PLAY, LEADERBOARDS, INSTRUCTIONS and QUIT rest on blue, yellow, purple and red.
	static readonly int[] TitleButtonPalette = [1, 3, 4, 0];
	const float TitleButtonRainbowRate = 7;
	const float TitleButtonBlendRate = 12;
	// Created on use: hotload leaves new fields null.
	float[] titleButtonBlend;
	float titleButtonRainbowTime;

	void UpdateTitleButtonRainbow( float delta )
	{
		titleButtonBlend ??= new float[TitleButtonCount];
		titleButtonRainbowTime = (titleButtonRainbowTime + delta * TitleButtonRainbowRate) % (Palette.Length * 100);
		// Lit (hovered or selected) labels ease toward the cycling rainbow, and back once unlit.
		float ease = 1 - MathF.Exp( -delta * TitleButtonBlendRate );
		for ( int i = 0; i < titleButtonBlend.Length; i++ )
			titleButtonBlend[i] += ((titleSelection == i ? 1 : 0) - titleButtonBlend[i]) * ease;
	}

	Color TitleButtonLabel( int index )
	{
		float blend = titleButtonBlend is { } values && index < values.Length ? values[index] : 0;
		// Offset each button's phase so neighbours don't flash the same color.
		return Color.Lerp( Palette[TitleButtonPalette[index]], ScoreColor( titleButtonRainbowTime + index * 1.3f ), blend );
	}

	// Quit and Leave warn on hover by flashing their background red, five times a second.
	// A selected title button lights (and warns) as if hovered; only a mouse press over it shows pressed.
	void DrawTitleButton( Painter p, Rect rect, string label, Vector2 mouse, float scale, float fontSize = 34, float brightness = 1, bool danger = false, bool? selected = null, Color? labelColor = null )
	{
		bool hover = !ModalOpen && rect.IsInside( mouse );
		bool lit = selected ?? hover;
		bool pressed = lit && hover && HasActive;
		if ( lit && danger ) p.Fill = Palette[0].WithAlpha( RealTime.Now * 5 % 1 < 0.5f ? (pressed ? 0.7f : 0.55f) : 0.14f );
		else p.Fill = Ink.WithAlpha( pressed ? 0.2f : lit ? 0.12f : 0.045f );
		p.Stroke = Stroke.None;
		p.Rect( rect );
		DrawScoreNumber( p, label, rect.Center, fontSize, (labelColor ?? (lit ? Color.White : Ink)) * brightness );
	}
}
facepunch.diamondgrinder / DiamondLeaderboard.cs
Game game
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using System.Threading.Tasks;
using Sandbox;

namespace Diamonds;

/// <summary>
/// The all-time boards: highest score and longest survival. Each game over submits its total score and
/// whole elapsed seconds as two stat values; each board is read with Max aggregation, highest first, so
/// every player shows their personal best. The stats are created on first submit, with no dashboard setup.
/// </summary>
public static class DiamondLeaderboard
{
	/// <summary>Bump to start fresh boards: it is baked into <see cref="StatName"/>, so older entries stop showing.</summary>
	public const int LEADERBOARD_NUM = 1;
	public const int MaxEntries = 200;

	public enum Mode { Score, Time }
	public static string StatName( Mode mode ) => mode == Mode.Time ? $"time_v{LEADERBOARD_NUM}" : $"score_v{LEADERBOARD_NUM}";

	/// <summary>A player's best: a score, or whole seconds survived.</summary>
	public readonly record struct Row( long SteamId, string Name, long Value );

	// This session's best submissions. Board responses are cached upstream for a few minutes, so a
	// fresh entry can be missing from them; splicing it in shows the new rank right away. Session-only
	// is enough, as it only has to bridge that cache window.
	static long sessionBestScore, sessionBestSeconds;
	static ref long SessionBest( Mode mode ) => ref mode == Mode.Time ? ref sessionBestSeconds : ref sessionBestScore;

	/// <summary>The in-game timer's MM:SS, which the time board shows too.</summary>
	public static string FormatTime( long seconds ) => $"{seconds / 60:00}:{seconds % 60:00}";
	public static string Format( Mode mode, long value ) =>
		mode == Mode.Time ? FormatTime( value ) : value.ToString( "N0", CultureInfo.InvariantCulture );

	/// <summary>Submit a finished game's total score and whole seconds survived. Max aggregation keeps only each player's best.</summary>
	public static void Submit( long score, long seconds )
	{
		SubmitStat( Mode.Score, score );
		SubmitStat( Mode.Time, seconds );
		Sandbox.Services.Stats.Flush();
	}

	static void SubmitStat( Mode mode, long value )
	{
		string stat = StatName( mode );
		if ( value <= 0 )
		{
			Log.Info( $"Diamonds: skipped '{stat}' submission for {Format( mode, value )}." );
			return;
		}
		// Submit every game: the engine drops a failed upload without retrying, so skipping values
		// below a best we only submitted could leave the board without it. Send the session best rather than
		// this game's value: SetValue replaces a queued, not yet uploaded value (Flush can hold one back ~20 s),
		// so a quick low game straight after a high one would otherwise erase the best.
		ref long best = ref SessionBest( mode );
		best = Math.Max( best, value );
		Sandbox.Services.Stats.SetValue( stat, best );
		Log.Info( best == value ? $"Diamonds: submitted {Format( mode, value )} to '{stat}'."
			: $"Diamonds: submitted session best {Format( mode, best )} to '{stat}' for a game with {Format( mode, value )}." );
	}

	/// <summary>The top <see cref="MaxEntries"/> players on <paramref name="mode"/>'s board, including this session's best when the
	/// response predates it. <paramref name="testPlayers"/> mixes generated players in by value, for previewing a busy board.</summary>
	public static async Task<List<Row>> FetchTop( Mode mode, int testPlayers = 0 )
	{
		var rows = new List<Row>();
		string stat = StatName( mode );
		try
		{
			var board = Sandbox.Services.Leaderboards.GetFromStat( stat );
			board.SetAggregationMax();
			board.SetSortDescending();
			board.FilterByNone();
			board.MaxEntries = MaxEntries;
			await board.Refresh();
			foreach ( var entry in board.Entries ?? [] )
				rows.Add( new Row( entry.SteamId, entry.DisplayName, (long)entry.Value ) );
		}
		catch ( Exception e )
		{
			// A stat with no entries omits them from the response, and the engine throws while reading it.
			// Treat that, and an unreachable backend, as an empty board.
			Log.Info( $"Diamonds: leaderboard empty or unavailable ({e.Message}) [stat '{stat}']." );
			rows.Clear();
		}
		if ( testPlayers > 0 )
		{
			rows.AddRange( TestRows( testPlayers, mode ) );
			// Stable, so real rows stay ahead of equal test values.
			rows = rows.OrderByDescending( r => r.Value ).Take( MaxEntries ).ToList();
		}
		long me = (long)Game.SteamId;
		Splice( rows, me, SessionBest( mode ), () => new Friend( me ).Name );
		return rows;
	}

	/// <summary>
	/// Deterministic generated players, highest first, with short, long, lowercase and non-Latin names.
	/// Scores fall from about 250,000 and times from 90 minutes, so a real entry lands among them. Their
	/// ids are in the engine's fake range, which draws generated avatars instead of asking Steam.
	/// </summary>
	public static List<Row> TestRows( int count, Mode mode = Mode.Score )
	{
		string[] names = ["crystalfan", "An Extremely Long Steam Username That Must Be Truncated", "Garry", "ダイヤモンド職人", "x",
			"GemGrinder_2000", "Ωmega Δiamond", "lowercase only name", "WWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWW", "Шлифовщик",
			"prism", "Sapphire Queen", "💎 facet 💎", "not_a_bot", "Tetrahedron", "Obsidian"];
		count = Math.Clamp( count, 0, MaxEntries );
		var rows = new List<Row>( count );
		float top = mode == Mode.Time ? 5400 : 250_000;
		long value = long.MaxValue;
		for ( int i = 0; i < count; i++ )
		{
			float t = count > 1 ? i / (float)(count - 1) : 0;
			// Geometric spacing, kept strictly descending where it flattens near the bottom.
			value = Math.Min( value - 1, (long)(top * MathF.Pow( (10 + count) / top, t )) );
			string name = names[i % names.Length] + (i >= names.Length ? $" {i / names.Length + 1}" : "");
			rows.Add( new Row( 90071996842377216 + 1000 + i, name, value ) );
		}
		return rows;
	}

	/// <summary>
	/// Show <paramref name="best"/> for <paramref name="me"/> when the board has a lower value or none:
	/// the old row is removed and the new one takes its rank. Ties go after existing rows, which were
	/// submitted earlier. A new entrant that falls outside a full window is left off.
	/// </summary>
	public static void Splice( List<Row> rows, long me, long best, Func<string> localName )
	{
		if ( best <= 0 ) return;
		int existing = rows.FindIndex( r => r.SteamId == me );
		if ( existing >= 0 && rows[existing].Value >= best ) return;
		string name = existing >= 0 ? rows[existing].Name : localName();
		if ( existing >= 0 ) rows.RemoveAt( existing );
		int index = rows.FindIndex( r => r.Value < best );
		if ( index < 0 ) index = rows.Count;
		if ( existing < 0 && index >= MaxEntries ) return;
		rows.Insert( index, new Row( me, name, best ) );
		if ( rows.Count > MaxEntries ) rows.RemoveRange( MaxEntries, rows.Count - MaxEntries );
	}
}
facepunch.diamondgrinder / DiamondPanel.Transition.cs
Game game
using System;
using Sandbox;

namespace Diamonds;

public sealed partial class DiamondPanel
{
	const float TransitionFadeOut = 0.1f, TransitionFadeIn = 0.25f;
	// Hotload leaves new fields at their defaults, so idle must be the default state.
	bool transitionActive, transitionSwitched, transitionToGame;
	float transitionTime;
	/// <summary>A title/game switch is fading the old screen out or the new one in.</summary>
	public bool Transitioning => transitionActive;
	/// <summary>The old screen is still fading out; the switch has not happened yet.</summary>
	public bool TransitionPending => transitionActive && !transitionSwitched;
	/// <summary>The pending or current switch leads into a game rather than the title.</summary>
	public bool TransitionToGame => transitionToGame;

	/// <summary>Fade to black, then switch between the title and a new game and fade back in.</summary>
	void BeginTransition( bool toGame )
	{
		if ( TransitionPending ) return;
		// Interrupting a fade-in continues from its current darkness rather than popping.
		transitionTime = transitionActive ? TransitionDarkness * TransitionFadeOut : 0;
		transitionActive = true;
		transitionSwitched = false;
		transitionToGame = toGame;
	}

	public void CancelTransition() => transitionActive = false;

	public void UpdateTransition( float delta )
	{
		if ( !transitionActive ) return;
		delta = MathF.Max( 0, delta );
		if ( !transitionSwitched )
		{
			transitionTime += delta;
			if ( transitionTime < TransitionFadeOut ) return;
			// Start the fade-in fully black; the switch frame's own cost must not skip it.
			transitionTime = TransitionFadeOut;
			transitionSwitched = true;
			if ( transitionToGame )
			{
				// A completed restart hold fades out under the black; the new game starts without it.
				ClearRestartHold();
				TitleScreen = false;
				PlayRequested?.Invoke();
			}
			else LeaveRequested?.Invoke();
			return;
		}
		// Cap hitch frames (such as the first gameplay frame) so the fade-in stays visible.
		transitionTime += MathF.Min( delta, 1f / 30 );
		if ( transitionTime >= TransitionFadeOut + TransitionFadeIn ) transitionActive = false;
	}

	float TransitionDarkness => !transitionActive ? 0
		: !transitionSwitched ? Math.Clamp( transitionTime / TransitionFadeOut, 0, 1 )
		: Math.Clamp( 1 - (transitionTime - TransitionFadeOut) / TransitionFadeIn, 0, 1 );

	void DrawTransition( Painter p )
	{
		float darkness = TransitionDarkness;
		if ( darkness <= 0 ) return;
		p.Fill = Color.Black.WithAlpha( darkness );
		p.Stroke = Stroke.None;
		p.Rect( p.Bounds );
	}
}
facepunch.diamondgrinder / DiamondSoundSamples.cs
Game game
using System;

namespace Diamonds;

/// <summary>Debris crushing foley, synthesized once as mono PCM.</summary>
public static class DiamondSoundSamples
{
	public const int Rate = 44100;

	public static byte[] Crush()
	{
		var samples = new float[(int)(Rate * 0.16f)];
		var random = new Random( 2609 );
		float[] clicks = [0.012f, 0.029f, 0.054f];
		float low = 0;
		for ( int i = 0; i < samples.Length; i++ )
		{
			float t = (float)i / Rate;
			float noise = (float)random.NextDouble() * 2 - 1;
			low += (noise - low) * 0.24f;
			// Dry granular snap, with several rapidly decaying fracture clicks.
			float envelope = MathF.Exp( -t * 45 );
			foreach ( float start in clicks )
				if ( t >= start ) envelope += 0.38f * MathF.Exp( -(t - start) * 180 );
			float edge = MathF.Min( 1, t / 0.001f ) * MathF.Min( 1, (samples.Length - 1 - i) / (Rate * 0.015f) );
			samples[i] = ((noise - low) * 0.5f + low * 0.7f) * envelope * edge;
		}
		return Pcm( samples, 0.76f );
	}

	/// <summary>A short, deep error buzz for a refused turn.</summary>
	public static byte[] RotateBump()
	{
		const float duration = 0.11f;
		var samples = new float[(int)(Rate * duration)];
		float phase = 0, sub = 0, low = 0;
		for ( int i = 0; i < samples.Length; i++ )
		{
			float t = (float)i / Rate;
			// A saturated low tone sagging from 128 to 92 Hz, over a sine an octave down.
			float frequency = 128 - 36 * (t / duration);
			phase += MathF.Tau * frequency / Rate;
			sub += MathF.Tau * frequency * 0.5f / Rate;
			float buzz = MathF.Tanh( 3 * MathF.Sin( phase ) ) + 0.3f * MathF.Tanh( 3 * MathF.Sin( phase * 1.06f ) );
			// Round off the buzz so it stays dull rather than harsh.
			low += (buzz - low) * 0.16f;
			float envelope = MathF.Min( 1, t / 0.003f ) * MathF.Exp( -t * 20 ) * MathF.Min( 1, (samples.Length - 1 - i) / (Rate * 0.02f) );
			samples[i] = (low * 0.8f + MathF.Sin( sub ) * 0.55f) * envelope;
		}
		return Pcm( samples, 0.8f );
	}

	static byte[] Pcm( float[] samples, float peak )
	{
		float maximum = 0.001f;
		foreach ( float value in samples ) maximum = MathF.Max( maximum, MathF.Abs( value ) );
		var bytes = new byte[samples.Length * 2];
		for ( int i = 0; i < samples.Length; i++ )
		{
			short value = (short)(samples[i] / maximum * peak * short.MaxValue);
			bytes[i * 2] = (byte)(value & 255);
			bytes[i * 2 + 1] = (byte)((value >> 8) & 255);
		}
		return bytes;
	}
}
facepunch.diamondgrinder / DiamondPanel.cs
Game game
using System;
using System.Collections.Generic;
using Sandbox;
using Sandbox.UI;

namespace Diamonds;

/// <summary>Every visible element is drawn with the new Painter API; no sprite assets.</summary>
public sealed partial class DiamondPanel : Panel
{
	public DiamondBoard Board { get; set; }
	public DiamondDebris Debris { get; set; }
	public DiamondAtmosphere Atmosphere { get; set; }
	public DiamondScoreEffects ScoreEffects { get; set; }
	public Vector2 ShakeOffset { get; set; }
	public Vector2 PieceShakeOffset { get; set; }
	public bool IsMuted { get; set; }
	/// <summary>Editor-only music state, drawn in the bottom-left screen corner when set.</summary>
	public string MusicDebugText { get; set; }
	public bool ShowLandingIndicator { get; set; } = false;
	/// <summary>Multiplies the landing point marker's original size.</summary>
	public float LandingPointScale { get; set; } = 1;
	/// <summary>Thumbnail capture: draw only the background dye, nothing else.</summary>
	public bool OnlyDye { get; set; }
	/// <summary>Thumbnail capture: skip every dye layer, including the static and menu dye.</summary>
	public bool HideDye { get; set; }
	/// <summary>Thumbnail capture: blends every dye layer from its normal faint wash (0) to opaque, fully saturated color (1).</summary>
	public float DyeVibrancy { get; set; }
	float DyeLayerOpacity( float opacity )
	{
		float vibrancy = Math.Clamp( DyeVibrancy, 0, 1 );
		return 0.22f * opacity + (1 - 0.22f * opacity) * vibrancy;
	}
	sealed class LandingPointGlow
	{
		public DiamondDamage.Contact Contact;
		public float Opacity;
		public float Rotation;
		public bool Visible;
		// Seconds since appearing, driving the uneven per-ray pulse; Appear eases the pop-in scale.
		public float Age;
		public float Appear;
		public float PulsePhase;
	}
	readonly List<LandingPointGlow> landingPointGlows = new();
	readonly List<DiamondDamage.Contact> landingPointContacts = new( 4 );
	Rect[] atmosphereTiles;
	int atmosphereTileVersion;
	Texture atmosphereTexture;
	Color32[] atmospherePixels;
	DyeTextureStamp atmosphereTextureStamp;
	struct DyeTextureStamp
	{
		public object Source;
		public ulong Version;
		public Color[] Palette;
		public Color Background;
		public float Vibrancy;
	}
	// Only the editor comparison tools enable the retained rectangle reference.
	bool atmosphereReferenceTiles = false;
	// Editor GPU checks compare this against full resolution on an identical frame.
	float dyeLayerResolution = 0.5f;

	public override void OnDeleted()
	{
		ReleaseGemAtlas();
		ReleaseSparkAtlas();
		ReleaseWallMasks();
		atmosphereTexture?.Dispose();
		atmosphereTexture = null;
		titleDyeTexture?.Dispose();
		titleDyeTexture = null;
		base.OnDeleted();
	}
	readonly Random glowRandom = new();
	DiamondBoard glowBoard;
	int glowResetVersion;
	float boundaryTime;
	DiamondBoard previewBoard;
	int previewResetVersion, previewSpawnVersion;
	float previewBounceTime = 0.45f;

	public void UpdateNextPreview( float delta )
	{
		if ( Board is null ) return;
		if ( previewBoard != Board || previewResetVersion != Board.ResetVersion || previewSpawnVersion != Board.SpawnVersion )
		{
			previewBoard = Board;
			previewResetVersion = Board.ResetVersion;
			previewSpawnVersion = Board.SpawnVersion;
			previewBounceTime = 0;
		}
		else if ( !Board.Paused ) previewBounceTime = MathF.Min( 0.45f, previewBounceTime + MathF.Max( 0, delta ) );
	}

	float NextPreviewScale
	{
		get
		{
			float t = previewBounceTime / 0.45f;
			return 1 + 0.09f * MathF.Sin( t * MathF.PI * 2 ) * (1 - t) * (1 - t);
		}
	}
	static readonly Color Ink = Color.Parse( "#e4eeef" ).Value;
	static readonly Color Muted = Color.Parse( "#82959d" ).Value;
	static readonly Color TimerInk = Color.Parse( "#50636d" ).Value;
	static void DrawTimer( Painter p, string value, Vector2 center ) =>
		DrawTimeNumber( p, value, center, 32, TimerInk.WithAlpha( 0.42f ), outline: false );
	[SkipHotload] static readonly Color ToothFill = Color.Parse( "#1c2933" ).Value;
	[SkipHotload] static readonly Color GameOverToothFill = Color.Parse( "#451e25" ).Value;
	[SkipHotload]
	static readonly Color FloorInk = Color.Parse( "#70818c" ).Value;
	[SkipHotload]
	static readonly Color GameOverSpikeInk = Color.Parse( "#e34343" ).Value;
	[SkipHotload]
	static readonly Color GameOverFlashRed = Color.Parse( "#ef3238" ).Value;
	[SkipHotload]
	static readonly Color GameOverFlashYellow = Color.Parse( "#ffe04a" ).Value;
	Color SpikeInk => Board.GameOver ? GameOverSpikeInk : FloorInk;
	// Saturated per-diamond colors, shared by the active piece, ghost and next preview.
	// These are presentation constants: refresh their initializers when hotloading.
	[SkipHotload]
	static readonly Color[] Palette = [Color.Parse( "#f23d3d" ).Value, Color.Parse( "#2298f0" ).Value,
		Color.Parse( "#32cc59" ).Value, Color.Parse( "#ffdc32" ).Value, Color.Parse( "#b63fc9" ).Value];
	// Preserve the 1592-wide canvas, keeping gem scale identical at every viewport size.
	const float Left = (1592 - DiamondBoard.BoardWidth) * 0.5f;
	// Fit the expanded board to the screen, scaling gems and boundary teeth together.
	// Keep gameplay and HUD placement stable; the board rendering extends above this margin.
	const float Top = 8;
	const float NextPreviewCenterY = Top + 212;
	const float CanvasWidth = DiamondBoard.BoardWidth + Left * 2;
	const float CanvasHeight = Top + DiamondBoard.BoardHeight + 8;
	float boardRenderTop;
	float boardOffsetY;
	Vector2 BoardOffset => ShakeOffset + new Vector2( 0, boardOffsetY );
	Rect BoardRenderBounds => new( Left, boardRenderTop, DiamondBoard.BoardWidth, Top + DiamondBoard.BoardHeight - boardRenderTop );

	// Include any extra horizontal margin outside the fitted canvas on wide screens.
	static float LetterboxCenter( Vector2 size, bool right = false )
	{
		float scale = MathF.Min( size.x / CanvasWidth, size.y / CanvasHeight );
		float extra = scale > 0 ? MathF.Max( 0, (size.x / scale - CanvasWidth) * 0.5f ) : 0;
		float center = (Left - extra) * 0.5f;
		return right ? CanvasWidth - center : center;
	}

	public DiamondPanel()
	{
		Style.Width = Length.Percent( 100 );
		Style.Height = Length.Percent( 100 );
		Style.PointerEvents = PointerEvents.All;
	}

	public void UpdateLandingPointGlows( float delta )
	{
		if ( Board != glowBoard || Board?.ResetVersion != glowResetVersion )
		{
			landingPointGlows.Clear();
			boundaryTime = 0;
			glowBoard = Board;
			glowResetVersion = Board?.ResetVersion ?? 0;
		}
		if ( Board is null || Board.Paused ) return;
		boundaryTime = (boundaryTime + MathF.Max( 0, delta )) % 3.6f;
		foreach ( var glow in landingPointGlows ) glow.Visible = false;
		Board.CopyLandingPointContacts( landingPointContacts );
		foreach ( var contact in landingPointContacts )
		{
			// Keep each marker's fade and rotation when projection rounding changes slightly.
			LandingPointGlow glow = null;
			foreach ( var candidate in landingPointGlows )
				if ( candidate.Contact.ColorIndex == contact.ColorIndex &&
					MathF.Abs( candidate.Contact.Lane - contact.Lane ) < 0.001f && MathF.Abs( candidate.Contact.Y - contact.Y ) < 0.02f )
				{ glow = candidate; break; }
			if ( glow is null )
			{
				glow = new LandingPointGlow { Contact = contact, Rotation = (float)glowRandom.NextDouble() * MathF.PI * 2,
					PulsePhase = (float)glowRandom.NextDouble() * MathF.PI * 2 };
				landingPointGlows.Add( glow );
			}
			glow.Visible = true;
		}
		delta = MathF.Max( 0, delta );
		for ( int i = landingPointGlows.Count - 1; i >= 0; i-- )
		{
			var glow = landingPointGlows[i];
			// Frame-rate independent lerp; retain old contact points until they fade away.
			float blend = 1 - MathF.Exp( -delta / (glow.Visible ? 0.09f : 0.07f) );
			glow.Opacity += ((glow.Visible ? 1 : 0) - glow.Opacity) * blend;
			glow.Rotation = (glow.Rotation + delta * 0.3f) % (MathF.PI * 2);
			glow.Age = (glow.Age + delta) % 1000;
			if ( glow.Visible ) glow.Appear = MathF.Min( 1, glow.Appear + delta / LandingPointAppearTime );
			if ( !glow.Visible && glow.Opacity < 0.01f ) landingPointGlows.RemoveAt( i );
		}
	}

	public override void OnDraw( Painter p )
	{
#if STANDALONE
#endif
		var screenTransform = p.Transform;
		p.Fill = Color.Parse( "#0b1017" ).Value;
		p.Stroke = Stroke.None;
		p.Rect( p.Bounds );
		if ( Board is null ) return;
		float scale = MathF.Min( p.Bounds.Width / CanvasWidth, p.Bounds.Height / CanvasHeight );
		if ( scale <= 0 ) return;
		// Halve the full bottom gap, including any vertical letterboxing, without moving the HUD.
		float verticalMargin = MathF.Max( 0, (p.Bounds.Height / scale - CanvasHeight) * 0.5f );
		boardOffsetY = (verticalMargin + 8) * 0.5f;
		// Reach the actual screen edge even after lowering the board or shaking it downward.
		boardRenderTop = -verticalMargin - boardOffsetY - MathF.Abs( ShakeOffset.y ) - 8;
		if ( OnlyDye )
		{
			DrawDyeOnly( p, scale );
			return;
		}
		if ( TitleScreen )
		{
			using ( p.Scope() )
			{
				p.Translate( (p.Bounds.Size - new Vector2( CanvasWidth, CanvasHeight ) * scale) * 0.5f );
				p.Scale( scale );
				DrawTitleScreen( p, scale );
			}
			DrawTutorial( p );
			DrawOptions( p );
			DrawLeaderboard( p );
			DrawTransition( p );
			DrawMusicDebug( p );
			return;
		}
		p.Translate( (p.Bounds.Size - new Vector2( CanvasWidth, CanvasHeight ) * scale) * 0.5f );
		p.Scale( scale );
		float rightCenter = LetterboxCenter( p.Bounds.Size, true );
		DrawTimer( p, DiamondLeaderboard.FormatTime( (long)Board.ElapsedSeconds ), new Vector2( LetterboxCenter( p.Bounds.Size ), Top + 40 ) );

		if ( !Board.GameOver )
		{
			var next = DiamondShapes.All[Board.NextShapeIndex];
			float previewLane = (next.Cells.Min( c => c.Lane ) + next.Cells.Max( c => c.Lane )) * 0.5f;
			float previewRow = (next.Cells.Min( c => c.Row ) + next.Cells.Max( c => c.Row )) * 0.5f;
			// Scale the entire mixed-color piece about its visual center, including its outlines.
			using var previewScope = p.Scope();
			var previewCenter = new Vector2( rightCenter, NextPreviewCenterY );
			p.Translate( previewCenter );
			p.Scale( NextPreviewScale );
			p.Translate( -previewCenter );
			DrawShape( p, Board.Next,
				new Vector2( rightCenter - previewLane * DiamondBoard.Width * 0.5f, NextPreviewCenterY - previewRow * DiamondBoard.Height * 0.5f ), false );
		}

		using ( p.Scope() )
		{
			p.Translate( BoardOffset );
			using ( p.Scope() )
			{
				ClipBoard( p );
				p.Fill = Color.Parse( "#080d13" ).Value;
				p.Stroke = Stroke.None;
				p.Rect( BoardRenderBounds );
				DrawAtmosphere( p );
				DrawFloorFill( p );
				DrawSideWalls( p );
				if ( ShowLandingIndicator && !Board.GameOver && !Board.IsSliding && !Board.IsResolving )
					DrawShape( p, Board.Active, Position( Board.Active.Lane, Board.LandingY ), true );
				var displayCells = DamageShakeCells();
				foreach ( var piece in displayCells )
					DrawDiamond( p, Position( piece.Lane, piece.Y ), piece.ColorIndex, false, health: piece.Health, crackCorner: piece.FirstCrack );
				DrawGroupOutlines( p, displayCells, Board.BondedGroups, new Vector2( Left, Top ), pulses: Board.BondPulses );
				if ( !Board.GameOver && !Board.IsResolving )
					DrawActivePiece( p );
				foreach ( var cell in Board.Shattering )
				{
					if ( Board.IsDamagePaused )
						DrawDiamond( p, Position( cell.Lane, cell.Y ), cell.ColorIndex, false, health: 1, crackCorner: cell.FirstCrack );
					else if ( Board.GameOver )
						DrawDiamond( p, Position( cell.Lane, cell.Y ), cell.ColorIndex, false,
							scale: 1 - Board.ShatterProgress * 0.75f, opacity: 1 - Board.ShatterProgress,
							health: cell.Health, crackCorner: cell.FirstCrack );
				}
				DrawDamageShines( p );

				// The floor slopes exactly match the lower half of a diamond.
				int cornerSkip = Board.FillCornerPockets ? 2 : 0;
				Span<Vector2> floor = stackalloc Vector2[DiamondBoard.Valleys * 2 + 1 - cornerSkip * 2];
				for ( int i = 0; i < floor.Length; i++ )
					floor[i] = new Vector2( Left + (i + cornerSkip) * DiamondBoard.Width * 0.5f,
						Top + DiamondBoard.BoardHeight - (i % 2 == 0 ? DiamondBoard.Height * 0.5f : 0) );
				p.Stroke = Stroke.Solid( SpikeInk.WithAlpha( 0.08f + Board.LockFlash * 0.18f ), 9 );
				p.Line( floor );
				p.Stroke = Stroke.Solid( SpikeInk, 2 );
				p.Line( floor );
				foreach ( var glow in landingPointGlows ) DrawLandingPointGlow( p, glow, LandingPointScale );
				foreach ( var flash in Board.DamageFlashes ) DrawDamageFlash( p, flash );
				foreach ( var flash in Board.FaceFlashes ) DrawFaceFlash( p, flash );
				DrawDebris( p );
				if ( Debris is not null )
				{
					foreach ( var burst in Debris.ShatterSunbursts )
						DrawSunburst( p, new Vector2( Left, Top ) + burst.Position, burst.ColorIndex,
							burst.Age / burst.Lifetime, burst.Rotation, burst.Brightness, burst.Scale );
					foreach ( var burst in Debris.CrushSunbursts )
						DrawSunburst( p, new Vector2( Left, Top ) + burst.Position, burst.ColorIndex,
							burst.Age / burst.Lifetime, burst.Rotation, burst.Brightness, burst.Scale );
				}
				DrawSmoke( p );
				DrawGameOverFlash( p );
			}
		}

		if ( !Board.GameOver ) DrawScoreNumber( p, "NEXT", new Vector2( rightCenter, Top + 72 ), 48, Muted * 0.6f );
		DrawScore( p, LetterboxCenter( p.Bounds.Size ) );
		DrawControls( p, rightCenter );
		DrawTutorialButton( p, scale );
		DrawOptionsButton( p, scale );
		DrawLeaveButton( p, scale );

		if ( Board.GameOver ) DrawGameOver( p, scale );
		DrawBoardBorder( p, scale );
		// After game over, the RESTARTING frame covers the RESTART button instead of the board center.
		DrawRestartHold( p, screenTransform, Board.GameOver ? GameOverRestartButton.Center
			: new Vector2( Left + DiamondBoard.BoardWidth * 0.5f, Top + boardOffsetY + DiamondBoard.BoardHeight * 0.5f ), scale );
		p.Transform = screenTransform;
		DrawTutorial( p );
		DrawOptions( p );
		DrawLeaderboard( p );
		DrawTransition( p );
		DrawMusicDebug( p );
	}

	/// <summary>Thumbnail capture: the current screen's dye over its plain background, without the board, HUD or overlays.</summary>
	void DrawDyeOnly( Painter p, float scale )
	{
		using var scope = p.Scope();
		p.Translate( (p.Bounds.Size - new Vector2( CanvasWidth, CanvasHeight ) * scale) * 0.5f );
		p.Scale( scale );
		if ( TitleScreen )
		{
			DrawTitleDye( p );
			return;
		}
		p.Translate( BoardOffset );
		ClipBoard( p );
		p.Fill = Color.Parse( "#080d13" ).Value;
		p.Stroke = Stroke.None;
		p.Rect( BoardRenderBounds );
		DrawAtmosphere( p );
	}

	/// <summary>Screen-space debug readout above everything, including the tutorial.</summary>
	void DrawMusicDebug( Painter p )
	{
		if ( string.IsNullOrEmpty( MusicDebugText ) ) return;
		var lines = MusicDebugText.Split( '\n' );
		const float size = 14, lineHeight = 18, padding = 10, width = 600;
		float height = lines.Length * lineHeight + padding * 2;
		float top = p.Bounds.Height - 14 - height;
		p.Fill = Color.Black.WithAlpha( 0.62f );
		p.Stroke = Stroke.None;
		p.Rect( new Rect( 14, top, width, height ) );
		for ( int i = 0; i < lines.Length; i++ )
			// Painter's text rect includes the font's ascent, so give each line generous height.
			Text( p, lines[i].TrimEnd( '\r' ), 14 + padding, top + padding + i * lineHeight - size * 0.4f, width - padding * 2, lineHeight * 2,
				size, Color.White.WithAlpha( 0.9f ), fontName: "Roboto Mono" );
	}

	static void DrawControls( Painter p, float center )
	{
		DrawScoreNumber( p, "ROTATE", new Vector2( center, Top + 374 ), 26, Muted * 0.65f );
		DrawControlKey( p, new Vector2( center, Top + 428 ), "↑" );
		string[] arrows = ["←", "↓", "→"];
		string[] labels = ["LEFT", "DROP", "RIGHT"];
		for ( int i = 0; i < 3; i++ )
		{
			float x = center + (i - 1) * 84;
			DrawControlKey( p, new Vector2( x, Top + 506 ), arrows[i] );
			DrawScoreNumber( p, labels[i], new Vector2( x, Top + 560 ), 20, Muted * 0.65f );
		}
	}

	static void DrawControlKey( Painter p, Vector2 center, string arrow )
	{
		p.Fill = Ink.WithAlpha( 0.025f );
		p.Stroke = Stroke.None;
		p.Rect( new Rect( center - new Vector2( 32 ), new Vector2( 64 ) ) );
		DrawScoreNumber( p, arrow, center, 58, Ink.WithAlpha( 0.5f ) );
	}

	void DrawBoardBorder( Painter p, float scale )
	{
		using var scope = p.Scope();
		p.Translate( BoardOffset );
		float cornerScale = Board.FillCornerPockets ? 1.5f : 0.5f;
		float cornerWidth = DiamondBoard.Width * cornerScale;
		float cornerHeight = DiamondBoard.Height * cornerScale;
		float right = Left + DiamondBoard.BoardWidth;
		float bottom = Top + DiamondBoard.BoardHeight;
		// Keep a 3.5-pixel outline at every canvas scale, with sharp joins and an open top.
		ReadOnlySpan<Vector2> points =
		[
			new( Left, boardRenderTop ), new( Left, bottom - cornerHeight ),
			new( Left + cornerWidth, bottom ), new( right - cornerWidth, bottom ),
			new( right, bottom - cornerHeight ), new( right, boardRenderTop )
		];
		if ( borderPoints is null || !points.SequenceEqual( borderPoints ) )
		{
			borderPoints = points.ToArray();
			borderMiters = BuildPerimeterMiters( points, false );
		}
		DrawPerimeter( p, points, borderMiters, SpikeInk, 3.5f / scale, false );
	}

	void DrawGameOverFlash( Painter p )
	{
		if ( !Board.GameOver || Board.Settled.Count == 0 && Board.Shattering.Count == 0 ) return;
		float yellow = 0.5f + 0.5f * MathF.Sin( boundaryTime * MathF.PI * 2 / 0.9f );
		p.Fill = new Color(
			GameOverFlashRed.r + (GameOverFlashYellow.r - GameOverFlashRed.r) * yellow,
			GameOverFlashRed.g + (GameOverFlashYellow.g - GameOverFlashRed.g) * yellow,
			GameOverFlashRed.b + (GameOverFlashYellow.b - GameOverFlashRed.b) * yellow )
			.WithAlpha( 0.055f + 0.035f * yellow );
		p.Stroke = Stroke.None;
		// Draw inside ClipBoard so the wash follows the cut corners and never enters the letterboxes.
		p.Rect( BoardRenderBounds );
	}

	void ClipBoard( Painter p )
	{
		p.Clip( BoardRenderBounds );
		// Intersect with two diagonal half-planes. The background, effects, and
		// game-over wash all stop at the actual end slopes instead of a rectangular box.
		float cornerHeight = DiamondBoard.Height * (Board.FillCornerPockets ? 1.5f : 0.5f);
		float extent = CanvasWidth + CanvasHeight + MathF.Abs( boardRenderTop );
		var transform = p.Transform;
		foreach ( int side in new[] { -1, 1 } )
		{
			p.Translate( Left + (side > 0 ? DiamondBoard.BoardWidth : 0), Top + DiamondBoard.BoardHeight - cornerHeight );
			p.Rotate( MathF.Atan2( DiamondBoard.Height, -side * DiamondBoard.Width ) * 180 / MathF.PI );
			p.Clip( new Rect( -extent, side < 0 ? -extent : 0, extent * 2, extent ) );
			// Clips capture their transform; restoring only the drawing transform keeps them active.
			p.Transform = transform;
		}
	}

	void DrawAtmosphere( Painter p )
	{
		if ( HideDye || Atmosphere is null || !Atmosphere.Enabled || Atmosphere.Opacity <= 0 ) return;
		if ( atmosphereReferenceTiles ) { DrawAtmosphereTiles( p ); return; }
		const int columns = DiamondAtmosphere.Columns, rows = DiamondAtmosphere.Rows;
		UpdateDyeTexture( Atmosphere, Atmosphere.DyeVersion, Atmosphere.Samples, columns, rows, Color.Parse( "#080d13" ).Value,
			"diamonds-gameplay-dye", Math.Clamp( DyeVibrancy, 0, 1 ), ref atmosphereTexture, ref atmospherePixels, ref atmosphereTextureStamp );
		var origin = DiamondAtmosphere.GridToWorld( 0, 0 ) + new Vector2( Left, Top ) - new Vector2( DiamondAtmosphere.CellSize * 0.5f );
		DrawDyeLayer( p, atmosphereTexture, BoardRenderBounds,
			new Rect( origin, new Vector2( columns, rows ) * DiamondAtmosphere.CellSize ), DyeLayerOpacity( Atmosphere.RenderOpacity ) );
	}

	void DrawDyeLayer( Painter p, Texture texture, Rect bounds, Rect image, float opacity )
	{
		float resolution = dyeLayerResolution;
		using var scope = p.Scope();
		p.Scale( 1 / resolution );
		var smallBounds = new Rect( bounds.Position * resolution, bounds.Size * resolution );
		var smallImage = new Rect( image.Position * resolution, image.Size * resolution );
		using var blur = p.BeginLayer( smallBounds, opacity, new Painter.Filter { Blur = 6 * resolution } );
		p.Texture( texture, smallImage );
	}

	// Both screens share color packing, linear texture format and reusable upload storage.
	static void UpdateDyeTexture( object source, ulong version, ReadOnlySpan<float> samples, int columns, int rows, Color background,
		string name, float vibrancy, ref Texture texture, ref Color32[] pixels, ref DyeTextureStamp stamp )
	{
		bool recreate = texture is null || !texture.IsValid || texture.Width != columns || texture.Height != rows;
		// Opacity and placement belong to Painter, so changing either needs no pixel upload.
		if ( !recreate && pixels?.Length == columns * rows && ReferenceEquals( stamp.Source, source ) &&
			stamp.Version == version && ReferenceEquals( stamp.Palette, Palette ) && stamp.Background.Equals( background ) && stamp.Vibrancy == vibrancy ) return;
		if ( recreate )
		{
			texture?.Dispose();
			// UI solid fills use these raw color values; avoid an extra sRGB decode.
			texture = Texture.Create( columns, rows, ImageFormat.RGBA8888_LINEAR )
				.WithDynamicUsage().WithName( name ).Finish();
		}
		if ( pixels is null || pixels.Length != columns * rows ) pixels = new Color32[columns * rows];
		for ( int n = 0; n < pixels.Length; n++ )
		{
			float total = 0, weightedTotal = 0, red = 0, green = 0, blue = 0;
			for ( int color = 0; color < DiamondBoard.ColorCount; color++ )
			{
				float amount = samples[n * DiamondBoard.ColorCount + color];
				total += amount;
				float vividAmount = amount * amount;
				weightedTotal += vividAmount;
				var ink = Palette[color];
				red += ink.r * vividAmount; green += ink.g * vividAmount; blue += ink.b * vividAmount;
			}
			// Vibrant dye saturates from a smaller amount; at full vibrancy only truly empty space stays dark.
			float coverage = Math.Clamp( (total - (0.04f - 0.035f * vibrancy)) / (0.28f - 0.22f * vibrancy), 0, 1 );
			coverage = coverage * coverage * (3 - 2 * coverage);
			pixels[n] = weightedTotal <= 0 || coverage <= 0 ? background :
				new Color( background.r + (red / weightedTotal - background.r) * coverage,
					background.g + (green / weightedTotal - background.g) * coverage,
					background.b + (blue / weightedTotal - background.b) * coverage );
		}
		texture.Update( pixels.AsSpan(), width: columns, height: rows );
		stamp = new DyeTextureStamp { Source = source, Version = version, Palette = Palette, Background = background, Vibrancy = vibrancy };
	}

	void DrawAtmosphereTiles( Painter p )
	{
		if ( Atmosphere is null || !Atmosphere.Enabled || Atmosphere.Opacity <= 0 ) return;
		if ( atmosphereTiles is null || atmosphereTiles.Length != DiamondAtmosphere.Columns * DiamondAtmosphere.Rows ||
			atmosphereTileVersion != 9 )
		{
			atmosphereTiles = BuildAtmosphereTiles();
			atmosphereTileVersion = 9;
		}
		using var layer = p.BeginLayer( BoardRenderBounds,
			0.22f * Atmosphere.RenderOpacity, new Painter.Filter { Blur = 6 } );
		var background = Color.Parse( "#080d13" ).Value;
		p.Stroke = Stroke.None;
		var samples = Atmosphere.Samples;
		for ( int y = 0; y < DiamondAtmosphere.Rows; y++ )
		for ( int x = 0; x < DiamondAtmosphere.Columns; x++ )
		{
			var tile = atmosphereTiles[y * DiamondAtmosphere.Columns + x];
			if ( tile.Width <= 0 ) continue;
			float total = 0, weightedTotal = 0, red = 0, green = 0, blue = 0;
			int sampleIndex = (y * DiamondAtmosphere.Columns + x) * DiamondBoard.ColorCount;
			for ( int color = 0; color < DiamondBoard.ColorCount; color++ )
			{
				float amount = samples[sampleIndex + color];
				total += amount;
				float vividAmount = amount * amount;
				weightedTotal += vividAmount;
				var ink = Palette[color];
				red += ink.r * vividAmount;
				green += ink.g * vividAmount;
				blue += ink.b * vividAmount;
			}
			float coverage = Math.Clamp( (total - 0.04f) / 0.28f, 0, 1 );
			coverage = coverage * coverage * (3 - 2 * coverage);
			if ( weightedTotal <= 0 || coverage <= 0 ) continue;
			p.Fill = new Color( background.r + (red / weightedTotal - background.r) * coverage,
				background.g + (green / weightedTotal - background.g) * coverage,
				background.b + (blue / weightedTotal - background.b) * coverage );
			p.Rect( tile );
		}
	}

	static Rect[] BuildAtmosphereTiles()
	{
		var tiles = new Rect[DiamondAtmosphere.Columns * DiamondAtmosphere.Rows];
		// Screen-aligned squares keep the previous overlap to hide seams under the blur.
		float radius = DiamondAtmosphere.CellSize * 0.5f + 3 * 0.70710678f;
		for ( int y = 0; y < DiamondAtmosphere.Rows; y++ )
		for ( int x = 0; x < DiamondAtmosphere.Columns; x++ )
		{
			Vector2 center = DiamondAtmosphere.GridToWorld( x, y );
			if ( center.x < -radius || center.x > DiamondBoard.BoardWidth + radius ||
				center.y < -radius || center.y > DiamondBoard.BoardHeight + radius ) continue;
			center += new Vector2( Left, Top );
			tiles[y * DiamondAtmosphere.Columns + x] = new Rect( center - new Vector2( radius ), new Vector2( radius * 2 ) );
		}
		return tiles;
	}

	void DrawFloorFill( Painter p )
	{
		p.Stroke = Stroke.None;
		for ( int i = 0; i <= DiamondBoard.Valleys; i++ )
		{
			var center = new Vector2( Left + i * DiamondBoard.Width, Top + DiamondBoard.BoardHeight );
			DrawFloorTooth( p, center, SpikeInk, Board.GameOver );
		}
	}

	static void DrawFloorTooth( Painter p, Vector2 center, Color ink, bool gameOver = false )
	{
		var left = center + new Vector2( -DiamondBoard.Width * 0.5f, 0 );
		var tip = center + new Vector2( 0, -DiamondBoard.Height * 0.5f );
		var right = center + new Vector2( DiamondBoard.Width * 0.5f, 0 );
		p.Stroke = Stroke.None;
		p.Fill = gameOver ? GameOverToothFill : ToothFill;
		p.Polygon( [left, tip, right] );
		p.Fill = ink.WithAlpha( 0.12f );
		p.Polygon( [left, tip, center] );
	}

	void DrawSideWalls( Painter p )
	{
		Span<Vector2> edge = stackalloc Vector2[3];
		// Continue the same tooth pattern above the gameplay bounds to the screen edge.
		for ( float y = DiamondBoard.BoardHeight; Top + y + DiamondBoard.Height * 0.5f > boardRenderTop; y -= DiamondBoard.Height )
		for ( int side = 0; side < 2; side++ )
		{
			var tooth = new DiamondBoard.Diamond( side == 0 ? -1 : DiamondBoard.LaneCount, y, 0 );
			if ( Board.FillCornerPockets && tooth.Y == DiamondBoard.BoardHeight ) continue;
			var center = Position( tooth.Lane, tooth.Y );
			float inward = tooth.Lane < 0 ? 1 : -1;
			edge[0] = center + new Vector2( 0, -DiamondBoard.Height * 0.5f );
			edge[1] = center + new Vector2( inward * DiamondBoard.Width * 0.5f, 0 );
			edge[2] = center + new Vector2( 0, DiamondBoard.Height * 0.5f );
			bool corner = Board.FillCornerPockets && tooth.Y == DiamondBoard.BoardHeight - DiamondBoard.Height;
			if ( corner ) edge[2] += new Vector2( inward * DiamondBoard.Width, 0 );
			p.Stroke = Stroke.None;
			if ( !corner )
			{
				p.Fill = Board.GameOver ? GameOverToothFill : ToothFill;
				p.Polygon( edge );
				p.Fill = SpikeInk.WithAlpha( 0.12f );
				p.Polygon( [edge[0], edge[1], center] );
			}
			if ( DrawWallStrokeMask( p, center, inward, corner ) ) continue;
			p.Stroke = Stroke.Solid( SpikeInk.WithAlpha( 0.08f + Board.LockFlash * 0.18f ), 9 );
			p.Line( edge );
			p.Stroke = Stroke.Solid( SpikeInk, 2 );
			p.Line( edge );
		}
	}

	void DrawDebris( Painter p )
	{
		if ( Debris is null ) return;
		p.Stroke = Stroke.None;
		foreach ( var chip in Debris.Particles )
		{
			if ( !chip.Visible ) continue;
			var position = new Vector2( Left, Top ) + chip.Position;
			// Include rotation, the widest mitered glow, and an antialiasing margin.
			float radius = (chip.Radius + (chip.IsSpark ? 6 : 7.5f)) * (chip.IsSpark ? 1.6f : 1) + 2;
			if ( !drawingReferenceGeometry && (position.x + radius < BoardRenderBounds.Left ||
				position.x - radius > BoardRenderBounds.Right || position.y + radius < BoardRenderBounds.Top ||
				position.y - radius > BoardRenderBounds.Bottom) ) continue;
			float opacity = chip.Opacity;
			using var scope = p.Scope();
			p.Translate( position );
			p.Rotate( chip.Angle );
			var color = GemColor( chip.ColorIndex, chip.IsSpark ? DiamondBoard.MaxHealth : 1 );
			float highlight = chip.IsSpark ? chip.Brightness : 0.12f + chip.Brightness * 0.25f;
			var tint = new Color( color.r + (1 - color.r) * highlight,
				color.g + (1 - color.g) * highlight, color.b + (1 - color.b) * highlight );
			if ( chip.IsSpark )
			{
				// Enlarge the light without changing the small physical collision fixture.
				p.Scale( 1.6f );
				if ( !DrawSparkGlow( p, chip, opacity ) )
					DrawParticleGlow( p, chip.Vertices, color, opacity * 0.24f, 1, ParticleGlowLayers( chip ) );
				p.Fill = tint.WithAlpha( opacity );
				p.Polygon( chip.Vertices );
				p.Polygon( [chip.Vertices[0], chip.Vertices[1], Vector2.Zero] );
				continue;
			}

			DrawShardFacets( p, chip.Vertices, color, tint, opacity, ParticleGlowLayers( chip ) );
			DrawShardShine( p, chip );
			DrawGemStroke( p, chip.Vertices, tint.WithAlpha( opacity * 0.45f ), 0.8f, true );
		}
	}

	static void DrawShardFacets( Painter p, Vector2[] vertices, Color color, Color tint, float opacity = 1, Vector2[][] glowLayers = null )
	{
		DrawParticleGlow( p, vertices, color, opacity * 0.12f, 1.25f, glowLayers );
		var shadow = new Color( color.r * 0.68f, color.g * 0.68f, color.b * 0.68f );
		var center = (vertices[0] + vertices[1] + vertices[2]) / 3;
		for ( int facet = 0; facet < 3; facet++ )
		{
			p.Fill = (facet == 0 ? tint : facet == 1 ? shadow : color).WithAlpha( opacity );
			p.Polygon( [vertices[facet], vertices[(facet + 1) % 3], center] );
		}
	}

	static void DrawParticleGlow( Painter p, ReadOnlySpan<Vector2> vertices, Color color, float opacity, float spread, Vector2[][] layers = null )
	{
		p.Stroke = Stroke.None;
		if ( layers is not null )
		{
			for ( int i = 0; i < layers.Length; i++ )
			{
				p.Fill = color.WithAlpha( opacity / (3 - i) );
				p.Polygon( layers[i] );
			}
			return;
		}
		// Tiny filled polygons use Painter's inline shape path. Stroked polygons
		// rebuild path primitives and a BVH for every layer of every particle.
		// Expand the convex triangle/quad along its face normals, retaining an
		// angular halo with the same three widths and falloff. Facets cover its core.
		Span<Vector2> corners = stackalloc Vector2[4];
		Span<Vector2> expanded = stackalloc Vector2[4];
		ParticleGlowCorners( vertices, corners );
		for ( int layer = 3; layer >= 1; layer-- )
		{
			for ( int i = 0; i < vertices.Length; i++ ) expanded[i] = vertices[i] + corners[i] * (layer * spread);
			p.Fill = color.WithAlpha( opacity / layer );
			p.Polygon( expanded[..vertices.Length] );
		}
	}

	static void ParticleGlowCorners( ReadOnlySpan<Vector2> vertices, Span<Vector2> corners )
	{
		float winding = 0;
		for ( int i = 0; i < vertices.Length; i++ )
		{
			var a = vertices[i]; var b = vertices[(i + 1) % vertices.Length];
			winding += a.x * b.y - a.y * b.x;
		}
		for ( int i = 0; i < vertices.Length; i++ )
		{
			var incoming = (vertices[i] - vertices[(i + vertices.Length - 1) % vertices.Length]).Normal;
			var outgoing = (vertices[(i + 1) % vertices.Length] - vertices[i]).Normal;
			var normal = new Vector2( incoming.y, -incoming.x ) * MathF.Sign( winding );
			var nextNormal = new Vector2( outgoing.y, -outgoing.x ) * MathF.Sign( winding );
			var bisector = (normal + nextNormal).Normal;
			corners[i] = bisector / MathF.Max( 0.5f, Vector2.Dot( bisector, normal ) );
		}
	}

	void DrawSmoke( Painter p )
	{
		if ( Debris is null ) return;
		p.Stroke = Stroke.None;
		foreach ( var puff in Debris.Smoke )
		{
			float t = Math.Clamp( puff.Age / puff.Lifetime, 0, 1 );
			float opacity = MathF.Min( 1, puff.Age / 0.035f ) * (1 - t) * (1 - t);
			float radius = puff.Radius * (1 + t * 1.4f);
			var center = new Vector2( Left, Top ) + puff.Position;
			// Soft, faint grey dust is composited last, over gems and chip fragments.
			for ( int layer = 4; layer >= 1; layer-- )
			{
				p.Fill = new Color( 0.70f, 0.74f, 0.78f ).WithAlpha( opacity * 0.05f );
				p.Circle( center, radius * layer / 4 );
			}
		}
	}

	const float LandingPointAppearTime = 0.14f;
	// Unrelated per-ray pulse rates (Hz), so the spokes never breathe in step.
	static readonly float[] LandingRayPulseRates = [1.31f, 2.07f, 1.63f, 2.53f, 1.12f, 1.87f];

	// Spoke core blur, widest and faintest first; stacked, the middle reaches about 92% opacity.
	[SkipHotload] static readonly (float Width, float Alpha)[] LandingCoreLayers = [(1.5f, 0.15f), (1.2f, 0.2f), (0.95f, 0.3f), (0.72f, 0.45f), (0.5f, 0.7f)];

	static float LandingRayLength( int ray ) => ray switch { 0 => 9, 1 => 5.5f, 2 => 7, 3 => 8, 4 => 5, _ => 6.5f };

	static void DrawLandingPointGlow( Painter p, LandingPointGlow glow, float scale )
	{
		if ( scale <= 0 ) return;
		using var scope = p.Scope();
		p.Translate( Position( glow.Contact.Lane, glow.Contact.Y ) );
		// Pop in from a quarter size with a slight ease-out-back overshoot; fading out keeps full size.
		float t = glow.Appear - 1;
		float grow = 1 + 2.2f * t * t * t + 1.2f * t * t;
		p.Scale( 1.6f * scale * (0.25f + 0.75f * grow) );
		var center = Vector2.Zero;
		var color = Palette[glow.Contact.ColorIndex];
		var tint = new Color( (color.r + 1) * 0.5f, (color.g + 1) * 0.5f, (color.b + 1) * 0.5f );
		p.Stroke = Stroke.None;
		p.Fill = Fill.RadialGradient()
			.WithStop( 0, tint.WithAlpha( 0.26f * glow.Opacity ) )
			.WithStop( 0.25f, tint.WithAlpha( 0.2f * glow.Opacity ) )
			.WithStop( 0.5f, tint.WithAlpha( 0.1f * glow.Opacity ) )
			.WithStop( 0.75f, tint.WithAlpha( 0.04f * glow.Opacity ) )
			.WithStop( 1, tint.WithAlpha( 0 ) );
		p.Circle( center, 13 );
		Span<float> pulses = stackalloc float[6], coreReach = stackalloc float[6];
		for ( int ray = 0; ray < 6; ray++ )
		{
			// Two detuned sines per ray give an irregular, subtle breathing.
			float phase = glow.PulsePhase * (ray + 1) * 1.618f;
			float w = glow.Age * LandingRayPulseRates[ray] * MathF.PI * 2;
			pulses[ray] = 1 + 0.1f * MathF.Sin( w + phase ) + 0.05f * MathF.Sin( w * 1.73f + phase * 0.5f );
			// The core's reach, as a fraction of its spoke, pulses on its own unrelated clock (about 0.45-0.75).
			float corePhase = glow.PulsePhase * (ray + 2) * 2.414f;
			float coreW = glow.Age * LandingRayPulseRates[5 - ray] * 1.37f * MathF.PI * 2;
			coreReach[ray] = 0.6f + 0.1f * MathF.Sin( coreW + corePhase ) + 0.05f * MathF.Sin( coreW * 1.61f + corePhase * 0.7f );
		}
		// Blurry white halos first: a soft radial gradient stretched into an ellipse along each spoke.
		var halo = Fill.RadialGradient()
			.WithStop( 0, Color.White.WithAlpha( 0.42f * glow.Opacity ) )
			.WithStop( 0.4f, Color.White.WithAlpha( 0.2f * glow.Opacity ) )
			.WithStop( 0.75f, Color.White.WithAlpha( 0.06f * glow.Opacity ) )
			.WithStop( 1, Color.White.WithAlpha( 0 ) );
		for ( int ray = 0; ray < 6; ray++ )
		{
			float angle = glow.Rotation + ray * MathF.PI / 3;
			float reach = LandingRayLength( ray ) * pulses[ray];
			float along = reach * 0.6f + 3, across = 3.4f * pulses[ray];
			using var haloScope = p.Scope();
			p.Translate( new Vector2( MathF.Cos( angle ), MathF.Sin( angle ) ) * reach * 0.45f );
			p.Rotate( angle * 180 / MathF.PI );
			p.Scale( 1, across / along );
			p.Fill = halo;
			p.Circle( Vector2.Zero, along );
		}
		p.Fill = tint.WithAlpha( glow.Opacity );
		for ( int ray = 0; ray < 6; ray++ )
		{
			float angle = glow.Rotation + ray * MathF.PI / 3;
			var direction = new Vector2( MathF.Cos( angle ), MathF.Sin( angle ) );
			var side = new Vector2( -direction.y, direction.x );
			float pulse = pulses[ray];
			p.Polygon( [center + side * 1.2f * pulse, center + direction * LandingRayLength( ray ) * pulse, center - side * 1.2f * pulse] );
		}
		// A thin near-white core partway down each spoke, instead of a round hotspot in the middle. Layers
		// share the tip but widen toward the center, so the core is blurred inside and sharp at the point.
		var core = new Color( (tint.r + 3) * 0.25f, (tint.g + 3) * 0.25f, (tint.b + 3) * 0.25f );
		for ( int layer = 0; layer < LandingCoreLayers.Length; layer++ )
		{
			var (width, alpha) = LandingCoreLayers[layer];
			p.Fill = core.WithAlpha( alpha * glow.Opacity );
			for ( int ray = 0; ray < 6; ray++ )
			{
				float angle = glow.Rotation + ray * MathF.PI / 3;
				var direction = new Vector2( MathF.Cos( angle ), MathF.Sin( angle ) );
				var side = new Vector2( -direction.y, direction.x );
				float pulse = pulses[ray];
				p.Polygon( [center + side * width * pulse, center + direction * LandingRayLength( ray ) * coreReach[ray] * pulse, center - side * width * pulse] );
			}
		}
	}

	static void DrawDamageFlash( Painter p, DiamondBoard.DamageFlash flash )
		=> DrawSunburst( p, Position( flash.Lane, flash.Y ), flash.ColorIndex,
			flash.Age / DiamondBoard.DamageFlashDuration, flash.Rotation, flash.Brightness, flash.Scale );

	static void DrawSunburst( Painter p, Vector2 position, int colorIndex, float progress, float rotation, float brightness, float scale )
		=> DrawSunburst( p, position, Palette[colorIndex], progress, rotation, brightness, scale );

	static void DrawSunburst( Painter p, Vector2 position, Color color, float progress, float rotation, float brightness, float scale )
	{
		using var scope = p.Scope();
		p.Translate( position );
		p.Scale( scale );
		float t = Math.Clamp( progress, 0, 1 );
		float fade = (1 - t) * (1 - t);
		float radius = 17 + 28 * (1 - MathF.Pow( 1 - t, 3 ));
		// Lift the receiver's color toward white while retaining its hue. Brightness
		// is chosen once per burst so fading doesn't flicker between random shades.
		var tint = new Color( color.r + (1 - color.r) * brightness,
			color.g + (1 - color.g) * brightness, color.b + (1 - color.b) * brightness );
		p.Stroke = Stroke.None;
		for ( int layer = 3; layer >= 1; layer-- )
		{
			p.Fill = tint.WithAlpha( fade * 0.07f );
			p.Circle( Vector2.Zero, radius * layer * 0.45f );
		}
		// Fixed ray orientation throughout the expanding, fading burst.
		for ( int ray = 0; ray < 12; ray++ )
		{
			float angle = ray * MathF.PI / 6 + rotation;
			var direction = new Vector2( MathF.Cos( angle ), MathF.Sin( angle ) );
			var side = new Vector2( -direction.y, direction.x );
			float length = radius * (ray % 3 == 0 ? 1 : ray % 2 == 0 ? 0.76f : 0.52f);
			float width = (ray % 3 == 0 ? 2.8f : 1.6f) * (1 - t * 0.6f);
			p.Fill = tint.WithAlpha( fade * 0.9f );
			p.Polygon( [side * width, direction * length, -side * width] );
		}
		p.Fill = new Color( (tint.r + 1) * 0.5f, (tint.g + 1) * 0.5f, (tint.b + 1) * 0.5f ).WithAlpha( fade );
		p.Circle( Vector2.Zero, 4.2f * (1 - t) );
	}

	static void DrawFaceFlash( Painter p, DiamondBoard.FaceFlash flash )
	{
		var face = flash.Contact;
		DrawFaceFlash( p, Position( face.StartLane, face.StartY ), Position( face.EndLane, face.EndY ),
			face.ColorIndex, flash.Age, flash.Seed, flash.Brightness, flash.DamageCount );
	}

	static void DrawFaceFlash( Painter p, Vector2 start, Vector2 end, int colorIndex,
		float age, float seed, float brightness, int damageCount )
		=> DrawFaceFlash( p, start, end, Palette[colorIndex], age, seed, brightness, damageCount );

	static void DrawFaceFlash( Painter p, Vector2 start, Vector2 end, Color color,
		float age, float seed, float brightness, int damageCount )
	{
		using var scope = p.Scope();
		var tangent = (end - start).Normal;
		var normal = new Vector2( -tangent.y, tangent.x );
		float t = Math.Clamp( age / DiamondBoard.DamageFlashDuration, 0, 1 );
		float fade = (1 - t) * (1 - t);
		float scale = 1 + 0.5f * Math.Max( 0, damageCount - 1 );
		var tint = new Color( color.r + (1 - color.r) * brightness,
			color.g + (1 - color.g) * brightness, color.b + (1 - color.b) * brightness );
		// Keep the seam anchored to the receiving face; only the escaping light grows.
		for ( int layer = 3; layer >= 1; layer-- )
		{
			p.Stroke = Stroke.Solid( tint.WithAlpha( fade * 0.10f ), layer * 4 * scale );
			p.Line( start, end );
		}
		p.Stroke = Stroke.None;
		p.Fill = tint.WithAlpha( fade * 0.9f );
		for ( int ray = 0; ray < 11; ray++ )
		{
			float along = (ray + 0.5f) / 11;
			var origin = start + (end - start) * along;
			for ( int side = -1; side <= 1; side += 2 )
			{
				// Each spoke fans out independently, with a fixed angle for its lifetime.
				float angle = MathF.Sin( seed * 1.37f + ray * 2.399f + side * 1.71f ) * 1.25f;
				var direction = normal * (side * MathF.Cos( angle )) + tangent * MathF.Sin( angle );
				var across = new Vector2( -direction.y, direction.x );
				float variation = 0.5f + 0.5f * MathF.Sin( seed + ray * 4.13f + side * 2.57f );
				float length = (13.5f + 31 * (1 - MathF.Pow( 1 - t, 3 ))) * (0.45f + variation * 0.55f) * scale;
				float width = (1.15f + variation * 1.15f) * (1 - t * 0.6f);
				p.Polygon( [origin - across * width, origin + direction * length, origin + across * width] );
			}
		}
		var core = new Color( (tint.r + 1) * 0.5f, (tint.g + 1) * 0.5f, (tint.b + 1) * 0.5f );
		p.Stroke = Stroke.Solid( core.WithAlpha( fade ), 2.8f * (1 - t) );
		p.Line( start, end );
	}

	static Vector2 Position( float lane, float y ) => new( Left + DiamondBoard.LaneX( lane ), Top + y );

	/// <summary>Draw the controlled piece trailing its lattice position while a step or turn animates.</summary>
	void DrawActivePiece( Painter p )
	{
		var piece = Board.Active;
		var trail = Board.ControlOffset;
		var anchor = Position( piece.Lane + trail.Lane, piece.Y + trail.Y ) + PieceShakeOffset;
		float turns = Board.ControlTurns;
		if ( MathF.Abs( turns ) < 0.0005f )
		{
			DrawShape( p, piece, anchor, false );
			return;
		}
		// Show the nearest whole rotation, turned the rest of the way on the lattice:
		// a rotation in half-width/half-height units, so each endpoint matches the
		// lattice exactly and the facets swap mid-turn while motion is fastest.
		int whole = (int)MathF.Round( turns );
		var shown = piece with { Rotation = (piece.Rotation - whole) & 3 };
		var pivotOffset = DiamondShapes.All[piece.ShapeIndex].Pivot;
		var pivot = anchor + new Vector2( pivotOffset.Lane * DiamondBoard.Width * 0.5f, pivotOffset.Row * DiamondBoard.Height * 0.5f );
		using var scope = p.Scope();
		p.Translate( pivot );
		p.Scale( DiamondBoard.Width * 0.5f, DiamondBoard.Height * 0.5f );
		p.Rotate( (whole - turns) * 90 );
		p.Scale( 2 / DiamondBoard.Width, 2 / DiamondBoard.Height );
		p.Translate( -pivot );
		DrawShape( p, shown, anchor, false );
	}

	void DrawShape( Painter p, DiamondBoard.Piece piece, Vector2 anchor, bool ghost, float scale = 1 )
	{
		if ( drawingReferenceGeometry )
		{
			var referenceCells = DiamondBoard.CellsOf( piece ).ToArray();
			foreach ( var cell in referenceCells )
				DrawDiamond( p, anchor + new Vector2( (cell.Lane - piece.Lane) * DiamondBoard.Width * 0.5f, cell.Y - piece.Y ) * scale,
					cell.ColorIndex, ghost, scale, health: cell.Health, crackCorner: cell.FirstCrack );
			DrawGroupOutlines( p, referenceCells, DiamondMatching.FindGroups( referenceCells ),
				anchor - new Vector2( DiamondBoard.LaneX( piece.Lane ), piece.Y ) * scale, scale, ghost ? 0.35f : 1, !ghost );
			return;
		}
		// Reuse cell/group storage while preserving the original layer bounds and pixel alignment.
		var cache = ghost ? ghostShapeGeometry : piece == Board.Next ? nextShapeGeometry : activeShapeGeometry;
		cache.Update( piece );
		var cells = cache.Cells;
		foreach ( var cell in cells )
			DrawDiamond( p, anchor + new Vector2( (cell.Lane - piece.Lane) * DiamondBoard.Width * 0.5f, cell.Y - piece.Y ) * scale,
				cell.ColorIndex, ghost, scale, health: cell.Health, crackCorner: cell.FirstCrack );
		DrawGroupOutlines( p, cells, cache.Groups,
			anchor - new Vector2( DiamondBoard.LaneX( piece.Lane ), piece.Y ) * scale, scale, ghost ? 0.35f : 1, !ghost,
			geometryCache: cache.Outlines );
	}

	void DrawGroupOutlines( Painter p, IReadOnlyList<DiamondBoard.Diamond> cells,
		IReadOnlyList<IReadOnlyList<int>> groups, Vector2 origin, float scale = 1, float opacity = 1, bool glow = true,
		IReadOnlyList<DiamondBoard.BondPulse> pulses = null, OutlineGeometryCache geometryCache = null )
	{
		if ( cells.Count == 0 || opacity <= 0 ) return;
		using var scope = p.Scope();
		p.Translate( origin );
		p.Scale( scale );
		// One filtered layer for the whole collection. Drawing coordinates inside
		// BeginLayer are local; the parent transform is applied when compositing.
		float minX = float.PositiveInfinity, maxX = float.NegativeInfinity;
		float minY = float.PositiveInfinity, maxY = float.NegativeInfinity;
		for ( int i = 0; i < cells.Count; i++ )
		{
			var cell = cells[i];
			float x = DiamondBoard.LaneX( cell.Lane );
			minX = MathF.Min( minX, x ); maxX = MathF.Max( maxX, x );
			minY = MathF.Min( minY, cell.Y ); maxY = MathF.Max( maxY, cell.Y );
		}
		minX -= DiamondBoard.Width * 0.5f + 20; maxX += DiamondBoard.Width * 0.5f + 20;
		minY -= DiamondBoard.Height * 0.5f + 20; maxY += DiamondBoard.Height * 0.5f + 20;
		using var layer = p.BeginLayer( new Rect( minX, minY, maxX - minX, maxY - minY ),
			opacity, new Painter.Filter { Blur = glow ? 3 : 0 } );
		p.Fill = Fill.None;
		geometryCache ??= ReferenceEquals( groups, Board?.BondedGroups ) ? settledOutlineGeometry : null;
		var geometry = !drawingReferenceGeometry && geometryCache is not null
			? geometryCache.Get( cells, groups ) : BuildOutlineGeometry( cells, groups );
		foreach ( var group in geometry )
		{
			if ( group is null ) continue;
			// Existing contour caches can survive hotload without the newly added offsets.
			group.Miters ??= Array.ConvertAll( group.Contours, c => BuildPerimeterMiters( c.Points, c.Closed ) );
			var firstEdge = group.FirstEdge with { X1 = group.FirstEdge.X1 + group.Offset.x, Y1 = group.FirstEdge.Y1 + group.Offset.y,
				X2 = group.FirstEdge.X2 + group.Offset.x, Y2 = group.FirstEdge.Y2 + group.Offset.y };
			float connection = 0;
			if ( pulses is not null )
				foreach ( var pulse in pulses )
					if ( pulse.Edges.Contains( firstEdge ) )
					{
						float t = Math.Clamp( pulse.Age / DiamondBoard.BondPulseDuration, 0, 1 );
						connection = MathF.Sin( MathF.PI * t ) * (1 - t);
						break;
					}
			float breathing = MathF.Sin( boundaryTime * MathF.PI * 2 / 3.6f );
			var color = Palette[group.FirstEdge.ColorIndex];
			float lift = glow ? 0.32f : 0;
			color = new Color( color.r + (1 - color.r) * lift,
				color.g + (1 - color.g) * lift, color.b + (1 - color.b) * lift );
			// A single continuous, softly filtered stroke: no stacked bands, end caps,
			// bright corner discs or detached rings. New bonds swell this same glow.
			for ( int i = 0; i < group.Contours.Length; i++ )
			{
				var contour = group.Contours[i];
				DrawPerimeter( p, contour.Points, group.Miters?[i], color.WithAlpha( glow ? 0.88f + breathing * 0.06f : 1 ),
					glow ? 4.5f + breathing * 0.25f + connection * 5 : 1, contour.Closed );
			}
		}
	}

	static Color GemColor( int colorIndex, int health )
	{
		var color = Palette[colorIndex];
		float brightness = health >= DiamondBoard.MaxHealth ? 1 : health == 2 ? 0.93f : 0.78f;
		return new Color( color.r * brightness, color.g * brightness, color.b * brightness );
	}

	void DrawDiamond( Painter p, Vector2 center, int colorIndex, bool ghost, float scale = 1, float opacity = 1, int health = DiamondBoard.MaxHealth, DiamondBoard.Corner crackCorner = DiamondBoard.Corner.Top )
	{
		// Ghosts and shatter fades have per-facet opacity, which must not be flattened.
		if ( !drawingReferenceGems && !drawingReferenceStrokes && !ghost && opacity == 1 && scale > 0 &&
			DrawAtlasGem( p, center, colorIndex, scale, health, crackCorner ) ) return;
		DrawDiamondVector( p, center, colorIndex, ghost, scale, opacity, health, crackCorner );
	}

	static void DrawDiamondVector( Painter p, Vector2 center, int colorIndex, bool ghost, float scale = 1, float opacity = 1, int health = DiamondBoard.MaxHealth, DiamondBoard.Corner crackCorner = DiamondBoard.Corner.Top )
	{
		using var scope = p.Scope();
		p.Translate( center );
		p.Scale( scale );
		Color color = GemColor( colorIndex, health );
		float w = DiamondBoard.Width * 0.5f;
		float h = DiamondBoard.Height * 0.5f;
		Vector2 top = new( 0, -h ), right = new( w, 0 ), bottom = new( 0, h ), left = new( -w, 0 );
		Vector2 middle = Vector2.Zero;

		// Every cut tiles the same diamond. Broad light/dark faces provide the visual
		// identity; there are no icons or marks floating above the gem surface.
		p.Fill = color.WithAlpha( opacity * (ghost ? 0.045f : 0.94f) );
		p.Stroke = Stroke.None;
		p.Quad( top, right, bottom, left );
		switch ( colorIndex )
		{
			case 0: // Classic: four facets meet at the center, retaining the original cut.
				Facet( p, color, 0.32f, opacity, ghost, [top, middle, left] );
				Facet( p, color, 0.02f, opacity, ghost, [top, right, middle] );
				Facet( p, color, -0.42f, opacity, ghost, [middle, right, bottom] );
				Facet( p, color, -0.12f, opacity, ghost, [left, middle, bottom] );
				break;
			case 1: // Bevel: a smaller diamond table surrounded by four broad bevels.
				Vector2 innerTop = top * 0.48f, innerRight = right * 0.48f;
				Vector2 innerBottom = bottom * 0.48f, innerLeft = left * 0.48f;
				Facet( p, color, 0.42f, opacity, ghost, [top, innerTop, innerLeft, left] );
				Facet( p, color, -0.12f, opacity, ghost, [top, right, innerRight, innerTop] );
				Facet( p, color, -0.48f, opacity, ghost, [right, bottom, innerBottom, innerRight] );
				Facet( p, color, -0.2f, opacity, ghost, [bottom, left, innerLeft, innerBottom] );
				Facet( p, color, 0.14f, opacity, ghost, [innerTop, innerRight, innerBottom, innerLeft] );
				break;
			case 2: // Table: a long, flat hexagonal face with clipped ends and narrow sides.
				Vector2 upperLeft = new( -w * 0.23f, -h * 0.54f ), upperRight = new( w * 0.23f, -h * 0.54f );
				Vector2 midLeft = new( -w * 0.5f, 0 ), midRight = new( w * 0.5f, 0 );
				Vector2 lowerLeft = new( -w * 0.23f, h * 0.54f ), lowerRight = new( w * 0.23f, h * 0.54f );
				Facet( p, color, 0.48f, opacity, ghost, [top, upperRight, upperLeft] );
				Facet( p, color, 0.24f, opacity, ghost, [top, upperLeft, midLeft, left] );
				Facet( p, color, -0.32f, opacity, ghost, [top, right, midRight, upperRight] );
				Facet( p, color, -0.12f, opacity, ghost, [left, midLeft, lowerLeft, bottom] );
				Facet( p, color, -0.48f, opacity, ghost, [right, bottom, lowerRight, midRight] );
				Facet( p, color, -0.25f, opacity, ghost, [bottom, lowerLeft, lowerRight] );
				Facet( p, color, 0.08f, opacity, ghost, [upperLeft, upperRight, midRight, lowerRight, lowerLeft, midLeft] );
				break;
			case 3: // Steps: three chevron ridges cut across a central spine.
				Vector2 a = new( -w * 0.5f, -h * 0.5f ), b = new( w * 0.5f, -h * 0.5f );
				Vector2 c = new( 0, -h * 0.2f ), d = new( 0, h * 0.35f );
				Vector2 e = new( -w * 0.5f, h * 0.5f ), f = new( w * 0.5f, h * 0.5f ), g = new( 0, h * 0.8f );
				Facet( p, color, 0.36f, opacity, ghost, [top, c, a] );
				Facet( p, color, -0.14f, opacity, ghost, [top, b, c] );
				Facet( p, color, -0.08f, opacity, ghost, [a, c, d, left] );
				Facet( p, color, -0.44f, opacity, ghost, [c, b, right, d] );
				Facet( p, color, 0.3f, opacity, ghost, [left, d, g, e] );
				Facet( p, color, -0.16f, opacity, ghost, [d, right, f, g] );
				Facet( p, color, -0.14f, opacity, ghost, [e, g, bottom] );
				Facet( p, color, -0.5f, opacity, ghost, [g, f, bottom] );
				break;
			case 4: // Fan: long triangular planes radiate from the top point.
				Vector2 fanLeft = (left + bottom) * 0.5f, fanRight = (right + bottom) * 0.5f;
				Facet( p, color, 0.32f, opacity, ghost, [top, fanLeft, left] );
				Facet( p, color, -0.34f, opacity, ghost, [top, bottom, fanLeft] );
				Facet( p, color, 0.12f, opacity, ghost, [top, fanRight, bottom] );
				Facet( p, color, -0.48f, opacity, ghost, [top, right, fanRight] );
				break;
		}

		if ( !ghost && health < DiamondBoard.MaxHealth ) DrawCracks( p, w, h, health, opacity, colorIndex, crackCorner );
		// The colored perimeter is drawn once around the bonded group after its facets.
	}

	static Vector2 CrackPoint( Vector2 point, float w, float h, float mirror, DiamondBoard.Corner corner )
	{
		point.x *= mirror;
		// Rotate in normalized gem coordinates before restoring its tall proportions.
		point = corner switch
		{
			DiamondBoard.Corner.Right => new( -point.y, point.x ),
			DiamondBoard.Corner.Bottom => new( -point.x, -point.y ),
			DiamondBoard.Corner.Left => new( point.y, -point.x ),
			_ => point
		};
		return new( point.x * w, point.y * h );
	}

	static void DrawCracks( Painter p, float w, float h, int health, float opacity, int colorIndex, DiamondBoard.Corner corner )
	{
		float mirror = colorIndex % 2 == 0 ? 1 : -1;
		Color crackColor = Color.Lerp( Palette[colorIndex], Color.Black, health == 1 ? 0.6f : 0.45f );
		float crackAlpha = health == 1 ? 0.94f : 0.88f;
		void Crack( Painter p, float w, float h, int health, float opacity, float mirror, DiamondBoard.Corner corner, Span<Vector2> path )
		{
			for ( int i = 0; i < path.Length; i++ ) path[i] = CrackPoint( path[i], w, h, mirror, corner );
			DrawGemStroke( p, path, crackColor.WithAlpha( opacity * crackAlpha ), health == 2 ? 2.2f : 3.3f, false );
			for ( int i = 0; i < path.Length; i++ ) path[i] += new Vector2( 1, -0.4f );
			DrawGemStroke( p, path, Color.White.WithAlpha( opacity * 0.55f ), 0.65f, false );
		}
		Crack( p, w, h, health, opacity, mirror, corner, stackalloc Vector2[] { new( 0, -1 ), new( 0.12f, -0.66f ), new( -0.10f, -0.43f ), new( 0.08f, -0.21f ), new( -0.12f, 0.06f ), new( 0.05f, 0.22f ) } );
		Crack( p, w, h, health, opacity, mirror, corner, stackalloc Vector2[] { new( -0.10f, -0.43f ), new( -0.31f, -0.29f ), new( -0.4f, -0.06f ) } );
		if ( health > 1 ) return;
		Crack( p, w, h, health, opacity, mirror, corner, stackalloc Vector2[] { new( 0.05f, 0.22f ), new( 0.11f, 0.67f ) } );
		Crack( p, w, h, health, opacity, mirror, corner, stackalloc Vector2[] { new( 0.05f, 0.22f ), new( 0.34f, -0.04f ), new( 0.55f, 0.12f ), new( 0.87f, 0.1f ) } );
		Crack( p, w, h, health, opacity, mirror, corner, stackalloc Vector2[] { new( 0.34f, -0.04f ), new( 0.25f, -0.24f ), new( 0.55f, -0.4f ) } );
		Crack( p, w, h, health, opacity, mirror, corner, stackalloc Vector2[] { new( 0.11f, 0.67f ), new( -0.12f, 0.7f ), new( 0, 0.98f ) } );
		p.Fill = crackColor.WithAlpha( opacity * 0.65f );
		p.Stroke = Stroke.None;
		p.Polygon( [CrackPoint( new( -0.12f, 0.06f ), w, h, mirror, corner ), CrackPoint( new( -0.04f, 0.13f ), w, h, mirror, corner ), CrackPoint( new( 0.05f, 0.22f ), w, h, mirror, corner )] );
	}

	static void Facet( Painter p, Color color, float light, float opacity, bool ghost, ReadOnlySpan<Vector2> vertices )
	{
		float amount = MathF.Abs( light );
		Color target = light >= 0 ? Color.White : Color.Black;
		Color face = new( color.r + (target.r - color.r) * amount,
			color.g + (target.g - color.g) * amount, color.b + (target.b - color.b) * amount );
		p.Fill = ghost ? Fill.None : face.WithAlpha( opacity * 0.96f );
		p.Stroke = Stroke.None;
		p.Polygon( vertices );
		DrawGemStroke( p, vertices, (ghost ? color : Color.Black).WithAlpha( opacity * (ghost ? 0.2f : 0.28f) ), 0.8f, true );
	}
	static void Text( Painter p, string text, float x, float y, float width, float height, float size, Color color, bool center = false, string fontName = "Roboto" )
	{
		p.TextStyle = new TextStyle { FontName = fontName, FontSize = size, Color = color, Alignment = center ? TextFlag.Center : TextFlag.LeftTop };
		p.Text( text, new Rect( x, y, width, height ) );
	}
}

facepunch.diamondgrinder / DiamondSettings.cs
Game game
using System;
using Sandbox;

namespace Diamonds;

/// <summary>
/// Player preferences from the options overlay, persisted as JSON in the game's local data folder.
/// Volumes are slider positions from zero to one, applied as linear gain.
/// </summary>
public sealed class DiamondSettings
{
	public const string FilePath = "diamonds_settings.json";
	static DiamondSettings current;

	/// <summary>Use a dim static dye background instead of gameplay fluid. Keep the original JSON key for saved preferences.</summary>
	[System.Text.Json.Serialization.JsonPropertyName( "HideDye" )]
	public bool DisableFluid { get; set; }

	/// <summary>Overall level for every game sound, including music.</summary>
	public float MasterVolume { get => masterVolume; set => masterVolume = Clamp( value ); }
	float masterVolume = 1;

	/// <summary>Music level, applied on top of the master volume.</summary>
	public float MusicVolume { get => musicVolume; set => musicVolume = Clamp( value ); }
	float musicVolume = 1;

	/// <summary>Controller vibration strength, from zero (off) to one.</summary>
	public float ControllerRumble { get => controllerRumble; set => controllerRumble = Clamp( value ); }
	float controllerRumble = 1;

	public const float RestartDelayStep = 0.05f;

	/// <summary>Seconds the restart key must be held, from zero to one in 0.05 steps. Zero restarts instantly, without a fade.</summary>
	public float RestartDelay
	{
		get => restartDelay;
		set => restartDelay = float.IsFinite( value ) ? MathF.Round( Math.Clamp( value, 0, 1 ) / RestartDelayStep ) * RestartDelayStep : 0.5f;
	}
	float restartDelay = 0.5f;

	/// <summary>A delay as the options slider shows it: 0s, 0.05s, 0.5s, 1s.</summary>
	public static string FormatSeconds( float seconds )
	{
		// Whole hundredths, formatted by hand so the decimal point never follows the system culture.
		int hundredths = Math.Max( 0, (int)MathF.Round( seconds * 100 ) );
		int whole = hundredths / 100, fraction = hundredths % 100;
		return fraction == 0 ? $"{whole}s" : fraction % 10 == 0 ? $"{whole}.{fraction / 10}s" : $"{whole}.{fraction:00}s";
	}

	public static DiamondSettings Current => current ??= Load();

	/// <summary>Linear gain for master-scaled sound effects.</summary>
	public static float EffectsGain => Current.MasterVolume;

	/// <summary>Linear gain for music: the master and music sliders combined.</summary>
	public static float MusicGain => Current.MasterVolume * Current.MusicVolume;


	static DiamondSettings Load()
	{
		try { return FileSystem.Data?.ReadJsonOrDefault( FilePath, new DiamondSettings() ) ?? new DiamondSettings(); }
		catch ( Exception e )
		{
			Log.Warning( $"Could not read {FilePath}; using default options. {e.Message}" );
			return new DiamondSettings();
		}
	}

	public static void Save()
	{
		try { FileSystem.Data?.WriteJson( FilePath, Current ); }
		catch ( Exception e ) { Log.Warning( $"Could not save {FilePath}. {e.Message}" ); }
	}

	static float Clamp( float value ) => float.IsFinite( value ) ? Math.Clamp( value, 0, 1 ) : 1;
}
facepunch.diamondgrinder / DiamondGame.cs
Game game
using Sandbox;
using Sandbox.UI;

namespace Diamonds;

/// <summary>Hosts the Painter canvas on a ScreenPanel and routes keyboard input.</summary>
public sealed class DiamondGame : PanelComponent
{
	readonly DiamondBoard board = new();
	readonly DiamondFeedback feedback = new();
	readonly DiamondDebris debris = new();
	readonly DiamondAtmosphere atmosphere = new();
	DiamondScoreEffects scoreEffects = new();
	DiamondMusic music = new();
	DiamondPanel canvas;
	bool gameStarted;
	// B both closes overlays and moves right; a press that closed one is ignored for movement until released.
	bool closeMenuHeld;
	// A (confirming PLAY or clicking RESTART) and the sticks (aiming the virtual cursor) also play. Each one still held
	// when a game starts is ignored for play until released, so the press that started it never soft-drops or moves.
	[System.Flags] enum PadInput { None = 0, SoftDrop = 1, Left = 2, Right = 4, Up = 8, Down = 16 }
	PadInput carriedPad;
	// The board reset whose game-over score was submitted.
	int submittedResetVersion = -1;
	// The board reset started by diamonds_fill, kept off the leaderboard.
	int debugFilledResetVersion = -1;
	// Board counters already added to the lifetime achievement stats, for the board reset bankedResetVersion.
	int bankedResetVersion = -1;
	long bankedBroken, bankedCrushed, bankedScore;
	RealTimeSince sinceAchievementBank;
	// A long game is banked now and then, so a crash or forced exit loses at most this much of its progress.
	const float AchievementBankInterval = 60;
	MouseVisibility previousMouseVisibility;
	// Queries the engine's modal state: the s&box escape menu freezes the whole game, like Block Party.
	readonly Game.Overlay engineOverlay = new();

	/// <summary>Debug score applied when starting or restarting a game. Zero or negative values start at zero.</summary>
	[Property, Group( "Debug" ), Title( "Starting Score" )]
	public long StartingScore
	{
		get => board.StartingScore;
		set => board.StartingScore = value;
	}

	/// <summary>Editor only: generated players mixed into the leaderboard, to preview scrolling and a busy board. Zero shows only real scores.</summary>
	[Property, Group( "Debug" ), Title( "Leaderboard Test Players" ), Range( 0, DiamondLeaderboard.MaxEntries )]
	public int LeaderboardTestPlayers { get; set; }

	/// <summary>Editor only, for thumbnail screenshots: draw just the background dye (the menu dye on the title, the board dye in game) and nothing else.</summary>
	[Property, Group( "Thumbnail" ), Title( "Only Render Dye" )]
	public bool ThumbnailOnlyDye { get; set; }

	/// <summary>Editor only, for thumbnail screenshots: never draw any dye, including the static Disable Fluid background and the menu dye.</summary>
	[Property, Group( "Thumbnail" ), Title( "Hide Dye" )]
	public bool ThumbnailHideDye { get; set; }

	/// <summary>Editor only, for bright thumbnail screenshots: brighten the dye by Vibrancy.</summary>
	[Property, Group( "Thumbnail" ), Title( "Vibrant Dye" )]
	public bool ThumbnailVibrantDye { get; set; }

	/// <summary>How far Vibrant Dye pushes the dye from its normal faint wash (0) to opaque, fully saturated color (1).</summary>
	[Property, Group( "Thumbnail" ), Title( "Vibrancy" ), Range( 0, 1 ), ShowIf( nameof( ThumbnailVibrantDye ), true )]
	public float ThumbnailVibrancy { get; set; } = 0.5f;

	/// <summary>Fill both bottom corner pockets. Changing this starts a fresh game.</summary>
	[Property, Group( "Board" ), Title( "Fill Corner Pockets" )]
	public bool FillCornerPockets
	{
		get => board.FillCornerPockets;
		set => board.FillCornerPockets = value;
	}

	/// <summary>Size of the glowing marker at the active piece's landing contact points. One is the original size.</summary>
	[Property, Group( "Board" ), Title( "Landing Indicator Scale" ), Range( 0, 4 )]
	public float LandingIndicatorScale { get; set; } = 1;

	/// <summary>Seconds a sideways step takes to draw. Collision still snaps to the lattice; zero draws it instantly.</summary>
	[Property, Group( "Controls" ), Title( "Move Smoothing (s)" ), Range( 0, 0.3f )]
	public float MoveSmoothing
	{
		get => board.MoveSmoothing;
		set => board.MoveSmoothing = value;
	}

	/// <summary>Seconds a quarter turn takes to draw. Collision still snaps to the lattice; zero draws it instantly.</summary>
	[Property, Group( "Controls" ), Title( "Rotate Smoothing (s)" ), Range( 0, 0.3f )]
	public float RotateSmoothing
	{
		get => board.RotateSmoothing;
		set => board.RotateSmoothing = value;
	}

	/// <summary>How long rotate must be held before it starts repeating.</summary>
	[Property, Group( "Controls" ), Title( "Rotate Repeat Delay (s)" ), Range( 0.05f, 1 )]
	public float RotateRepeatDelay
	{
		get => board.RotateRepeatDelay;
		set => board.RotateRepeatDelay = value;
	}

	/// <summary>Seconds between repeated turns while rotate stays held.</summary>
	[Property, Group( "Controls" ), Title( "Rotate Repeat Interval (s)" ), Range( 0.03f, 1 )]
	public float RotateRepeatInterval
	{
		get => board.RotateRepeatInterval;
		set => board.RotateRepeatInterval = value;
	}

	/// <summary>Normal fall speed at the start of a game, in diamond heights per second.</summary>
	[Property, Group( "Difficulty" ), Title( "Starting Fall Speed" ), Range( 0.1f, 8 )]
	public float StartingFallSpeed
	{
		get => board.StartingFallSpeed;
		set => board.StartingFallSpeed = value;
	}

	/// <summary>Minutes until normal falling reaches the chosen speed multiplier.</summary>
	[Property, Group( "Difficulty" ), Title( "Ramp Reference Time (min)" ), Range( 0.5f, 60 )]
	public float FallRampMinutes
	{
		get => board.FallRampMinutes;
		set => board.FallRampMinutes = value;
	}

	/// <summary>Normal fall speed at the reference time, relative to the starting speed.</summary>
	[Property, Group( "Difficulty" ), Title( "Speed at Reference Time (x)" ), Range( 1, 10 )]
	public float FallRampMultiplier
	{
		get => board.FallRampMultiplier;
		set => board.FallRampMultiplier = value;
	}

	/// <summary>Values above one delay most of the speed increase until later in the ramp.</summary>
	[Property, Group( "Difficulty" ), Title( "Ramp Curve Exponent" ), Range( 0.5f, 4 )]
	public float FallRampExponent
	{
		get => board.FallRampExponent;
		set => board.FallRampExponent = value;
	}

	/// <summary>Minutes of play before pieces can gain extra distinct colors. Chance is zero at this time.</summary>
	[Property, Group( "Difficulty - Colors" ), Title( "Mix Starts At (min)" ), Range( 0, 60 )]
	public float ColorMixStartMinutes
	{
		get => board.ColorMixStartMinutes;
		set => board.ColorMixStartMinutes = value;
	}

	/// <summary>Minutes after the mix starts until the reference chance is reached.</summary>
	[Property, Group( "Difficulty - Colors" ), Title( "Mix Reference Time (min)" ), Range( 0.5f, 60 )]
	public float ColorMixRampMinutes
	{
		get => board.ColorMixRampMinutes;
		set => board.ColorMixRampMinutes = value;
	}

	/// <summary>Chance of a three- or four-color piece at the reference time. The chance keeps rising afterward, up to 100%.</summary>
	[Property, Group( "Difficulty - Colors" ), Title( "Mix Chance at Reference (%)" ), Range( 0, 100 )]
	public float ColorMixChanceAtReference
	{
		get => board.ColorMixChanceAtReference;
		set => board.ColorMixChanceAtReference = value;
	}

	/// <summary>Higher values keep the mix chance low for longer after it starts.</summary>
	[Property, Group( "Difficulty - Colors" ), Title( "Mix Curve Exponent" ), Range( 0.5f, 4 )]
	public float ColorMixRampExponent
	{
		get => board.ColorMixRampExponent;
		set => board.ColorMixRampExponent = value;
	}

	/// <summary>Among varied four-diamond pieces, the percentage with four different colors rather than three.</summary>
	[Property, Group( "Difficulty - Colors" ), Title( "Four-Color Share (%)" ), Range( 0, 100 )]
	public float FourColorShare
	{
		get => board.FourColorShare;
		set => board.FourColorShare = value;
	}

	/// <summary>Scales gameplay dye opacity. One preserves the original appearance.</summary>
	[Property, Group( "Fluid" ), Title( "Dye Opacity" ), Range( 0, 1 )]
	public float DyeOpacity
	{
		get => atmosphere.Opacity;
		set => atmosphere.Opacity = value;
	}

	/// <summary>Total dye added at each piece spawn, divided among its diamond colors.</summary>
	[Property, Group( "Fluid" ), Title( "Spawn Dye Amount" ), Range( 0, 30 )]
	public float SpawnDyeAmount
	{
		get => atmosphere.SpawnDyeAmount;
		set => atmosphere.SpawnDyeAmount = value;
	}

	/// <summary>Dye added in the broken diamond's color for each shatter.</summary>
	[Property, Group( "Fluid" ), Title( "Shatter Dye Amount" ), Range( 0, 15 )]
	public float ShatterDyeAmount
	{
		get => atmosphere.ShatterDyeAmount;
		set => atmosphere.ShatterDyeAmount = value;
	}

	/// <summary>Minimum seconds between explosion dye pushes. Each batch pushes at most eight gems; dye emission is never throttled.</summary>
	[Property, Group( "Fluid" ), Title( "Shatter Push Interval (s)" ), Range( 0, 1 )]
	public float ShatterPushInterval
	{
		get => atmosphere.ShatterPushInterval;
		set => atmosphere.ShatterPushInterval = value;
	}

	/// <summary>Percentage of dye faded per second, shared by every color and source.</summary>
	[Property, Group( "Fluid" ), Title( "Dye Fade Rate (%/s)" ), Range( 0, 10 )]
	public float DyeFadeRate
	{
		get => atmosphere.DyeFadeRate;
		set => atmosphere.DyeFadeRate = value;
	}

	/// <summary>Scales launch speed for impact, rotation, sliding, and shatter sparks. One is the original force.</summary>
	[Property, Group( "Sparks" ), Title( "Force Multiplier" ), Range( 0, 10 )]
	public float SparkForceMultiplier
	{
		get => debris.SparkForceMultiplier;
		set => debris.SparkForceMultiplier = value;
	}

	/// <summary>Spark bounce restitution, from zero to one. The original value was 0.24. Applies to existing sparks too.</summary>
	[Property, Group( "Sparks" ), Title( "Bounciness" ), Range( 0, 1 )]
	public float SparkBounciness
	{
		get => debris.SparkBounciness;
		set => debris.SparkBounciness = value;
	}

	/// <summary>Scales the original 0.5–0.8 second lifetime for newly emitted sparks. Four gives 2–3.2 seconds.</summary>
	[Property, Group( "Sparks" ), Title( "Lifetime Multiplier" ), Range( 0.1f, 10 )]
	public float SparkLifetimeMultiplier
	{
		get => debris.SparkLifetimeMultiplier;
		set => debris.SparkLifetimeMultiplier = value;
	}

	/// <summary>Lowest per-spark gravity scale for newly emitted sparks. One is the original world gravity.</summary>
	[Property, Group( "Sparks" ), Title( "Gravity Min" ), Range( 0, 4 )]
	public float SparkGravityMin
	{
		get => debris.SparkGravityMin;
		set => debris.SparkGravityMin = value;
	}

	/// <summary>Highest per-spark gravity scale for newly emitted sparks. Each spark picks uniformly between min and max.</summary>
	[Property, Group( "Sparks" ), Title( "Gravity Max" ), Range( 0, 4 )]
	public float SparkGravityMax
	{
		get => debris.SparkGravityMax;
		set => debris.SparkGravityMax = value;
	}

	/// <summary>Shortest lifetime of a newly created shard before it blinks out, whether moving or resting.</summary>
	[Property, Group( "Shards" ), Title( "Lifetime Min (s)" ), Range( 0, 120 )]
	public float ShardLifetimeMin
	{
		get => debris.ShardLifetimeMin;
		set => debris.ShardLifetimeMin = value;
	}

	/// <summary>Longest lifetime of a newly created shard before it blinks out. Each shard picks uniformly between min and max.</summary>
	[Property, Group( "Shards" ), Title( "Lifetime Max (s)" ), Range( 0, 120 )]
	public float ShardLifetimeMax
	{
		get => debris.ShardLifetimeMax;
		set => debris.ShardLifetimeMax = value;
	}

	/// <summary>Layered music level, on top of the Music mixer and scaled by the player's Options volumes.</summary>
	[Property, Group( "Music" ), Title( "Volume" ), Range( 0, 1 )]
	public float MusicVolume { get; set; } = 0.5f;

	/// <summary>Forces a music tier (0–3) for auditioning. -1 follows pieces placed.</summary>
	[Property, Group( "Music" ), Title( "Tier Override" ), Range( -1, 3 )]
	public int MusicTierOverride { get; set; } = -1;

	/// <summary>Tier the menu music holds at. Each menu visit picks a fresh random motif and clips at this tier.</summary>
	[Property, Group( "Music" ), Title( "Menu Tier" ), Range( 0, 3 )]
	public int MusicMenuTier { get; set; } = 2;

	/// <summary>Pieces placed before the music moves to tier 1.</summary>
	[Property, Group( "Music" ), Title( "Tier 1 At (pieces)" ), Range( 0, 200 )]
	public int MusicTier1Pieces { get; set; } = 4;

	/// <summary>Pieces placed before the music moves to tier 2.</summary>
	[Property, Group( "Music" ), Title( "Tier 2 At (pieces)" ), Range( 0, 200 )]
	public int MusicTier2Pieces { get; set; } = 12;

	/// <summary>Pieces placed before the music moves to tier 3.</summary>
	[Property, Group( "Music" ), Title( "Tier 3 At (pieces)" ), Range( 0, 200 )]
	public int MusicTier3Pieces { get; set; } = 28;

	/// <summary>Chance per 8-bar swap roll that a layer changes to another clip fitting the current tier. Tier changes only take effect through these rolls.</summary>
	[Property, Group( "Music" ), Title( "Swap Chance" ), Range( 0, 1 )]
	public float MusicSwapChance { get; set; } = 0.25f;

	/// <summary>Each swap in a roll multiplies Swap Chance for the layers still to roll (0.35: 25% for the first, then about 9%, 3%...), so several rarely change at once.</summary>
	[Property, Group( "Music" ), Title( "Subsequent Swap Multiplier" ), Range( 0, 1 )]
	public float MusicSwapFalloff { get; set; } = 0.35f;

	/// <summary>Editor only: overlay the live music state (tier, motif, clip per layer, mix levels).</summary>
	[Property, Group( "Music" ), Title( "Show Debug" )]
	public bool MusicDebug { get; set; }

	/// <summary>Looping 150 BPM clips. Each layer plays one eligible clip at a time; runs pick a motif from the clips' tags.</summary>
	[Property, Group( "Music" ), Title( "Clips" )]
	public List<DiamondMusicClip> MusicClips { get; set; } = new();

	// Hotload keeps old instances without running initializers for new fields, so rebuild the runtime player instead.
	DiamondMusic Music
	{
		get
		{
			if ( music is null || !music.IsIntact )
			{
				music?.Stop();
				music = new DiamondMusic();
			}
			return music;
		}
	}

	protected override void OnEnabled()
	{
		previousMouseVisibility = Mouse.Visibility;
		Mouse.Visibility = MouseVisibility.Visible;
		feedback.Prepare();
		DiamondDebris.Prepare();
		DiamondAchievements.CheckProgress();
	}

	protected override void OnDisabled()
	{
		BankAchievementProgress();
		canvas?.CloseTutorial();
		canvas?.CloseOptions();
		canvas?.CloseLeaderboard();
		canvas?.CancelTransition();
		canvas?.ClearRestartHold();
		canvas?.StopTitleAudio();
		canvas?.TitleEffects.Clear();
		canvas?.TitleDye.Clear();
		Mouse.Visibility = previousMouseVisibility;
		Input.EnableVirtualCursor = true;
		DiamondGamepad.Clear();
		DiamondHaptics.StopAll();
		feedback.Stop();
		music?.Stop();
		debris.Clear();
		scoreEffects?.Clear();
		atmosphere.Clear();
	}

	protected override void OnUpdate()
	{
		Mouse.Visibility = MouseVisibility.Visible;
		atmosphere.Enabled = true;
		atmosphere.DisableFluid = DiamondSettings.Current.DisableFluid;
		// Prepare a changed background even while Options has paused gameplay.
		if ( gameStarted ) atmosphere.Update( board, 0 );
		// PanelComponent recreates its root when disabled/enabled or hot reloaded.
		if ( Panel is null ) return;
		scoreEffects ??= new DiamondScoreEffects();
		if ( canvas is null || !canvas.IsValid() || canvas.Parent != Panel )
		{
			// Hotload can preserve the child while dropping our field reference.
			canvas = Panel.Children.OfType<DiamondPanel>().FirstOrDefault() ?? new DiamondPanel { Parent = Panel };
			canvas.Board = board;
			canvas.Debris = debris;
			canvas.Atmosphere = atmosphere;
			canvas.ScoreEffects = scoreEffects;
			foreach ( var stale in Panel.Children.OfType<DiamondPanel>().Where( p => p != canvas ).ToArray() ) stale.Delete();
		}
		canvas.ScoreEffects ??= scoreEffects;
		canvas.Atmosphere ??= atmosphere;
		canvas.TitleScreen = !gameStarted;
		canvas.PlayRequested = StartGame;
		canvas.QuitRequested = Game.Close;
		canvas.LeaveRequested = LeaveGame;
		// Every gameplay, effect, title and fade clock advances from here, so skipping them freezes the whole screen.
		bool escapeMenuOpen = engineOverlay.IsPauseMenuOpen;
		// The engine's virtual cursor moves the mouse with the left stick and clicks with A, which would fight
		// gameplay (and could click Leave) and the title menu's stick/A selection, so it only runs on overlays and
		// game over. Wait for A (soft drop / confirm) to be released first, so the press that got here can't click.
		bool menuCursor = canvas.ModalOpen || escapeMenuOpen || gameStarted && board.GameOver;
		Input.EnableVirtualCursor = menuCursor && (Input.EnableVirtualCursor || !Input.Down( "SoftDropAlt" ) && !Input.Down( "MenuConfirm" ));
		if ( !escapeMenuOpen )
		{
			canvas.UpdateTransition( Time.Delta );
			// Holding restart never pauses play; a completed hold fades through PlayRequested, and a zero delay restarts now.
			if ( canvas.UpdateRestartHold( Time.Delta, Input.Down( "Restart" ), DiamondSettings.Current.RestartDelay ) ) StartGame();
		}
		canvas.IsMuted = feedback.IsMuted;
		canvas.LandingPointScale = LandingIndicatorScale;
		canvas.OnlyDye = Game.IsEditor && ThumbnailOnlyDye;
		canvas.HideDye = Game.IsEditor && ThumbnailHideDye;
		canvas.DyeVibrancy = Game.IsEditor && ThumbnailVibrantDye ? ThumbnailVibrancy : 0;
		var music = Music;
		music.Clips = MusicClips;
		music.Volume = MusicVolume * DiamondSettings.MusicGain;
		music.TierOverride = MusicTierOverride;
		music.MenuTier = MusicMenuTier;
		music.SwapChance = MusicSwapChance;
		music.SwapFalloff = MusicSwapFalloff;
		music.TierPieces = (MusicTier1Pieces, MusicTier2Pieces, MusicTier3Pieces);
		// Options pauses play but leaves the music unducked, so its volume sliders are heard as set.
		// Pauses only muffle; the escape menu also lowers the volume.
		// A title/game switch starts the next screen's music as the old screen begins fading out.
		bool switching = canvas.TransitionPending;
		music.Update( board, switching ? canvas.TransitionToGame : gameStarted, feedback.IsMuted,
			escapeMenuOpen || canvas.TutorialOpen || board.Paused && !canvas.OptionsOpen, escapeMenuOpen, switching );
		canvas.MusicDebugText = Game.IsEditor && MusicDebug ? music.DebugText( board ) : null;
		canvas.LeaderboardTestPlayers = Game.IsEditor ? LeaderboardTestPlayers : 0;
		if ( escapeMenuOpen )
		{
			feedback.Suspend();
			canvas.StopTitleAudio();
			canvas.StopTutorialAudio();
			DiamondHaptics.StopAll();
			return;
		}
		canvas.UpdateTitleScreen( Time.Delta );
		DiamondGamepad.Update();
		DiamondHaptics.UpdateSource();
		// Pulses fired later this frame start on the next tick, a frame behind their sound at most.
		DiamondHaptics.Tick( Time.Delta );
		canvas.UpdateButtonHover();
		// Up/down arrows, W/S, D-pad and left stick step the title selection; Space, Enter and A confirm it.
		canvas.UpdateTitleMenu(
			Input.Pressed( "MenuUp" ) || Input.Pressed( "MenuUpAlt" ) || DiamondGamepad.UpPressed,
			Input.Pressed( "MenuDown" ) || Input.Pressed( "MenuDownAlt" ) || DiamondGamepad.DownPressed,
			Input.Pressed( "MenuConfirm" ) || Input.Pressed( "MenuConfirmAlt" ) );
		if ( canvas.ModalOpen ) closeMenuHeld = Input.Down( "MoveRightAlt" );
		else if ( !Input.Down( "MoveRightAlt" ) ) closeMenuHeld = false;
		var pad = HeldPad();
		// Out of play (title, game over, fades) everything held carries over; in play, each release clears its own.
		carriedPad = !gameStarted || board.GameOver || canvas.TransitionPending ? pad : carriedPad & pad;
		// The modal owns input and its own clock. No gameplay/effect clock advances behind it.
		if ( canvas.TutorialOpen )
		{
			feedback.Suspend();
			canvas.UpdateTutorial( Time.Delta, Input.Pressed( "CloseMenu" ) );
			return;
		}
		if ( canvas.OptionsOpen )
		{
			feedback.Suspend();
			canvas.UpdateOptions( Time.Delta, Input.Pressed( "CloseMenu" ) );
			return;
		}
		if ( canvas.LeaderboardOpen )
		{
			feedback.Suspend();
			canvas.UpdateLeaderboard( Time.Delta, Input.Pressed( "CloseMenu" ) );
			return;
		}
		// Nothing advances behind a fade-out; a new game runs once fading in.
		if ( canvas.TransitionPending )
		{
			feedback.Suspend();
			return;
		}
		if ( !gameStarted )
		{
			feedback.Stop();
			return;
		}

		// Each control has a primary (arrow / D-pad) and an alternate (WASD / face button) action, both rebindable,
		// plus either controller stick.
		var playPad = pad & ~carriedPad;
		bool rotate = Input.Down( "Rotate" ) || Input.Down( "RotateAlt" ) || playPad.HasFlag( PadInput.Up );
		int direction = (Input.Down( "MoveRight" ) || Input.Down( "MoveRightAlt" ) && !closeMenuHeld || playPad.HasFlag( PadInput.Right ) ? 1 : 0)
			- (Input.Down( "MoveLeft" ) || Input.Down( "MoveLeftAlt" ) || playPad.HasFlag( PadInput.Left ) ? 1 : 0);
		bool softDrop = Input.Down( "SoftDrop" ) || playPad.HasFlag( PadInput.SoftDrop ) || playPad.HasFlag( PadInput.Down );
#if STANDALONE
#endif
		board.Update( Time.Delta, direction, softDrop, rotate );
		if ( DebugGameReason() is null ) DiamondAchievements.CheckGame( board );
		SubmitGameOverScore();
		if ( sinceAchievementBank > AchievementBankInterval ) BankAchievementProgress();
		canvas.UpdateNextPreview( Time.Delta );
		canvas.UpdateGameOver( Time.Delta );
		atmosphere.Update( board, Time.Delta );
		debris.Update( board, Time.Delta );
		scoreEffects.Update( board, debris.CrushAwards, Time.Delta );
		feedback.Update( board, Time.Delta, debris.CrushedThisFrame );
		feedback.ScoreArrivals( scoreEffects.Arrivals );
		if ( board.RotatedThisUpdate )
		{
			feedback.Rotate( board );
			if ( !board.IsResolving && !board.GameOver ) debris.EmitRotationSparks( board.Active );
		}
		if ( board.RotationRefusedThisUpdate ) feedback.RotateBump();
		canvas.ShakeOffset = feedback.ShakeOffset;
		canvas.PieceShakeOffset = feedback.PieceShakeOffset;
		canvas.IsMuted = feedback.IsMuted;
		canvas.UpdateLandingPointGlows( Time.Delta );
	}

	static PadInput HeldPad() =>
		(Input.Down( "SoftDropAlt" ) ? PadInput.SoftDrop : 0) | (DiamondGamepad.Left ? PadInput.Left : 0)
		| (DiamondGamepad.Right ? PadInput.Right : 0) | (DiamondGamepad.Up ? PadInput.Up : 0) | (DiamondGamepad.Down ? PadInput.Down : 0);

	/// <summary>Submit the total score and survival time once, on the frame the game ends, and bank its achievement progress.
	/// Debug games stay off the board.</summary>
	void SubmitGameOverScore()
	{
		if ( !board.GameOver || submittedResetVersion == board.ResetVersion ) return;
		submittedResetVersion = board.ResetVersion;
		string debugGame = DebugGameReason();
		if ( debugGame is not null ) Log.Info( $"Diamonds: skipped leaderboard submission for {debugGame}." );
		else DiamondLeaderboard.Submit( board.Score, (long)board.ElapsedSeconds );
		BankAchievementProgress();
	}

	/// <summary>Why the current game stays off the leaderboard and achievements, or null for a normal game. Checked every
	/// frame, so it returns constant strings.</summary>
	string DebugGameReason()
	{
#if STANDALONE
#endif
		if ( debugFilledResetVersion == board.ResetVersion ) return "a diamonds_fill debug game";
		if ( board.StartingScore > 0 ) return "a game started at a debug Starting Score";
		return null;
	}

	/// <summary>Add the running game's progress since the last bank to the lifetime achievement stats: at game over, every
	/// <see cref="AchievementBankInterval"/> seconds of a long game, and when a game is restarted, left or shut down before
	/// then. Only the new part of each counter is added, so banking twice in one game never counts anything twice.</summary>
	void BankAchievementProgress()
	{
		sinceAchievementBank = 0;
		if ( !gameStarted ) return;
		if ( bankedResetVersion != board.ResetVersion )
		{
			bankedResetVersion = board.ResetVersion;
			bankedBroken = bankedCrushed = bankedScore = 0;
		}
		if ( DebugGameReason() is null )
			DiamondAchievements.Bank( board.DiamondsBroken - bankedBroken, board.ShardsCrushed - bankedCrushed, board.Score - bankedScore );
		bankedBroken = board.DiamondsBroken;
		bankedCrushed = board.ShardsCrushed;
		bankedScore = board.Score;
	}

	/// <summary>Editor only: start a new game over a stack filled to near the top, for testing game over.</summary>
	[ConCmd( "diamonds_fill" )]
	static void DebugFillCommand( float freeHeights = 2 )
	{
		if ( !Game.IsEditor )
		{
			Log.Warning( "diamonds_fill only works in the editor." );
			return;
		}
		var game = Game.ActiveScene?.GetAllComponents<DiamondGame>().FirstOrDefault();
		if ( game?.canvas is null )
		{
			Log.Warning( "diamonds_fill: no running Diamonds game." );
			return;
		}
		game.canvas.CloseTutorial();
		game.canvas.CloseOptions();
		game.canvas.CloseLeaderboard();
		game.canvas.CancelTransition();
		game.canvas.ClearRestartHold();
		// Keep a running game's score; from the title, start at the usual starting score.
		long score = game.board.Score;
		bool keepScore = game.gameStarted;
		game.StartGame();
		game.board.FillToNearTop( freeHeights, keepScore ? score : game.board.Score );
		Log.Info( $"diamonds_fill: {game.board.Settled.Count} gems, score {game.board.Score:N0}." );
		game.debugFilledResetVersion = game.board.ResetVersion;
	}

	/// <summary>Abandon the current game and return to the title screen.</summary>
	void LeaveGame()
	{
		BankAchievementProgress();
		gameStarted = false;
		board.Reset();
		debris.Clear();
		scoreEffects.Clear();
		atmosphere.Clear();
		feedback.Stop();
		canvas.ShakeOffset = Vector2.Zero;
		canvas.PieceShakeOffset = Vector2.Zero;
		canvas.TitleScreen = true;
	}

	void StartGame()
	{
		// A restart abandons the running game; whatever it achieved still counts.
		BankAchievementProgress();
		board.Reset();
		debris.Clear();
		scoreEffects.Clear();
		atmosphere.Clear();
		feedback.Stop();
		canvas.ShakeOffset = Vector2.Zero;
		canvas.PieceShakeOffset = Vector2.Zero;
		gameStarted = true;
		canvas.TitleScreen = false;
	}
}
facepunch.diamondgrinder / DiamondPanel.Leave.cs
Game game
using System;
using Sandbox;

namespace Diamonds;

public sealed partial class DiamondPanel
{
	public Action LeaveRequested { get; set; }

	static Rect LeaveButton( Vector2 size ) => new( LetterboxCenter( size, true ) - 136, Top + 876, 272, 64 );

	/// <summary>The letterbox Leave button fades straight back to the title; its flashing red hover is the warning.</summary>
	public bool HandleLeaveClick( Vector2 mouse, Vector2 size )
	{
		if ( TitleScreen || ModalOpen || TransitionPending || Board is null ) return false;
		float scale = MathF.Min( size.x / CanvasWidth, size.y / CanvasHeight );
		if ( scale <= 0 ) return false;
		var point = (mouse - (size - new Vector2( CanvasWidth, CanvasHeight ) * scale) * 0.5f) / scale;
		if ( !LeaveButton( size ).IsInside( point ) ) return false;
		PlayClickSound();
		BeginTransition( toGame: false );
		return true;
	}

	void DrawLeaveButton( Painter p, float scale )
	{
		var size = Box.Rect.Size;
		var mouse = (MousePosition - (size - new Vector2( CanvasWidth, CanvasHeight ) * scale) * 0.5f) / scale;
		DrawTitleButton( p, LeaveButton( size ), "LEAVE", mouse, scale, fontSize: 26, brightness: 0.7f, danger: true );
	}
}
facepunch.diamondgrinder / DiamondPanel.Options.cs
Game game
using System;
using Sandbox;
using Sandbox.UI;

namespace Diamonds;

public sealed partial class DiamondPanel
{
	public enum OptionsSlider { None, RestartDelay, Rumble, Master, Music }

	public bool OptionsOpen { get; private set; }
	/// <summary>Any overlay owns input and pauses gameplay and title animation behind it.</summary>
	public bool ModalOpen => TutorialOpen || OptionsOpen || LeaderboardOpen;
	public OptionsSlider DraggingSlider { get; private set; }
	bool optionsWasPaused;
	DiamondBoard optionsBoard;
	float optionsColorPhase;
	float disableFluidKnob;
	SoundHandle optionsPreview;

	const float OptionsWidth = 700, OptionsHeight = 604;
	const float OptionsLabelX = 48, DisableFluidY = 150, RestartDelayY = 226, RumbleY = 302, VolumeDividerY = 351, VolumeHeadingY = 398, MasterY = 468, MusicY = 544;
	// Restart Delay and Controller Rumble have longer labels than the volume sliders, so their shared track start is farther right.
	const float SliderRight = 568, SliderValueX = 618, VolumeTrackLeft = 196, LongLabelTrackLeft = 404;
	const int DisableFluidColor = 0, RestartDelayColor = 2, RumbleColor = 3, MasterColor = 1, MusicColor = 4;
	static Rect OptionsButton( Vector2 size ) => new( LetterboxCenter( size, true ) - 136, Top + 796, 272, 64 );
	// Share the tutorial's screen-pixel fitting, so the close button and scale match it.
	static Rect OptionsFrame( Vector2 size )
	{
		float scale = MathF.Min( size.x * 0.5f / OptionsWidth, size.y * 0.62f / OptionsHeight );
		var extent = new Vector2( OptionsWidth, OptionsHeight ) * scale;
		return new Rect( (size - extent) * 0.5f, extent );
	}
	static Rect DisableFluidRow => new( 32, DisableFluidY - 34, OptionsWidth - 64, 68 );
	static Rect DisableFluidTrack => new( SliderRight - 28, DisableFluidY - 7, 112, 14 );
	static float SliderY( OptionsSlider id ) => id switch
	{
		OptionsSlider.RestartDelay => RestartDelayY, OptionsSlider.Rumble => RumbleY, OptionsSlider.Master => MasterY, _ => MusicY
	};
	static Rect SliderTrack( OptionsSlider id )
	{
		float left = id is OptionsSlider.RestartDelay or OptionsSlider.Rumble ? LongLabelTrackLeft : VolumeTrackLeft;
		return new( left, SliderY( id ) - 4, SliderRight - left, 8 );
	}
	static Rect SliderHit( OptionsSlider id )
	{
		var track = SliderTrack( id );
		return new( track.Left - 24, track.Center.y - 34, track.Width + 48, 68 );
	}

	public void OpenOptions()
	{
		if ( ModalOpen || Board is null ) return;
		optionsBoard = Board;
		optionsWasPaused = Board.Paused;
		if ( !Board.Paused ) Board.TogglePause();
		OptionsOpen = true;
		DraggingSlider = OptionsSlider.None;
		disableFluidKnob = DiamondSettings.Current.DisableFluid ? 1 : 0;
		StopTitleAudio();
	}

	public void CloseOptions()
	{
		if ( !OptionsOpen ) return;
		EndOptionsDrag();
		if ( optionsBoard is not null && optionsBoard.Paused != optionsWasPaused ) optionsBoard.TogglePause();
		OptionsOpen = false;
		optionsBoard = null;
	}

	public void UpdateOptions( float delta, bool backspace )
	{
		if ( !OptionsOpen ) return;
		if ( backspace ) { CloseOptions(); return; }
		delta = MathF.Max( 0, delta );
		optionsColorPhase = (optionsColorPhase + delta * 2.4f) % Palette.Length;
		float target = DiamondSettings.Current.DisableFluid ? 1 : 0;
		disableFluidKnob = target > disableFluidKnob ? MathF.Min( target, disableFluidKnob + delta / 0.14f ) : MathF.Max( target, disableFluidKnob - delta / 0.14f );
		if ( DraggingSlider == OptionsSlider.None ) return;
		// Mouse-up normally ends the drag; losing the active state covers releases we never saw.
		if ( !HasActive ) { EndOptionsDrag(); return; }
		DragOptionsSlider( MousePosition, Box.Rect.Size );
	}

	/// <summary>Opens Options from the letterbox button, or handles a press inside the open overlay.</summary>
	public void HandleOptionsClick( Vector2 mouse, Vector2 size )
	{
		if ( !OptionsOpen )
		{
			if ( TitleScreen || TutorialOpen ) return;
			float scale = MathF.Min( size.x / CanvasWidth, size.y / CanvasHeight );
			if ( scale <= 0 ) return;
			var point = (mouse - (size - new Vector2( CanvasWidth, CanvasHeight ) * scale) * 0.5f) / scale;
			if ( OptionsButton( size ).IsInside( point ) ) { PlayClickSound(); OpenOptions(); }
			return;
		}
		var frame = OptionsFrame( size );
		if ( TutorialClose( frame, OptionsWidth ).IsInside( mouse ) ) { PlayClickSound(); CloseOptions(); return; }
		if ( !frame.IsInside( mouse ) ) { CloseOptions(); return; }
		var local = OptionsLocal( mouse, frame );
		if ( DisableFluidRow.IsInside( local ) )
		{
			PlayClickSound();
			DiamondSettings.Current.DisableFluid = !DiamondSettings.Current.DisableFluid;
			DiamondSettings.Save();
			return;
		}
		DraggingSlider = SliderHit( OptionsSlider.RestartDelay ).IsInside( local ) ? OptionsSlider.RestartDelay
			: SliderHit( OptionsSlider.Rumble ).IsInside( local ) ? OptionsSlider.Rumble
			: SliderHit( OptionsSlider.Master ).IsInside( local ) ? OptionsSlider.Master
			: SliderHit( OptionsSlider.Music ).IsInside( local ) ? OptionsSlider.Music : OptionsSlider.None;
		DragOptionsSlider( mouse, size );
	}

	public void DragOptionsSlider( Vector2 mouse, Vector2 size )
	{
		if ( DraggingSlider == OptionsSlider.None ) return;
		var track = SliderTrack( DraggingSlider );
		float value = Math.Clamp( (OptionsLocal( mouse, OptionsFrame( size ) ).x - track.Left) / track.Width, 0, 1 );
		// The delay setter snaps to its 0.05-second steps.
		if ( DraggingSlider == OptionsSlider.RestartDelay ) DiamondSettings.Current.RestartDelay = value;
		else if ( DraggingSlider == OptionsSlider.Rumble ) DiamondSettings.Current.ControllerRumble = MathF.Round( value * 100 ) / 100;
		else if ( DraggingSlider == OptionsSlider.Master ) DiamondSettings.Current.MasterVolume = MathF.Round( value * 100 ) / 100;
		else DiamondSettings.Current.MusicVolume = MathF.Round( value * 100 ) / 100;
	}

	/// <summary>Finish a slider drag, saving it and previewing the new effects or rumble level.</summary>
	public void EndOptionsDrag()
	{
		if ( DraggingSlider == OptionsSlider.None ) return;
		bool master = DraggingSlider == OptionsSlider.Master;
		bool rumble = DraggingSlider == OptionsSlider.Rumble;
		DraggingSlider = OptionsSlider.None;
		DiamondSettings.Save();
		// Preview on a real cue: the hit of a slam that cracks a diamond.
		if ( rumble ) DiamondHaptics.DamageHit( 0.7f );
		if ( !master || IsMuted ) return;
		optionsPreview?.Stop();
		optionsPreview = Sound.Play( "sfx/diamond_impact.sound" );
		if ( optionsPreview is null ) return;
		optionsPreview.SpacialBlend = 0;
		optionsPreview.Volume *= 0.7f * DiamondSettings.EffectsGain;
	}

	protected override void OnMouseUp( MousePanelEvent e )
	{
		if ( e.Button != "mouseleft" ) return;
		EndOptionsDrag();
		leaderboardDragging = false;
	}

	static Vector2 OptionsLocal( Vector2 mouse, Rect frame ) => (mouse - frame.Position) / (frame.Width / OptionsWidth);

	void DrawOptionsButton( Painter p, float scale )
	{
		var size = Box.Rect.Size;
		var mouse = (MousePosition - (size - new Vector2( CanvasWidth, CanvasHeight ) * scale) * 0.5f) / scale;
		DrawTitleButton( p, OptionsButton( size ), "OPTIONS", mouse, scale, fontSize: 26, brightness: 0.7f );
	}

	void DrawOptions( Painter p )
	{
		if ( !OptionsOpen ) return;
		using var scope = p.Scope();
		p.Fill = Color.Black.WithAlpha( 0.32f );
		p.Stroke = Stroke.None;
		p.Rect( p.Bounds );
		var frame = OptionsFrame( p.Bounds.Size );
		p.Fill = Color.Parse( "#0d151e" ).Value;
		p.Rect( frame );
		p.Clip( frame );
		float scale = frame.Width / OptionsWidth;
		if ( scale <= 0 ) return;
		DrawModalClose( p, frame, OptionsWidth );
		var mouse = OptionsLocal( MousePosition, frame );
		p.Translate( frame.Position );
		p.Scale( scale );
		var settings = DiamondSettings.Current;
		DrawScoreNumber( p, "OPTIONS", new Vector2( OptionsWidth * 0.5f, 60 ), 52, ScoreColor( optionsColorPhase ) );
		DrawDisableFluidToggle( p, mouse );
		DrawOptionsSlider( p, "RESTART DELAY", settings.RestartDelay, DiamondSettings.FormatSeconds( settings.RestartDelay ),
			RestartDelayColor, OptionsSlider.RestartDelay, mouse );
		DrawOptionsSlider( p, "CONTROLLER RUMBLE", settings.ControllerRumble, $"{MathF.Round( settings.ControllerRumble * 100 )}",
			RumbleColor, OptionsSlider.Rumble, mouse );
		p.Fill = Ink.WithAlpha( 0.07f );
		p.Stroke = Stroke.None;
		p.Rect( new Rect( 32, VolumeDividerY - 1, OptionsWidth - 64, 2 ) );
		DrawScoreNumber( p, "VOLUME", new Vector2( OptionsLabelX, VolumeHeadingY ), 34, Muted * 0.8f, 0 );
		DrawOptionsSlider( p, "MASTER", settings.MasterVolume, $"{MathF.Round( settings.MasterVolume * 100 )}", MasterColor, OptionsSlider.Master, mouse );
		DrawOptionsSlider( p, "MUSIC", settings.MusicVolume, $"{MathF.Round( settings.MusicVolume * 100 )}", MusicColor, OptionsSlider.Music, mouse );
	}

	void DrawDisableFluidToggle( Painter p, Vector2 mouse )
	{
		bool hover = DraggingSlider == OptionsSlider.None && DisableFluidRow.IsInside( mouse );
		bool pressed = hover && HasActive;
		p.Stroke = Stroke.None;
		if ( hover )
		{
			p.Fill = Ink.WithAlpha( pressed ? 0.07f : 0.04f );
			p.Rect( DisableFluidRow );
		}
		DrawScoreNumber( p, "DISABLE FLUID", new Vector2( OptionsLabelX, DisableFluidY ), 30, hover ? Ink : Ink * 0.8f, 0 );
		var track = DisableFluidTrack;
		float t = disableFluidKnob * disableFluidKnob * (3 - 2 * disableFluidKnob);
		float x = track.Left + 17 + (track.Width - 34) * t;
		p.Fill = Ink.WithAlpha( hover ? 0.1f : 0.06f );
		p.Rect( track );
		DrawTrackFill( p, track, x, Palette[DisableFluidColor], t );
		DrawOptionsHandle( p, new Vector2( x, DisableFluidY ), DisableFluidColor, hover ? 0.68f : 0.62f, t );
	}

	void DrawOptionsSlider( Painter p, string label, float value, string valueText, int color, OptionsSlider id, Vector2 mouse, float labelSize = 26 )
	{
		bool active = DraggingSlider == id;
		bool hover = active || DraggingSlider == OptionsSlider.None && SliderHit( id ).IsInside( mouse );
		float y = SliderY( id );
		DrawScoreNumber( p, label, new Vector2( OptionsLabelX, y ), labelSize, hover ? Ink : Ink * 0.8f, 0 );
		var track = SliderTrack( id );
		p.Stroke = Stroke.None;
		p.Fill = Ink.WithAlpha( hover ? 0.1f : 0.06f );
		p.Rect( track );
		// Faint quarter notches, cut as small diamonds into the empty track.
		for ( int i = 1; i < 4; i++ )
		{
			var notch = new Vector2( track.Left + track.Width * i / 4, y );
			p.Fill = Ink.WithAlpha( 0.14f );
			p.Polygon( [notch + new Vector2( 0, -7 ), notch + new Vector2( 4, 0 ), notch + new Vector2( 0, 7 ), notch + new Vector2( -4, 0 )] );
		}
		float x = track.Left + track.Width * value;
		DrawTrackFill( p, track, x, Palette[color], 1 );
		DrawOptionsHandle( p, new Vector2( x, y ), color, active ? 0.72f : hover ? 0.68f : 0.62f, 1 );
		var valueCenter = new Vector2( SliderValueX, y );
		if ( valueText.EndsWith( 's' ) ) DrawSecondsValue( p, valueText[..^1], valueCenter, 26, hover ? Ink : Muted );
		else DrawScoreNumber( p, valueText, valueCenter, 26, hover ? Ink : Muted );
	}

	/// <summary>Score-font digits followed by a smaller score-font S, sharing their baseline so it reads as a unit, not a 5.</summary>
	static void DrawSecondsValue( Painter p, string number, Vector2 center, float fontSize, Color color )
	{
		const float UnitScale = 0.68f, CapHeight = 0.52f;
		float unitSize = fontSize * UnitScale, gap = fontSize * 0.1f;
		float numberWidth = DiamondScoreEffects.MeasureNumber( number ).x * fontSize / DiamondScoreEffects.PickupFontSize;
		float unitWidth = DiamondScoreEffects.MeasureNumber( "S" ).x * unitSize / DiamondScoreEffects.PickupFontSize;
		float left = center.x - (numberWidth + gap + unitWidth) * 0.5f;
		DrawScoreNumber( p, number, new Vector2( left, center.y ), fontSize, color, 0 );
		// Caps-only glyphs sit centered on the line, so drop the smaller S by half the cap-height difference.
		DrawScoreNumber( p, "S", new Vector2( left + numberWidth + gap, center.y + (fontSize - unitSize) * CapHeight * 0.5f ), unitSize, color, 0 );
	}

	static void DrawTrackFill( Painter p, Rect track, float x, Color color, float opacity )
	{
		if ( x <= track.Left || opacity <= 0 ) return;
		p.Stroke = Stroke.None;
		p.Fill = color.WithAlpha( 0.12f * opacity );
		p.Rect( new Rect( track.Left, track.Center.y - 8, x - track.Left, 16 ) );
		p.Fill = color.WithAlpha( 0.8f * opacity );
		p.Rect( new Rect( track.Left, track.Top, x - track.Left, track.Height ) );
	}

	/// <summary>A small in-game gem with its bonded outline; <paramref name="lit"/> fades it in from dull steel.</summary>
	void DrawOptionsHandle( Painter p, Vector2 center, int color, float scale, float lit )
	{
		if ( lit < 1 ) DrawSteelHandle( p, center, scale, 1 - lit );
		if ( lit <= 0 ) return;
		if ( lit >= 1 ) DrawDiamond( p, center, color, false, scale );
		else DrawDiamondVector( p, center, color, false, scale, lit );
		var cells = new DiamondBoard.Diamond[] { new( 0, 0, color ) };
		DrawGroupOutlines( p, cells, DiamondMatching.FindGroups( cells ),
			center - new Vector2( DiamondBoard.LaneX( 0 ) * scale, 0 ), scale, lit );
	}

	static void DrawSteelHandle( Painter p, Vector2 center, float scale, float opacity )
	{
		using var scope = p.Scope();
		p.Translate( center );
		p.Scale( scale );
		float w = DiamondBoard.Width * 0.5f, h = DiamondBoard.Height * 0.5f;
		Vector2 top = new( 0, -h ), right = new( w, 0 ), bottom = new( 0, h ), left = new( -w, 0 ), middle = Vector2.Zero;
		var steel = FloorInk * 0.75f;
		Facet( p, steel, 0.32f, opacity, false, [top, middle, left] );
		Facet( p, steel, 0.02f, opacity, false, [top, right, middle] );
		Facet( p, steel, -0.42f, opacity, false, [middle, right, bottom] );
		Facet( p, steel, -0.12f, opacity, false, [left, middle, bottom] );
		DrawGemStroke( p, [top, right, bottom, left], FloorInk.WithAlpha( opacity ), 2, true );
	}
}
facepunch.diamondgrinder / diamondgame.cs.scss
Game game
DiamondGame
{
	width: 100%;
	height: 100%;
}
facepunch.diamondgrinder / Assembly.cs
Game game
global using Sandbox;
global using System.Collections.Generic;
global using System.Linq;
facepunch.diamondgrinder / DiamondAchievements.cs
Game game
using System;
using System.Collections.Generic;
using Sandbox;

namespace Diamonds;

/// <summary>
/// Backend achievements, following Block Party's scheme. Each one is defined on the s&amp;box website (ident, title, icon,
/// unlock mode); the game only feeds stats or names manual unlocks, and the website's unlock mode decides which applies.
///
/// STAT achievements show a progress bar. The game submits a lifetime running total and the engine unlocks the achievement
/// once the stat crosses the website's max, so targets can be retuned without a game update. Every source stat needs
/// HIGHEST aggregation: totals go out whole through SetValue, never Increment, because the engine drops a failed flush
/// without retrying. A re-sent total heals on the next submit or boot, where a lost increment would be gone for good.
/// Stat names are unversioned, unlike the leaderboard's, so a leaderboard wipe never resets lifetime progress.
///
/// MANUAL achievements are one-shot deeds, unlocked the moment they are earned. Each must be MANUAL on the website: the
/// engine silently ignores a manual unlock aimed at a stat achievement. Earned idents are persisted and re-sent every boot,
/// since the engine never retries a lost unlock request; the backend drops repeats.
/// <list type="table">
/// <item><term>survive_10_minutes</term><description>MANUAL — <see cref="Survive10Minutes"/></description></item>
/// <item><term>survive_12_minutes</term><description>MANUAL — <see cref="Survive12Minutes"/></description></item>
/// <item><term>survive_15_minutes</term><description>MANUAL — <see cref="Survive15Minutes"/></description></item>
/// <item><term>damage_50_diamond_piece</term><description>MANUAL — <see cref="DamageHugePiece"/></description></item>
/// <item><term>score_20000</term><description>MANUAL — <see cref="Score20000"/></description></item>
/// <item><term>score_50000</term><description>MANUAL — <see cref="Score50000"/></description></item>
/// <item><term>score_100000</term><description>MANUAL — <see cref="Score100000"/></description></item>
/// <item><term>score_10000_in_1_minute</term><description>MANUAL — <see cref="QuickScore"/></description></item>
/// <item><term>break_3_colors_in_one_turn</term><description>MANUAL — <see cref="BreakThreeColors"/></description></item>
/// <item><term>lose_with_no_group_over_3</term><description>MANUAL — <see cref="LoseWithSmallGroups"/></description></item>
/// <item><term>break_10000_diamonds</term><description>STAT — <see cref="BrokenStat"/>, HIGHEST, max 10000</description></item>
/// <item><term>crush_100000_shards</term><description>STAT — <see cref="CrushedStat"/>, HIGHEST, max 100000</description></item>
/// <item><term>score_1000000_points</term><description>STAT — <see cref="PointsStat"/>, HIGHEST, max 1000000</description></item>
/// </list>
/// Debug games (a positive Starting Score, diamonds_fill, profiling runs) never count; DiamondGame decides that. The editor
/// runs under the #local package, which has no achievements, so it only logs what would happen and never touches the tallies.
/// </summary>
public static class DiamondAchievements
{
	public const string FilePath = "diamonds_achievements.json";

	/// <summary>Lifetime diamonds shattered by play (game-over cleanup excluded).</summary>
	public const string BrokenStat = "diamonds_broken";
	/// <summary>Lifetime shards crushed for points.</summary>
	public const string CrushedStat = "shards_crushed";
	/// <summary>Lifetime points scored, summed across games.</summary>
	public const string PointsStat = "points_scored";

	public const string Survive10Minutes = "survive_10_minutes";
	public const string Survive12Minutes = "survive_12_minutes";
	public const string Survive15Minutes = "survive_15_minutes";
	/// <summary>Damage an established same-color bonded group of at least <see cref="HugePieceSize"/> diamonds.</summary>
	public const string DamageHugePiece = "damage_50_diamond_piece";
	public const int HugePieceSize = 50;

	public const string Score20000 = "score_20000";
	public const string Score50000 = "score_50000";
	public const string Score100000 = "score_100000";
	/// <summary>Reach <see cref="QuickScoreTarget"/> within the first <see cref="DiamondBoard.FirstMinuteSeconds"/> of unpaused play.</summary>
	public const string QuickScore = "score_10000_in_1_minute";
	public const long QuickScoreTarget = 10_000;
	/// <summary>Shatter diamonds of <see cref="BreakColorsTarget"/> different colors within one turn (a spawn until the next).</summary>
	public const string BreakThreeColors = "break_3_colors_in_one_turn";
	public const int BreakColorsTarget = 3;
	/// <summary>Reach game over with no bonded same-color group in the arena larger than <see cref="SmallGroupLimit"/>.
	/// Corner-only contacts never bond, so they never join a group.</summary>
	public const string LoseWithSmallGroups = "lose_with_no_group_over_3";
	public const int SmallGroupLimit = 3;

	/// <summary>Unpaused game time, as the timer shows it, that unlocks each survival achievement.</summary>
	public static readonly (string Ident, float Minutes)[] Survival =
		[(Survive10Minutes, 10), (Survive12Minutes, 12), (Survive15Minutes, 15)];

	/// <summary>The running game's score that unlocks each score achievement, the moment the score passes it.</summary>
	public static readonly (string Ident, long Score)[] ScoreTargets =
		[(Score20000, 20_000), (Score50000, 50_000), (Score100000, 100_000)];

	/// <summary>The durable local copy of lifetime tallies and earned manual idents, re-sent every boot.</summary>
	public sealed class SavedProgress
	{
		public Dictionary<string, long> Tallies { get; set; } = new();
		public List<string> Earned { get; set; } = new();
	}

	static SavedProgress progress;
	static SavedProgress Progress => progress ??= Load();
	// Manual idents requested this session, so a per-frame check sends each at most once.
	static readonly HashSet<string> requested = new();
	// Highest value submitted per stat this session; totals only climb, so lower re-sends are dropped.
	static readonly Dictionary<string, long> sent = new();
	static bool resent;

	static readonly string[] LifetimeStats = [BrokenStat, CrushedStat, PointsStat];

	/// <summary>Once per session: re-send every earned manual ident and lifetime tally, healing submissions lost offline.</summary>
	public static void CheckProgress()
	{
		if ( Game.IsEditor || resent ) return;
		resent = true;
		foreach ( var ident in Progress.Earned.ToArray() ) Unlock( ident );
		_ = ResendTalliesAsync();
	}

	// Local tallies start from zero on another machine or after a data wipe, and HIGHEST aggregation would ignore every
	// total until it passed the backend's. Adopt the backend's best first, so new progress adds on top of it.
	static async System.Threading.Tasks.Task ResendTalliesAsync()
	{
		try
		{
			var stats = Sandbox.Services.Stats.LocalPlayer;
			if ( stats is not null )
			{
				var before = new Dictionary<string, long>( Progress.Tallies );
				await stats.Refresh();
				bool raised = false;
				foreach ( string name in LifetimeStats )
				{
					long local = before.GetValueOrDefault( name );
					long backend = stats.TryGet( name, out var stat ) ? (long)stat.Max : 0;
					if ( backend <= local ) continue;
					// Keep anything banked while the refresh was in flight on top of the adopted total.
					Progress.Tallies[name] = backend + Progress.Tallies.GetValueOrDefault( name ) - local;
					raised = true;
				}
				if ( raised ) Save();
			}
		}
		catch ( Exception e ) { Log.Warning( $"Diamonds: could not read achievement stats; re-sending local totals. {e.Message}" ); }
		bool submitted = false;
		foreach ( string name in LifetimeStats ) submitted |= SubmitHighest( name, Progress.Tallies.GetValueOrDefault( name ) );
		if ( submitted ) Sandbox.Services.Stats.Flush();
	}

	/// <summary>Unlock whichever manual achievements the running game has earned. Called every frame of a counted game.</summary>
	public static void CheckGame( DiamondBoard board )
	{
		foreach ( var (ident, minutes) in Survival )
			if ( board.ElapsedSeconds >= minutes * 60 ) Unlock( ident );
		if ( board.LargestDamagedGroup >= HugePieceSize ) Unlock( DamageHugePiece );
		// Score counts as each crush is awarded, not when its flying number reaches the displayed total.
		foreach ( var (ident, score) in ScoreTargets )
			if ( board.Score >= score ) Unlock( ident );
		if ( board.FirstMinuteScore >= QuickScoreTarget ) Unlock( QuickScore );
		if ( board.MostColorsBrokenInTurn >= BreakColorsTarget ) Unlock( BreakThreeColors );
		if ( board.GameOver && board.LargestGroupAtGameOver <= SmallGroupLimit ) Unlock( LoseWithSmallGroups );
	}

	/// <summary>Add a counted game's new progress to the lifetime tallies and submit the totals. Saved before submitting, so a
	/// crash can neither lose nor double-count it.</summary>
	public static void Bank( long broken, long crushed, long points )
	{
		if ( broken <= 0 && crushed <= 0 && points <= 0 ) return;
		if ( Game.IsEditor )
		{
			Log.Info( $"Diamonds: would add {broken:N0} broken, {crushed:N0} crushed, {points:N0} points to achievement stats (editor: no backend achievements)." );
			return;
		}
		var tallies = Progress.Tallies;
		void Add( string stat, long amount ) { if ( amount > 0 ) tallies[stat] = tallies.GetValueOrDefault( stat ) + amount; }
		Add( BrokenStat, broken );
		Add( CrushedStat, crushed );
		Add( PointsStat, points );
		Save();
		bool submitted = SubmitHighest( BrokenStat, tallies.GetValueOrDefault( BrokenStat ) );
		submitted |= SubmitHighest( CrushedStat, tallies.GetValueOrDefault( CrushedStat ) );
		submitted |= SubmitHighest( PointsStat, tallies.GetValueOrDefault( PointsStat ) );
		if ( submitted ) Sandbox.Services.Stats.Flush();
	}

	/// <summary>Unlock a manual achievement. Safe to call repeatedly; only the first call per session does anything.</summary>
	public static void Unlock( string ident )
	{
		if ( string.IsNullOrEmpty( ident ) || !requested.Add( ident ) ) return;
		if ( Game.IsEditor )
		{
			Log.Info( $"Diamonds: achievement '{ident}' would unlock (editor: no backend achievements)." );
			return;
		}
		// Persist before sending, so a request lost offline stays owed until a later boot re-sends it.
		if ( !Progress.Earned.Contains( ident ) )
		{
			Progress.Earned.Add( ident );
			Save();
		}
		Sandbox.Services.Achievements.Unlock( ident );
	}

	static bool SubmitHighest( string stat, long value )
	{
		if ( value <= 0 || (sent.TryGetValue( stat, out long previous ) && value <= previous) ) return false;
		sent[stat] = value;
		Sandbox.Services.Stats.SetValue( stat, value );
		return true;
	}

	static SavedProgress Load()
	{
		try
		{
			var loaded = FileSystem.Data?.ReadJsonOrDefault( FilePath, new SavedProgress() ) ?? new SavedProgress();
			// A hand-edited or truncated file can null either collection.
			loaded.Tallies ??= new();
			loaded.Earned ??= new();
			return loaded;
		}
		catch ( Exception e )
		{
			Log.Warning( $"Could not read {FilePath}; starting achievement progress fresh. {e.Message}" );
			return new SavedProgress();
		}
	}

	static void Save()
	{
		try { FileSystem.Data?.WriteJson( FilePath, Progress ); }
		catch ( Exception e ) { Log.Warning( $"Could not save {FilePath}. {e.Message}" ); }
	}
}
facepunch.diamondgrinder / DiamondAtmosphere.cs
Game game
using System;
using System.Collections.Generic;
using Sandbox;

namespace Diamonds;

/// <summary>A small, damped velocity and five-dye field behind the playfield.</summary>
public sealed partial class DiamondAtmosphere
{
	public const int Columns = 96;
	public const int Rows = 96;
	public const float CellSize = 15;
	// Screen-aligned cells retain the fine-grid density and ample offscreen spawn space.
	const float FlowUnit = 30;
	const float FlowSpeed = 1.6f;
	const float DyeDensityScale = FlowUnit * FlowUnit / (CellSize * CellSize);
	const float WallBounce = 0.65f;
	const float SpawnBandWidth = 176f;
	const float SpawnBandY = -64f;
	const int Count = Columns * Rows;
	bool disabled, synchronizeOnEnable;
	float? opacity;
	public bool Enabled
	{
		get => !disabled;
		set
		{
			if ( value == Enabled ) return;
			disabled = !value;
			previousBoard = null;
			synchronizeOnEnable = true;
		}
	}
	public float Opacity { get => opacity ?? 1f; set => opacity = Math.Clamp( value, 0, 1 ); }
	float? spawnDyeAmount, shatterDyeAmount, dyeFadeRate;
	public float SpawnDyeAmount { get => spawnDyeAmount ?? 7f; set => spawnDyeAmount = MathF.Max( 0, value ); }
	public float ShatterDyeAmount { get => shatterDyeAmount ?? 2.5f; set => shatterDyeAmount = MathF.Max( 0, value ); }
	public float DyeFadeRate { get => dyeFadeRate ?? 0.3f; set => dyeFadeRate = Math.Clamp( value, 0, 100 ); }
	float[] velocityX = new float[Count], velocityY = new float[Count];
	float[] nextX = new float[Count], nextY = new float[Count];
	float[] dye = new float[Count * DiamondBoard.ColorCount];
	float[] nextDye = new float[Count * DiamondBoard.ColorCount];
	float[] pressure = new float[Count], nextPressure = new float[Count], divergence = new float[Count];
	float[] influence = new float[Count];
	readonly List<DiamondBoard.Diamond> previousSettled = new();
	Random layoutRandom = new();
	DiamondBoard.Piece previousActive;
	DiamondBoard previousBoard;
	int resetVersion, previousPlaced, previousSpawnVersion = -1, modelRevision;
	long previousShatter;
	bool hasPrevious;
	float accumulator;
	// Painter reads one contiguous view instead of calling Sample for every color of every tile.
	public ReadOnlySpan<float> Samples => DisableFluid && staticDye is not null ? staticDye : dye;
	// Rendering revision: velocity-only updates leave the uploaded colors reusable.
	public ulong DyeVersion { get; private set; }

	public float Sample( int x, int y, int color )
	{
		int index = (y * Columns + x) * DiamondBoard.ColorCount + color;
		return index < Samples.Length ? Samples[index] : 0;
	}
	public static Vector2 GridToWorld( int x, int y )
	{
		float u = (x + 0.5f - Columns * 0.5f) * CellSize;
		float v = (y + 0.5f - Rows * 0.5f) * CellSize;
		return new Vector2( DiamondBoard.BoardWidth * 0.5f + u,
			DiamondBoard.BoardHeight * 0.5f + v );
	}

	static (float X, float Y) WorldToGrid( float x, float y )
	{
		float dx = x - DiamondBoard.BoardWidth * 0.5f;
		float dy = y - DiamondBoard.BoardHeight * 0.5f;
		return (dx / CellSize + Columns * 0.5f - 0.5f,
			dy / CellSize + Rows * 0.5f - 0.5f);
	}

	static float ReflectX( float x ) => x < 0 ? -x : x > DiamondBoard.BoardWidth ? 2 * DiamondBoard.BoardWidth - x : x;
	static float ReflectBottom( float y ) => y > DiamondBoard.BoardHeight ? 2 * DiamondBoard.BoardHeight - y : y;

	(float X, float Y) Backtrace( int x, int y, float vx, float vy, float dt )
	{
		Vector2 point = GridToWorld( x, y );
		// Faster transport without extra simulation steps or faster dye fading.
		dt *= FlowSpeed;
		float worldX = point.x - vx * FlowUnit * dt;
		float worldY = point.y - vy * FlowUnit * dt;
		var target = WorldToGrid( ReflectX( worldX ), ReflectBottom( worldY ) );
		return TraceFluid( x, y, target.X, target.Y );
	}

	public void Clear()
	{
		staticBoard = null;
		staticDye = null;
		previousBoard = null;
		if ( DisableFluid ) { DyeVersion++; return; }
		ClearFluid();
	}

	void ClearFluid()
	{
		// Defer even field seeding until dye is enabled again.
		if ( !Enabled ) { previousBoard = null; return; }
		DyeVersion++;
		// Fast hotload can retain a field created with the previous grid size.
		if ( velocityX.Length != Count )
		{
			velocityX = new float[Count]; velocityY = new float[Count];
			nextX = new float[Count]; nextY = new float[Count];
			dye = new float[Count * DiamondBoard.ColorCount];
			nextDye = new float[dye.Length];
			pressure = new float[Count]; nextPressure = new float[Count];
			divergence = new float[Count]; influence = new float[Count];
		}
		Array.Clear( velocityX ); Array.Clear( velocityY );
		Array.Clear( nextX ); Array.Clear( nextY );
		Array.Clear( dye ); Array.Clear( nextDye );
		Array.Clear( pressure ); Array.Clear( nextPressure ); Array.Clear( divergence );
		Array.Clear( influence );
		ClearObstacles();
		previousSettled.Clear();
		hasPrevious = false;
		previousSpawnVersion = -1;
		previousShatter = 0;
		shatterPushCooldown = 0;
		accumulator = 0;
		layoutRandom ??= new Random();
		float foldPhase = RandomPhase(), foldBend = RandomPhase();
		float curlPhase = RandomPhase(), curlBend = RandomPhase();
		float swirlPhase = RandomPhase(), swirlBend = RandomPhase();
		float colorPhase = RandomPhase();
		float RandomPhase() => (float)(layoutRandom.NextDouble() * Math.PI * 2);
		// Start as one continuous mixed field. Broad, overlapping waves vary the
		// five dyes without placing a separate cloud at each color's location.
		// Cosmetic phases change on every new game without affecting the piece bag.
		for ( int y = 0; y < Rows; y++ )
		for ( int x = 0; x < Columns; x++ )
		{
			float u = (x + 0.5f) / Columns - 0.5f;
			float v = (y + 0.5f) / Rows - 0.5f;
			float fold = 0.5f + 0.5f * MathF.Sin( u * 9 + foldPhase + MathF.Sin( v * 6 + foldBend ) * 1.3f );
			float curl = 0.5f + 0.5f * MathF.Sin( v * 10 - u * 4.5f + curlPhase + MathF.Cos( u * 5 + curlBend ) * 0.9f );
			float cloud = Math.Clamp( (fold * 0.6f + curl * 0.4f - 0.22f) / 0.58f, 0, 1 );
			cloud = cloud * cloud * (3 - 2 * cloud);
			float swirl = MathF.Sin( u * 6.2f + v * 1.8f + swirlPhase ) * 1.7f +
				MathF.Cos( v * 6.8f - u * 2.5f + swirlBend ) * 1.4f + u * 3.1f;
			for ( int color = 0; color < DiamondBoard.ColorCount; color++ )
			{
				float phase = color * MathF.PI * 2 / DiamondBoard.ColorCount;
				float band = MathF.Max( 0, MathF.Cos( swirl - phase + colorPhase ) );
				int index = (y * Columns + x) * DiamondBoard.ColorCount + color;
				dye[index] = cloud * (0.012f + 0.23f * band * band);
			}
		}
	}

	public void Update( DiamondBoard board, float delta )
	{
		if ( !Enabled ) return;
		if ( DisableFluid ) { PrepareStaticBackground( board ); return; }
		if ( board != previousBoard || board.ResetVersion != resetVersion || modelRevision != 17 )
		{
			ClearFluid();
			previousBoard = board;
			resetVersion = board.ResetVersion;
			modelRevision = 17;
		}
		if ( synchronizeOnEnable )
		{
			// Discard events from the disabled interval and rebuild obstacles from current positions.
			previousSpawnVersion = board.SpawnVersion;
			foreach ( var burst in board.ShatterBursts ) previousShatter = Math.Max( previousShatter, burst.Id );
			synchronizeOnEnable = false;
		}
		if ( board.Paused ) return;
		delta = Math.Clamp( delta, 0, 0.1f );
		if ( delta <= 0 ) return;
		shatterPushCooldown = MathF.Max( 0, shatterPushCooldown - delta );
		if ( !board.GameOver && board.SpawnVersion != previousSpawnVersion )
		{
			EmitSpawn( board.Active );
			previousSpawnVersion = board.SpawnVersion;
		}
		int firstShatter = board.ShatterBursts.Count;
		for ( int i = 0; i < board.ShatterBursts.Count; i++ )
		{
			var burst = board.ShatterBursts[i];
			if ( burst.Id <= previousShatter ) continue;
			firstShatter = Math.Min( firstShatter, i );
			EmitShatterDye( burst.Cell );
			previousShatter = burst.Id;
		}

		UpdateObstacles( board, delta );
		// Push only after dead gems have released their fluid obstacles.
		PushShatterDye( board, firstShatter );

		previousSettled.Clear();
		previousSettled.AddRange( board.Settled );
		previousActive = board.Active;
		previousPlaced = board.Placed;
		hasPrevious = true;

		accumulator = MathF.Min( accumulator + delta, 0.12f );
		while ( accumulator >= 1f / 30 )
		{
			Step( 1f / 30 );
			accumulator -= 1f / 30;
		}
	}

	void EmitSpawn( DiamondBoard.Piece piece )
	{
		int[] counts = new int[DiamondBoard.ColorCount];
		int count = 0;
		float centerX = 0;
		foreach ( var cell in DiamondBoard.CellsOf( piece ) )
		{
			counts[cell.ColorIndex]++;
			centerX += DiamondBoard.LaneX( cell.Lane );
			count++;
		}
		if ( count == 0 ) return;
		centerX /= count;
		float left = Math.Clamp( centerX - SpawnBandWidth * 0.5f, 0, DiamondBoard.BoardWidth - SpawnBandWidth );
		float right = left + SpawnBandWidth;
		// Each slot carries the same dye mass. Grouping equal colors into adjacent
		// slots gives exact 3:1, 2:2, or four-color proportions without blending
		// all colors at the emission point.
		int[] slotColors = new int[count];
		int slotIndex = 0;
		for ( int color = 0; color < counts.Length; color++ )
		for ( int n = 0; n < counts[color]; n++ ) slotColors[slotIndex++] = color;
		float[] weightSums = new float[count];
		for ( int y = 1; y < Rows - 1; y++ )
		for ( int x = 1; x < Columns - 1; x++ )
		{
			Vector2 point = GridToWorld( x, y );
			float weight = BandWeight( point );
			if ( weight > 0 ) weightSums[Slot( point.x )] += weight;
		}
		float massPerSlot = SpawnDyeAmount * DyeDensityScale / count;
		bool changed = false;
		for ( int y = 1; y < Rows - 1; y++ )
		for ( int x = 1; x < Columns - 1; x++ )
		{
			Vector2 point = GridToWorld( x, y );
			float weight = BandWeight( point );
			if ( weight <= 0 ) continue;
			int slot = Slot( point.x );
			if ( weightSums[slot] > 0 )
			{
				int index = (y * Columns + x) * DiamondBoard.ColorCount + slotColors[slot];
				float before = dye[index];
				dye[index] += massPerSlot * weight / weightSums[slot];
				changed |= dye[index] != before;
			}
		}
		if ( changed ) DyeVersion++;
		for ( int slot = 0; slot < count; slot++ )
			Splat( left + SpawnBandWidth * (slot + 0.5f) / count, SpawnBandY, 0, 6f / count, true );

		int Slot( float x ) => Math.Min( count - 1, (int)((x - left) * count / SpawnBandWidth) );
		float BandWeight( Vector2 point )
		{
			// Keep the entire source above the visible tiles. Its downward impulse
			// carries the color through the top edge before the new piece arrives.
			if ( point.x <= left || point.x >= right || MathF.Abs( point.y - SpawnBandY ) > 41 ) return 0;
			float edge = Math.Clamp( MathF.Min( point.x - left, right - point.x ) / FlowUnit, 0, 1 );
			float dy = (point.y - SpawnBandY) / 25;
			return edge * MathF.Exp( -0.5f * dy * dy );
		}
	}

	void EmitShatterDye( DiamondBoard.Diamond cell )
	{
		float x = DiamondBoard.LaneX( cell.Lane ), y = cell.Y;
		const float radius = 84f, spread = 36f;
		// Only cells within this local square can receive dye; retain the radial test below and
		// the original summation order so the result matches a full-grid scan.
		var (gx, gy) = WorldToGrid( x, y );
		float reach = radius / CellSize;
		int left = Math.Max( 1, (int)MathF.Floor( gx - reach ) );
		int right = Math.Min( Columns - 2, (int)MathF.Ceiling( gx + reach ) );
		int top = Math.Max( 1, (int)MathF.Floor( gy - reach ) );
		int bottom = Math.Min( Rows - 2, (int)MathF.Ceiling( gy + reach ) );
		float weightSum = 0;
		for ( int row = top; row <= bottom; row++ )
		for ( int column = left; column <= right; column++ )
			weightSum += Weight( GridToWorld( column, row ) );
		if ( weightSum <= 0 ) return;
		bool changed = false;
		for ( int row = top; row <= bottom; row++ )
		for ( int column = left; column <= right; column++ )
		{
			float weight = Weight( GridToWorld( column, row ) );
			if ( weight > 0 )
			{
				int index = (row * Columns + column) * DiamondBoard.ColorCount + cell.ColorIndex;
				float before = dye[index];
				dye[index] += ShatterDyeAmount * DyeDensityScale * weight / weightSum;
				changed |= dye[index] != before;
			}
		}
		if ( changed ) DyeVersion++;

		float Weight( Vector2 point )
		{
			float dx = point.x - x, dy = point.y - y;
			float distanceSquared = dx * dx + dy * dy;
			return distanceSquared < radius * radius
				? MathF.Exp( -0.5f * distanceSquared / (spread * spread) ) : 0;
		}
	}

	void Splat( float x, float y, float vx, float vy, bool spread )
	{
		var (gx, gy) = WorldToGrid( x, y );
		int reach = (int)MathF.Ceiling( 2 * FlowUnit / CellSize );
		int left = Math.Max( 1, (int)MathF.Floor( gx ) - reach );
		int right = Math.Min( Columns - 2, (int)MathF.Ceiling( gx ) + reach );
		int top = Math.Max( 1, (int)MathF.Floor( gy ) - reach );
		int bottom = Math.Min( Rows - 2, (int)MathF.Ceiling( gy ) + reach );
		for ( int j = top; j <= bottom; j++ )
		for ( int i = left; i <= right; i++ )
		{
			float ox = (i - gx) * CellSize / FlowUnit, oy = (j - gy) * CellSize / FlowUnit;
			float weight = MathF.Exp( -(ox * ox + oy * oy) * (spread ? 0.14f : 0.275f) );
			int n = j * Columns + i;
			velocityX[n] += vx * weight;
			velocityY[n] += vy * weight;
			influence[n] = MathF.Max( influence[n], MathF.Min( 1, weight * 1.25f ) );
		}
	}

	float Bilinear( float[] field, float x, float y, int stride = 1, int channel = 0 )
	{
		x = Math.Clamp( x, 1, Columns - 2.001f ); y = Math.Clamp( y, 1, Rows - 2.001f );
		int i = (int)x, j = (int)y;
		float fx = x - i, fy = y - j;
		int n = (j * Columns + i) * stride + channel;
		return (field[n] * (1 - fx) + field[n + stride] * fx) * (1 - fy) +
			(field[n + Columns * stride] * (1 - fx) + field[n + (Columns + 1) * stride] * fx) * fy;
	}

	void Step( float dt )
	{
		// Every color and source fades equally, including during game-over cleanup.
		float dyeRetention = MathF.Pow( 1 - DyeFadeRate * 0.01f, dt );
		// Damp, diffuse, then project the velocity so a push curls around nearby
		// gems instead of remaining a straight colored streak.
		for ( int y = 1; y < Rows - 1; y++ )
		for ( int x = 1; x < Columns - 1; x++ )
		{
			int n = y * Columns + x;
			if ( wallDistance[n] < 0 )
			{
				nextX[n] = wallVelocity[n].x; nextY[n] = wallVelocity[n].y;
				continue;
			}
			var (bx, by) = Backtrace( x, y, velocityX[n], velocityY[n], dt );
			nextX[n] = (Bilinear( velocityX, bx, by ) * 0.985f +
				(velocityX[n - 1] + velocityX[n + 1] + velocityX[n - Columns] + velocityX[n + Columns]) * 0.0035f) * 0.992f;
			nextY[n] = (Bilinear( velocityY, bx, by ) * 0.985f +
				(velocityY[n - 1] + velocityY[n + 1] + velocityY[n - Columns] + velocityY[n + Columns]) * 0.0035f) * 0.992f;
		}
		for ( int y = 1; y < Rows - 1; y++ )
		for ( int x = 1; x < Columns - 1; x++ )
		{
			int n = y * Columns + x;
			divergence[n] = wallDistance[n] < 0 ? 0 : -0.5f * (nextX[n + 1] - nextX[n - 1] + nextY[n + Columns] - nextY[n - Columns]);
			pressure[n] = 0;
		}
		for ( int iteration = 0; iteration < 10; iteration++ )
		{
			for ( int y = 1; y < Rows - 1; y++ )
			for ( int x = 1; x < Columns - 1; x++ )
			{
				int n = y * Columns + x;
				nextPressure[n] = wallDistance[n] < 0 ? 0 : (divergence[n] + NeighborPressure( n - 1, n ) +
					NeighborPressure( n + 1, n ) + NeighborPressure( n - Columns, n ) + NeighborPressure( n + Columns, n )) * 0.25f;
			}
			// Both buffers have zero boundary cells; swap instead of copying the whole grid.
			(pressure, nextPressure) = (nextPressure, pressure);
		}
		for ( int y = 1; y < Rows - 1; y++ )
		for ( int x = 1; x < Columns - 1; x++ )
		{
			int n = y * Columns + x;
			if ( wallDistance[n] < 0 )
			{
				velocityX[n] = wallVelocity[n].x; velocityY[n] = wallVelocity[n].y;
				continue;
			}
			// Project throughout the fluid, with no flow through the gem boundaries.
			float local = Math.Clamp( influence[n] * 1.1f, 0, 1 );
			float vx = nextX[n] - 0.5f * (NeighborPressure( n + 1, n ) - NeighborPressure( n - 1, n ));
			// Preserve the same upward drift after aligning the velocity axes to the screen.
			float vy = nextY[n] - 0.5f * (NeighborPressure( n + Columns, n ) - NeighborPressure( n - Columns, n )) - 0.0011313708f * local;
			if ( wallDistance[n] < CellSize )
			{
				float incoming = (vx - wallVelocity[n].x) * wallNormal[n].x + (vy - wallVelocity[n].y) * wallNormal[n].y;
				if ( incoming < 0 ) { vx -= wallNormal[n].x * incoming * 1.65f; vy -= wallNormal[n].y * incoming * 1.65f; }
			}
			float speed = MathF.Sqrt( vx * vx + vy * vy );
			float limit = speed > MaxFlowVelocity ? MaxFlowVelocity / speed : 1;
			velocityX[n] = vx * limit; velocityY[n] = vy * limit;
			Vector2 point = GridToWorld( x, y );
			float worldX = velocityX[n];
			float worldY = velocityY[n];
			if ( (point.x <= FlowUnit && worldX < 0) ||
				(point.x >= DiamondBoard.BoardWidth - FlowUnit && worldX > 0) ) worldX *= -WallBounce;
			if ( point.y >= DiamondBoard.BoardHeight - FlowUnit && worldY > 0 ) worldY *= -WallBounce;
			velocityX[n] = worldX;
			velocityY[n] = worldY;
			influence[n] *= 0.96f;
		}
		TransportDye( dt, dyeRetention );
	}
}
facepunch.diamondgrinder / DiamondDebris.Crushing.cs
Game game
using System;
using System.Collections.Generic;
using Sandbox;
using static Diamonds.DiamondBoard;

namespace Diamonds;

public sealed partial class DiamondDebris
{
	readonly record struct CrushFace( Vector2 Normal, float Distance, Vector2 Velocity, int Owner );
	readonly List<CrushFace> crushFaces = new();
	readonly record struct CrushDiamond( Vector2 Center, Vector2 Velocity );
	readonly List<CrushDiamond> crushDiamonds = new();
	bool crushDiamondsMoving;
	Vector2 crushPosition, crushVelocity;
	float crushDelta;

	void CacheCrushDiamonds()
	{
#if STANDALONE
#endif
		// Cross the native boundary once per diamond per step, not once for
		// every shard/face pair. Refresh after stepping so enclosure stays exact.
		crushDiamonds.Clear();
		crushDiamondsMoving = false;
		foreach ( var body in settledBodies ) Cache( body );
		foreach ( var body in activeBodies ) Cache( body );
		void Cache( PhysicsBody body )
		{
			var position = body.Position;
			var velocity = body.Velocity;
			crushDiamonds.Add( new( new( position.x, position.y ), new( velocity.x, velocity.y ) ) );
			crushDiamondsMoving |= velocity.LengthSquared > 1;
		}
	}

	void CrushTrappedChips( float delta )
	{
#if STANDALONE
#endif
		// Rebuilt lock/shatter colliders can already enclose a fragment before
		// their first step, with no closing velocity left for the seam test.
		CrushEmbeddedShards();
		// Stationary gems/walls cannot close a press, even in a dense shard pile.
		if ( !crushDiamondsMoving ) return;
		crushDelta = delta;
		for ( int i = particles.Count - 1; i >= 0; i-- )
		{
			var chip = particles[i];
			if ( chip.IsSpark ) continue;
			crushPosition = chip.Position;
			var velocity = chip.Body.Velocity;
			crushVelocity = new Vector2( velocity.x, velocity.y );
			crushFaces.Clear();
			int owner = 0;
			foreach ( var diamond in crushDiamonds ) AddDiamondFaces( chip, diamond, ++owner );
			for ( int edge = 0; edge < Valleys * 2; edge++ )
			{
				float x = edge * Width * 0.5f;
				AddFace( chip, new Vector2( x, BoardHeight - (edge % 2 == 0 ? Height * 0.5f : 0) ),
					new Vector2( x + Width * 0.5f, BoardHeight - (edge % 2 == 0 ? 0 : Height * 0.5f) ), Vector2.Zero, -1 );
			}
			foreach ( var tooth in SideWallTeeth() )
			{
				float x = LaneX( tooth.Lane );
				bool left = tooth.Lane < 0;
				var top = new Vector2( x, tooth.Y - Height * 0.5f );
				var tip = new Vector2( x + (left ? 1 : -1) * Width * 0.5f, tooth.Y );
				var bottom = new Vector2( x, tooth.Y + Height * 0.5f );
				AddFace( chip, left ? top : bottom, tip, Vector2.Zero, left ? -2 : -3 );
				AddFace( chip, tip, left ? bottom : top, Vector2.Zero, left ? -2 : -3 );
			}
			foreach ( var corner in CornerFills( FillCornerPockets ) )
				AddDiamondFaces( chip, new( new Vector2( LaneX( corner.Lane ), corner.Y ), Vector2.Zero ), corner.Lane == 0 ? -2 : -3 );
			AddFace( chip, new Vector2( BoardWidth, 0 ), new Vector2( 0, 0 ), Vector2.Zero, -4 );
			if ( !IsPressedFlat( chip, delta ) ) continue;
			AwardCrush( chip );
			EmitSmoke( chip, true );
			chip.Body.Remove();
			particles.RemoveAt( i );
		}
	}

	void CrushEmbeddedShards()
	{
#if STANDALONE
#endif
		Span<Vector2> polygon = stackalloc Vector2[8];
		for ( int i = particles.Count - 1; i >= 0; i-- )
		{
			var shard = particles[i];
			if ( shard.IsSpark ) continue;
			var position = shard.Position;
			var vertices = polygon[..shard.Vertices.Length];
			bool transformed = false;
			float coveredArea = 0;
			bool embedded = false;
			foreach ( var diamond in crushDiamonds )
			{
				if ( !NearDiamond( position, shard.Radius, diamond.Center ) ) continue;
				if ( EnclosesShard( diamond.Center, position, shard.Radius ) ) { embedded = true; break; }
				if ( !transformed ) { TransformShard( shard, vertices ); transformed = true; }
				coveredArea += CoveredArea( vertices, diamond.Center );
			}
			// Board diamonds have disjoint interiors: adding their clipped areas
			// detects a fragment buried across shared faces or corner junctions.
			// A centroid-only check misses precisely those internal seams.
			if ( transformed && coveredArea > PolygonArea( vertices ) * 0.55f ) embedded = true;
			if ( !embedded )
			{
				if ( !transformed || ClearOfDiamonds( vertices, position, shard.Radius ) )
				{
					shard.LastClearTransform = shard.Body.Transform;
					shard.HasClearTransform = true;
				}
				continue;
			}
			// Other shards can force a solver overlap without a diamond closing
			// the pocket. Recover the last clear pose if it is still available.
			// A newly placed/moving gem covering that pose still causes a crush.
			if ( RestoreClearShardPose( shard, vertices ) ) continue;
			AwardCrush( shard );
			EmitSmoke( shard, true );
			shard.Body.Remove();
			particles.RemoveAt( i );
		}
	}

	void AwardCrush( Particle shard )
	{
		if ( currentBoard.GameOver ) return;
		EmitCrushSunburst( shard );
		crushAwards.Add( new( shard.Position, shard.ColorIndex, currentBoard.NextShardPoints ) );
		currentBoard.AwardShardCrush( 1 );
		CrushedThisFrame++;
	}

	static bool EnclosesShard( Vector2 center, Vector2 position, float radius )
	{
		var offset = position - center;
		// Distance from the centroid to the nearest diamond face. Tolerate
		// shallow solver overlap, but never let a gem contain a shard's core.
		float penetration = (1 - MathF.Abs( offset.x ) / (Width / 2) - MathF.Abs( offset.y ) / (Height / 2))
			/ MathF.Sqrt( 4 / (Width * Width) + 4 / (Height * Height) );
		return penetration > MathF.Max( 1.5f, radius * 0.2f );
	}

	void AddDiamondFaces( Particle chip, CrushDiamond diamond, int owner )
	{
		var center = diamond.Center;
		var difference = crushPosition - center;
		float reach = chip.Radius * 2.5f + (diamond.Velocity - crushVelocity).Length * crushDelta;
		if ( MathF.Abs( difference.x ) > Width * 0.5f + reach ||
			MathF.Abs( difference.y ) > Height * 0.5f + reach ) return;
		var velocity = diamond.Velocity;
		var top = center + new Vector2( 0, -Height * 0.5f );
		var right = center + new Vector2( Width * 0.5f, 0 );
		var bottom = center + new Vector2( 0, Height * 0.5f );
		var left = center + new Vector2( -Width * 0.5f, 0 );
		AddFace( chip, top, right, velocity, owner );
		AddFace( chip, right, bottom, velocity, owner );
		AddFace( chip, bottom, left, velocity, owner );
		AddFace( chip, left, top, velocity, owner );
	}

	void AddFace( Particle chip, Vector2 a, Vector2 b, Vector2 velocity, int owner )
	{
		var edge = b - a;
		float length = edge.Length;
		var tangent = edge / length;
		var offset = crushPosition - a;
		float along = Vector2.Dot( offset, tangent );
		// Stay on the actual segment; extrapolated planes at tips aren't presses.
		if ( along < 0 || along > length ) return;
		var normal = new Vector2( tangent.y, -tangent.x );
		float distance = Vector2.Dot( offset, normal );
		float reach = chip.Radius * 2.5f + (velocity - crushVelocity).Length * crushDelta;
		if ( distance < -chip.Radius || distance > reach ) return;
		crushFaces.Add( new( normal, distance, velocity, owner ) );
	}

	bool IsPressedFlat( Particle chip, float delta )
	{
		for ( int a = 0; a < crushFaces.Count; a++ )
		for ( int b = a + 1; b < crushFaces.Count; b++ )
		{
			var first = crushFaces[a];
			var second = crushFaces[b];
			// Adjacent faces of one gem and zigzag valley supports cannot crush.
			if ( first.Owner == second.Owner || Vector2.Dot( first.Normal, second.Normal ) > -0.95f ) continue;
			float min = float.PositiveInfinity, max = float.NegativeInfinity;
			var rotation = chip.Body.Rotation;
			foreach ( var vertex in chip.Vertices )
			{
				var rotated = rotation * new Vector3( vertex.x, vertex.y, 0 );
				float projection = Vector2.Dot( new Vector2( rotated.x, rotated.y ), first.Normal );
				min = MathF.Min( min, projection ); max = MathF.Max( max, projection );
			}
			float thickness = max - min;
			float gap = first.Distance + second.Distance;
			float closing = Vector2.Dot( first.Velocity - second.Velocity, first.Normal );
			// Test the substep's approaching surfaces before Box2D ejects a trapped
			// chip. Small solver overlaps are harmless; require >55% compression.
			if ( closing > 1 && gap - closing * delta < thickness * 0.45f ) return true;
		}
		return false;
	}
}
Debug: View Raw JSON Response
{
    "TotalCount": 59,
    "Files": [
        {
            "Ident": "facepunch.diamondgrinder",
            "Path": "DiamondDamage.cs",
            "FileName": "DiamondDamage.cs",
            "PackageType": "game",
            "CodeKind": "Game",
            "AssetVersionId": 398652,
            "IsPrivate": false,
            "Code": "using System;\r\nusing System.Collections.Generic;\r\nusing static Diamonds.DiamondBoard;\r\n\r\nnamespace Diamonds;\r\n\r\n/// <summary>One damage wave per collision, propagated only through pre-existing face bonds.</summary>\r\npublic static class DiamondDamage\r\n{\r\n\tconst float Tolerance = 0.02f;\r\n\tpublic const float SlideDamageSteps = 3;\r\n\tpublic readonly record struct Contact( float Lane, float Y, int ColorIndex );\r\n\tpublic readonly record struct FaceContact( float StartLane, float StartY, float EndLane, float EndY, int ColorIndex );\r\n\r\n\tpublic static Dictionary<int, Corner> Targets( IReadOnlyList<Diamond> cells,\r\n\t\tIReadOnlyList<DiamondMatching.CellMove> moving, IReadOnlyList<IReadOnlyList<int>> bonds,\r\n\t\tfloat velocityX, float velocityY, List<Contact> contacts = null, List<FaceContact> faceContacts = null )\r\n\t{\r\n\t\tvar hit = new Dictionary<int, Corner>();\r\n\t\tif ( velocityY <= 0 ) return hit;\r\n\t\t// Bottom-to-top points damage only on straight vertical falls. Diagonal\r\n\t\t// slides must qualify through their full leading faces, even at high speed.\r\n\t\tif ( velocityX == 0 && velocityY > DamageSpeedThreshold )\r\n\t\t\thit = PointTargets( cells, moving, contacts );\r\n\t\tSlideFaceTargets( cells, moving, hit, faceContacts );\r\n\t\tvar movingIndices = new HashSet<int>();\r\n\t\tforeach ( var move in moving ) movingIndices.Add( move.Index );\r\n\t\tvar damaged = new Dictionary<int, Corner>( hit );\r\n\t\tforeach ( var group in bonds )\r\n\t\t{\r\n\t\t\tforeach ( int index in group )\r\n\t\t\t{\r\n\t\t\t\tif ( !hit.TryGetValue( index, out var corner ) ) continue;\r\n\t\t\t\t// Indirect fractures follow the initiating strike. Direct contacts retain\r\n\t\t\t\t// their own corner, including simultaneous hits on the same bonded group.\r\n\t\t\t\tforeach ( int member in group ) if ( !movingIndices.Contains( member ) ) damaged.TryAdd( member, corner );\r\n\t\t\t\tbreak;\r\n\t\t\t}\r\n\t\t}\r\n\t\treturn damaged;\r\n\t}\r\n\r\n\t// SettlementMove spans one uninterrupted slide, already coalesced across lattice\r\n\t// steps. A stop, turn, group change or replan starts a new From, so distance from\r\n\t// an earlier slide can never survive a landing or the destruction of a support.\r\n\tstatic void SlideFaceTargets( IReadOnlyList<Diamond> cells, IReadOnlyList<DiamondMatching.CellMove> moving,\r\n\t\tDictionary<int, Corner> hit, List<FaceContact> contacts )\r\n\t{\r\n\t\tvar movingIndices = new HashSet<int>();\r\n\t\tforeach ( var move in moving ) movingIndices.Add( move.Index );\r\n\t\tvar seen = new HashSet<(int, int)>();\r\n\t\tforeach ( var move in moving )\r\n\t\t{\r\n\t\t\tfloat lanes = move.To.Lane - move.From.Lane;\r\n\t\t\tfloat dy = move.To.Y - move.From.Y;\r\n\t\t\tif ( lanes == 0 || dy <= 0 || MathF.Abs( dy - MathF.Abs( lanes ) * Height * 0.5f ) > Tolerance ||\r\n\t\t\t\tMathF.Abs( lanes ) < SlideDamageSteps ) continue;\r\n\t\t\tint direction = Math.Sign( lanes );\r\n\t\t\tfor ( int other = 0; other < cells.Count; other++ )\r\n\t\t\t{\r\n\t\t\t\tif ( movingIndices.Contains( other ) || cells[other].Health <= 0 ) continue;\r\n\t\t\t\tvar receiver = cells[other];\r\n\t\t\t\t// Only the lower face facing into the slide closes onto a full opposing\r\n\t\t\t\t// face. The supporting slope is tangential; tips and partial faces don't count.\r\n\t\t\t\tif ( MathF.Abs( receiver.Lane - move.To.Lane - direction ) * Width * 0.5f >= Tolerance ||\r\n\t\t\t\t\tMathF.Abs( receiver.Y - move.To.Y - Height * 0.5f ) >= Tolerance ) continue;\r\n\t\t\t\thit.TryAdd( other, Corner.Top );\r\n\t\t\t\tif ( contacts is not null && seen.Add( (other, direction) ) )\r\n\t\t\t\t\tcontacts.Add( new( receiver.Lane, receiver.Y - Height * 0.5f,\r\n\t\t\t\t\t\treceiver.Lane - direction, receiver.Y, receiver.ColorIndex ) );\r\n\t\t\t}\r\n\t\t}\r\n\t}\r\n\r\n\t/// <summary>Exposed point contacts for a downward preview, regardless of speed, including floor peaks.</summary>\r\n\tpublic static IReadOnlyList<Contact> PointContacts( IReadOnlyList<Diamond> cells, IReadOnlyList<DiamondMatching.CellMove> moving,\r\n\t\tbool fillCornerPockets = true )\r\n\t{\r\n\t\tvar contacts = new List<Contact>();\r\n\t\tPointTargets( cells, moving, contacts, includeFloor: true, fillCornerPockets: fillCornerPockets );\r\n\t\treturn contacts;\r\n\t}\r\n\r\n\t// Caller-owned storage: previews reuse this without sharing state with real damage waves.\r\n\tinternal sealed class PointContactScratch\r\n\t{\r\n\t\tpublic readonly Dictionary<int, Corner> Hit = new();\r\n\t\tpublic readonly HashSet<int> Moving = new();\r\n\t\tpublic readonly HashSet<(int, Corner)> Corners = new();\r\n\t}\r\n\r\n\tinternal static void CopyPointContacts( IReadOnlyList<Diamond> cells, IReadOnlyList<DiamondMatching.CellMove> moving,\r\n\t\tList<Contact> contacts, PointContactScratch scratch, bool fillCornerPockets = true )\r\n\t{\r\n\t\tcontacts.Clear();\r\n\t\tPointTargets( cells, moving, contacts, includeFloor: true, scratch: scratch, fillCornerPockets: fillCornerPockets );\r\n\t}\r\n\r\n\t/// <summary>New exposed tip collisions for cosmetic sparks, including harmless side tips.</summary>\r\n\tpublic static IReadOnlyList<Contact> SparkContacts( IReadOnlyList<Diamond> cells, IReadOnlyList<DiamondMatching.CellMove> moving,\r\n\t\tbool fillCornerPockets = true )\r\n\t{\r\n\t\tvar contacts = new List<Contact>( PointContacts( cells, moving, fillCornerPockets ) );\r\n\t\tvar movingIndices = new HashSet<int>( moving.Select( m => m.Index ) );\r\n\t\tforeach ( var move in moving )\r\n\t\tfor ( int other = 0; other < cells.Count; other++ )\r\n\t\t{\r\n\t\t\tif ( movingIndices.Contains( other ) || cells[other].Health <= 0 ) continue;\r\n\t\t\tvar receiver = cells[other];\r\n\t\t\tfloat dx = (receiver.Lane - move.To.Lane) * Width * 0.5f;\r\n\t\t\tif ( MathF.Abs( MathF.Abs( dx ) - Width ) > Tolerance || MathF.Abs( receiver.Y - move.To.Y ) > Tolerance ) continue;\r\n\t\t\tint side = Math.Sign( dx );\r\n\t\t\tif ( (move.To.Lane - move.From.Lane) * side <= 0 ||\r\n\t\t\t\t!ExposedPoint( cells, move.Index, side, 0, movingIndices ) ||\r\n\t\t\t\t!ExposedPoint( cells, other, -side, 0, movingIndices ) ) continue;\r\n\t\t\tvar contact = new Contact( (move.To.Lane + receiver.Lane) * 0.5f, receiver.Y, receiver.ColorIndex );\r\n\t\t\tif ( !contacts.Contains( contact ) ) contacts.Add( contact );\r\n\t\t}\r\n\t\treturn contacts;\r\n\t}\r\n\r\n\tstatic Dictionary<int, Corner> PointTargets( IReadOnlyList<Diamond> cells,\r\n\t\tIReadOnlyList<DiamondMatching.CellMove> moving,\r\n\t\tList<Contact> contacts, bool includeFloor = false, PointContactScratch scratch = null, bool fillCornerPockets = true )\r\n\t{\r\n\t\tvar hit = scratch?.Hit ?? new Dictionary<int, Corner>();\r\n\t\tvar movingIndices = scratch?.Moving ?? new HashSet<int>();\r\n\t\tvar contactCorners = scratch?.Corners ?? new HashSet<(int, Corner)>();\r\n\t\thit.Clear(); movingIndices.Clear(); contactCorners.Clear();\r\n\t\tfor ( int i = 0; i < moving.Count; i++ ) movingIndices.Add( moving[i].Index );\r\n\t\tfor ( int i = 0; i < moving.Count; i++ )\r\n\t\t{\r\n\t\t\tvar move = moving[i];\r\n\t\t\tvar cell = move.To;\r\n\t\t\tif ( cell.Y <= move.From.Y ) continue;\r\n\t\t\t// Odd lanes sit over upward floor peaks; even lanes nest between two faces.\r\n\t\t\tif ( includeFloor && MathF.Abs( cell.Lane % 2 - 1 ) * Width * 0.5f < Tolerance &&\r\n\t\t\t\tMathF.Abs( cell.Y - FloorY( cell.Lane ) ) < Tolerance &&\r\n\t\t\t\tExposedPoint( cells, move.Index, 0, 1, movingIndices ) && ExposedFloorPeak( cells, cell, movingIndices, fillCornerPockets ) &&\r\n\t\t\t\tcontactCorners.Add( (move.Index, Corner.Bottom) ) )\r\n\t\t\t\tcontacts.Add( new( cell.Lane, cell.Y + Height * 0.5f, cell.ColorIndex ) );\r\n\t\t\t// Only receiving diamonds take damage; the floor cannot receive gem damage.\r\n\t\t\tfor ( int other = 0; other < cells.Count; other++ )\r\n\t\t\t{\r\n\t\t\t\tif ( movingIndices.Contains( other ) ) continue;\r\n\t\t\t\tvar receiver = cells[other];\r\n\t\t\t\t// Only a moving bottom tip striking a receiving top tip can damage.\r\n\t\t\t\t// Side tips meeting at the end of a downhill slide are harmless.\r\n\t\t\t\tif ( !BottomToTopContact( cell, receiver ) || BottomToTopContact( move.From, receiver, 0.0001f ) ) continue;\r\n\t\t\t\tif ( !ExposedPoint( cells, move.Index, 0, 1, movingIndices ) ||\r\n\t\t\t\t\t!ExposedPoint( cells, other, 0, -1, movingIndices ) ) continue;\r\n\t\t\t\tconst Corner corner = Corner.Top;\r\n\t\t\t\thit.TryAdd( other, corner );\r\n\t\t\t\tif ( contacts is not null && contactCorners.Add( (other, corner) ) )\r\n\t\t\t\t\tcontacts.Add( new( (cell.Lane + receiver.Lane) * 0.5f, (cell.Y + receiver.Y) * 0.5f, receiver.ColorIndex ) );\r\n\t\t\t}\r\n\t\t}\r\n\t\treturn hit;\r\n\t}\r\n\r\n\t// Either face adjoining the struck point can extend it into a smooth edge.\r\n\t// Check the whole physical assembly, including other colors, but exclude the\r\n\t// opposite collision side: new contacts must not hide the points being tested.\r\n\tstatic bool ExposedPoint( IReadOnlyList<Diamond> cells, int index, int pointX, int pointY, HashSet<int> moving )\r\n\t{\r\n\t\tvar cell = cells[index];\r\n\t\tbool isMoving = moving.Contains( index );\r\n\t\tfor ( int other = 0; other < cells.Count; other++ )\r\n\t\t{\r\n\t\t\tif ( other == index || moving.Contains( other ) != isMoving || cells[other].Health <= 0 ) continue;\r\n\t\t\tfloat dx = (cells[other].Lane - cell.Lane) * Width * 0.5f;\r\n\t\t\tfloat dy = cells[other].Y - cell.Y;\r\n\t\t\tif ( MathF.Abs( MathF.Abs( dx ) - Width * 0.5f ) >= Tolerance ||\r\n\t\t\t\tMathF.Abs( MathF.Abs( dy ) - Height * 0.5f ) >= Tolerance ) continue;\r\n\t\t\tif ( (pointX != 0 && Math.Sign( dx ) == pointX) ||\r\n\t\t\t\t(pointY != 0 && Math.Sign( dy ) == pointY) ) return false;\r\n\t\t}\r\n\t\treturn true;\r\n\t}\r\n\r\n\t// A peak flanked by a resting gem or filled corner slope is part of a smooth\r\n\t// edge: the mover's lower face lands flush on that neighbor's upper face.\r\n\tstatic bool ExposedFloorPeak( IReadOnlyList<Diamond> cells, Diamond cell, HashSet<int> moving, bool fillCornerPockets )\r\n\t{\r\n\t\tfor ( int other = 0; other < cells.Count; other++ )\r\n\t\t\tif ( !moving.Contains( other ) && cells[other].Health > 0 && FlanksPeak( cells[other] ) ) return false;\r\n\t\t// Matches DiamondBoard.CornerFills without allocating an iterator per preview frame.\r\n\t\treturn !fillCornerPockets || (!FlanksPeak( new( 0, BoardHeight - Height * 0.5f, 0 ) ) &&\r\n\t\t\t!FlanksPeak( new( LaneCount - 1, BoardHeight - Height * 0.5f, 0 ) ));\r\n\t\tbool FlanksPeak( Diamond flank ) => MathF.Abs( MathF.Abs( flank.Lane - cell.Lane ) * Width * 0.5f - Width * 0.5f ) < Tolerance &&\r\n\t\t\tMathF.Abs( flank.Y - cell.Y - Height * 0.5f ) < Tolerance;\r\n\t}\r\n\r\n\tstatic bool BottomToTopContact( Diamond mover, Diamond receiver, float tolerance = Tolerance )\r\n\t{\r\n\t\tfloat dx = MathF.Abs( mover.Lane - receiver.Lane ) * Width * 0.5f;\r\n\t\treturn dx < tolerance && MathF.Abs( receiver.Y - mover.Y - Height ) < tolerance;\r\n\t}\r\n\r\n\tpublic static bool Apply( List<Diamond> cells, IReadOnlyDictionary<int, Corner> targets )\r\n\t{\r\n\t\tbool destroyed = false;\r\n\t\tforeach ( var target in targets )\r\n\t\t{\r\n\t\t\tint index = target.Key;\r\n\t\t\tcells[index] = cells[index] with\r\n\t\t\t{\r\n\t\t\t\tHealth = Math.Max( 0, cells[index].Health - 1 ),\r\n\t\t\t\tFirstCrack = cells[index].Health == MaxHealth ? target.Value : cells[index].FirstCrack\r\n\t\t\t};\r\n\t\t\tdestroyed |= cells[index].Health == 0;\r\n\t\t}\r\n\t\treturn destroyed;\r\n\t}\r\n}\r\n"
        },
        {
            "Ident": "facepunch.diamondgrinder",
            "Path": "DiamondDebris.cs",
            "FileName": "DiamondDebris.cs",
            "PackageType": "game",
            "CodeKind": "Game",
            "AssetVersionId": 398652,
            "IsPrivate": false,
            "Code": "using System;\r\nusing System.Collections.Generic;\r\nusing Sandbox;\r\nusing static Diamonds.DiamondBoard;\r\n\r\nnamespace Diamonds;\r\n\r\n/// <summary>Box2D debris in Painter coordinates (+Y down). Only crushed shards award points.</summary>\r\npublic sealed partial class DiamondDebris\r\n{\r\n\tpublic sealed class Particle\r\n\t{\r\n\t\tinternal PhysicsBody Body;\r\n\t\tinternal float Age, BlinkTime = -1;\r\n\t\tinternal float MaxAge;\r\n\t\tinternal float ShineDelay, ShinePeriod = 3.5f;\r\n\t\tpublic int ShineDirection { get; internal set; } = 1;\r\n\t\t// The debris age stops on pause, including while the physics body sleeps.\r\n\t\tpublic float ShineProgress => IsSpark || Age < ShineDelay ? -1 : (Age - ShineDelay) % ShinePeriod / 0.32f;\r\n\t\tinternal Transform LastClearTransform;\r\n\t\tinternal bool HasClearTransform;\r\n\t\tpublic bool IsSpark { get; internal set; }\r\n\t\tpublic float Opacity => IsSpark ? MathF.Pow( Math.Clamp( 1 - Age / MaxAge, 0, 1 ), 1.4f ) : 1;\r\n\t\tpublic float Radius { get; internal set; }\r\n\t\tpublic int ColorIndex { get; internal set; }\r\n\t\tpublic float Brightness { get; internal set; }\r\n\t\tpublic Vector2[] Vertices { get; internal set; }\r\n\t\tpublic Vector2 Position\r\n\t\t{\r\n\t\t\tget\r\n\t\t\t{\r\n\t\t\t\t// Read the native transform once for both coordinates.\r\n\t\t\t\tvar position = Body.Position;\r\n\t\t\t\treturn new( position.x, position.y );\r\n\t\t\t}\r\n\t\t}\r\n\t\tpublic float Angle => Body.Rotation.Yaw();\r\n\t\tpublic bool Visible => IsSpark ? Age < MaxAge : BlinkTime < 0 || (int)(BlinkTime / (BlinkTime < 0.45f ? 0.12f : 0.06f)) % 2 == 0;\r\n\t}\r\n\r\n\tconst int MaxShards = 320;\r\n\tconst int MaxSparks = 960;\r\n\tpublic const float MaxSparkSpeed = 8000;\r\n\tconst float BlinkDuration = 0.9f;\r\n\tconst int PhysicsRevision = 10;\r\n\tint physicsRevision;\r\n\tint settledVersion, activeSpawnVersion;\r\n\treadonly Random random;\r\n\tpublic DiamondDebris( int? seed = null ) => random = seed.HasValue ? new Random( seed.Value ) : new Random();\r\n\treadonly List<Particle> particles = new();\r\n\treadonly List<PhysicsBody> settledBodies = new();\r\n\treadonly List<PhysicsBody> activeBodies = new();\r\n\treadonly List<Diamond> cells = new();\r\n\treadonly Surface surface = new() { Friction = 0.65f, Elasticity = 0.24f, Density = 1000, RollingResistance = 0.2f };\r\n\treadonly Surface sparkSurface = new() { Friction = 0.65f, Elasticity = 0.96f, Density = 1000, RollingResistance = 0.2f };\r\n\tpublic float SparkForceMultiplier { get; set; } = 4;\r\n\tpublic float SparkLifetimeMultiplier { get; set; } = 4;\r\n\tpublic float SparkGravityMin { get; set; } = 0.75f;\r\n\tpublic float SparkGravityMax { get; set; } = 1.6f;\r\n\tpublic float ShardLifetimeMin { get; set; } = 38;\r\n\tpublic float ShardLifetimeMax { get; set; } = 42;\r\n\tpublic float SparkBounciness\r\n\t{\r\n\t\tget => sparkSurface.Elasticity;\r\n\t\tset\r\n\t\t{\r\n\t\t\tfloat elasticity = Math.Clamp( value, 0, 1 );\r\n\t\t\tif ( sparkSurface.Elasticity == elasticity ) return;\r\n\t\t\tsparkSurface.Elasticity = elasticity;\r\n\t\t\t// Box2D copies surface values into each fixture when assigned.\r\n\t\t\tforeach ( var particle in particles )\r\n\t\t\t{\r\n\t\t\t\tif ( !particle.IsSpark ) continue;\r\n\t\t\t\tforeach ( var shape in particle.Body.Shapes ) shape.Surface = sparkSurface;\r\n\t\t\t}\r\n\t\t}\r\n\t}\r\n\tScene physicsScene;\r\n\tDiamondBoard currentBoard;\r\n\tbool FillCornerPockets => currentBoard?.FillCornerPockets ?? true;\r\n\tint resetVersion;\r\n\tlong lastFlash;\r\n\tlong lastFaceFlash;\r\n\tlong lastShatter;\r\n\tlong lastCornerSpark;\r\n\tpublic int CrushedThisFrame { get; private set; }\r\n\tpublic readonly record struct CrushAward( Vector2 Position, int ColorIndex, long Points );\r\n\treadonly List<CrushAward> crushAwards = new();\r\n\tpublic IReadOnlyList<CrushAward> CrushAwards => crushAwards;\r\n\tpublic IReadOnlyList<Particle> Particles => particles;\r\n#if STANDALONE\r\n#endif\r\n\tPhysicsWorld World => physicsScene.PhysicsWorld;\r\n\r\n\tstatic bool prepared;\r\n\t/// <summary>Compile and initialize particle/physics paths before play, in an isolated disposable world.</summary>\r\n\tpublic static void Prepare()\r\n\t{\r\n\t\tif ( prepared ) return;\r\n\t\tvar probe = new DiamondDebris( 0 );\r\n\t\ttry\r\n\t\t{\r\n\t\t\tprobe.CreateWorld();\r\n\t\t\t// Exercise both collision tags, native fixtures and the filter callback.\r\n\t\t\t// The probe owns its randomness and never publishes particles or awards.\r\n\t\t\tprobe.EmitShatter( new Diamond( 16, FloorY( 16 ), 0 ) );\r\n\t\t\tprobe.AddSpark( new Vector2( LaneX( 16 ), FloorY( 16 ) ), Vector2.Zero, 1, 0 );\r\n\t\t\tprobe.AddSpark( new Vector2( LaneX( 16 ), FloorY( 16 ) ), Vector2.Zero, 1, 0 );\r\n\t\t\tprobe.World.Step( 1 / 480f, 1 );\r\n\t\t\tprepared = true;\r\n\t\t}\r\n\t\tfinally { probe.Clear(); }\r\n\t}\r\n\r\n\tpublic void Clear()\r\n\t{\r\n\t\tphysicsScene?.Destroy();\r\n\t\tphysicsScene = null;\r\n\t\tparticles.Clear();\r\n\t\tsmoke.Clear();\r\n\t\tshatterSunbursts.Clear();\r\n\t\tcrushSunbursts.Clear();\r\n\t\tsettledBodies.Clear();\r\n\t\tactiveBodies.Clear();\r\n\t\tcells.Clear();\r\n\t\tcrushDiamonds.Clear();\r\n\t\tcrushFaces.Clear();\r\n\t\tcurrentBoard = null;\r\n\t\tlastFlash = 0;\r\n\t\tlastFaceFlash = 0;\r\n\t\tlastShatter = 0;\r\n\t\tlastCornerSpark = 0;\r\n\t\tCrushedThisFrame = 0;\r\n\t\tcrushAwards.Clear();\r\n\t}\r\n\r\n\tvoid CreateWorld()\r\n\t{\r\n\t\t// A private scene selects the engine's Box2D backend. It isn't added to the\r\n\t\t// game scene hierarchy or ticked; we step only its physics world below.\r\n\t\tphysicsScene = new Scene { PhysicsMode = ScenePhysicsMode.Physics2D };\r\n\t\tphysicsRevision = PhysicsRevision;\r\n\t\tWorld.Gravity = new Vector3( 0, 780, 0 );\r\n\t\tWorld.SleepingEnabled = true;\r\n\t\tvar rules = new Sandbox.Physics.CollisionRules();\r\n\t\trules.Pairs[(\"spark\", \"spark\")] = Sandbox.Physics.CollisionRules.Result.Ignore;\r\n\t\trules.Pairs[(\"shard\", \"spark\")] = Sandbox.Physics.CollisionRules.Result.Ignore;\r\n\t\trules.Serialize(); // Validate and rebuild the Box2D runtime tag filter.\r\n\t\tWorld.CollisionRules = rules;\r\n\t\tvar floor = World.CreateBody();\r\n\t\tfloor.BodyType = PhysicsBodyType.Static;\r\n\t\tfor ( int i = 0; i < Valleys * 2; i++ )\r\n\t\t{\r\n\t\t\tfloat x = i * Width * 0.5f, nextX = x + Width * 0.5f;\r\n\t\t\tfloat y = BoardHeight - (i % 2 == 0 ? Height * 0.5f : 0);\r\n\t\t\tfloat nextY = BoardHeight - (i % 2 == 0 ? 0 : Height * 0.5f);\r\n\t\t\tfloor.AddHullShape( Vector3.Zero, Rotation.Identity, new List<Vector3>\r\n\t\t\t{\r\n\t\t\t\tnew( x, y, 0 ), new( nextX, nextY, 0 ), new( nextX, BoardHeight + 32, 0 ), new( x, BoardHeight + 32, 0 )\r\n\t\t\t} ).Surface = surface;\r\n\t\t}\r\n\t\tfloor.AddBoxShape( new Vector3( -32, BoardHeight * 0.5f, 0 ), Rotation.Identity, new Vector3( 32, BoardHeight * 0.5f + 64, 1 ) ).Surface = surface;\r\n\t\tfloor.AddBoxShape( new Vector3( BoardWidth + 32, BoardHeight * 0.5f, 0 ), Rotation.Identity, new Vector3( 32, BoardHeight * 0.5f + 64, 1 ) ).Surface = surface;\r\n\t\tfloor.AddBoxShape( new Vector3( BoardWidth * 0.5f, -32, 0 ), Rotation.Identity, new Vector3( BoardWidth * 0.5f + 64, 32, 1 ) ).Surface = surface;\r\n\t\tforeach ( var tooth in SideWallTeeth() )\r\n\t\t{\r\n\t\t\tfloat x = LaneX( tooth.Lane ), inward = tooth.Lane < 0 ? 1 : -1;\r\n\t\t\tfloor.AddHullShape( Vector3.Zero, Rotation.Identity, new List<Vector3>\r\n\t\t\t{\r\n\t\t\t\tnew( x, tooth.Y - Height * 0.5f, 0 ), new( x + inward * Width * 0.5f, tooth.Y, 0 ),\r\n\t\t\t\tnew( x, tooth.Y + Height * 0.5f, 0 )\r\n\t\t\t} ).Surface = surface;\r\n\t\t}\r\n\t\tforeach ( var corner in CornerFills( FillCornerPockets ) )\r\n\t\t{\r\n\t\t\tfloat x = LaneX( corner.Lane ), y = corner.Y;\r\n\t\t\tfloor.AddHullShape( Vector3.Zero, Rotation.Identity, new List<Vector3>\r\n\t\t\t{\r\n\t\t\t\tnew( x, y - Height * 0.5f, 0 ), new( x + Width * 0.5f, y, 0 ),\r\n\t\t\t\tnew( x, y + Height * 0.5f, 0 ), new( x - Width * 0.5f, y, 0 )\r\n\t\t\t} ).Surface = surface;\r\n\t\t}\r\n\t\tfloor.EnableTouch = false;\r\n\t}\r\n\r\n\tPhysicsBody CreateDiamond( Diamond cell )\r\n\t{\r\n\t\tvar body = World.CreateBody();\r\n\t\tbody.BodyType = PhysicsBodyType.Keyframed;\r\n\t\tbody.Position = new Vector3( LaneX( cell.Lane ), cell.Y, 0 );\r\n\t\t// Convex fixtures tile the complete piece, preserving concave notches and\r\n\t\t// holes rather than filling them with a convex hull of the whole assembly.\r\n\t\tbody.AddHullShape( Vector3.Zero, Rotation.Identity, new List<Vector3>\r\n\t\t{\r\n\t\t\tnew( 0, -Height * 0.5f, 0 ), new( Width * 0.5f, 0, 0 ),\r\n\t\t\tnew( 0, Height * 0.5f, 0 ), new( -Width * 0.5f, 0, 0 )\r\n\t\t} ).Surface = surface;\r\n\t\tbody.EnableTouch = false;\r\n\t\treturn body;\r\n\t}\r\n\r\n\tvoid SyncBodies( List<PhysicsBody> bodies, IReadOnlyList<Diamond> diamonds, float delta, bool rebuild = false )\r\n\t{\r\n#if STANDALONE\r\n#endif\r\n\t\t// A lethal landing can add exactly as many cells as it removes. Indices\r\n\t\t// then refer to different gems despite an unchanged count; never animate\r\n\t\t// those replacement colliders across the board from their former owners.\r\n\t\tif ( rebuild || bodies.Count != diamonds.Count )\r\n\t\t{\r\n\t\t\tforeach ( var body in bodies ) body.Remove();\r\n\t\t\tbodies.Clear();\r\n\t\t\tforeach ( var cell in diamonds ) bodies.Add( CreateDiamond( cell ) );\r\n\t\t}\r\n\t\tfor ( int i = 0; i < diamonds.Count; i++ )\r\n\t\t{\r\n\t\t\tvar cell = diamonds[i];\r\n\t\t\tvar target = new Vector3( LaneX( cell.Lane ), cell.Y, 0 );\r\n\t\t\t// Keyframed velocities carry/push debris along with animated supports.\r\n\t\t\tvar body = bodies[i];\r\n\t\t\tif ( (body.Position - target).LengthSquared > 0.0001f ) body.Move( new Transform( target ), delta );\r\n\t\t\telse if ( body.Velocity != Vector3.Zero ) body.Velocity = Vector3.Zero;\r\n\t\t}\r\n\t}\r\n\r\n\tpublic void Update( DiamondBoard board, float delta )\r\n\t{\r\n#if STANDALONE\r\n#endif\r\n\t\tCrushedThisFrame = 0;\r\n\t\tcrushAwards.Clear();\r\n\t\tif ( board != currentBoard || board.ResetVersion != resetVersion || physicsRevision != PhysicsRevision )\r\n\t\t{\r\n\t\t\tClear();\r\n\t\t\tcurrentBoard = board;\r\n\t\t\tresetVersion = board.ResetVersion;\r\n\t\t}\r\n\t\tif ( board.Paused || delta <= 0 ) return;\r\n\t\tdelta = Math.Clamp( delta, 0, 0.1f );\r\n\t\tif ( physicsScene is null ) CreateWorld();\r\n\t\tSyncBodies( settledBodies, board.Settled, delta, settledVersion != board.SettledVersion );\r\n\t\tsettledVersion = board.SettledVersion;\r\n\t\tcells.Clear();\r\n\t\tif ( !board.GameOver && !board.IsResolving ) CopyCellsOf( board.Active, cells );\r\n\t\tSyncBodies( activeBodies, cells, delta, activeSpawnVersion != board.SpawnVersion );\r\n\t\tactiveSpawnVersion = board.SpawnVersion;\r\n\t\tcells.AddRange( board.Settled );\r\n\t\tUpdateShatterSunbursts( delta );\r\n\t\tEmitSlidingSparks( board, delta );\r\n\t\tforeach ( var flash in board.DamageFlashes )\r\n\t\t{\r\n\t\t\tif ( flash.Id <= lastFlash ) continue;\r\n\t\t\tEmit( flash, board.GameOver );\r\n\t\t\tlastFlash = flash.Id;\r\n\t\t}\r\n\t\tforeach ( var contact in board.CornerSparks )\r\n\t\t{\r\n\t\t\tif ( contact.Id <= lastCornerSpark ) continue;\r\n\t\t\tbool gentle = contact.Speed <= DamageSpeedThreshold;\r\n\t\t\tEmitCorner( contact.Contact, gentle ? random.Next( 3, 6 ) : SparkCount( 1 ),\r\n\t\t\t\tgentle ? Range( 0.18f, 0.28f ) : SparkStrength( 1 ) );\r\n\t\t\tlastCornerSpark = contact.Id;\r\n\t\t}\r\n\t\tforeach ( var burst in board.ShatterBursts )\r\n\t\t{\r\n\t\t\tif ( burst.Id <= lastShatter ) continue;\r\n\t\t\tif ( !board.GameOver ) EmitShatter( burst.Cell );\r\n\t\t\tEmitShatterEffects( burst.Cell, board.GameOver );\r\n\t\t\tlastShatter = burst.Id;\r\n\t\t}\r\n\t\tforeach ( var flash in board.FaceFlashes )\r\n\t\t{\r\n\t\t\tif ( flash.Id <= lastFaceFlash ) continue;\r\n\t\t\tEmitFaceSparks( flash );\r\n\t\t\tlastFaceFlash = flash.Id;\r\n\t\t}\r\n\t\t// Keep solver substeps at most 1/480s: softened contacts at 1/240s can\r\n\t\t// sleep with visibly compressed shards in a stack pocket. Outer steps\r\n\t\t// still batch geometry checks at 60 Hz, or 120 Hz for fast closing gems.\r\n\t\tbool fastGems = settledBodies.Any( b => b.Velocity.LengthSquared > 480 * 480 ) ||\r\n\t\t\tactiveBodies.Any( b => b.Velocity.LengthSquared > 480 * 480 );\r\n\t\tint steps = Math.Max( 1, (int)MathF.Ceiling( delta * (fastGems ? 120 : 60) ) );\r\n\t\tint solverSubsteps = Math.Max( 4, (int)MathF.Ceiling( delta / steps * 480 ) );\r\n#if STANDALONE\r\n#endif\r\n\t\tUpdateSmoke( delta );\r\n\t\tfor ( int step = 0; step < steps; step++ )\r\n\t\t{\r\n\t\t\tCacheCrushDiamonds();\r\n\t\t\tCrushTrappedChips( delta / steps );\r\n\t\t\tConstrainParticles();\r\n\t\t\t{\r\n#if STANDALONE\r\n#endif\r\n\t\t\t\tWorld.Step( delta / steps, solverSubsteps );\r\n\t\t\t}\r\n\t\t\tConstrainParticles();\r\n\t\t\tCacheCrushDiamonds();\r\n\t\t\tCrushEmbeddedShards();\r\n\t\t\tResolveShardOverlaps();\r\n\t\t}\r\n#if STANDALONE\r\n#endif\r\n\t\tfor ( int i = particles.Count - 1; i >= 0; i-- )\r\n\t\t{\r\n\t\t\tvar chip = particles[i];\r\n\t\t\tchip.Age += delta;\r\n\t\t\t// Shards blink out once their own sampled lifetime passes; sparks fade out by age.\r\n\t\t\tif ( !chip.IsSpark && chip.BlinkTime < 0 && chip.Age > chip.MaxAge ) chip.BlinkTime = 0;\r\n\t\t\tif ( chip.BlinkTime >= 0 ) chip.BlinkTime += delta;\r\n\t\t\tif ( (chip.IsSpark && chip.Age >= chip.MaxAge) || chip.BlinkTime >= BlinkDuration || chip.Position.y > BoardHeight + 64 )\r\n\t\t\t{\r\n\t\t\t\tif ( chip.BlinkTime >= BlinkDuration && chip.Position.y <= BoardHeight ) EmitSmoke( chip, false );\r\n\t\t\t\tchip.Body.Remove();\r\n\t\t\t\tparticles.RemoveAt( i );\r\n\t\t\t}\r\n\t\t}\r\n\t}\r\n\r\n\tfloat Range( float min, float max ) => min + (max - min) * (float)random.NextDouble();\r\n\r\n\tbool HasRoom( Vector2 position, float radius )\r\n\t{\r\n\t\tif ( position.x < radius || position.x > BoardWidth - radius || position.y < radius ) return false;\r\n\t\tfloat wall = SideWallInset( position.y, FillCornerPockets ) + radius * MathF.Sqrt( 1 + Width * Width / (Height * Height) );\r\n\t\tif ( position.x < wall || position.x > BoardWidth - wall ) return false;\r\n\t\tfloat floor = BoardHeight - Height * 0.5f * MathF.Abs( 1 - position.x / (Width * 0.5f) % 2 );\r\n\t\tif ( position.y > floor - radius * MathF.Sqrt( 1 + Height * Height / (Width * Width) ) ) return false;\r\n\t\tforeach ( var cell in cells )\r\n\t\t{\r\n\t\t\tfloat dx = MathF.Abs( position.x - LaneX( cell.Lane ) );\r\n\t\t\tfloat dy = MathF.Abs( position.y - cell.Y );\r\n\t\t\t// Expanded face half-planes, conservative at the tips for a round chip.\r\n\t\t\tif ( dx / (Width * 0.5f) + dy / (Height * 0.5f) <\r\n\t\t\t\t1 + radius * MathF.Sqrt( 4 / (Width * Width) + 4 / (Height * Height) ) ) return false;\r\n\t\t}\r\n\t\treturn true;\r\n\t}\r\n\r\n\tvoid Emit( DamageFlash flash, bool gameOver )\r\n\t{\r\n\t\tEmitCorner( new( flash.Lane, flash.Y, flash.ColorIndex ),\r\n\t\t\tgameOver ? random.Next( 3, 6 ) : SparkCount( flash.DamageCount ), SparkStrength( flash.DamageCount ) );\r\n\t}\r\n\r\n\tvoid EmitCorner( DiamondDamage.Contact contact, int count, float strength )\r\n\t{\r\n#if STANDALONE\r\n#endif\r\n\t\tvar origin = new Vector2( LaneX( contact.Lane ), contact.Y );\r\n\t\tfor ( int i = 0; i < count; i++ )\r\n\t\t{\r\n\t\t\tfloat radius = Range( 0.7f, 1.8f );\r\n\t\t\tVector2 direction = default, position = origin;\r\n\t\t\tbool found = false;\r\n\t\t\t// Separate just outside the struck tips, so Box2D doesn't eject a chip\r\n\t\t\t// from inside the receiving or incoming diamond with an arbitrary impulse.\r\n\t\t\tfor ( int attempt = 0; attempt < 24 && !found; attempt++ )\r\n\t\t\t{\r\n\t\t\t\tfloat angle = Range( -MathF.PI, MathF.PI );\r\n\t\t\t\tdirection = new Vector2( MathF.Cos( angle ), MathF.Sin( angle ) );\r\n\t\t\t\tfor ( float distance = radius + 1; distance <= 12; distance += 2 )\r\n\t\t\t\t{\r\n\t\t\t\t\tposition = origin + direction * distance;\r\n\t\t\t\t\tif ( HasRoom( position, radius + 0.5f ) ) { found = true; break; }\r\n\t\t\t\t}\r\n\t\t\t}\r\n\t\t\tif ( !found ) continue;\r\n\t\t\tvar velocity = new Vector2( direction.x * Range( 100, 260 ), direction.y * Range( 90, 220 ) - 130 ) * strength;\r\n\t\t\tAddSpark( position, velocity, radius, contact.ColorIndex );\r\n\t\t}\r\n\t}\r\n\r\n\tint SparkCount( int damageCount )\r\n\t{\r\n\t\t// Quiet single hits; broad bonded damage gets a much denser shower.\r\n\t\tfloat count = 10 + 6 * MathF.Pow( Math.Clamp( damageCount, 1, 32 ) - 1, 1.5f );\r\n\t\treturn Math.Clamp( (int)MathF.Round( count * Range( 0.9f, 1.1f ) ), 8, 240 );\r\n\t}\r\n\r\n\tfloat SparkStrength( int damageCount ) =>\r\n\t\t(1 + 0.22f * Math.Min( 12, Math.Max( 0, damageCount - 1 ) )) * Range( 0.95f, 1.05f );\r\n\r\n\tvoid EmitShatter( Diamond cell )\r\n\t{\r\n\t\tvar center = new Vector2( LaneX( cell.Lane ), cell.Y );\r\n\t\t// Each horizontal half is already almost equilateral (56 x 52 x 52).\r\n\t\t// Quarter it through its edge midpoints, then inset each triangle about\r\n\t\t// its centroid so the eight fragments start with breathing room.\r\n\t\tforeach ( int side in new[] { -1, 1 } )\r\n\t\t{\r\n\t\t\tvar tip = new Vector2( 0, side * Height / 2 );\r\n\t\t\tvar left = new Vector2( -Width / 2, 0 );\r\n\t\t\tvar right = new Vector2( Width / 2, 0 );\r\n\t\t\tvar leftMid = (tip + left) / 2;\r\n\t\t\tvar rightMid = (tip + right) / 2;\r\n\t\t\tAddShard( tip, leftMid, rightMid );\r\n\t\t\tAddShard( left, Vector2.Zero, leftMid );\r\n\t\t\tAddShard( right, rightMid, Vector2.Zero );\r\n\t\t\tAddShard( Vector2.Zero, rightMid, leftMid );\r\n\t\t}\r\n\r\n\t\tvoid AddShard( Vector2 a, Vector2 b, Vector2 c )\r\n\t\t{\r\n\t\t\tvar offset = (a + b + c) / 3;\r\n\t\t\tfloat scale = Range( 0.66f, 0.78f );\r\n\t\t\tVector2[] vertices = [(a - offset) * scale, (b - offset) * scale, (c - offset) * scale];\r\n\t\t\tvar outward = offset.Normal;\r\n\t\t\tfloat angle = Range( -0.25f, 0.25f );\r\n\t\t\tvar direction = outward * MathF.Cos( angle ) + new Vector2( -outward.y, outward.x ) * MathF.Sin( angle );\r\n\t\t\tAddParticle( center + offset, direction * Range( 600, 900 ), vertices, cell.ColorIndex, false );\r\n\t\t}\r\n\t}\r\n\r\n\tvoid EmitFaceSparks( FaceFlash flash )\r\n\t{\r\n\t\tvar face = flash.Contact;\r\n\t\tvar start = new Vector2( LaneX( face.StartLane ), face.StartY );\r\n\t\tvar end = new Vector2( LaneX( face.EndLane ), face.EndY );\r\n\t\tvar tangent = (end - start).Normal;\r\n\t\t// A full face seam is sealed by two gems. Escape around its endpoints,\r\n\t\t// searching outside both bodies instead of spawning inside either one.\r\n\t\tint count = SparkCount( flash.DamageCount );\r\n\t\tfloat strength = SparkStrength( flash.DamageCount );\r\n\t\tfor ( int i = 0; i < count; i++ )\r\n\t\t{\r\n\t\t\tfloat radius = Range( 0.7f, 1.8f );\r\n\t\t\tbool atStart = i % 2 == 0;\r\n\t\t\tvar origin = atStart ? start : end;\r\n\t\t\tvar outward = tangent * (atStart ? -1 : 1);\r\n\t\t\tfor ( int attempt = 0; attempt < 24; attempt++ )\r\n\t\t\t{\r\n\t\t\t\tfloat angle = Range( -1.2f, 1.2f );\r\n\t\t\t\tvar direction = outward * MathF.Cos( angle ) + new Vector2( -outward.y, outward.x ) * MathF.Sin( angle );\r\n\t\t\t\tvar position = origin + direction * Range( 3, 14 );\r\n\t\t\t\tif ( !HasRoom( position, radius + 0.5f ) ) continue;\r\n\t\t\t\tAddSpark( position, direction * Range( 130, 310 ) * strength, radius, face.ColorIndex );\r\n\t\t\t\tbreak;\r\n\t\t\t}\r\n\t\t}\r\n\t}\r\n\r\n\tvoid AddSpark( Vector2 position, Vector2 velocity, float radius, int colorIndex )\r\n\t{\r\n\t\t// Three quarters triangular splinters; the remaining chips are squares.\r\n\t\tbool triangle = random.NextDouble() < 0.76;\r\n\t\tfloat w = radius * Range( 0.65f, 1 ), h = radius;\r\n\t\tVector2[] vertices = triangle\r\n\t\t\t? [new( -w, h * 0.65f ), new( w, h * 0.65f ), new( Range( -0.35f, 0.35f ) * w, -h )]\r\n\t\t\t: [new( -w, -w ), new( w, -w ), new( w, w ), new( -w, w )];\r\n\t\tAddParticle( position, velocity * MathF.Max( 0, SparkForceMultiplier ), vertices, colorIndex, true );\r\n\t}\r\n\r\n\tvoid AddParticle( Vector2 position, Vector2 velocity, Vector2[] vertices, int colorIndex, bool spark )\r\n\t{\r\n\t\t// Sparks cannot evict the long-lived, score-bearing shards.\r\n\t\tint count = 0, oldest = -1;\r\n\t\tfor ( int i = 0; i < particles.Count; i++ )\r\n\t\t{\r\n\t\t\tif ( particles[i].IsSpark != spark ) continue;\r\n\t\t\tif ( oldest < 0 ) oldest = i;\r\n\t\t\tcount++;\r\n\t\t}\r\n\t\tif ( count >= (spark ? MaxSparks : MaxShards) )\r\n\t\t{\r\n\t\t\tparticles[oldest].Body.Remove();\r\n\t\t\tparticles.RemoveAt( oldest );\r\n\t\t}\r\n\t\tvar body = World.CreateBody();\r\n\t\tbody.BodyType = PhysicsBodyType.Dynamic;\r\n\t\tbody.Position = new Vector3( position.x, position.y, 0 );\r\n\t\tfloat radius = 0;\r\n\t\tfor ( int i = 0; i < vertices.Length; i++ ) radius = MathF.Max( radius, vertices[i].Length );\r\n\t\t// Sub-unit spark polygons fall below Box2D's hull-welding tolerance.\r\n\t\t// Use a tiny round fixture; full-size shards retain their exact triangles.\r\n\t\tPhysicsShape shape;\r\n\t\tif ( spark ) shape = body.AddSphereShape( Vector3.Zero, radius );\r\n\t\telse\r\n\t\t{\r\n\t\t\tvar hull = new List<Vector3>( vertices.Length );\r\n\t\t\tfor ( int i = 0; i < vertices.Length; i++ ) hull.Add( new( vertices[i].x, vertices[i].y, 0 ) );\r\n\t\t\tshape = body.AddHullShape( Vector3.Zero, Rotation.Identity, hull );\r\n\t\t}\r\n\t\tshape.Surface = spark ? sparkSurface : surface;\r\n\t\t// Debris uses geometric crush checks, not engine contact callbacks.\r\n\t\tshape.EnableTouch = false;\r\n\t\tshape.Tags.Add( spark ? \"spark\" : \"shard\" );\r\n\t\tbody.Rotation = spark ? Rotation.FromYaw( Range( 0, 360 ) ) : Rotation.Identity;\r\n\t\tif ( spark && velocity.LengthSquared > MaxSparkSpeed * MaxSparkSpeed ) velocity = velocity.Normal * MaxSparkSpeed;\r\n\t\tbody.Velocity = new Vector3( velocity.x, velocity.y, 0 );\r\n\t\tbody.AngularVelocity = new Vector3( 0, 0, spark ? Range( -620, 620 ) : Range( -160, 160 ) );\r\n\t\t// Each spark samples its own weight so a burst fans into varied arcs.\r\n\t\tif ( spark ) body.GravityScale = Range( MathF.Max( 0, SparkGravityMin ), MathF.Max( SparkGravityMin, SparkGravityMax ) );\r\n\t\tbody.LinearDamping = 0.35f;\r\n\t\tbody.AngularDamping = 1.8f;\r\n\t\tbody.EnhancedCcd = true;\r\n\t\tbody.AutoSleep = true;\r\n\t\tbody.SleepThreshold = 8;\r\n\t\tparticles.Add( new Particle { Body = body, Radius = radius, Vertices = vertices, ColorIndex = colorIndex,\r\n\t\t\tIsSpark = spark, Brightness = spark ? Range( 0.5f, 0.8f ) : Range( 0.15f, 0.4f ),\r\n\t\t\tMaxAge = spark ? Range( 0.5f, 0.9f ) * MathF.Max( 0.1f, SparkLifetimeMultiplier ) : Range( MathF.Max( 0, ShardLifetimeMin ), MathF.Max( ShardLifetimeMin, ShardLifetimeMax ) ),\r\n\t\t\tShineDelay = spark ? 0 : Range( 0.5f, 3 ), ShinePeriod = spark ? 3.5f : Range( 2.5f, 4.5f ),\r\n\t\t\tShineDirection = spark || random.Next( 2 ) == 0 ? 1 : -1 } );\r\n\t}\r\n}\r\n"
        },
        {
            "Ident": "facepunch.diamondgrinder",
            "Path": "DiamondPanel.Strokes.cs",
            "FileName": "DiamondPanel.Strokes.cs",
            "PackageType": "game",
            "CodeKind": "Game",
            "AssetVersionId": 398652,
            "IsPrivate": false,
            "Code": "using System;\nusing Sandbox;\n\nnamespace Diamonds;\n\npublic sealed partial class DiamondPanel\n{\n\t// Used only by editor comparisons; never exposed as a gameplay setting.\n\tstatic bool drawingReferenceStrokes = false;\n\n\t// Small mitered strips stay on Painter's inline polygon path instead of allocating\n\t// a stroke primitive tree. This is restricted to the simple gem/fragment geometry.\n\tstatic void DrawGemStroke( Painter p, ReadOnlySpan<Vector2> points, Color color, float width, bool closed )\n\t{\n\t\tif ( drawingReferenceStrokes || (!closed && points.Length > 4) )\n\t\t{\n\t\t\tp.Fill = Fill.None;\n\t\t\tp.Stroke = Stroke.Solid( color, width );\n\t\t\tif ( closed ) p.Polygon( points ); else p.Line( points );\n\t\t\treturn;\n\t\t}\n\t\tSpan<Vector2> offsets = stackalloc Vector2[points.Length];\n\t\tfor ( int i = 0; i < points.Length; i++ )\n\t\t{\n\t\t\tvar incoming = (points[i] - points[(i + points.Length - 1) % points.Length]).Normal;\n\t\t\tvar outgoing = (points[(i + 1) % points.Length] - points[i]).Normal;\n\t\t\tif ( !closed && i == 0 ) incoming = outgoing;\n\t\t\tif ( !closed && i == points.Length - 1 ) outgoing = incoming;\n\t\t\tvar n0 = new Vector2( -incoming.y, incoming.x );\n\t\t\tvar n1 = new Vector2( -outgoing.y, outgoing.x );\n\t\t\tfloat denominator = 1 + Vector2.Dot( n0, n1 );\n\t\t\tvar miter = denominator > 0.00001f ? (n0 + n1) / denominator : n0;\n\t\t\t// Match the default four-width miter limit; extreme shard tips use the original path.\n\t\t\tif ( denominator <= 0.00001f || miter.Length > 4 )\n\t\t\t{\n\t\t\t\tp.Fill = Fill.None; p.Stroke = Stroke.Solid( color, width );\n\t\t\t\tif ( closed ) p.Polygon( points ); else p.Line( points );\n\t\t\t\treturn;\n\t\t\t}\n\t\t\toffsets[i] = miter * (width * 0.5f);\n\t\t}\n\t\tp.Stroke = Stroke.None;\n\t\tp.Fill = color;\n\t\tif ( !closed )\n\t\t{\n\t\t\t// A single polygon keeps crack joins seamless. Longer cracks retain Painter's\n\t\t\t// stroke path rather than splitting them across independently antialiased edges.\n\t\t\tSpan<Vector2> polygon = stackalloc Vector2[points.Length * 2];\n\t\t\tfor ( int i = 0; i < points.Length; i++ )\n\t\t\t{\n\t\t\t\tpolygon[i] = points[i] + offsets[i];\n\t\t\t\tpolygon[polygon.Length - 1 - i] = points[i] - offsets[i];\n\t\t\t}\n\t\t\tp.Polygon( polygon );\n\t\t\treturn;\n\t\t}\n\t\tfor ( int i = 0; i < points.Length; i++ )\n\t\t{\n\t\t\tint next = (i + 1) % points.Length;\n\t\t\tp.Quad( points[i] + offsets[i], points[next] + offsets[next],\n\t\t\t\tpoints[next] - offsets[next], points[i] - offsets[i] );\n\t\t}\n\t}\n}\n"
        },
        {
            "Ident": "facepunch.diamondgrinder",
            "Path": "DiamondDebris.Shatter.cs",
            "FileName": "DiamondDebris.Shatter.cs",
            "PackageType": "game",
            "CodeKind": "Game",
            "AssetVersionId": 398652,
            "IsPrivate": false,
            "Code": "using System;\nusing System.Collections.Generic;\nusing Sandbox;\nusing static Diamonds.DiamondBoard;\n\nnamespace Diamonds;\n\npublic sealed partial class DiamondDebris\n{\n\tpublic sealed class ShatterSunburst\n\t{\n\t\tpublic Vector2 Position { get; internal set; }\n\t\tpublic float Age { get; internal set; }\n\t\tpublic float Lifetime { get; internal set; }\n\t\tpublic float Scale { get; internal set; }\n\t\tpublic float Rotation { get; internal set; }\n\t\tpublic float Brightness { get; internal set; }\n\t\tpublic int ColorIndex { get; internal set; }\n\t\tinternal Vector2 Velocity;\n\t}\n\n\treadonly List<ShatterSunburst> shatterSunbursts = new();\n\tpublic IReadOnlyList<ShatterSunburst> ShatterSunbursts => shatterSunbursts;\n\treadonly List<ShatterSunburst> crushSunbursts = new();\n\tpublic IReadOnlyList<ShatterSunburst> CrushSunbursts => crushSunbursts;\n\tpublic const float CrushBurstDuration = 0.25f;\n\tpublic const float CrushBurstScale = 0.6f;\n\n\tvoid EmitCrushSunburst( Particle shard )\n\t{\n\t\tif ( crushSunbursts.Count >= MaxShards ) crushSunbursts.RemoveAt( 0 );\n\t\tcrushSunbursts.Add( new ShatterSunburst\n\t\t{\n\t\t\tPosition = shard.Position, ColorIndex = shard.ColorIndex,\n\t\t\tLifetime = CrushBurstDuration, Scale = CrushBurstScale,\n\t\t\tRotation = Range( -MathF.PI, MathF.PI ), Brightness = 0.4f\n\t\t} );\n\t}\n\n\tvoid UpdateShatterSunbursts( float delta )\n\t{\n\t\tUpdateSunbursts( shatterSunbursts, delta );\n\t\tUpdateSunbursts( crushSunbursts, delta );\n\t}\n\n\tstatic void UpdateSunbursts( List<ShatterSunburst> bursts, float delta )\n\t{\n\t\tfor ( int i = bursts.Count - 1; i >= 0; i-- )\n\t\t{\n\t\t\tvar burst = bursts[i];\n\t\t\tburst.Age += delta;\n\t\t\tif ( burst.Age >= burst.Lifetime ) { bursts.RemoveAt( i ); continue; }\n\t\t\tburst.Position += burst.Velocity * delta;\n\t\t}\n\t}\n\n\tvoid EmitShatterEffects( Diamond cell, bool gameOver )\n\t{\n\t\tvar center = new Vector2( LaneX( cell.Lane ), cell.Y );\n\t\tfor ( int i = 0; i < 3; i++ )\n\t\t{\n\t\t\tif ( shatterSunbursts.Count >= 192 ) shatterSunbursts.RemoveAt( 0 );\n\t\t\tfloat angle = Range( -MathF.PI, MathF.PI );\n\t\t\tvar outward = new Vector2( MathF.Cos( angle ), MathF.Sin( angle ) );\n\t\t\tshatterSunbursts.Add( new ShatterSunburst\n\t\t\t{\n\t\t\t\tPosition = center + outward * (i == 0 ? 0 : Range( 8, 18 )),\n\t\t\t\tVelocity = outward * (i == 0 ? 0 : Range( 35, 75 )),\n\t\t\t\tLifetime = Range( 0.35f, 0.5f ), Scale = i == 0 ? 1.3f : Range( 0.5f, 0.8f ),\n\t\t\t\tRotation = Range( -MathF.PI, MathF.PI ), Brightness = Range( 0.4f, 0.6f ), ColorIndex = cell.ColorIndex\n\t\t\t} );\n\t\t}\n\t\tint count = gameOver ? random.Next( 9, 15 ) : random.Next( 28, 43 );\n\t\tfor ( int i = 0; i < count; i++ )\n\t\t{\n\t\t\tfloat angle = Range( -MathF.PI, MathF.PI );\n\t\t\tvar outward = new Vector2( MathF.Cos( angle ), MathF.Sin( angle ) );\n\t\t\tfloat radius = Range( 0.8f, 1.9f );\n\t\t\tvar position = center + outward * Range( 2, 18 );\n\t\t\tif ( !HasRoom( position, radius + 0.5f ) ) continue;\n\t\t\t// The vanished gem leaves an open pocket. Launch from inside it so\n\t\t\t// nearby surviving gems naturally block and deflect the explosion.\n\t\t\tAddSpark( position, outward * Range( 450, 950 ), radius, cell.ColorIndex );\n\t\t}\n\t}\n}\n"
        },
        {
            "Ident": "facepunch.diamondgrinder",
            "Path": "DiamondOutline.cs",
            "FileName": "DiamondOutline.cs",
            "PackageType": "game",
            "CodeKind": "Game",
            "AssetVersionId": 398652,
            "IsPrivate": false,
            "Code": "using System;\nusing System.Collections.Generic;\nusing static Diamonds.DiamondBoard;\n\nnamespace Diamonds;\n\n/// <summary>Exposed perimeter edges of actual bonded groups, in board coordinates.</summary>\npublic static class DiamondOutline\n{\n\tpublic readonly record struct Edge( float X1, float Y1, float X2, float Y2, int ColorIndex );\n\n\t/// <summary>Whether a traced perimeter can be drawn as a closed polygon.</summary>\n\tpublic static bool IsClosed( IReadOnlyList<Edge> contour ) => contour.Count >= 3\n\t\t&& MathF.Abs( contour[^1].X2 - contour[0].X1 ) <= 0.01f\n\t\t&& MathF.Abs( contour[^1].Y2 - contour[0].Y1 ) <= 0.01f;\n\n\t/// <summary>Order one group's perimeter into contours, including holes.\n\t/// Animated positions can leave open chains; use IsClosed before closing a path.\n\t/// At a shared tip, follow the rightmost turn so touching loops stay separate.</summary>\n\tpublic static List<List<Edge>> Contours( IReadOnlyList<Edge> edges )\n\t{\n\t\tvar contours = new List<List<Edge>>();\n\t\tvar used = new bool[edges.Count];\n\t\tfor ( int start = 0; start < edges.Count; start++ )\n\t\t{\n\t\t\tif ( used[start] ) continue;\n\t\t\tvar contour = new List<Edge>();\n\t\t\tint current = start;\n\t\t\twhile ( current >= 0 && !used[current] )\n\t\t\t{\n\t\t\t\tvar edge = edges[current];\n\t\t\t\tused[current] = true;\n\t\t\t\tcontour.Add( edge );\n\t\t\t\tint next = -1;\n\t\t\t\tfloat bestTurn = float.NegativeInfinity;\n\t\t\t\tfor ( int candidate = 0; candidate < edges.Count; candidate++ )\n\t\t\t\t{\n\t\t\t\t\tvar other = edges[candidate];\n\t\t\t\t\tif ( (used[candidate] && candidate != start) || other.ColorIndex != edge.ColorIndex ||\n\t\t\t\t\t\tMathF.Abs( other.X1 - edge.X2 ) > 0.01f || MathF.Abs( other.Y1 - edge.Y2 ) > 0.01f ) continue;\n\t\t\t\t\tfloat dx = edge.X2 - edge.X1, dy = edge.Y2 - edge.Y1;\n\t\t\t\t\tfloat ox = other.X2 - other.X1, oy = other.Y2 - other.Y1;\n\t\t\t\t\tfloat turn = MathF.Atan2( dx * oy - dy * ox, dx * ox + dy * oy );\n\t\t\t\t\tif ( turn > bestTurn ) { next = candidate; bestTurn = turn; }\n\t\t\t\t}\n\t\t\t\tcurrent = next;\n\t\t\t}\n\t\t\tcontours.Add( contour );\n\t\t}\n\t\treturn contours;\n\t}\n\n\tpublic static List<Edge> Build( IReadOnlyList<Diamond> cells, IReadOnlyList<IReadOnlyList<int>> groups )\n\t{\n\t\tvar edges = new List<Edge>();\n\t\tforeach ( var group in groups )\n\t\tforeach ( int index in group )\n\t\t{\n\t\t\tvar cell = cells[index];\n\t\t\tint shared = 0;\n\t\t\tforeach ( int otherIndex in group )\n\t\t\t{\n\t\t\t\tif ( index == otherIndex ) continue;\n\t\t\t\tvar other = cells[otherIndex];\n\t\t\t\tif ( cell.ColorIndex != other.ColorIndex || !DiamondMatching.Touching( cell, other ) ) continue;\n\t\t\t\tint side = other.Lane > cell.Lane ? (other.Y < cell.Y ? 0 : 1) : (other.Y > cell.Y ? 2 : 3);\n\t\t\t\tshared |= 1 << side;\n\t\t\t}\n\t\t\tfloat x = LaneX( cell.Lane ), y = cell.Y;\n\t\t\tfloat w = Width * 0.5f, h = Height * 0.5f;\n\t\t\tif ( (shared & 1) == 0 ) edges.Add( new( x, y - h, x + w, y, cell.ColorIndex ) );\n\t\t\tif ( (shared & 2) == 0 ) edges.Add( new( x + w, y, x, y + h, cell.ColorIndex ) );\n\t\t\tif ( (shared & 4) == 0 ) edges.Add( new( x, y + h, x - w, y, cell.ColorIndex ) );\n\t\t\tif ( (shared & 8) == 0 ) edges.Add( new( x - w, y, x, y - h, cell.ColorIndex ) );\n\t\t}\n\t\treturn edges;\n\t}\n}\n"
        },
        {
            "Ident": "facepunch.diamondgrinder",
            "Path": "DiamondPanel.Score.cs",
            "FileName": "DiamondPanel.Score.cs",
            "PackageType": "game",
            "CodeKind": "Game",
            "AssetVersionId": 398652,
            "IsPrivate": false,
            "Code": "using System;\nusing Sandbox;\n\nnamespace Diamonds;\n\npublic sealed partial class DiamondPanel\n{\n\treadonly DiamondScoreLayout fallbackScoreLayout = new();\n\tstatic Color ScoreColor( float phase, int step = 1 )\n\t{\n\t\tint index = ((int)phase * step) % Palette.Length;\n\t\tfloat blend = phase - MathF.Floor( phase );\n\t\tblend = blend * blend * (3 - 2 * blend);\n\t\treturn Color.Lerp( Palette[index], Palette[(index + step) % Palette.Length], blend );\n\t}\n\n\tvoid DrawScore( Painter p, float center )\n\t{\n\t\tvar effects = ScoreEffects;\n\t\tvar origin = new Vector2( Left, Top + boardOffsetY );\n\t\tvar target = new Vector2( center, NextPreviewCenterY );\n\t\tif ( effects is not null ) effects.Target = target - origin;\n\t\tvar color = ScoreColor( effects?.ColorPhase ?? 0 );\n\t\tvar layout = effects?.Layout ?? fallbackScoreLayout;\n\t\t// Use the actual letterbox, including extra space on wide viewports.\n\t\t// Keep 16 units clear on each side; never shrink the digits to fit.\n\t\tlayout.MaxWidth = (Left - center) * 2 - 32;\n\t\tlayout.Update( effects?.DisplayedScore ?? Board.Score );\n\t\tfloat scoreScale = effects?.Scale ?? 1;\n\t\tfor ( int i = 0; i < layout.Rows.Count; i++ )\n\t\t\tDrawScoreNumber( p, layout.Rows[i].Text, target + new Vector2( 0, i * DiamondScoreLayout.RowSpacing * scoreScale ),\n\t\t\t\tDiamondScoreLayout.FontSize * scoreScale, color );\n\t\tif ( effects is null ) return;\n\t\tforeach ( var pickup in effects.Pickups )\n\t\t{\n\t\t\tvar position = origin + pickup.Position;\n\t\t\tvar tint = Palette[pickup.ColorIndex % Palette.Length];\n\t\t\tfloat launch = Math.Clamp( pickup.Age / 0.12f, 0, 1 );\n\t\t\tfloat pop = 1 + MathF.Sin( launch * MathF.PI ) * 0.28f;\n\t\t\tfloat scale = MathF.Min( 2, DiamondScoreEffects.ValueScale( pickup.Points ) * pop );\n\t\t\tfloat pickupSize = DiamondScoreEffects.PickupFontSize * scale * (1 - pickup.PullProgress * 0.45f);\n\t\t\tif ( pickup.PullProgress > 0 )\n\t\t\t{\n\t\t\t\tvar tail = pickup.Velocity.Normal * MathF.Min( 45, pickup.Velocity.Length * 0.018f );\n\t\t\t\tp.Stroke = Stroke.Solid( tint.WithAlpha( 0.24f ), 2 );\n\t\t\t\tp.Line( position - tail, position );\n\t\t\t}\n\t\t\tDrawScoreNumber( p, $\"+{pickup.Points}\", position, pickupSize, tint );\n\t\t}\n\t\tforeach ( var spark in effects.Sparks )\n\t\t{\n\t\t\tfloat fade = 1 - spark.Age / spark.Lifetime;\n\t\t\tvar position = origin + spark.Position;\n\t\t\tvar tint = Color.Lerp( Palette[spark.ColorIndex % Palette.Length], Color.White, 0.55f );\n\t\t\tp.Stroke = Stroke.Solid( tint.WithAlpha( fade * fade ), 1.8f * fade );\n\t\t\tp.Line( position - spark.Velocity * 0.024f, position );\n\t\t}\n\t}\n\n\t/// <summary>Draw at <paramref name=\"center\"/>; <paramref name=\"align\"/> 0 places the text's left edge there instead.</summary>\n\tstatic void DrawScoreNumber( Painter p, string value, Vector2 center, float fontSize, Color color, float align = 0.5f, bool outline = true )\n\t{\n\t\tusing var scope = p.Scope();\n\t\tp.Translate( center );\n\t\tp.Scale( fontSize / DiamondScoreEffects.PickupFontSize );\n\t\tp.TextStyle = new TextStyle( DiamondScoreEffects.ScoreFont, DiamondScoreEffects.PickupFontSize, color )\n\t\t{\n\t\t\tAlignment = TextFlag.Center,\n\t\t\tOutline = new TextRendering.Outline { Enabled = outline, Color = Color.Black.WithAlpha( 0.8f ), Size = 1.5f }\n\t\t};\n\t\t// Keep layout at a fixed size; Painter transforms animate the cached glyphs.\n\t\tvar bounds = p.MeasureText( value ) + new Vector2( 4 );\n\t\tp.Text( value, new Rect( new Vector2( -bounds.x * align, -bounds.y * 0.5f ), bounds ) );\n\t}\n\n\t// Advances at PickupFontSize, measured on first use.\n\tstatic float[] timeAdvances;\n\n\t/// <summary>\n\t/// A character's cell width in a time. Enixe's numerals are proportional only because 1 is narrow,\n\t/// so every digit takes the widest numeral's cell and a ticking time keeps its width and positions.\n\t/// </summary>\n\tstatic float TimeAdvance( char c )\n\t{\n\t\tif ( c >= 128 ) return DiamondScoreEffects.MeasureNumber( c.ToString() ).x;\n\t\tif ( timeAdvances is null )\n\t\t{\n\t\t\tfloat digit = 0;\n\t\t\tfor ( char d = '0'; d <= '9'; d++ ) digit = MathF.Max( digit, DiamondScoreEffects.MeasureNumber( d.ToString() ).x );\n\t\t\t// Don't keep widths measured before the font is available.\n\t\t\tif ( digit <= 0 ) return 0;\n\t\t\ttimeAdvances = new float[128];\n\t\t\tfor ( char d = '0'; d <= '9'; d++ ) timeAdvances[d] = digit;\n\t\t}\n\t\tif ( timeAdvances[c] <= 0 ) timeAdvances[c] = DiamondScoreEffects.MeasureNumber( c.ToString() ).x;\n\t\treturn timeAdvances[c];\n\t}\n\n\tstatic float MeasureTimeNumber( string value, float fontSize )\n\t{\n\t\tfloat width = 0;\n\t\tforeach ( char c in value ) width += TimeAdvance( c );\n\t\treturn width * fontSize / DiamondScoreEffects.PickupFontSize;\n\t}\n\n\t/// <summary>Draw a time like <see cref=\"DrawScoreNumber\"/>, but with each glyph centered in a fixed-width cell.</summary>\n\tstatic void DrawTimeNumber( Painter p, string value, Vector2 center, float fontSize, Color color, float align = 0.5f, bool outline = true )\n\t{\n\t\tfloat scale = fontSize / DiamondScoreEffects.PickupFontSize;\n\t\tfloat x = center.x - MeasureTimeNumber( value, fontSize ) * align;\n\t\tforeach ( char c in value )\n\t\t{\n\t\t\tfloat advance = TimeAdvance( c ) * scale;\n\t\t\tDrawScoreNumber( p, c.ToString(), new Vector2( x + advance * 0.5f, center.y ), fontSize, color, outline: outline );\n\t\t\tx += advance;\n\t\t}\n\t}\n}\n"
        },
        {
            "Ident": "facepunch.diamondgrinder",
            "Path": "DiamondPanel.Title.cs",
            "FileName": "DiamondPanel.Title.cs",
            "PackageType": "game",
            "CodeKind": "Game",
            "AssetVersionId": 398652,
            "IsPrivate": false,
            "Code": "using System;\r\nusing Sandbox;\r\n\r\nnamespace Diamonds;\r\n\r\npublic sealed partial class DiamondPanel\r\n{\r\n\tbool titleScreen;\r\n\tpublic bool TitleScreen\r\n\t{\r\n\t\tget => titleScreen;\r\n\t\tset\r\n\t\t{\r\n\t\t\tif ( titleScreen == value ) return;\r\n\t\t\ttitleScreen = value;\r\n\t\t\t// Every visit to the title starts with PLAY selected.\r\n\t\t\ttitleSelection = 0;\r\n\t\t\tif ( titleButtonBlend is not null ) Array.Clear( titleButtonBlend );\r\n\t\t\tTitleAnimationTime = 0;\r\n\t\t\ttitleColorTime = 0;\r\n\t\t\ttitleSparkAccumulator = 0;\r\n\t\t\ttitleEffects.Clear();\r\n\t\t\ttitleDye.Clear();\r\n\t\t\tStopTitleAudio();\r\n\t\t}\r\n\t}\r\n\tpublic Action PlayRequested { get; set; }\r\n\tpublic Action QuitRequested { get; set; }\r\n\tpublic float TitleAnimationTime { get; private set; }\r\n\tconst float TitleSlideDuration = 0.72f;\r\n\tconst float TitleShineDuration = 0.24f;\r\n\tconst float TitleAngle = -51;\r\n\tfloat titleColorTime;\r\n\tfloat titleSparkAccumulator;\r\n\treadonly DiamondTitleEffects titleEffects = new();\r\n\treadonly DiamondMenuDye titleDye = new();\r\n\tTexture titleDyeTexture;\r\n\tColor32[] titleDyePixels;\r\n\tDyeTextureStamp titleDyeTextureStamp;\r\n\t// Only the editor comparison tools enable the retained rectangle reference.\r\n\tbool titleDyeReferenceTiles = false;\r\n\tpublic DiamondTitleEffects TitleEffects => titleEffects;\r\n\tpublic DiamondMenuDye TitleDye => titleDye;\r\n\treadonly DiamondSlideAudio titleSlideAudio = new();\r\n\tSoundHandle titleImpact;\r\n\t// Stacked below the title's lowest glyph (y ~650), ending above the canvas bottom.\r\n\tstatic Rect TitleMenuButton( int index ) => new( (CanvasWidth - 360) * 0.5f, 688 + index * 74, 360, 64 );\r\n\tstatic Rect PlayButton => TitleMenuButton( 0 );\r\n\tstatic Rect LeaderboardButton => TitleMenuButton( 1 );\r\n\tstatic Rect InstructionsButton => TitleMenuButton( 2 );\r\n\tstatic Rect QuitButton => TitleMenuButton( 3 );\r\n\t// Refilled on use: hotload keeps static values and leaves new fields null.\r\n\tRect[] titleButtons;\r\n\t/// <summary>Solid walls for the menu dye and sparks.</summary>\r\n\tRect[] TitleButtons\r\n\t{\r\n\t\tget\r\n\t\t{\r\n\t\t\ttitleButtons ??= new Rect[4];\r\n\t\t\tfor ( int i = 0; i < titleButtons.Length; i++ ) titleButtons[i] = TitleMenuButton( i );\r\n\t\t\treturn titleButtons;\r\n\t\t}\r\n\t}\r\n\tVector2 TitleShakeOffset => new Vector2( -0.6293f, 0.7771f ) *\r\n\t\t(TitleShake( TitleAnimationTime - TitleSlideDuration ) + TitleShake( TitleAnimationTime - TitleSlideDuration * 2 ));\r\n\r\n\tpublic void StopTitleAudio()\r\n\t{\r\n\t\ttitleSlideAudio.Stop();\r\n\t\ttitleImpact?.Stop();\r\n\t\ttitleImpact = null;\r\n\t}\r\n\r\n\tpublic void UpdateTitleScreen( float delta )\r\n\t{\r\n\t\tif ( !TitleScreen ) { StopTitleAudio(); return; }\r\n\t\tUpdateTitleButtonRainbow( MathF.Max( 0, delta ) );\r\n\t\tif ( ModalOpen ) { StopTitleAudio(); return; }\r\n\t\tdelta = MathF.Max( 0, delta );\r\n\t\tfloat previous = TitleAnimationTime;\r\n\t\t// Substep cosmetic physics and emission together, including frames spanning a landing.\r\n\t\tfloat remaining = MathF.Min( delta, MathF.Max( 0, 4.5f - previous ) );\r\n\t\twhile ( remaining > 0 )\r\n\t\t{\r\n\t\t\tfloat step = MathF.Min( remaining, 1f / 120 );\r\n\t\t\tfloat before = TitleAnimationTime;\r\n\t\t\tTitleAnimationTime = MathF.Min( 4.5f, before + step );\r\n\t\t\ttitleEffects.Update( step, TitleViewport, TitleButtons );\r\n\t\t\tUpdateTitleDye( step );\r\n\t\t\tfor ( int word = 0; word < 2; word++ )\r\n\t\t\t{\r\n\t\t\t\tfloat start = word * TitleSlideDuration, hit = start + TitleSlideDuration;\r\n\t\t\t\tfloat sliding = MathF.Max( 0, MathF.Min( TitleAnimationTime, hit ) - MathF.Max( before, start ) );\r\n\t\t\t\ttitleSparkAccumulator += sliding * 190;\r\n\t\t\t\twhile ( titleSparkAccumulator >= 1 )\r\n\t\t\t\t{\r\n\t\t\t\t\ttitleSparkAccumulator--;\r\n\t\t\t\t\tfloat speed = MathF.Pow( Math.Clamp( (TitleAnimationTime - start) / TitleSlideDuration, 0, 1 ), 1.8f );\r\n\t\t\t\t\tvar local = new Vector2( titleEffects.Range( -345, 335 ), 49 );\r\n\t\t\t\t\tvar position = TitleWordPoint( word, local, TitleAnimationTime );\r\n\t\t\t\t\tif ( TitleViewport.IsInside( position ) ) titleEffects.EmitSlide( position, TitleWordColor( word, titleColorTime + TitleAnimationTime - previous ), speed );\r\n\t\t\t\t}\r\n\t\t\t\tif ( before < hit && TitleAnimationTime >= hit )\r\n\t\t\t\t{\r\n\t\t\t\t\tvar color = TitleWordColor( word, titleColorTime + hit - previous );\r\n\t\t\t\t\tif ( word == 0 ) titleEffects.EmitImpact( TitleContactPoint( word ), color );\r\n\t\t\t\t\telse titleEffects.EmitFaceImpact(\r\n\t\t\t\t\t\tTitleWordPoint( 1, DiamondTitleGlyphs.Words[1][0][9], hit ),\r\n\t\t\t\t\t\tTitleWordPoint( 1, DiamondTitleGlyphs.Words[1][0][10], hit ), color );\r\n\t\t\t\t}\r\n\t\t\t}\r\n\t\t\tremaining -= step;\r\n\t\t}\r\n\t\tfloat idle = delta - (TitleAnimationTime - previous);\r\n\t\tif ( idle > 0.0001f ) UpdateTitleDye( idle );\r\n\t\ttitleColorTime = (titleColorTime + delta) % 100;\r\n\t\tfor ( int word = 1; word <= 2; word++ )\r\n\t\t{\r\n\t\t\tfloat landing = TitleSlideDuration * word;\r\n\t\t\tif ( previous >= landing || TitleAnimationTime < landing ) continue;\r\n\t\t\ttitleSlideAudio.Stop();\r\n\t\t\tif ( IsMuted ) continue;\r\n\t\t\ttitleImpact?.Stop();\r\n\t\t\ttitleImpact = Sound.Play( \"sfx/diamond_impact.sound\" );\r\n\t\t\tif ( titleImpact is null ) continue;\r\n\t\t\ttitleImpact.SpacialBlend = 0;\r\n\t\t\ttitleImpact.Volume *= (word == 1 ? 0.72f : 0.85f) * DiamondSettings.EffectsGain;\r\n\t\t\ttitleImpact.Pitch *= word == 1 ? 0.92f : 0.80f;\r\n\t\t}\r\n\t\tif ( IsMuted ) { StopTitleAudio(); return; }\r\n\t\tif ( TitleAnimationTime < TitleSlideDuration * 2 )\r\n\t\t{\r\n\t\t\tfloat progress = (TitleAnimationTime % TitleSlideDuration) / TitleSlideDuration;\r\n\t\t\t// The derivative of the power-2.8 slide drives the same foley as gameplay.\r\n\t\t\ttitleSlideAudio.Update( MathF.Pow( progress, 1.8f ), delta );\r\n\t\t}\r\n\t\telse titleSlideAudio.Stop();\r\n\t}\r\n\r\n\tpublic void HandleTitleClick( Vector2 mouse, Vector2 size )\r\n\t{\r\n\t\tif ( !TitleScreen || ModalOpen || TransitionPending ) return;\r\n\t\tfloat scale = MathF.Min( size.x / CanvasWidth, size.y / CanvasHeight );\r\n\t\tif ( scale <= 0 ) return;\r\n\t\tvar point = (mouse - (size - new Vector2( CanvasWidth, CanvasHeight ) * scale) * 0.5f) / scale;\r\n\t\tpoint -= TitleShakeOffset;\r\n\t\tfor ( int i = 0; i < TitleButtonCount; i++ )\r\n\t\t\tif ( TitleMenuButton( i ).IsInside( point ) ) { ActivateTitleButton( i ); return; }\r\n\t}\r\n\r\n\tvoid DrawTitleScreen( Painter p, float scale )\r\n\t{\r\n\t\tusing var scope = p.Scope();\r\n\t\tp.Translate( TitleShakeOffset );\r\n\t\tDrawTitleDye( p );\r\n\t\tDrawTitleWord( p, \"DIAMOND\", 0 );\r\n\t\tDrawTitleWord( p, \"GRINDER\", 1 );\r\n\t\tvar size = Box.Rect.Size;\r\n\t\tvar mouse = (MousePosition - (size - new Vector2( CanvasWidth, CanvasHeight ) * scale) * 0.5f) / scale;\r\n\t\tmouse -= TitleShakeOffset;\r\n\t\t// The fluid layer is blurred, so cover its filtered fringe inside the solid buttons.\r\n\t\tp.Stroke = Stroke.None;\r\n\t\tp.Fill = Color.Parse( \"#0b1017\" ).Value;\r\n\t\tforeach ( var button in TitleButtons ) p.Rect( button );\r\n\t\t// The selection, not the mouse, lights title buttons; hovering one selects it.\r\n\t\tDrawTitleButton( p, PlayButton, \"PLAY\", mouse, scale, selected: titleSelection == 0, labelColor: TitleButtonLabel( 0 ) );\r\n\t\tDrawTitleButton( p, LeaderboardButton, \"LEADERBOARDS\", mouse, scale, selected: titleSelection == 1, labelColor: TitleButtonLabel( 1 ) );\r\n\t\tDrawTitleButton( p, InstructionsButton, \"INSTRUCTIONS\", mouse, scale, selected: titleSelection == 2, labelColor: TitleButtonLabel( 2 ) );\r\n\t\tDrawTitleButton( p, QuitButton, \"QUIT\", mouse, scale, danger: true, selected: titleSelection == 3, labelColor: TitleButtonLabel( 3 ) );\r\n\t\tDrawTitleEffects( p );\r\n\t}\r\n\r\n\tstatic float TitleShake( float age ) => age < 0 || age > 0.48f ? 0\r\n\t\t: 11 * MathF.Exp( -age * 10 ) * MathF.Cos( age * 95 );\r\n\r\n\tstatic Color TitleWordColor( int word, float time )\r\n\t{\r\n\t\t// DIAMOND reaches pure blue exactly as it lands, so its darker face shows the shine.\r\n\t\tfloat phase = word == 0 ? 1 + (time - TitleSlideDuration) * 0.85f : 3 - time * 1.05f;\r\n\t\t// Wrap the reverse cycle into the palette's positive range before interpolating.\r\n\t\treturn ScoreColor( (phase % Palette.Length + Palette.Length) % Palette.Length );\r\n\t}\r\n\r\n\tRect TitleViewport\r\n\t{\r\n\t\tget\r\n\t\t{\r\n\t\t\tvar size = Box.Rect.Size;\r\n\t\t\tfloat scale = MathF.Min( size.x / CanvasWidth, size.y / CanvasHeight );\r\n\t\t\tvar extent = scale > 0 ? size / scale : new Vector2( CanvasWidth, CanvasHeight );\r\n\t\t\treturn new Rect( (new Vector2( CanvasWidth, CanvasHeight ) - extent) * 0.5f, extent );\r\n\t\t}\r\n\t}\r\n\r\n\tVector2 TitleWordPoint( int word, Vector2 local, float time )\r\n\t{\r\n\t\tfloat progress = Math.Clamp( (time - word * TitleSlideDuration) / TitleSlideDuration, 0, 1 );\r\n\t\tfloat slide = (910 + MathF.Max( 0, -TitleViewport.Top ) / 0.777146f) * (1 - MathF.Pow( progress, 2.8f ));\r\n\t\t// Compensate for D/G's different lowest vertices, aligning the visible tips.\r\n\t\tlocal += new Vector2( slide + (word == 0 ? -9.72f : 45), word == 0 ? -54 : 54 );\r\n\t\treturn new Vector2( CanvasWidth * 0.5f, 374 ) + new Vector2( local.x * 0.62932f + local.y * 0.777146f, -local.x * 0.777146f + local.y * 0.62932f );\r\n\t}\r\n\r\n\tVector2 TitleContactPoint( int word ) => TitleWordPoint( word,\r\n\t\tword == 0 ? new Vector2( -348.5f, 47.11f ) : new Vector2( -324.64f, 36.14f ), TitleSlideDuration * 2 );\r\n\r\n\tVector2 TitleWordCenter( int index )\r\n\t{\r\n\t\tfloat age = TitleAnimationTime - index * TitleSlideDuration;\r\n\t\tfloat damage = Math.Clamp( (age - TitleSlideDuration) / 0.24f, 0, 1 );\r\n\t\tfloat magnitude = age < TitleSlideDuration ? 0 : 12 * (1 - damage) * (1 - damage);\r\n\t\tfloat angle = MathF.Floor( damage * 18 ) * 2.39996f + index * 1.7f;\r\n\t\treturn TitleWordPoint( index, Vector2.Zero, TitleAnimationTime ) + new Vector2( MathF.Cos( angle ), MathF.Sin( angle ) ) * magnitude;\r\n\t}\r\n\r\n\tvoid UpdateTitleDye( float delta ) => titleDye.Update( delta, TitleViewport, TitleButtons,\r\n\t\tTitleWordCenter( 0 ), TitleWordCenter( 1 ) );\r\n\r\n\tvoid DrawTitleDye( Painter p )\r\n\t{\r\n\t\tif ( HideDye ) return;\r\n\t\tif ( !titleDye.Ready( TitleViewport ) ) UpdateTitleDye( 0 );\r\n\t\tif ( titleDyeReferenceTiles ) { DrawTitleDyeTiles( p ); return; }\r\n\t\tUpdateDyeTexture( titleDye, titleDye.DyeVersion, titleDye.Samples, DiamondMenuDye.Columns, DiamondMenuDye.Rows,\r\n\t\t\tColor.Parse( \"#0b1017\" ).Value, \"diamonds-menu-dye\", Math.Clamp( DyeVibrancy, 0, 1 ), ref titleDyeTexture, ref titleDyePixels, ref titleDyeTextureStamp );\r\n\t\tusing var scope = p.Scope();\r\n\t\tp.Clip( TitleViewport );\r\n\t\tDrawDyeLayer( p, titleDyeTexture, TitleViewport, TitleViewport, DyeLayerOpacity( 1 ) );\r\n\t}\r\n\r\n\tvoid DrawTitleDyeTiles( Painter p )\r\n\t{\r\n\t\tif ( !titleDye.Ready( TitleViewport ) ) UpdateTitleDye( 0 );\r\n\t\tusing var scope = p.Scope();\r\n\t\tp.Clip( TitleViewport );\r\n\t\tusing var layer = p.BeginLayer( TitleViewport, 0.22f, new Painter.Filter { Blur = 6 } );\r\n\t\tvar background = Color.Parse( \"#0b1017\" ).Value;\r\n\t\tp.Stroke = Stroke.None;\r\n\t\tfor ( int y = 0; y < DiamondMenuDye.Rows; y++ )\r\n\t\tfor ( int x = 0; x < DiamondMenuDye.Columns; x++ )\r\n\t\t{\r\n\t\t\tfloat total = 0, weight = 0;\r\n\t\t\tvar rgb = Vector3.Zero;\r\n\t\t\tfor ( int color = 0; color < 5; color++ )\r\n\t\t\t{\r\n\t\t\t\tfloat amount = titleDye.Sample( x, y, color );\r\n\t\t\t\ttotal += amount; weight += amount * amount;\r\n\t\t\t\trgb += new Vector3( Palette[color].r, Palette[color].g, Palette[color].b ) * (amount * amount);\r\n\t\t\t}\r\n\t\t\tif ( weight <= 0 ) continue;\r\n\t\t\tfloat coverage = Math.Clamp( (total - 0.04f) / 0.28f, 0, 1 );\r\n\t\t\tcoverage = coverage * coverage * (3 - 2 * coverage);\r\n\t\t\trgb /= weight;\r\n\t\t\tp.Fill = Color.Lerp( background, new Color( rgb.x, rgb.y, rgb.z ), coverage );\r\n\t\t\tp.Rect( new Rect( titleDye.Position( x, y ) - titleDye.CellSize * 0.5f, titleDye.CellSize + new Vector2( 0.6f ) ) );\r\n\t\t}\r\n\t}\r\n\r\n\tvoid DrawTitleWord( Painter p, string word, int index )\r\n\t{\r\n\t\tfloat age = TitleAnimationTime - index * TitleSlideDuration;\r\n\t\tif ( age <= 0 ) return;\r\n\t\tvar color = TitleWordColor( index, titleColorTime );\r\n\t\tusing var scope = p.Scope();\r\n\t\tp.Translate( TitleWordCenter( index ) );\r\n\t\tp.Rotate( TitleAngle );\r\n\t\tp.TextStyle = new TextStyle( DiamondScoreEffects.ScoreFont, 100, color ) { FontWeight = 700, Alignment = TextFlag.Center };\r\n\t\tvar bounds = p.MeasureText( word );\r\n\t\tif ( bounds.x <= 0 || bounds.y <= 0 ) return;\r\n\t\tp.Scale( 700 / bounds.x, 1.8f );\r\n\t\tvar rect = new Rect( -(bounds + new Vector2( 4 )) * 0.5f, bounds + new Vector2( 4 ) );\r\n\t\tp.Text( word, rect );\r\n\t\tfloat shine = (age - TitleSlideDuration) / TitleShineDuration;\r\n\t\tif ( shine < 0 || shine >= 1 ) return;\r\n\t\t// Sweep downhill along the glyphs, clipping bright text instead of drawing over its gaps.\r\n\t\tfloat sweep = bounds.x * (0.65f - shine * 1.3f);\r\n\t\t// A blurred copy of a wider band spills light past the glyph edges.\r\n\t\tusing ( p.BeginLayer( new Rect( rect.Position - new Vector2( 32 ), rect.Size + new Vector2( 64 ) ), 0.9f * (1 - shine * shine),\r\n\t\t\tnew Painter.Filter { Blur = 7 } ) )\r\n\t\t\tDrawTitleShineBands( p, word, rect, bounds, color, sweep, 2.2f, 1 );\r\n\t\tDrawTitleShineBands( p, word, rect, bounds, color, sweep, 1, 0.94f );\r\n\t}\r\n\r\n\tstatic void DrawTitleShineBands( Painter p, string word, Rect rect, Vector2 bounds, Color color, float sweep, float spread, float brightness )\r\n\t{\r\n\t\tfloat width = bounds.x * 0.0065f * spread;\r\n\t\tfor ( int band = 0; band < 25; band++ )\r\n\t\t{\r\n\t\t\tusing var highlight = p.Scope();\r\n\t\t\t// Overlap the antialiased clip edges so the reflection has no dark seams.\r\n\t\t\tp.Clip( new Rect( sweep + (band - 12.5f) * width, -bounds.y, width + 1, bounds.y * 2 ) );\r\n\t\t\tfloat intensity = 1 - MathF.Abs( band - 12 ) / 13;\r\n\t\t\tp.TextStyle = new TextStyle( DiamondScoreEffects.ScoreFont, 100, Color.Lerp( color, Color.White, intensity * brightness ) )\r\n\t\t\t\t{ FontWeight = 700, Alignment = TextFlag.Center };\r\n\t\t\tp.Text( word, rect );\r\n\t\t}\r\n\t}\r\n\r\n\tvoid DrawTitleEffects( Painter p )\r\n\t{\r\n\t\tforeach ( var spark in titleEffects.Particles )\r\n\t\t{\r\n\t\t\tfloat fade = Math.Clamp( (spark.Lifetime - spark.Age) / 0.45f, 0, 1 );\r\n\t\t\tvar tint = Color.Lerp( spark.Color, Color.White, 0.45f );\r\n\t\t\tp.Stroke = Stroke.Solid( tint.WithAlpha( fade * 0.25f ), spark.Radius * 3 );\r\n\t\t\tvar tail = spark.Position - spark.Velocity.Normal * MathF.Min( 15, spark.Velocity.Length * 0.012f );\r\n\t\t\tp.Line( tail, spark.Position );\r\n\t\t\tp.Stroke = Stroke.Solid( tint.WithAlpha( fade ), spark.Radius );\r\n\t\t\tp.Line( tail, spark.Position );\r\n\t\t}\r\n\t\tforeach ( var burst in titleEffects.Bursts )\r\n\t\t{\r\n\t\t\tif ( burst.FaceEnd.HasValue )\r\n\t\t\t{\r\n\t\t\t\tvar jolt = TitleWordCenter( 1 ) - TitleWordPoint( 1, Vector2.Zero, TitleAnimationTime );\r\n\t\t\t\tDrawFaceFlash( p, burst.Position + jolt, burst.FaceEnd.Value + jolt, burst.Color, burst.Age, burst.Rotation, 0.42f, 2 );\r\n\t\t\t}\r\n\t\t\telse DrawSunburst( p, burst.Position, burst.Color, burst.Age / 0.25f, burst.Rotation, 0.42f, 2.4f );\r\n\t\t}\r\n\t}\r\n\r\n\t// PLAY, LEADERBOARDS, INSTRUCTIONS and QUIT rest on blue, yellow, purple and red.\r\n\tstatic readonly int[] TitleButtonPalette = [1, 3, 4, 0];\r\n\tconst float TitleButtonRainbowRate = 7;\r\n\tconst float TitleButtonBlendRate = 12;\r\n\t// Created on use: hotload leaves new fields null.\r\n\tfloat[] titleButtonBlend;\r\n\tfloat titleButtonRainbowTime;\r\n\r\n\tvoid UpdateTitleButtonRainbow( float delta )\r\n\t{\r\n\t\ttitleButtonBlend ??= new float[TitleButtonCount];\r\n\t\ttitleButtonRainbowTime = (titleButtonRainbowTime + delta * TitleButtonRainbowRate) % (Palette.Length * 100);\r\n\t\t// Lit (hovered or selected) labels ease toward the cycling rainbow, and back once unlit.\r\n\t\tfloat ease = 1 - MathF.Exp( -delta * TitleButtonBlendRate );\r\n\t\tfor ( int i = 0; i < titleButtonBlend.Length; i++ )\r\n\t\t\ttitleButtonBlend[i] += ((titleSelection == i ? 1 : 0) - titleButtonBlend[i]) * ease;\r\n\t}\r\n\r\n\tColor TitleButtonLabel( int index )\r\n\t{\r\n\t\tfloat blend = titleButtonBlend is { } values && index < values.Length ? values[index] : 0;\r\n\t\t// Offset each button's phase so neighbours don't flash the same color.\r\n\t\treturn Color.Lerp( Palette[TitleButtonPalette[index]], ScoreColor( titleButtonRainbowTime + index * 1.3f ), blend );\r\n\t}\r\n\r\n\t// Quit and Leave warn on hover by flashing their background red, five times a second.\r\n\t// A selected title button lights (and warns) as if hovered; only a mouse press over it shows pressed.\r\n\tvoid DrawTitleButton( Painter p, Rect rect, string label, Vector2 mouse, float scale, float fontSize = 34, float brightness = 1, bool danger = false, bool? selected = null, Color? labelColor = null )\r\n\t{\r\n\t\tbool hover = !ModalOpen && rect.IsInside( mouse );\r\n\t\tbool lit = selected ?? hover;\r\n\t\tbool pressed = lit && hover && HasActive;\r\n\t\tif ( lit && danger ) p.Fill = Palette[0].WithAlpha( RealTime.Now * 5 % 1 < 0.5f ? (pressed ? 0.7f : 0.55f) : 0.14f );\r\n\t\telse p.Fill = Ink.WithAlpha( pressed ? 0.2f : lit ? 0.12f : 0.045f );\r\n\t\tp.Stroke = Stroke.None;\r\n\t\tp.Rect( rect );\r\n\t\tDrawScoreNumber( p, label, rect.Center, fontSize, (labelColor ?? (lit ? Color.White : Ink)) * brightness );\r\n\t}\r\n}\r\n"
        },
        {
            "Ident": "facepunch.diamondgrinder",
            "Path": "DiamondLeaderboard.cs",
            "FileName": "DiamondLeaderboard.cs",
            "PackageType": "game",
            "CodeKind": "Game",
            "AssetVersionId": 398652,
            "IsPrivate": false,
            "Code": "using System;\r\nusing System.Collections.Generic;\r\nusing System.Globalization;\r\nusing System.Linq;\r\nusing System.Threading.Tasks;\r\nusing Sandbox;\r\n\r\nnamespace Diamonds;\r\n\r\n/// <summary>\r\n/// The all-time boards: highest score and longest survival. Each game over submits its total score and\r\n/// whole elapsed seconds as two stat values; each board is read with Max aggregation, highest first, so\r\n/// every player shows their personal best. The stats are created on first submit, with no dashboard setup.\r\n/// </summary>\r\npublic static class DiamondLeaderboard\r\n{\r\n\t/// <summary>Bump to start fresh boards: it is baked into <see cref=\"StatName\"/>, so older entries stop showing.</summary>\r\n\tpublic const int LEADERBOARD_NUM = 1;\r\n\tpublic const int MaxEntries = 200;\r\n\r\n\tpublic enum Mode { Score, Time }\r\n\tpublic static string StatName( Mode mode ) => mode == Mode.Time ? $\"time_v{LEADERBOARD_NUM}\" : $\"score_v{LEADERBOARD_NUM}\";\r\n\r\n\t/// <summary>A player's best: a score, or whole seconds survived.</summary>\r\n\tpublic readonly record struct Row( long SteamId, string Name, long Value );\r\n\r\n\t// This session's best submissions. Board responses are cached upstream for a few minutes, so a\r\n\t// fresh entry can be missing from them; splicing it in shows the new rank right away. Session-only\r\n\t// is enough, as it only has to bridge that cache window.\r\n\tstatic long sessionBestScore, sessionBestSeconds;\r\n\tstatic ref long SessionBest( Mode mode ) => ref mode == Mode.Time ? ref sessionBestSeconds : ref sessionBestScore;\r\n\r\n\t/// <summary>The in-game timer's MM:SS, which the time board shows too.</summary>\r\n\tpublic static string FormatTime( long seconds ) => $\"{seconds / 60:00}:{seconds % 60:00}\";\r\n\tpublic static string Format( Mode mode, long value ) =>\r\n\t\tmode == Mode.Time ? FormatTime( value ) : value.ToString( \"N0\", CultureInfo.InvariantCulture );\r\n\r\n\t/// <summary>Submit a finished game's total score and whole seconds survived. Max aggregation keeps only each player's best.</summary>\r\n\tpublic static void Submit( long score, long seconds )\r\n\t{\r\n\t\tSubmitStat( Mode.Score, score );\r\n\t\tSubmitStat( Mode.Time, seconds );\r\n\t\tSandbox.Services.Stats.Flush();\r\n\t}\r\n\r\n\tstatic void SubmitStat( Mode mode, long value )\r\n\t{\r\n\t\tstring stat = StatName( mode );\r\n\t\tif ( value <= 0 )\r\n\t\t{\r\n\t\t\tLog.Info( $\"Diamonds: skipped '{stat}' submission for {Format( mode, value )}.\" );\r\n\t\t\treturn;\r\n\t\t}\r\n\t\t// Submit every game: the engine drops a failed upload without retrying, so skipping values\r\n\t\t// below a best we only submitted could leave the board without it. Send the session best rather than\r\n\t\t// this game's value: SetValue replaces a queued, not yet uploaded value (Flush can hold one back ~20 s),\r\n\t\t// so a quick low game straight after a high one would otherwise erase the best.\r\n\t\tref long best = ref SessionBest( mode );\r\n\t\tbest = Math.Max( best, value );\r\n\t\tSandbox.Services.Stats.SetValue( stat, best );\r\n\t\tLog.Info( best == value ? $\"Diamonds: submitted {Format( mode, value )} to '{stat}'.\"\r\n\t\t\t: $\"Diamonds: submitted session best {Format( mode, best )} to '{stat}' for a game with {Format( mode, value )}.\" );\r\n\t}\r\n\r\n\t/// <summary>The top <see cref=\"MaxEntries\"/> players on <paramref name=\"mode\"/>'s board, including this session's best when the\r\n\t/// response predates it. <paramref name=\"testPlayers\"/> mixes generated players in by value, for previewing a busy board.</summary>\r\n\tpublic static async Task<List<Row>> FetchTop( Mode mode, int testPlayers = 0 )\r\n\t{\r\n\t\tvar rows = new List<Row>();\r\n\t\tstring stat = StatName( mode );\r\n\t\ttry\r\n\t\t{\r\n\t\t\tvar board = Sandbox.Services.Leaderboards.GetFromStat( stat );\r\n\t\t\tboard.SetAggregationMax();\r\n\t\t\tboard.SetSortDescending();\r\n\t\t\tboard.FilterByNone();\r\n\t\t\tboard.MaxEntries = MaxEntries;\r\n\t\t\tawait board.Refresh();\r\n\t\t\tforeach ( var entry in board.Entries ?? [] )\r\n\t\t\t\trows.Add( new Row( entry.SteamId, entry.DisplayName, (long)entry.Value ) );\r\n\t\t}\r\n\t\tcatch ( Exception e )\r\n\t\t{\r\n\t\t\t// A stat with no entries omits them from the response, and the engine throws while reading it.\r\n\t\t\t// Treat that, and an unreachable backend, as an empty board.\r\n\t\t\tLog.Info( $\"Diamonds: leaderboard empty or unavailable ({e.Message}) [stat '{stat}'].\" );\r\n\t\t\trows.Clear();\r\n\t\t}\r\n\t\tif ( testPlayers > 0 )\r\n\t\t{\r\n\t\t\trows.AddRange( TestRows( testPlayers, mode ) );\r\n\t\t\t// Stable, so real rows stay ahead of equal test values.\r\n\t\t\trows = rows.OrderByDescending( r => r.Value ).Take( MaxEntries ).ToList();\r\n\t\t}\r\n\t\tlong me = (long)Game.SteamId;\r\n\t\tSplice( rows, me, SessionBest( mode ), () => new Friend( me ).Name );\r\n\t\treturn rows;\r\n\t}\r\n\r\n\t/// <summary>\r\n\t/// Deterministic generated players, highest first, with short, long, lowercase and non-Latin names.\r\n\t/// Scores fall from about 250,000 and times from 90 minutes, so a real entry lands among them. Their\r\n\t/// ids are in the engine's fake range, which draws generated avatars instead of asking Steam.\r\n\t/// </summary>\r\n\tpublic static List<Row> TestRows( int count, Mode mode = Mode.Score )\r\n\t{\r\n\t\tstring[] names = [\"crystalfan\", \"An Extremely Long Steam Username That Must Be Truncated\", \"Garry\", \"\u30c0\u30a4\u30e4\u30e2\u30f3\u30c9\u8077\u4eba\", \"x\",\r\n\t\t\t\"GemGrinder_2000\", \"\u03a9mega \u0394iamond\", \"lowercase only name\", \"WWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWW\", \"\u0428\u043b\u0438\u0444\u043e\u0432\u0449\u0438\u043a\",\r\n\t\t\t\"prism\", \"Sapphire Queen\", \"\ud83d\udc8e facet \ud83d\udc8e\", \"not_a_bot\", \"Tetrahedron\", \"Obsidian\"];\r\n\t\tcount = Math.Clamp( count, 0, MaxEntries );\r\n\t\tvar rows = new List<Row>( count );\r\n\t\tfloat top = mode == Mode.Time ? 5400 : 250_000;\r\n\t\tlong value = long.MaxValue;\r\n\t\tfor ( int i = 0; i < count; i++ )\r\n\t\t{\r\n\t\t\tfloat t = count > 1 ? i / (float)(count - 1) : 0;\r\n\t\t\t// Geometric spacing, kept strictly descending where it flattens near the bottom.\r\n\t\t\tvalue = Math.Min( value - 1, (long)(top * MathF.Pow( (10 + count) / top, t )) );\r\n\t\t\tstring name = names[i % names.Length] + (i >= names.Length ? $\" {i / names.Length + 1}\" : \"\");\r\n\t\t\trows.Add( new Row( 90071996842377216 + 1000 + i, name, value ) );\r\n\t\t}\r\n\t\treturn rows;\r\n\t}\r\n\r\n\t/// <summary>\r\n\t/// Show <paramref name=\"best\"/> for <paramref name=\"me\"/> when the board has a lower value or none:\r\n\t/// the old row is removed and the new one takes its rank. Ties go after existing rows, which were\r\n\t/// submitted earlier. A new entrant that falls outside a full window is left off.\r\n\t/// </summary>\r\n\tpublic static void Splice( List<Row> rows, long me, long best, Func<string> localName )\r\n\t{\r\n\t\tif ( best <= 0 ) return;\r\n\t\tint existing = rows.FindIndex( r => r.SteamId == me );\r\n\t\tif ( existing >= 0 && rows[existing].Value >= best ) return;\r\n\t\tstring name = existing >= 0 ? rows[existing].Name : localName();\r\n\t\tif ( existing >= 0 ) rows.RemoveAt( existing );\r\n\t\tint index = rows.FindIndex( r => r.Value < best );\r\n\t\tif ( index < 0 ) index = rows.Count;\r\n\t\tif ( existing < 0 && index >= MaxEntries ) return;\r\n\t\trows.Insert( index, new Row( me, name, best ) );\r\n\t\tif ( rows.Count > MaxEntries ) rows.RemoveRange( MaxEntries, rows.Count - MaxEntries );\r\n\t}\r\n}\r\n"
        },
        {
            "Ident": "facepunch.diamondgrinder",
            "Path": "DiamondPanel.Transition.cs",
            "FileName": "DiamondPanel.Transition.cs",
            "PackageType": "game",
            "CodeKind": "Game",
            "AssetVersionId": 398652,
            "IsPrivate": false,
            "Code": "using System;\r\nusing Sandbox;\r\n\r\nnamespace Diamonds;\r\n\r\npublic sealed partial class DiamondPanel\r\n{\r\n\tconst float TransitionFadeOut = 0.1f, TransitionFadeIn = 0.25f;\r\n\t// Hotload leaves new fields at their defaults, so idle must be the default state.\r\n\tbool transitionActive, transitionSwitched, transitionToGame;\r\n\tfloat transitionTime;\r\n\t/// <summary>A title/game switch is fading the old screen out or the new one in.</summary>\r\n\tpublic bool Transitioning => transitionActive;\r\n\t/// <summary>The old screen is still fading out; the switch has not happened yet.</summary>\r\n\tpublic bool TransitionPending => transitionActive && !transitionSwitched;\r\n\t/// <summary>The pending or current switch leads into a game rather than the title.</summary>\r\n\tpublic bool TransitionToGame => transitionToGame;\r\n\r\n\t/// <summary>Fade to black, then switch between the title and a new game and fade back in.</summary>\r\n\tvoid BeginTransition( bool toGame )\r\n\t{\r\n\t\tif ( TransitionPending ) return;\r\n\t\t// Interrupting a fade-in continues from its current darkness rather than popping.\r\n\t\ttransitionTime = transitionActive ? TransitionDarkness * TransitionFadeOut : 0;\r\n\t\ttransitionActive = true;\r\n\t\ttransitionSwitched = false;\r\n\t\ttransitionToGame = toGame;\r\n\t}\r\n\r\n\tpublic void CancelTransition() => transitionActive = false;\r\n\r\n\tpublic void UpdateTransition( float delta )\r\n\t{\r\n\t\tif ( !transitionActive ) return;\r\n\t\tdelta = MathF.Max( 0, delta );\r\n\t\tif ( !transitionSwitched )\r\n\t\t{\r\n\t\t\ttransitionTime += delta;\r\n\t\t\tif ( transitionTime < TransitionFadeOut ) return;\r\n\t\t\t// Start the fade-in fully black; the switch frame's own cost must not skip it.\r\n\t\t\ttransitionTime = TransitionFadeOut;\r\n\t\t\ttransitionSwitched = true;\r\n\t\t\tif ( transitionToGame )\r\n\t\t\t{\r\n\t\t\t\t// A completed restart hold fades out under the black; the new game starts without it.\r\n\t\t\t\tClearRestartHold();\r\n\t\t\t\tTitleScreen = false;\r\n\t\t\t\tPlayRequested?.Invoke();\r\n\t\t\t}\r\n\t\t\telse LeaveRequested?.Invoke();\r\n\t\t\treturn;\r\n\t\t}\r\n\t\t// Cap hitch frames (such as the first gameplay frame) so the fade-in stays visible.\r\n\t\ttransitionTime += MathF.Min( delta, 1f / 30 );\r\n\t\tif ( transitionTime >= TransitionFadeOut + TransitionFadeIn ) transitionActive = false;\r\n\t}\r\n\r\n\tfloat TransitionDarkness => !transitionActive ? 0\r\n\t\t: !transitionSwitched ? Math.Clamp( transitionTime / TransitionFadeOut, 0, 1 )\r\n\t\t: Math.Clamp( 1 - (transitionTime - TransitionFadeOut) / TransitionFadeIn, 0, 1 );\r\n\r\n\tvoid DrawTransition( Painter p )\r\n\t{\r\n\t\tfloat darkness = TransitionDarkness;\r\n\t\tif ( darkness <= 0 ) return;\r\n\t\tp.Fill = Color.Black.WithAlpha( darkness );\r\n\t\tp.Stroke = Stroke.None;\r\n\t\tp.Rect( p.Bounds );\r\n\t}\r\n}\r\n"
        },
        {
            "Ident": "facepunch.diamondgrinder",
            "Path": "DiamondSoundSamples.cs",
            "FileName": "DiamondSoundSamples.cs",
            "PackageType": "game",
            "CodeKind": "Game",
            "AssetVersionId": 398652,
            "IsPrivate": false,
            "Code": "using System;\r\n\r\nnamespace Diamonds;\r\n\r\n/// <summary>Debris crushing foley, synthesized once as mono PCM.</summary>\r\npublic static class DiamondSoundSamples\r\n{\r\n\tpublic const int Rate = 44100;\r\n\r\n\tpublic static byte[] Crush()\r\n\t{\r\n\t\tvar samples = new float[(int)(Rate * 0.16f)];\r\n\t\tvar random = new Random( 2609 );\r\n\t\tfloat[] clicks = [0.012f, 0.029f, 0.054f];\r\n\t\tfloat low = 0;\r\n\t\tfor ( int i = 0; i < samples.Length; i++ )\r\n\t\t{\r\n\t\t\tfloat t = (float)i / Rate;\r\n\t\t\tfloat noise = (float)random.NextDouble() * 2 - 1;\r\n\t\t\tlow += (noise - low) * 0.24f;\r\n\t\t\t// Dry granular snap, with several rapidly decaying fracture clicks.\r\n\t\t\tfloat envelope = MathF.Exp( -t * 45 );\r\n\t\t\tforeach ( float start in clicks )\r\n\t\t\t\tif ( t >= start ) envelope += 0.38f * MathF.Exp( -(t - start) * 180 );\r\n\t\t\tfloat edge = MathF.Min( 1, t / 0.001f ) * MathF.Min( 1, (samples.Length - 1 - i) / (Rate * 0.015f) );\r\n\t\t\tsamples[i] = ((noise - low) * 0.5f + low * 0.7f) * envelope * edge;\r\n\t\t}\r\n\t\treturn Pcm( samples, 0.76f );\r\n\t}\r\n\r\n\t/// <summary>A short, deep error buzz for a refused turn.</summary>\r\n\tpublic static byte[] RotateBump()\r\n\t{\r\n\t\tconst float duration = 0.11f;\r\n\t\tvar samples = new float[(int)(Rate * duration)];\r\n\t\tfloat phase = 0, sub = 0, low = 0;\r\n\t\tfor ( int i = 0; i < samples.Length; i++ )\r\n\t\t{\r\n\t\t\tfloat t = (float)i / Rate;\r\n\t\t\t// A saturated low tone sagging from 128 to 92 Hz, over a sine an octave down.\r\n\t\t\tfloat frequency = 128 - 36 * (t / duration);\r\n\t\t\tphase += MathF.Tau * frequency / Rate;\r\n\t\t\tsub += MathF.Tau * frequency * 0.5f / Rate;\r\n\t\t\tfloat buzz = MathF.Tanh( 3 * MathF.Sin( phase ) ) + 0.3f * MathF.Tanh( 3 * MathF.Sin( phase * 1.06f ) );\r\n\t\t\t// Round off the buzz so it stays dull rather than harsh.\r\n\t\t\tlow += (buzz - low) * 0.16f;\r\n\t\t\tfloat envelope = MathF.Min( 1, t / 0.003f ) * MathF.Exp( -t * 20 ) * MathF.Min( 1, (samples.Length - 1 - i) / (Rate * 0.02f) );\r\n\t\t\tsamples[i] = (low * 0.8f + MathF.Sin( sub ) * 0.55f) * envelope;\r\n\t\t}\r\n\t\treturn Pcm( samples, 0.8f );\r\n\t}\r\n\r\n\tstatic byte[] Pcm( float[] samples, float peak )\r\n\t{\r\n\t\tfloat maximum = 0.001f;\r\n\t\tforeach ( float value in samples ) maximum = MathF.Max( maximum, MathF.Abs( value ) );\r\n\t\tvar bytes = new byte[samples.Length * 2];\r\n\t\tfor ( int i = 0; i < samples.Length; i++ )\r\n\t\t{\r\n\t\t\tshort value = (short)(samples[i] / maximum * peak * short.MaxValue);\r\n\t\t\tbytes[i * 2] = (byte)(value & 255);\r\n\t\t\tbytes[i * 2 + 1] = (byte)((value >> 8) & 255);\r\n\t\t}\r\n\t\treturn bytes;\r\n\t}\r\n}\r\n"
        },
        {
            "Ident": "facepunch.diamondgrinder",
            "Path": "DiamondPanel.cs",
            "FileName": "DiamondPanel.cs",
            "PackageType": "game",
            "CodeKind": "Game",
            "AssetVersionId": 398652,
            "IsPrivate": false,
            "Code": "using System;\r\nusing System.Collections.Generic;\r\nusing Sandbox;\r\nusing Sandbox.UI;\r\n\r\nnamespace Diamonds;\r\n\r\n/// <summary>Every visible element is drawn with the new Painter API; no sprite assets.</summary>\r\npublic sealed partial class DiamondPanel : Panel\r\n{\r\n\tpublic DiamondBoard Board { get; set; }\r\n\tpublic DiamondDebris Debris { get; set; }\r\n\tpublic DiamondAtmosphere Atmosphere { get; set; }\r\n\tpublic DiamondScoreEffects ScoreEffects { get; set; }\r\n\tpublic Vector2 ShakeOffset { get; set; }\r\n\tpublic Vector2 PieceShakeOffset { get; set; }\r\n\tpublic bool IsMuted { get; set; }\r\n\t/// <summary>Editor-only music state, drawn in the bottom-left screen corner when set.</summary>\r\n\tpublic string MusicDebugText { get; set; }\r\n\tpublic bool ShowLandingIndicator { get; set; } = false;\r\n\t/// <summary>Multiplies the landing point marker's original size.</summary>\r\n\tpublic float LandingPointScale { get; set; } = 1;\r\n\t/// <summary>Thumbnail capture: draw only the background dye, nothing else.</summary>\r\n\tpublic bool OnlyDye { get; set; }\r\n\t/// <summary>Thumbnail capture: skip every dye layer, including the static and menu dye.</summary>\r\n\tpublic bool HideDye { get; set; }\r\n\t/// <summary>Thumbnail capture: blends every dye layer from its normal faint wash (0) to opaque, fully saturated color (1).</summary>\r\n\tpublic float DyeVibrancy { get; set; }\r\n\tfloat DyeLayerOpacity( float opacity )\r\n\t{\r\n\t\tfloat vibrancy = Math.Clamp( DyeVibrancy, 0, 1 );\r\n\t\treturn 0.22f * opacity + (1 - 0.22f * opacity) * vibrancy;\r\n\t}\r\n\tsealed class LandingPointGlow\r\n\t{\r\n\t\tpublic DiamondDamage.Contact Contact;\r\n\t\tpublic float Opacity;\r\n\t\tpublic float Rotation;\r\n\t\tpublic bool Visible;\r\n\t\t// Seconds since appearing, driving the uneven per-ray pulse; Appear eases the pop-in scale.\r\n\t\tpublic float Age;\r\n\t\tpublic float Appear;\r\n\t\tpublic float PulsePhase;\r\n\t}\r\n\treadonly List<LandingPointGlow> landingPointGlows = new();\r\n\treadonly List<DiamondDamage.Contact> landingPointContacts = new( 4 );\r\n\tRect[] atmosphereTiles;\r\n\tint atmosphereTileVersion;\r\n\tTexture atmosphereTexture;\r\n\tColor32[] atmospherePixels;\r\n\tDyeTextureStamp atmosphereTextureStamp;\r\n\tstruct DyeTextureStamp\r\n\t{\r\n\t\tpublic object Source;\r\n\t\tpublic ulong Version;\r\n\t\tpublic Color[] Palette;\r\n\t\tpublic Color Background;\r\n\t\tpublic float Vibrancy;\r\n\t}\r\n\t// Only the editor comparison tools enable the retained rectangle reference.\r\n\tbool atmosphereReferenceTiles = false;\r\n\t// Editor GPU checks compare this against full resolution on an identical frame.\r\n\tfloat dyeLayerResolution = 0.5f;\r\n\r\n\tpublic override void OnDeleted()\r\n\t{\r\n\t\tReleaseGemAtlas();\r\n\t\tReleaseSparkAtlas();\r\n\t\tReleaseWallMasks();\r\n\t\tatmosphereTexture?.Dispose();\r\n\t\tatmosphereTexture = null;\r\n\t\ttitleDyeTexture?.Dispose();\r\n\t\ttitleDyeTexture = null;\r\n\t\tbase.OnDeleted();\r\n\t}\r\n\treadonly Random glowRandom = new();\r\n\tDiamondBoard glowBoard;\r\n\tint glowResetVersion;\r\n\tfloat boundaryTime;\r\n\tDiamondBoard previewBoard;\r\n\tint previewResetVersion, previewSpawnVersion;\r\n\tfloat previewBounceTime = 0.45f;\r\n\r\n\tpublic void UpdateNextPreview( float delta )\r\n\t{\r\n\t\tif ( Board is null ) return;\r\n\t\tif ( previewBoard != Board || previewResetVersion != Board.ResetVersion || previewSpawnVersion != Board.SpawnVersion )\r\n\t\t{\r\n\t\t\tpreviewBoard = Board;\r\n\t\t\tpreviewResetVersion = Board.ResetVersion;\r\n\t\t\tpreviewSpawnVersion = Board.SpawnVersion;\r\n\t\t\tpreviewBounceTime = 0;\r\n\t\t}\r\n\t\telse if ( !Board.Paused ) previewBounceTime = MathF.Min( 0.45f, previewBounceTime + MathF.Max( 0, delta ) );\r\n\t}\r\n\r\n\tfloat NextPreviewScale\r\n\t{\r\n\t\tget\r\n\t\t{\r\n\t\t\tfloat t = previewBounceTime / 0.45f;\r\n\t\t\treturn 1 + 0.09f * MathF.Sin( t * MathF.PI * 2 ) * (1 - t) * (1 - t);\r\n\t\t}\r\n\t}\r\n\tstatic readonly Color Ink = Color.Parse( \"#e4eeef\" ).Value;\r\n\tstatic readonly Color Muted = Color.Parse( \"#82959d\" ).Value;\r\n\tstatic readonly Color TimerInk = Color.Parse( \"#50636d\" ).Value;\r\n\tstatic void DrawTimer( Painter p, string value, Vector2 center ) =>\r\n\t\tDrawTimeNumber( p, value, center, 32, TimerInk.WithAlpha( 0.42f ), outline: false );\r\n\t[SkipHotload] static readonly Color ToothFill = Color.Parse( \"#1c2933\" ).Value;\r\n\t[SkipHotload] static readonly Color GameOverToothFill = Color.Parse( \"#451e25\" ).Value;\r\n\t[SkipHotload]\r\n\tstatic readonly Color FloorInk = Color.Parse( \"#70818c\" ).Value;\r\n\t[SkipHotload]\r\n\tstatic readonly Color GameOverSpikeInk = Color.Parse( \"#e34343\" ).Value;\r\n\t[SkipHotload]\r\n\tstatic readonly Color GameOverFlashRed = Color.Parse( \"#ef3238\" ).Value;\r\n\t[SkipHotload]\r\n\tstatic readonly Color GameOverFlashYellow = Color.Parse( \"#ffe04a\" ).Value;\r\n\tColor SpikeInk => Board.GameOver ? GameOverSpikeInk : FloorInk;\r\n\t// Saturated per-diamond colors, shared by the active piece, ghost and next preview.\r\n\t// These are presentation constants: refresh their initializers when hotloading.\r\n\t[SkipHotload]\r\n\tstatic readonly Color[] Palette = [Color.Parse( \"#f23d3d\" ).Value, Color.Parse( \"#2298f0\" ).Value,\r\n\t\tColor.Parse( \"#32cc59\" ).Value, Color.Parse( \"#ffdc32\" ).Value, Color.Parse( \"#b63fc9\" ).Value];\r\n\t// Preserve the 1592-wide canvas, keeping gem scale identical at every viewport size.\r\n\tconst float Left = (1592 - DiamondBoard.BoardWidth) * 0.5f;\r\n\t// Fit the expanded board to the screen, scaling gems and boundary teeth together.\r\n\t// Keep gameplay and HUD placement stable; the board rendering extends above this margin.\r\n\tconst float Top = 8;\r\n\tconst float NextPreviewCenterY = Top + 212;\r\n\tconst float CanvasWidth = DiamondBoard.BoardWidth + Left * 2;\r\n\tconst float CanvasHeight = Top + DiamondBoard.BoardHeight + 8;\r\n\tfloat boardRenderTop;\r\n\tfloat boardOffsetY;\r\n\tVector2 BoardOffset => ShakeOffset + new Vector2( 0, boardOffsetY );\r\n\tRect BoardRenderBounds => new( Left, boardRenderTop, DiamondBoard.BoardWidth, Top + DiamondBoard.BoardHeight - boardRenderTop );\r\n\r\n\t// Include any extra horizontal margin outside the fitted canvas on wide screens.\r\n\tstatic float LetterboxCenter( Vector2 size, bool right = false )\r\n\t{\r\n\t\tfloat scale = MathF.Min( size.x / CanvasWidth, size.y / CanvasHeight );\r\n\t\tfloat extra = scale > 0 ? MathF.Max( 0, (size.x / scale - CanvasWidth) * 0.5f ) : 0;\r\n\t\tfloat center = (Left - extra) * 0.5f;\r\n\t\treturn right ? CanvasWidth - center : center;\r\n\t}\r\n\r\n\tpublic DiamondPanel()\r\n\t{\r\n\t\tStyle.Width = Length.Percent( 100 );\r\n\t\tStyle.Height = Length.Percent( 100 );\r\n\t\tStyle.PointerEvents = PointerEvents.All;\r\n\t}\r\n\r\n\tpublic void UpdateLandingPointGlows( float delta )\r\n\t{\r\n\t\tif ( Board != glowBoard || Board?.ResetVersion != glowResetVersion )\r\n\t\t{\r\n\t\t\tlandingPointGlows.Clear();\r\n\t\t\tboundaryTime = 0;\r\n\t\t\tglowBoard = Board;\r\n\t\t\tglowResetVersion = Board?.ResetVersion ?? 0;\r\n\t\t}\r\n\t\tif ( Board is null || Board.Paused ) return;\r\n\t\tboundaryTime = (boundaryTime + MathF.Max( 0, delta )) % 3.6f;\r\n\t\tforeach ( var glow in landingPointGlows ) glow.Visible = false;\r\n\t\tBoard.CopyLandingPointContacts( landingPointContacts );\r\n\t\tforeach ( var contact in landingPointContacts )\r\n\t\t{\r\n\t\t\t// Keep each marker's fade and rotation when projection rounding changes slightly.\r\n\t\t\tLandingPointGlow glow = null;\r\n\t\t\tforeach ( var candidate in landingPointGlows )\r\n\t\t\t\tif ( candidate.Contact.ColorIndex == contact.ColorIndex &&\r\n\t\t\t\t\tMathF.Abs( candidate.Contact.Lane - contact.Lane ) < 0.001f && MathF.Abs( candidate.Contact.Y - contact.Y ) < 0.02f )\r\n\t\t\t\t{ glow = candidate; break; }\r\n\t\t\tif ( glow is null )\r\n\t\t\t{\r\n\t\t\t\tglow = new LandingPointGlow { Contact = contact, Rotation = (float)glowRandom.NextDouble() * MathF.PI * 2,\r\n\t\t\t\t\tPulsePhase = (float)glowRandom.NextDouble() * MathF.PI * 2 };\r\n\t\t\t\tlandingPointGlows.Add( glow );\r\n\t\t\t}\r\n\t\t\tglow.Visible = true;\r\n\t\t}\r\n\t\tdelta = MathF.Max( 0, delta );\r\n\t\tfor ( int i = landingPointGlows.Count - 1; i >= 0; i-- )\r\n\t\t{\r\n\t\t\tvar glow = landingPointGlows[i];\r\n\t\t\t// Frame-rate independent lerp; retain old contact points until they fade away.\r\n\t\t\tfloat blend = 1 - MathF.Exp( -delta / (glow.Visible ? 0.09f : 0.07f) );\r\n\t\t\tglow.Opacity += ((glow.Visible ? 1 : 0) - glow.Opacity) * blend;\r\n\t\t\tglow.Rotation = (glow.Rotation + delta * 0.3f) % (MathF.PI * 2);\r\n\t\t\tglow.Age = (glow.Age + delta) % 1000;\r\n\t\t\tif ( glow.Visible ) glow.Appear = MathF.Min( 1, glow.Appear + delta / LandingPointAppearTime );\r\n\t\t\tif ( !glow.Visible && glow.Opacity < 0.01f ) landingPointGlows.RemoveAt( i );\r\n\t\t}\r\n\t}\r\n\r\n\tpublic override void OnDraw( Painter p )\r\n\t{\r\n#if STANDALONE\r\n#endif\r\n\t\tvar screenTransform = p.Transform;\r\n\t\tp.Fill = Color.Parse( \"#0b1017\" ).Value;\r\n\t\tp.Stroke = Stroke.None;\r\n\t\tp.Rect( p.Bounds );\r\n\t\tif ( Board is null ) return;\r\n\t\tfloat scale = MathF.Min( p.Bounds.Width / CanvasWidth, p.Bounds.Height / CanvasHeight );\r\n\t\tif ( scale <= 0 ) return;\r\n\t\t// Halve the full bottom gap, including any vertical letterboxing, without moving the HUD.\r\n\t\tfloat verticalMargin = MathF.Max( 0, (p.Bounds.Height / scale - CanvasHeight) * 0.5f );\r\n\t\tboardOffsetY = (verticalMargin + 8) * 0.5f;\r\n\t\t// Reach the actual screen edge even after lowering the board or shaking it downward.\r\n\t\tboardRenderTop = -verticalMargin - boardOffsetY - MathF.Abs( ShakeOffset.y ) - 8;\r\n\t\tif ( OnlyDye )\r\n\t\t{\r\n\t\t\tDrawDyeOnly( p, scale );\r\n\t\t\treturn;\r\n\t\t}\r\n\t\tif ( TitleScreen )\r\n\t\t{\r\n\t\t\tusing ( p.Scope() )\r\n\t\t\t{\r\n\t\t\t\tp.Translate( (p.Bounds.Size - new Vector2( CanvasWidth, CanvasHeight ) * scale) * 0.5f );\r\n\t\t\t\tp.Scale( scale );\r\n\t\t\t\tDrawTitleScreen( p, scale );\r\n\t\t\t}\r\n\t\t\tDrawTutorial( p );\r\n\t\t\tDrawOptions( p );\r\n\t\t\tDrawLeaderboard( p );\r\n\t\t\tDrawTransition( p );\r\n\t\t\tDrawMusicDebug( p );\r\n\t\t\treturn;\r\n\t\t}\r\n\t\tp.Translate( (p.Bounds.Size - new Vector2( CanvasWidth, CanvasHeight ) * scale) * 0.5f );\r\n\t\tp.Scale( scale );\r\n\t\tfloat rightCenter = LetterboxCenter( p.Bounds.Size, true );\r\n\t\tDrawTimer( p, DiamondLeaderboard.FormatTime( (long)Board.ElapsedSeconds ), new Vector2( LetterboxCenter( p.Bounds.Size ), Top + 40 ) );\r\n\r\n\t\tif ( !Board.GameOver )\r\n\t\t{\r\n\t\t\tvar next = DiamondShapes.All[Board.NextShapeIndex];\r\n\t\t\tfloat previewLane = (next.Cells.Min( c => c.Lane ) + next.Cells.Max( c => c.Lane )) * 0.5f;\r\n\t\t\tfloat previewRow = (next.Cells.Min( c => c.Row ) + next.Cells.Max( c => c.Row )) * 0.5f;\r\n\t\t\t// Scale the entire mixed-color piece about its visual center, including its outlines.\r\n\t\t\tusing var previewScope = p.Scope();\r\n\t\t\tvar previewCenter = new Vector2( rightCenter, NextPreviewCenterY );\r\n\t\t\tp.Translate( previewCenter );\r\n\t\t\tp.Scale( NextPreviewScale );\r\n\t\t\tp.Translate( -previewCenter );\r\n\t\t\tDrawShape( p, Board.Next,\r\n\t\t\t\tnew Vector2( rightCenter - previewLane * DiamondBoard.Width * 0.5f, NextPreviewCenterY - previewRow * DiamondBoard.Height * 0.5f ), false );\r\n\t\t}\r\n\r\n\t\tusing ( p.Scope() )\r\n\t\t{\r\n\t\t\tp.Translate( BoardOffset );\r\n\t\t\tusing ( p.Scope() )\r\n\t\t\t{\r\n\t\t\t\tClipBoard( p );\r\n\t\t\t\tp.Fill = Color.Parse( \"#080d13\" ).Value;\r\n\t\t\t\tp.Stroke = Stroke.None;\r\n\t\t\t\tp.Rect( BoardRenderBounds );\r\n\t\t\t\tDrawAtmosphere( p );\r\n\t\t\t\tDrawFloorFill( p );\r\n\t\t\t\tDrawSideWalls( p );\r\n\t\t\t\tif ( ShowLandingIndicator && !Board.GameOver && !Board.IsSliding && !Board.IsResolving )\r\n\t\t\t\t\tDrawShape( p, Board.Active, Position( Board.Active.Lane, Board.LandingY ), true );\r\n\t\t\t\tvar displayCells = DamageShakeCells();\r\n\t\t\t\tforeach ( var piece in displayCells )\r\n\t\t\t\t\tDrawDiamond( p, Position( piece.Lane, piece.Y ), piece.ColorIndex, false, health: piece.Health, crackCorner: piece.FirstCrack );\r\n\t\t\t\tDrawGroupOutlines( p, displayCells, Board.BondedGroups, new Vector2( Left, Top ), pulses: Board.BondPulses );\r\n\t\t\t\tif ( !Board.GameOver && !Board.IsResolving )\r\n\t\t\t\t\tDrawActivePiece( p );\r\n\t\t\t\tforeach ( var cell in Board.Shattering )\r\n\t\t\t\t{\r\n\t\t\t\t\tif ( Board.IsDamagePaused )\r\n\t\t\t\t\t\tDrawDiamond( p, Position( cell.Lane, cell.Y ), cell.ColorIndex, false, health: 1, crackCorner: cell.FirstCrack );\r\n\t\t\t\t\telse if ( Board.GameOver )\r\n\t\t\t\t\t\tDrawDiamond( p, Position( cell.Lane, cell.Y ), cell.ColorIndex, false,\r\n\t\t\t\t\t\t\tscale: 1 - Board.ShatterProgress * 0.75f, opacity: 1 - Board.ShatterProgress,\r\n\t\t\t\t\t\t\thealth: cell.Health, crackCorner: cell.FirstCrack );\r\n\t\t\t\t}\r\n\t\t\t\tDrawDamageShines( p );\r\n\r\n\t\t\t\t// The floor slopes exactly match the lower half of a diamond.\r\n\t\t\t\tint cornerSkip = Board.FillCornerPockets ? 2 : 0;\r\n\t\t\t\tSpan<Vector2> floor = stackalloc Vector2[DiamondBoard.Valleys * 2 + 1 - cornerSkip * 2];\r\n\t\t\t\tfor ( int i = 0; i < floor.Length; i++ )\r\n\t\t\t\t\tfloor[i] = new Vector2( Left + (i + cornerSkip) * DiamondBoard.Width * 0.5f,\r\n\t\t\t\t\t\tTop + DiamondBoard.BoardHeight - (i % 2 == 0 ? DiamondBoard.Height * 0.5f : 0) );\r\n\t\t\t\tp.Stroke = Stroke.Solid( SpikeInk.WithAlpha( 0.08f + Board.LockFlash * 0.18f ), 9 );\r\n\t\t\t\tp.Line( floor );\r\n\t\t\t\tp.Stroke = Stroke.Solid( SpikeInk, 2 );\r\n\t\t\t\tp.Line( floor );\r\n\t\t\t\tforeach ( var glow in landingPointGlows ) DrawLandingPointGlow( p, glow, LandingPointScale );\r\n\t\t\t\tforeach ( var flash in Board.DamageFlashes ) DrawDamageFlash( p, flash );\r\n\t\t\t\tforeach ( var flash in Board.FaceFlashes ) DrawFaceFlash( p, flash );\r\n\t\t\t\tDrawDebris( p );\r\n\t\t\t\tif ( Debris is not null )\r\n\t\t\t\t{\r\n\t\t\t\t\tforeach ( var burst in Debris.ShatterSunbursts )\r\n\t\t\t\t\t\tDrawSunburst( p, new Vector2( Left, Top ) + burst.Position, burst.ColorIndex,\r\n\t\t\t\t\t\t\tburst.Age / burst.Lifetime, burst.Rotation, burst.Brightness, burst.Scale );\r\n\t\t\t\t\tforeach ( var burst in Debris.CrushSunbursts )\r\n\t\t\t\t\t\tDrawSunburst( p, new Vector2( Left, Top ) + burst.Position, burst.ColorIndex,\r\n\t\t\t\t\t\t\tburst.Age / burst.Lifetime, burst.Rotation, burst.Brightness, burst.Scale );\r\n\t\t\t\t}\r\n\t\t\t\tDrawSmoke( p );\r\n\t\t\t\tDrawGameOverFlash( p );\r\n\t\t\t}\r\n\t\t}\r\n\r\n\t\tif ( !Board.GameOver ) DrawScoreNumber( p, \"NEXT\", new Vector2( rightCenter, Top + 72 ), 48, Muted * 0.6f );\r\n\t\tDrawScore( p, LetterboxCenter( p.Bounds.Size ) );\r\n\t\tDrawControls( p, rightCenter );\r\n\t\tDrawTutorialButton( p, scale );\r\n\t\tDrawOptionsButton( p, scale );\r\n\t\tDrawLeaveButton( p, scale );\r\n\r\n\t\tif ( Board.GameOver ) DrawGameOver( p, scale );\r\n\t\tDrawBoardBorder( p, scale );\r\n\t\t// After game over, the RESTARTING frame covers the RESTART button instead of the board center.\r\n\t\tDrawRestartHold( p, screenTransform, Board.GameOver ? GameOverRestartButton.Center\r\n\t\t\t: new Vector2( Left + DiamondBoard.BoardWidth * 0.5f, Top + boardOffsetY + DiamondBoard.BoardHeight * 0.5f ), scale );\r\n\t\tp.Transform = screenTransform;\r\n\t\tDrawTutorial( p );\r\n\t\tDrawOptions( p );\r\n\t\tDrawLeaderboard( p );\r\n\t\tDrawTransition( p );\r\n\t\tDrawMusicDebug( p );\r\n\t}\r\n\r\n\t/// <summary>Thumbnail capture: the current screen's dye over its plain background, without the board, HUD or overlays.</summary>\r\n\tvoid DrawDyeOnly( Painter p, float scale )\r\n\t{\r\n\t\tusing var scope = p.Scope();\r\n\t\tp.Translate( (p.Bounds.Size - new Vector2( CanvasWidth, CanvasHeight ) * scale) * 0.5f );\r\n\t\tp.Scale( scale );\r\n\t\tif ( TitleScreen )\r\n\t\t{\r\n\t\t\tDrawTitleDye( p );\r\n\t\t\treturn;\r\n\t\t}\r\n\t\tp.Translate( BoardOffset );\r\n\t\tClipBoard( p );\r\n\t\tp.Fill = Color.Parse( \"#080d13\" ).Value;\r\n\t\tp.Stroke = Stroke.None;\r\n\t\tp.Rect( BoardRenderBounds );\r\n\t\tDrawAtmosphere( p );\r\n\t}\r\n\r\n\t/// <summary>Screen-space debug readout above everything, including the tutorial.</summary>\r\n\tvoid DrawMusicDebug( Painter p )\r\n\t{\r\n\t\tif ( string.IsNullOrEmpty( MusicDebugText ) ) return;\r\n\t\tvar lines = MusicDebugText.Split( '\\n' );\r\n\t\tconst float size = 14, lineHeight = 18, padding = 10, width = 600;\r\n\t\tfloat height = lines.Length * lineHeight + padding * 2;\r\n\t\tfloat top = p.Bounds.Height - 14 - height;\r\n\t\tp.Fill = Color.Black.WithAlpha( 0.62f );\r\n\t\tp.Stroke = Stroke.None;\r\n\t\tp.Rect( new Rect( 14, top, width, height ) );\r\n\t\tfor ( int i = 0; i < lines.Length; i++ )\r\n\t\t\t// Painter's text rect includes the font's ascent, so give each line generous height.\r\n\t\t\tText( p, lines[i].TrimEnd( '\\r' ), 14 + padding, top + padding + i * lineHeight - size * 0.4f, width - padding * 2, lineHeight * 2,\r\n\t\t\t\tsize, Color.White.WithAlpha( 0.9f ), fontName: \"Roboto Mono\" );\r\n\t}\r\n\r\n\tstatic void DrawControls( Painter p, float center )\r\n\t{\r\n\t\tDrawScoreNumber( p, \"ROTATE\", new Vector2( center, Top + 374 ), 26, Muted * 0.65f );\r\n\t\tDrawControlKey( p, new Vector2( center, Top + 428 ), \"\u2191\" );\r\n\t\tstring[] arrows = [\"\u2190\", \"\u2193\", \"\u2192\"];\r\n\t\tstring[] labels = [\"LEFT\", \"DROP\", \"RIGHT\"];\r\n\t\tfor ( int i = 0; i < 3; i++ )\r\n\t\t{\r\n\t\t\tfloat x = center + (i - 1) * 84;\r\n\t\t\tDrawControlKey( p, new Vector2( x, Top + 506 ), arrows[i] );\r\n\t\t\tDrawScoreNumber( p, labels[i], new Vector2( x, Top + 560 ), 20, Muted * 0.65f );\r\n\t\t}\r\n\t}\r\n\r\n\tstatic void DrawControlKey( Painter p, Vector2 center, string arrow )\r\n\t{\r\n\t\tp.Fill = Ink.WithAlpha( 0.025f );\r\n\t\tp.Stroke = Stroke.None;\r\n\t\tp.Rect( new Rect( center - new Vector2( 32 ), new Vector2( 64 ) ) );\r\n\t\tDrawScoreNumber( p, arrow, center, 58, Ink.WithAlpha( 0.5f ) );\r\n\t}\r\n\r\n\tvoid DrawBoardBorder( Painter p, float scale )\r\n\t{\r\n\t\tusing var scope = p.Scope();\r\n\t\tp.Translate( BoardOffset );\r\n\t\tfloat cornerScale = Board.FillCornerPockets ? 1.5f : 0.5f;\r\n\t\tfloat cornerWidth = DiamondBoard.Width * cornerScale;\r\n\t\tfloat cornerHeight = DiamondBoard.Height * cornerScale;\r\n\t\tfloat right = Left + DiamondBoard.BoardWidth;\r\n\t\tfloat bottom = Top + DiamondBoard.BoardHeight;\r\n\t\t// Keep a 3.5-pixel outline at every canvas scale, with sharp joins and an open top.\r\n\t\tReadOnlySpan<Vector2> points =\r\n\t\t[\r\n\t\t\tnew( Left, boardRenderTop ), new( Left, bottom - cornerHeight ),\r\n\t\t\tnew( Left + cornerWidth, bottom ), new( right - cornerWidth, bottom ),\r\n\t\t\tnew( right, bottom - cornerHeight ), new( right, boardRenderTop )\r\n\t\t];\r\n\t\tif ( borderPoints is null || !points.SequenceEqual( borderPoints ) )\r\n\t\t{\r\n\t\t\tborderPoints = points.ToArray();\r\n\t\t\tborderMiters = BuildPerimeterMiters( points, false );\r\n\t\t}\r\n\t\tDrawPerimeter( p, points, borderMiters, SpikeInk, 3.5f / scale, false );\r\n\t}\r\n\r\n\tvoid DrawGameOverFlash( Painter p )\r\n\t{\r\n\t\tif ( !Board.GameOver || Board.Settled.Count == 0 && Board.Shattering.Count == 0 ) return;\r\n\t\tfloat yellow = 0.5f + 0.5f * MathF.Sin( boundaryTime * MathF.PI * 2 / 0.9f );\r\n\t\tp.Fill = new Color(\r\n\t\t\tGameOverFlashRed.r + (GameOverFlashYellow.r - GameOverFlashRed.r) * yellow,\r\n\t\t\tGameOverFlashRed.g + (GameOverFlashYellow.g - GameOverFlashRed.g) * yellow,\r\n\t\t\tGameOverFlashRed.b + (GameOverFlashYellow.b - GameOverFlashRed.b) * yellow )\r\n\t\t\t.WithAlpha( 0.055f + 0.035f * yellow );\r\n\t\tp.Stroke = Stroke.None;\r\n\t\t// Draw inside ClipBoard so the wash follows the cut corners and never enters the letterboxes.\r\n\t\tp.Rect( BoardRenderBounds );\r\n\t}\r\n\r\n\tvoid ClipBoard( Painter p )\r\n\t{\r\n\t\tp.Clip( BoardRenderBounds );\r\n\t\t// Intersect with two diagonal half-planes. The background, effects, and\r\n\t\t// game-over wash all stop at the actual end slopes instead of a rectangular box.\r\n\t\tfloat cornerHeight = DiamondBoard.Height * (Board.FillCornerPockets ? 1.5f : 0.5f);\r\n\t\tfloat extent = CanvasWidth + CanvasHeight + MathF.Abs( boardRenderTop );\r\n\t\tvar transform = p.Transform;\r\n\t\tforeach ( int side in new[] { -1, 1 } )\r\n\t\t{\r\n\t\t\tp.Translate( Left + (side > 0 ? DiamondBoard.BoardWidth : 0), Top + DiamondBoard.BoardHeight - cornerHeight );\r\n\t\t\tp.Rotate( MathF.Atan2( DiamondBoard.Height, -side * DiamondBoard.Width ) * 180 / MathF.PI );\r\n\t\t\tp.Clip( new Rect( -extent, side < 0 ? -extent : 0, extent * 2, extent ) );\r\n\t\t\t// Clips capture their transform; restoring only the drawing transform keeps them active.\r\n\t\t\tp.Transform = transform;\r\n\t\t}\r\n\t}\r\n\r\n\tvoid DrawAtmosphere( Painter p )\r\n\t{\r\n\t\tif ( HideDye || Atmosphere is null || !Atmosphere.Enabled || Atmosphere.Opacity <= 0 ) return;\r\n\t\tif ( atmosphereReferenceTiles ) { DrawAtmosphereTiles( p ); return; }\r\n\t\tconst int columns = DiamondAtmosphere.Columns, rows = DiamondAtmosphere.Rows;\r\n\t\tUpdateDyeTexture( Atmosphere, Atmosphere.DyeVersion, Atmosphere.Samples, columns, rows, Color.Parse( \"#080d13\" ).Value,\r\n\t\t\t\"diamonds-gameplay-dye\", Math.Clamp( DyeVibrancy, 0, 1 ), ref atmosphereTexture, ref atmospherePixels, ref atmosphereTextureStamp );\r\n\t\tvar origin = DiamondAtmosphere.GridToWorld( 0, 0 ) + new Vector2( Left, Top ) - new Vector2( DiamondAtmosphere.CellSize * 0.5f );\r\n\t\tDrawDyeLayer( p, atmosphereTexture, BoardRenderBounds,\r\n\t\t\tnew Rect( origin, new Vector2( columns, rows ) * DiamondAtmosphere.CellSize ), DyeLayerOpacity( Atmosphere.RenderOpacity ) );\r\n\t}\r\n\r\n\tvoid DrawDyeLayer( Painter p, Texture texture, Rect bounds, Rect image, float opacity )\r\n\t{\r\n\t\tfloat resolution = dyeLayerResolution;\r\n\t\tusing var scope = p.Scope();\r\n\t\tp.Scale( 1 / resolution );\r\n\t\tvar smallBounds = new Rect( bounds.Position * resolution, bounds.Size * resolution );\r\n\t\tvar smallImage = new Rect( image.Position * resolution, image.Size * resolution );\r\n\t\tusing var blur = p.BeginLayer( smallBounds, opacity, new Painter.Filter { Blur = 6 * resolution } );\r\n\t\tp.Texture( texture, smallImage );\r\n\t}\r\n\r\n\t// Both screens share color packing, linear texture format and reusable upload storage.\r\n\tstatic void UpdateDyeTexture( object source, ulong version, ReadOnlySpan<float> samples, int columns, int rows, Color background,\r\n\t\tstring name, float vibrancy, ref Texture texture, ref Color32[] pixels, ref DyeTextureStamp stamp )\r\n\t{\r\n\t\tbool recreate = texture is null || !texture.IsValid || texture.Width != columns || texture.Height != rows;\r\n\t\t// Opacity and placement belong to Painter, so changing either needs no pixel upload.\r\n\t\tif ( !recreate && pixels?.Length == columns * rows && ReferenceEquals( stamp.Source, source ) &&\r\n\t\t\tstamp.Version == version && ReferenceEquals( stamp.Palette, Palette ) && stamp.Background.Equals( background ) && stamp.Vibrancy == vibrancy ) return;\r\n\t\tif ( recreate )\r\n\t\t{\r\n\t\t\ttexture?.Dispose();\r\n\t\t\t// UI solid fills use these raw color values; avoid an extra sRGB decode.\r\n\t\t\ttexture = Texture.Create( columns, rows, ImageFormat.RGBA8888_LINEAR )\r\n\t\t\t\t.WithDynamicUsage().WithName( name ).Finish();\r\n\t\t}\r\n\t\tif ( pixels is null || pixels.Length != columns * rows ) pixels = new Color32[columns * rows];\r\n\t\tfor ( int n = 0; n < pixels.Length; n++ )\r\n\t\t{\r\n\t\t\tfloat total = 0, weightedTotal = 0, red = 0, green = 0, blue = 0;\r\n\t\t\tfor ( int color = 0; color < DiamondBoard.ColorCount; color++ )\r\n\t\t\t{\r\n\t\t\t\tfloat amount = samples[n * DiamondBoard.ColorCount + color];\r\n\t\t\t\ttotal += amount;\r\n\t\t\t\tfloat vividAmount = amount * amount;\r\n\t\t\t\tweightedTotal += vividAmount;\r\n\t\t\t\tvar ink = Palette[color];\r\n\t\t\t\tred += ink.r * vividAmount; green += ink.g * vividAmount; blue += ink.b * vividAmount;\r\n\t\t\t}\r\n\t\t\t// Vibrant dye saturates from a smaller amount; at full vibrancy only truly empty space stays dark.\r\n\t\t\tfloat coverage = Math.Clamp( (total - (0.04f - 0.035f * vibrancy)) / (0.28f - 0.22f * vibrancy), 0, 1 );\r\n\t\t\tcoverage = coverage * coverage * (3 - 2 * coverage);\r\n\t\t\tpixels[n] = weightedTotal <= 0 || coverage <= 0 ? background :\r\n\t\t\t\tnew Color( background.r + (red / weightedTotal - background.r) * coverage,\r\n\t\t\t\t\tbackground.g + (green / weightedTotal - background.g) * coverage,\r\n\t\t\t\t\tbackground.b + (blue / weightedTotal - background.b) * coverage );\r\n\t\t}\r\n\t\ttexture.Update( pixels.AsSpan(), width: columns, height: rows );\r\n\t\tstamp = new DyeTextureStamp { Source = source, Version = version, Palette = Palette, Background = background, Vibrancy = vibrancy };\r\n\t}\r\n\r\n\tvoid DrawAtmosphereTiles( Painter p )\r\n\t{\r\n\t\tif ( Atmosphere is null || !Atmosphere.Enabled || Atmosphere.Opacity <= 0 ) return;\r\n\t\tif ( atmosphereTiles is null || atmosphereTiles.Length != DiamondAtmosphere.Columns * DiamondAtmosphere.Rows ||\r\n\t\t\tatmosphereTileVersion != 9 )\r\n\t\t{\r\n\t\t\tatmosphereTiles = BuildAtmosphereTiles();\r\n\t\t\tatmosphereTileVersion = 9;\r\n\t\t}\r\n\t\tusing var layer = p.BeginLayer( BoardRenderBounds,\r\n\t\t\t0.22f * Atmosphere.RenderOpacity, new Painter.Filter { Blur = 6 } );\r\n\t\tvar background = Color.Parse( \"#080d13\" ).Value;\r\n\t\tp.Stroke = Stroke.None;\r\n\t\tvar samples = Atmosphere.Samples;\r\n\t\tfor ( int y = 0; y < DiamondAtmosphere.Rows; y++ )\r\n\t\tfor ( int x = 0; x < DiamondAtmosphere.Columns; x++ )\r\n\t\t{\r\n\t\t\tvar tile = atmosphereTiles[y * DiamondAtmosphere.Columns + x];\r\n\t\t\tif ( tile.Width <= 0 ) continue;\r\n\t\t\tfloat total = 0, weightedTotal = 0, red = 0, green = 0, blue = 0;\r\n\t\t\tint sampleIndex = (y * DiamondAtmosphere.Columns + x) * DiamondBoard.ColorCount;\r\n\t\t\tfor ( int color = 0; color < DiamondBoard.ColorCount; color++ )\r\n\t\t\t{\r\n\t\t\t\tfloat amount = samples[sampleIndex + color];\r\n\t\t\t\ttotal += amount;\r\n\t\t\t\tfloat vividAmount = amount * amount;\r\n\t\t\t\tweightedTotal += vividAmount;\r\n\t\t\t\tvar ink = Palette[color];\r\n\t\t\t\tred += ink.r * vividAmount;\r\n\t\t\t\tgreen += ink.g * vividAmount;\r\n\t\t\t\tblue += ink.b * vividAmount;\r\n\t\t\t}\r\n\t\t\tfloat coverage = Math.Clamp( (total - 0.04f) / 0.28f, 0, 1 );\r\n\t\t\tcoverage = coverage * coverage * (3 - 2 * coverage);\r\n\t\t\tif ( weightedTotal <= 0 || coverage <= 0 ) continue;\r\n\t\t\tp.Fill = new Color( background.r + (red / weightedTotal - background.r) * coverage,\r\n\t\t\t\tbackground.g + (green / weightedTotal - background.g) * coverage,\r\n\t\t\t\tbackground.b + (blue / weightedTotal - background.b) * coverage );\r\n\t\t\tp.Rect( tile );\r\n\t\t}\r\n\t}\r\n\r\n\tstatic Rect[] BuildAtmosphereTiles()\r\n\t{\r\n\t\tvar tiles = new Rect[DiamondAtmosphere.Columns * DiamondAtmosphere.Rows];\r\n\t\t// Screen-aligned squares keep the previous overlap to hide seams under the blur.\r\n\t\tfloat radius = DiamondAtmosphere.CellSize * 0.5f + 3 * 0.70710678f;\r\n\t\tfor ( int y = 0; y < DiamondAtmosphere.Rows; y++ )\r\n\t\tfor ( int x = 0; x < DiamondAtmosphere.Columns; x++ )\r\n\t\t{\r\n\t\t\tVector2 center = DiamondAtmosphere.GridToWorld( x, y );\r\n\t\t\tif ( center.x < -radius || center.x > DiamondBoard.BoardWidth + radius ||\r\n\t\t\t\tcenter.y < -radius || center.y > DiamondBoard.BoardHeight + radius ) continue;\r\n\t\t\tcenter += new Vector2( Left, Top );\r\n\t\t\ttiles[y * DiamondAtmosphere.Columns + x] = new Rect( center - new Vector2( radius ), new Vector2( radius * 2 ) );\r\n\t\t}\r\n\t\treturn tiles;\r\n\t}\r\n\r\n\tvoid DrawFloorFill( Painter p )\r\n\t{\r\n\t\tp.Stroke = Stroke.None;\r\n\t\tfor ( int i = 0; i <= DiamondBoard.Valleys; i++ )\r\n\t\t{\r\n\t\t\tvar center = new Vector2( Left + i * DiamondBoard.Width, Top + DiamondBoard.BoardHeight );\r\n\t\t\tDrawFloorTooth( p, center, SpikeInk, Board.GameOver );\r\n\t\t}\r\n\t}\r\n\r\n\tstatic void DrawFloorTooth( Painter p, Vector2 center, Color ink, bool gameOver = false )\r\n\t{\r\n\t\tvar left = center + new Vector2( -DiamondBoard.Width * 0.5f, 0 );\r\n\t\tvar tip = center + new Vector2( 0, -DiamondBoard.Height * 0.5f );\r\n\t\tvar right = center + new Vector2( DiamondBoard.Width * 0.5f, 0 );\r\n\t\tp.Stroke = Stroke.None;\r\n\t\tp.Fill = gameOver ? GameOverToothFill : ToothFill;\r\n\t\tp.Polygon( [left, tip, right] );\r\n\t\tp.Fill = ink.WithAlpha( 0.12f );\r\n\t\tp.Polygon( [left, tip, center] );\r\n\t}\r\n\r\n\tvoid DrawSideWalls( Painter p )\r\n\t{\r\n\t\tSpan<Vector2> edge = stackalloc Vector2[3];\r\n\t\t// Continue the same tooth pattern above the gameplay bounds to the screen edge.\r\n\t\tfor ( float y = DiamondBoard.BoardHeight; Top + y + DiamondBoard.Height * 0.5f > boardRenderTop; y -= DiamondBoard.Height )\r\n\t\tfor ( int side = 0; side < 2; side++ )\r\n\t\t{\r\n\t\t\tvar tooth = new DiamondBoard.Diamond( side == 0 ? -1 : DiamondBoard.LaneCount, y, 0 );\r\n\t\t\tif ( Board.FillCornerPockets && tooth.Y == DiamondBoard.BoardHeight ) continue;\r\n\t\t\tvar center = Position( tooth.Lane, tooth.Y );\r\n\t\t\tfloat inward = tooth.Lane < 0 ? 1 : -1;\r\n\t\t\tedge[0] = center + new Vector2( 0, -DiamondBoard.Height * 0.5f );\r\n\t\t\tedge[1] = center + new Vector2( inward * DiamondBoard.Width * 0.5f, 0 );\r\n\t\t\tedge[2] = center + new Vector2( 0, DiamondBoard.Height * 0.5f );\r\n\t\t\tbool corner = Board.FillCornerPockets && tooth.Y == DiamondBoard.BoardHeight - DiamondBoard.Height;\r\n\t\t\tif ( corner ) edge[2] += new Vector2( inward * DiamondBoard.Width, 0 );\r\n\t\t\tp.Stroke = Stroke.None;\r\n\t\t\tif ( !corner )\r\n\t\t\t{\r\n\t\t\t\tp.Fill = Board.GameOver ? GameOverToothFill : ToothFill;\r\n\t\t\t\tp.Polygon( edge );\r\n\t\t\t\tp.Fill = SpikeInk.WithAlpha( 0.12f );\r\n\t\t\t\tp.Polygon( [edge[0], edge[1], center] );\r\n\t\t\t}\r\n\t\t\tif ( DrawWallStrokeMask( p, center, inward, corner ) ) continue;\r\n\t\t\tp.Stroke = Stroke.Solid( SpikeInk.WithAlpha( 0.08f + Board.LockFlash * 0.18f ), 9 );\r\n\t\t\tp.Line( edge );\r\n\t\t\tp.Stroke = Stroke.Solid( SpikeInk, 2 );\r\n\t\t\tp.Line( edge );\r\n\t\t}\r\n\t}\r\n\r\n\tvoid DrawDebris( Painter p )\r\n\t{\r\n\t\tif ( Debris is null ) return;\r\n\t\tp.Stroke = Stroke.None;\r\n\t\tforeach ( var chip in Debris.Particles )\r\n\t\t{\r\n\t\t\tif ( !chip.Visible ) continue;\r\n\t\t\tvar position = new Vector2( Left, Top ) + chip.Position;\r\n\t\t\t// Include rotation, the widest mitered glow, and an antialiasing margin.\r\n\t\t\tfloat radius = (chip.Radius + (chip.IsSpark ? 6 : 7.5f)) * (chip.IsSpark ? 1.6f : 1) + 2;\r\n\t\t\tif ( !drawingReferenceGeometry && (position.x + radius < BoardRenderBounds.Left ||\r\n\t\t\t\tposition.x - radius > BoardRenderBounds.Right || position.y + radius < BoardRenderBounds.Top ||\r\n\t\t\t\tposition.y - radius > BoardRenderBounds.Bottom) ) continue;\r\n\t\t\tfloat opacity = chip.Opacity;\r\n\t\t\tusing var scope = p.Scope();\r\n\t\t\tp.Translate( position );\r\n\t\t\tp.Rotate( chip.Angle );\r\n\t\t\tvar color = GemColor( chip.ColorIndex, chip.IsSpark ? DiamondBoard.MaxHealth : 1 );\r\n\t\t\tfloat highlight = chip.IsSpark ? chip.Brightness : 0.12f + chip.Brightness * 0.25f;\r\n\t\t\tvar tint = new Color( color.r + (1 - color.r) * highlight,\r\n\t\t\t\tcolor.g + (1 - color.g) * highlight, color.b + (1 - color.b) * highlight );\r\n\t\t\tif ( chip.IsSpark )\r\n\t\t\t{\r\n\t\t\t\t// Enlarge the light without changing the small physical collision fixture.\r\n\t\t\t\tp.Scale( 1.6f );\r\n\t\t\t\tif ( !DrawSparkGlow( p, chip, opacity ) )\r\n\t\t\t\t\tDrawParticleGlow( p, chip.Vertices, color, opacity * 0.24f, 1, ParticleGlowLayers( chip ) );\r\n\t\t\t\tp.Fill = tint.WithAlpha( opacity );\r\n\t\t\t\tp.Polygon( chip.Vertices );\r\n\t\t\t\tp.Polygon( [chip.Vertices[0], chip.Vertices[1], Vector2.Zero] );\r\n\t\t\t\tcontinue;\r\n\t\t\t}\r\n\r\n\t\t\tDrawShardFacets( p, chip.Vertices, color, tint, opacity, ParticleGlowLayers( chip ) );\r\n\t\t\tDrawShardShine( p, chip );\r\n\t\t\tDrawGemStroke( p, chip.Vertices, tint.WithAlpha( opacity * 0.45f ), 0.8f, true );\r\n\t\t}\r\n\t}\r\n\r\n\tstatic void DrawShardFacets( Painter p, Vector2[] vertices, Color color, Color tint, float opacity = 1, Vector2[][] glowLayers = null )\r\n\t{\r\n\t\tDrawParticleGlow( p, vertices, color, opacity * 0.12f, 1.25f, glowLayers );\r\n\t\tvar shadow = new Color( color.r * 0.68f, color.g * 0.68f, color.b * 0.68f );\r\n\t\tvar center = (vertices[0] + vertices[1] + vertices[2]) / 3;\r\n\t\tfor ( int facet = 0; facet < 3; facet++ )\r\n\t\t{\r\n\t\t\tp.Fill = (facet == 0 ? tint : facet == 1 ? shadow : color).WithAlpha( opacity );\r\n\t\t\tp.Polygon( [vertices[facet], vertices[(facet + 1) % 3], center] );\r\n\t\t}\r\n\t}\r\n\r\n\tstatic void DrawParticleGlow( Painter p, ReadOnlySpan<Vector2> vertices, Color color, float opacity, float spread, Vector2[][] layers = null )\r\n\t{\r\n\t\tp.Stroke = Stroke.None;\r\n\t\tif ( layers is not null )\r\n\t\t{\r\n\t\t\tfor ( int i = 0; i < layers.Length; i++ )\r\n\t\t\t{\r\n\t\t\t\tp.Fill = color.WithAlpha( opacity / (3 - i) );\r\n\t\t\t\tp.Polygon( layers[i] );\r\n\t\t\t}\r\n\t\t\treturn;\r\n\t\t}\r\n\t\t// Tiny filled polygons use Painter's inline shape path. Stroked polygons\r\n\t\t// rebuild path primitives and a BVH for every layer of every particle.\r\n\t\t// Expand the convex triangle/quad along its face normals, retaining an\r\n\t\t// angular halo with the same three widths and falloff. Facets cover its core.\r\n\t\tSpan<Vector2> corners = stackalloc Vector2[4];\r\n\t\tSpan<Vector2> expanded = stackalloc Vector2[4];\r\n\t\tParticleGlowCorners( vertices, corners );\r\n\t\tfor ( int layer = 3; layer >= 1; layer-- )\r\n\t\t{\r\n\t\t\tfor ( int i = 0; i < vertices.Length; i++ ) expanded[i] = vertices[i] + corners[i] * (layer * spread);\r\n\t\t\tp.Fill = color.WithAlpha( opacity / layer );\r\n\t\t\tp.Polygon( expanded[..vertices.Length] );\r\n\t\t}\r\n\t}\r\n\r\n\tstatic void ParticleGlowCorners( ReadOnlySpan<Vector2> vertices, Span<Vector2> corners )\r\n\t{\r\n\t\tfloat winding = 0;\r\n\t\tfor ( int i = 0; i < vertices.Length; i++ )\r\n\t\t{\r\n\t\t\tvar a = vertices[i]; var b = vertices[(i + 1) % vertices.Length];\r\n\t\t\twinding += a.x * b.y - a.y * b.x;\r\n\t\t}\r\n\t\tfor ( int i = 0; i < vertices.Length; i++ )\r\n\t\t{\r\n\t\t\tvar incoming = (vertices[i] - vertices[(i + vertices.Length - 1) % vertices.Length]).Normal;\r\n\t\t\tvar outgoing = (vertices[(i + 1) % vertices.Length] - vertices[i]).Normal;\r\n\t\t\tvar normal = new Vector2( incoming.y, -incoming.x ) * MathF.Sign( winding );\r\n\t\t\tvar nextNormal = new Vector2( outgoing.y, -outgoing.x ) * MathF.Sign( winding );\r\n\t\t\tvar bisector = (normal + nextNormal).Normal;\r\n\t\t\tcorners[i] = bisector / MathF.Max( 0.5f, Vector2.Dot( bisector, normal ) );\r\n\t\t}\r\n\t}\r\n\r\n\tvoid DrawSmoke( Painter p )\r\n\t{\r\n\t\tif ( Debris is null ) return;\r\n\t\tp.Stroke = Stroke.None;\r\n\t\tforeach ( var puff in Debris.Smoke )\r\n\t\t{\r\n\t\t\tfloat t = Math.Clamp( puff.Age / puff.Lifetime, 0, 1 );\r\n\t\t\tfloat opacity = MathF.Min( 1, puff.Age / 0.035f ) * (1 - t) * (1 - t);\r\n\t\t\tfloat radius = puff.Radius * (1 + t * 1.4f);\r\n\t\t\tvar center = new Vector2( Left, Top ) + puff.Position;\r\n\t\t\t// Soft, faint grey dust is composited last, over gems and chip fragments.\r\n\t\t\tfor ( int layer = 4; layer >= 1; layer-- )\r\n\t\t\t{\r\n\t\t\t\tp.Fill = new Color( 0.70f, 0.74f, 0.78f ).WithAlpha( opacity * 0.05f );\r\n\t\t\t\tp.Circle( center, radius * layer / 4 );\r\n\t\t\t}\r\n\t\t}\r\n\t}\r\n\r\n\tconst float LandingPointAppearTime = 0.14f;\r\n\t// Unrelated per-ray pulse rates (Hz), so the spokes never breathe in step.\r\n\tstatic readonly float[] LandingRayPulseRates = [1.31f, 2.07f, 1.63f, 2.53f, 1.12f, 1.87f];\r\n\r\n\t// Spoke core blur, widest and faintest first; stacked, the middle reaches about 92% opacity.\r\n\t[SkipHotload] static readonly (float Width, float Alpha)[] LandingCoreLayers = [(1.5f, 0.15f), (1.2f, 0.2f), (0.95f, 0.3f), (0.72f, 0.45f), (0.5f, 0.7f)];\r\n\r\n\tstatic float LandingRayLength( int ray ) => ray switch { 0 => 9, 1 => 5.5f, 2 => 7, 3 => 8, 4 => 5, _ => 6.5f };\r\n\r\n\tstatic void DrawLandingPointGlow( Painter p, LandingPointGlow glow, float scale )\r\n\t{\r\n\t\tif ( scale <= 0 ) return;\r\n\t\tusing var scope = p.Scope();\r\n\t\tp.Translate( Position( glow.Contact.Lane, glow.Contact.Y ) );\r\n\t\t// Pop in from a quarter size with a slight ease-out-back overshoot; fading out keeps full size.\r\n\t\tfloat t = glow.Appear - 1;\r\n\t\tfloat grow = 1 + 2.2f * t * t * t + 1.2f * t * t;\r\n\t\tp.Scale( 1.6f * scale * (0.25f + 0.75f * grow) );\r\n\t\tvar center = Vector2.Zero;\r\n\t\tvar color = Palette[glow.Contact.ColorIndex];\r\n\t\tvar tint = new Color( (color.r + 1) * 0.5f, (color.g + 1) * 0.5f, (color.b + 1) * 0.5f );\r\n\t\tp.Stroke = Stroke.None;\r\n\t\tp.Fill = Fill.RadialGradient()\r\n\t\t\t.WithStop( 0, tint.WithAlpha( 0.26f * glow.Opacity ) )\r\n\t\t\t.WithStop( 0.25f, tint.WithAlpha( 0.2f * glow.Opacity ) )\r\n\t\t\t.WithStop( 0.5f, tint.WithAlpha( 0.1f * glow.Opacity ) )\r\n\t\t\t.WithStop( 0.75f, tint.WithAlpha( 0.04f * glow.Opacity ) )\r\n\t\t\t.WithStop( 1, tint.WithAlpha( 0 ) );\r\n\t\tp.Circle( center, 13 );\r\n\t\tSpan<float> pulses = stackalloc float[6], coreReach = stackalloc float[6];\r\n\t\tfor ( int ray = 0; ray < 6; ray++ )\r\n\t\t{\r\n\t\t\t// Two detuned sines per ray give an irregular, subtle breathing.\r\n\t\t\tfloat phase = glow.PulsePhase * (ray + 1) * 1.618f;\r\n\t\t\tfloat w = glow.Age * LandingRayPulseRates[ray] * MathF.PI * 2;\r\n\t\t\tpulses[ray] = 1 + 0.1f * MathF.Sin( w + phase ) + 0.05f * MathF.Sin( w * 1.73f + phase * 0.5f );\r\n\t\t\t// The core's reach, as a fraction of its spoke, pulses on its own unrelated clock (about 0.45-0.75).\r\n\t\t\tfloat corePhase = glow.PulsePhase * (ray + 2) * 2.414f;\r\n\t\t\tfloat coreW = glow.Age * LandingRayPulseRates[5 - ray] * 1.37f * MathF.PI * 2;\r\n\t\t\tcoreReach[ray] = 0.6f + 0.1f * MathF.Sin( coreW + corePhase ) + 0.05f * MathF.Sin( coreW * 1.61f + corePhase * 0.7f );\r\n\t\t}\r\n\t\t// Blurry white halos first: a soft radial gradient stretched into an ellipse along each spoke.\r\n\t\tvar halo = Fill.RadialGradient()\r\n\t\t\t.WithStop( 0, Color.White.WithAlpha( 0.42f * glow.Opacity ) )\r\n\t\t\t.WithStop( 0.4f, Color.White.WithAlpha( 0.2f * glow.Opacity ) )\r\n\t\t\t.WithStop( 0.75f, Color.White.WithAlpha( 0.06f * glow.Opacity ) )\r\n\t\t\t.WithStop( 1, Color.White.WithAlpha( 0 ) );\r\n\t\tfor ( int ray = 0; ray < 6; ray++ )\r\n\t\t{\r\n\t\t\tfloat angle = glow.Rotation + ray * MathF.PI / 3;\r\n\t\t\tfloat reach = LandingRayLength( ray ) * pulses[ray];\r\n\t\t\tfloat along = reach * 0.6f + 3, across = 3.4f * pulses[ray];\r\n\t\t\tusing var haloScope = p.Scope();\r\n\t\t\tp.Translate( new Vector2( MathF.Cos( angle ), MathF.Sin( angle ) ) * reach * 0.45f );\r\n\t\t\tp.Rotate( angle * 180 / MathF.PI );\r\n\t\t\tp.Scale( 1, across / along );\r\n\t\t\tp.Fill = halo;\r\n\t\t\tp.Circle( Vector2.Zero, along );\r\n\t\t}\r\n\t\tp.Fill = tint.WithAlpha( glow.Opacity );\r\n\t\tfor ( int ray = 0; ray < 6; ray++ )\r\n\t\t{\r\n\t\t\tfloat angle = glow.Rotation + ray * MathF.PI / 3;\r\n\t\t\tvar direction = new Vector2( MathF.Cos( angle ), MathF.Sin( angle ) );\r\n\t\t\tvar side = new Vector2( -direction.y, direction.x );\r\n\t\t\tfloat pulse = pulses[ray];\r\n\t\t\tp.Polygon( [center + side * 1.2f * pulse, center + direction * LandingRayLength( ray ) * pulse, center - side * 1.2f * pulse] );\r\n\t\t}\r\n\t\t// A thin near-white core partway down each spoke, instead of a round hotspot in the middle. Layers\r\n\t\t// share the tip but widen toward the center, so the core is blurred inside and sharp at the point.\r\n\t\tvar core = new Color( (tint.r + 3) * 0.25f, (tint.g + 3) * 0.25f, (tint.b + 3) * 0.25f );\r\n\t\tfor ( int layer = 0; layer < LandingCoreLayers.Length; layer++ )\r\n\t\t{\r\n\t\t\tvar (width, alpha) = LandingCoreLayers[layer];\r\n\t\t\tp.Fill = core.WithAlpha( alpha * glow.Opacity );\r\n\t\t\tfor ( int ray = 0; ray < 6; ray++ )\r\n\t\t\t{\r\n\t\t\t\tfloat angle = glow.Rotation + ray * MathF.PI / 3;\r\n\t\t\t\tvar direction = new Vector2( MathF.Cos( angle ), MathF.Sin( angle ) );\r\n\t\t\t\tvar side = new Vector2( -direction.y, direction.x );\r\n\t\t\t\tfloat pulse = pulses[ray];\r\n\t\t\t\tp.Polygon( [center + side * width * pulse, center + direction * LandingRayLength( ray ) * coreReach[ray] * pulse, center - side * width * pulse] );\r\n\t\t\t}\r\n\t\t}\r\n\t}\r\n\r\n\tstatic void DrawDamageFlash( Painter p, DiamondBoard.DamageFlash flash )\r\n\t\t=> DrawSunburst( p, Position( flash.Lane, flash.Y ), flash.ColorIndex,\r\n\t\t\tflash.Age / DiamondBoard.DamageFlashDuration, flash.Rotation, flash.Brightness, flash.Scale );\r\n\r\n\tstatic void DrawSunburst( Painter p, Vector2 position, int colorIndex, float progress, float rotation, float brightness, float scale )\r\n\t\t=> DrawSunburst( p, position, Palette[colorIndex], progress, rotation, brightness, scale );\r\n\r\n\tstatic void DrawSunburst( Painter p, Vector2 position, Color color, float progress, float rotation, float brightness, float scale )\r\n\t{\r\n\t\tusing var scope = p.Scope();\r\n\t\tp.Translate( position );\r\n\t\tp.Scale( scale );\r\n\t\tfloat t = Math.Clamp( progress, 0, 1 );\r\n\t\tfloat fade = (1 - t) * (1 - t);\r\n\t\tfloat radius = 17 + 28 * (1 - MathF.Pow( 1 - t, 3 ));\r\n\t\t// Lift the receiver's color toward white while retaining its hue. Brightness\r\n\t\t// is chosen once per burst so fading doesn't flicker between random shades.\r\n\t\tvar tint = new Color( color.r + (1 - color.r) * brightness,\r\n\t\t\tcolor.g + (1 - color.g) * brightness, color.b + (1 - color.b) * brightness );\r\n\t\tp.Stroke = Stroke.None;\r\n\t\tfor ( int layer = 3; layer >= 1; layer-- )\r\n\t\t{\r\n\t\t\tp.Fill = tint.WithAlpha( fade * 0.07f );\r\n\t\t\tp.Circle( Vector2.Zero, radius * layer * 0.45f );\r\n\t\t}\r\n\t\t// Fixed ray orientation throughout the expanding, fading burst.\r\n\t\tfor ( int ray = 0; ray < 12; ray++ )\r\n\t\t{\r\n\t\t\tfloat angle = ray * MathF.PI / 6 + rotation;\r\n\t\t\tvar direction = new Vector2( MathF.Cos( angle ), MathF.Sin( angle ) );\r\n\t\t\tvar side = new Vector2( -direction.y, direction.x );\r\n\t\t\tfloat length = radius * (ray % 3 == 0 ? 1 : ray % 2 == 0 ? 0.76f : 0.52f);\r\n\t\t\tfloat width = (ray % 3 == 0 ? 2.8f : 1.6f) * (1 - t * 0.6f);\r\n\t\t\tp.Fill = tint.WithAlpha( fade * 0.9f );\r\n\t\t\tp.Polygon( [side * width, direction * length, -side * width] );\r\n\t\t}\r\n\t\tp.Fill = new Color( (tint.r + 1) * 0.5f, (tint.g + 1) * 0.5f, (tint.b + 1) * 0.5f ).WithAlpha( fade );\r\n\t\tp.Circle( Vector2.Zero, 4.2f * (1 - t) );\r\n\t}\r\n\r\n\tstatic void DrawFaceFlash( Painter p, DiamondBoard.FaceFlash flash )\r\n\t{\r\n\t\tvar face = flash.Contact;\r\n\t\tDrawFaceFlash( p, Position( face.StartLane, face.StartY ), Position( face.EndLane, face.EndY ),\r\n\t\t\tface.ColorIndex, flash.Age, flash.Seed, flash.Brightness, flash.DamageCount );\r\n\t}\r\n\r\n\tstatic void DrawFaceFlash( Painter p, Vector2 start, Vector2 end, int colorIndex,\r\n\t\tfloat age, float seed, float brightness, int damageCount )\r\n\t\t=> DrawFaceFlash( p, start, end, Palette[colorIndex], age, seed, brightness, damageCount );\r\n\r\n\tstatic void DrawFaceFlash( Painter p, Vector2 start, Vector2 end, Color color,\r\n\t\tfloat age, float seed, float brightness, int damageCount )\r\n\t{\r\n\t\tusing var scope = p.Scope();\r\n\t\tvar tangent = (end - start).Normal;\r\n\t\tvar normal = new Vector2( -tangent.y, tangent.x );\r\n\t\tfloat t = Math.Clamp( age / DiamondBoard.DamageFlashDuration, 0, 1 );\r\n\t\tfloat fade = (1 - t) * (1 - t);\r\n\t\tfloat scale = 1 + 0.5f * Math.Max( 0, damageCount - 1 );\r\n\t\tvar tint = new Color( color.r + (1 - color.r) * brightness,\r\n\t\t\tcolor.g + (1 - color.g) * brightness, color.b + (1 - color.b) * brightness );\r\n\t\t// Keep the seam anchored to the receiving face; only the escaping light grows.\r\n\t\tfor ( int layer = 3; layer >= 1; layer-- )\r\n\t\t{\r\n\t\t\tp.Stroke = Stroke.Solid( tint.WithAlpha( fade * 0.10f ), layer * 4 * scale );\r\n\t\t\tp.Line( start, end );\r\n\t\t}\r\n\t\tp.Stroke = Stroke.None;\r\n\t\tp.Fill = tint.WithAlpha( fade * 0.9f );\r\n\t\tfor ( int ray = 0; ray < 11; ray++ )\r\n\t\t{\r\n\t\t\tfloat along = (ray + 0.5f) / 11;\r\n\t\t\tvar origin = start + (end - start) * along;\r\n\t\t\tfor ( int side = -1; side <= 1; side += 2 )\r\n\t\t\t{\r\n\t\t\t\t// Each spoke fans out independently, with a fixed angle for its lifetime.\r\n\t\t\t\tfloat angle = MathF.Sin( seed * 1.37f + ray * 2.399f + side * 1.71f ) * 1.25f;\r\n\t\t\t\tvar direction = normal * (side * MathF.Cos( angle )) + tangent * MathF.Sin( angle );\r\n\t\t\t\tvar across = new Vector2( -direction.y, direction.x );\r\n\t\t\t\tfloat variation = 0.5f + 0.5f * MathF.Sin( seed + ray * 4.13f + side * 2.57f );\r\n\t\t\t\tfloat length = (13.5f + 31 * (1 - MathF.Pow( 1 - t, 3 ))) * (0.45f + variation * 0.55f) * scale;\r\n\t\t\t\tfloat width = (1.15f + variation * 1.15f) * (1 - t * 0.6f);\r\n\t\t\t\tp.Polygon( [origin - across * width, origin + direction * length, origin + across * width] );\r\n\t\t\t}\r\n\t\t}\r\n\t\tvar core = new Color( (tint.r + 1) * 0.5f, (tint.g + 1) * 0.5f, (tint.b + 1) * 0.5f );\r\n\t\tp.Stroke = Stroke.Solid( core.WithAlpha( fade ), 2.8f * (1 - t) );\r\n\t\tp.Line( start, end );\r\n\t}\r\n\r\n\tstatic Vector2 Position( float lane, float y ) => new( Left + DiamondBoard.LaneX( lane ), Top + y );\r\n\r\n\t/// <summary>Draw the controlled piece trailing its lattice position while a step or turn animates.</summary>\r\n\tvoid DrawActivePiece( Painter p )\r\n\t{\r\n\t\tvar piece = Board.Active;\r\n\t\tvar trail = Board.ControlOffset;\r\n\t\tvar anchor = Position( piece.Lane + trail.Lane, piece.Y + trail.Y ) + PieceShakeOffset;\r\n\t\tfloat turns = Board.ControlTurns;\r\n\t\tif ( MathF.Abs( turns ) < 0.0005f )\r\n\t\t{\r\n\t\t\tDrawShape( p, piece, anchor, false );\r\n\t\t\treturn;\r\n\t\t}\r\n\t\t// Show the nearest whole rotation, turned the rest of the way on the lattice:\r\n\t\t// a rotation in half-width/half-height units, so each endpoint matches the\r\n\t\t// lattice exactly and the facets swap mid-turn while motion is fastest.\r\n\t\tint whole = (int)MathF.Round( turns );\r\n\t\tvar shown = piece with { Rotation = (piece.Rotation - whole) & 3 };\r\n\t\tvar pivotOffset = DiamondShapes.All[piece.ShapeIndex].Pivot;\r\n\t\tvar pivot = anchor + new Vector2( pivotOffset.Lane * DiamondBoard.Width * 0.5f, pivotOffset.Row * DiamondBoard.Height * 0.5f );\r\n\t\tusing var scope = p.Scope();\r\n\t\tp.Translate( pivot );\r\n\t\tp.Scale( DiamondBoard.Width * 0.5f, DiamondBoard.Height * 0.5f );\r\n\t\tp.Rotate( (whole - turns) * 90 );\r\n\t\tp.Scale( 2 / DiamondBoard.Width, 2 / DiamondBoard.Height );\r\n\t\tp.Translate( -pivot );\r\n\t\tDrawShape( p, shown, anchor, false );\r\n\t}\r\n\r\n\tvoid DrawShape( Painter p, DiamondBoard.Piece piece, Vector2 anchor, bool ghost, float scale = 1 )\r\n\t{\r\n\t\tif ( drawingReferenceGeometry )\r\n\t\t{\r\n\t\t\tvar referenceCells = DiamondBoard.CellsOf( piece ).ToArray();\r\n\t\t\tforeach ( var cell in referenceCells )\r\n\t\t\t\tDrawDiamond( p, anchor + new Vector2( (cell.Lane - piece.Lane) * DiamondBoard.Width * 0.5f, cell.Y - piece.Y ) * scale,\r\n\t\t\t\t\tcell.ColorIndex, ghost, scale, health: cell.Health, crackCorner: cell.FirstCrack );\r\n\t\t\tDrawGroupOutlines( p, referenceCells, DiamondMatching.FindGroups( referenceCells ),\r\n\t\t\t\tanchor - new Vector2( DiamondBoard.LaneX( piece.Lane ), piece.Y ) * scale, scale, ghost ? 0.35f : 1, !ghost );\r\n\t\t\treturn;\r\n\t\t}\r\n\t\t// Reuse cell/group storage while preserving the original layer bounds and pixel alignment.\r\n\t\tvar cache = ghost ? ghostShapeGeometry : piece == Board.Next ? nextShapeGeometry : activeShapeGeometry;\r\n\t\tcache.Update( piece );\r\n\t\tvar cells = cache.Cells;\r\n\t\tforeach ( var cell in cells )\r\n\t\t\tDrawDiamond( p, anchor + new Vector2( (cell.Lane - piece.Lane) * DiamondBoard.Width * 0.5f, cell.Y - piece.Y ) * scale,\r\n\t\t\t\tcell.ColorIndex, ghost, scale, health: cell.Health, crackCorner: cell.FirstCrack );\r\n\t\tDrawGroupOutlines( p, cells, cache.Groups,\r\n\t\t\tanchor - new Vector2( DiamondBoard.LaneX( piece.Lane ), piece.Y ) * scale, scale, ghost ? 0.35f : 1, !ghost,\r\n\t\t\tgeometryCache: cache.Outlines );\r\n\t}\r\n\r\n\tvoid DrawGroupOutlines( Painter p, IReadOnlyList<DiamondBoard.Diamond> cells,\r\n\t\tIReadOnlyList<IReadOnlyList<int>> groups, Vector2 origin, float scale = 1, float opacity = 1, bool glow = true,\r\n\t\tIReadOnlyList<DiamondBoard.BondPulse> pulses = null, OutlineGeometryCache geometryCache = null )\r\n\t{\r\n\t\tif ( cells.Count == 0 || opacity <= 0 ) return;\r\n\t\tusing var scope = p.Scope();\r\n\t\tp.Translate( origin );\r\n\t\tp.Scale( scale );\r\n\t\t// One filtered layer for the whole collection. Drawing coordinates inside\r\n\t\t// BeginLayer are local; the parent transform is applied when compositing.\r\n\t\tfloat minX = float.PositiveInfinity, maxX = float.NegativeInfinity;\r\n\t\tfloat minY = float.PositiveInfinity, maxY = float.NegativeInfinity;\r\n\t\tfor ( int i = 0; i < cells.Count; i++ )\r\n\t\t{\r\n\t\t\tvar cell = cells[i];\r\n\t\t\tfloat x = DiamondBoard.LaneX( cell.Lane );\r\n\t\t\tminX = MathF.Min( minX, x ); maxX = MathF.Max( maxX, x );\r\n\t\t\tminY = MathF.Min( minY, cell.Y ); maxY = MathF.Max( maxY, cell.Y );\r\n\t\t}\r\n\t\tminX -= DiamondBoard.Width * 0.5f + 20; maxX += DiamondBoard.Width * 0.5f + 20;\r\n\t\tminY -= DiamondBoard.Height * 0.5f + 20; maxY += DiamondBoard.Height * 0.5f + 20;\r\n\t\tusing var layer = p.BeginLayer( new Rect( minX, minY, maxX - minX, maxY - minY ),\r\n\t\t\topacity, new Painter.Filter { Blur = glow ? 3 : 0 } );\r\n\t\tp.Fill = Fill.None;\r\n\t\tgeometryCache ??= ReferenceEquals( groups, Board?.BondedGroups ) ? settledOutlineGeometry : null;\r\n\t\tvar geometry = !drawingReferenceGeometry && geometryCache is not null\r\n\t\t\t? geometryCache.Get( cells, groups ) : BuildOutlineGeometry( cells, groups );\r\n\t\tforeach ( var group in geometry )\r\n\t\t{\r\n\t\t\tif ( group is null ) continue;\r\n\t\t\t// Existing contour caches can survive hotload without the newly added offsets.\r\n\t\t\tgroup.Miters ??= Array.ConvertAll( group.Contours, c => BuildPerimeterMiters( c.Points, c.Closed ) );\r\n\t\t\tvar firstEdge = group.FirstEdge with { X1 = group.FirstEdge.X1 + group.Offset.x, Y1 = group.FirstEdge.Y1 + group.Offset.y,\r\n\t\t\t\tX2 = group.FirstEdge.X2 + group.Offset.x, Y2 = group.FirstEdge.Y2 + group.Offset.y };\r\n\t\t\tfloat connection = 0;\r\n\t\t\tif ( pulses is not null )\r\n\t\t\t\tforeach ( var pulse in pulses )\r\n\t\t\t\t\tif ( pulse.Edges.Contains( firstEdge ) )\r\n\t\t\t\t\t{\r\n\t\t\t\t\t\tfloat t = Math.Clamp( pulse.Age / DiamondBoard.BondPulseDuration, 0, 1 );\r\n\t\t\t\t\t\tconnection = MathF.Sin( MathF.PI * t ) * (1 - t);\r\n\t\t\t\t\t\tbreak;\r\n\t\t\t\t\t}\r\n\t\t\tfloat breathing = MathF.Sin( boundaryTime * MathF.PI * 2 / 3.6f );\r\n\t\t\tvar color = Palette[group.FirstEdge.ColorIndex];\r\n\t\t\tfloat lift = glow ? 0.32f : 0;\r\n\t\t\tcolor = new Color( color.r + (1 - color.r) * lift,\r\n\t\t\t\tcolor.g + (1 - color.g) * lift, color.b + (1 - color.b) * lift );\r\n\t\t\t// A single continuous, softly filtered stroke: no stacked bands, end caps,\r\n\t\t\t// bright corner discs or detached rings. New bonds swell this same glow.\r\n\t\t\tfor ( int i = 0; i < group.Contours.Length; i++ )\r\n\t\t\t{\r\n\t\t\t\tvar contour = group.Contours[i];\r\n\t\t\t\tDrawPerimeter( p, contour.Points, group.Miters?[i], color.WithAlpha( glow ? 0.88f + breathing * 0.06f : 1 ),\r\n\t\t\t\t\tglow ? 4.5f + breathing * 0.25f + connection * 5 : 1, contour.Closed );\r\n\t\t\t}\r\n\t\t}\r\n\t}\r\n\r\n\tstatic Color GemColor( int colorIndex, int health )\r\n\t{\r\n\t\tvar color = Palette[colorIndex];\r\n\t\tfloat brightness = health >= DiamondBoard.MaxHealth ? 1 : health == 2 ? 0.93f : 0.78f;\r\n\t\treturn new Color( color.r * brightness, color.g * brightness, color.b * brightness );\r\n\t}\r\n\r\n\tvoid DrawDiamond( Painter p, Vector2 center, int colorIndex, bool ghost, float scale = 1, float opacity = 1, int health = DiamondBoard.MaxHealth, DiamondBoard.Corner crackCorner = DiamondBoard.Corner.Top )\r\n\t{\r\n\t\t// Ghosts and shatter fades have per-facet opacity, which must not be flattened.\r\n\t\tif ( !drawingReferenceGems && !drawingReferenceStrokes && !ghost && opacity == 1 && scale > 0 &&\r\n\t\t\tDrawAtlasGem( p, center, colorIndex, scale, health, crackCorner ) ) return;\r\n\t\tDrawDiamondVector( p, center, colorIndex, ghost, scale, opacity, health, crackCorner );\r\n\t}\r\n\r\n\tstatic void DrawDiamondVector( Painter p, Vector2 center, int colorIndex, bool ghost, float scale = 1, float opacity = 1, int health = DiamondBoard.MaxHealth, DiamondBoard.Corner crackCorner = DiamondBoard.Corner.Top )\r\n\t{\r\n\t\tusing var scope = p.Scope();\r\n\t\tp.Translate( center );\r\n\t\tp.Scale( scale );\r\n\t\tColor color = GemColor( colorIndex, health );\r\n\t\tfloat w = DiamondBoard.Width * 0.5f;\r\n\t\tfloat h = DiamondBoard.Height * 0.5f;\r\n\t\tVector2 top = new( 0, -h ), right = new( w, 0 ), bottom = new( 0, h ), left = new( -w, 0 );\r\n\t\tVector2 middle = Vector2.Zero;\r\n\r\n\t\t// Every cut tiles the same diamond. Broad light/dark faces provide the visual\r\n\t\t// identity; there are no icons or marks floating above the gem surface.\r\n\t\tp.Fill = color.WithAlpha( opacity * (ghost ? 0.045f : 0.94f) );\r\n\t\tp.Stroke = Stroke.None;\r\n\t\tp.Quad( top, right, bottom, left );\r\n\t\tswitch ( colorIndex )\r\n\t\t{\r\n\t\t\tcase 0: // Classic: four facets meet at the center, retaining the original cut.\r\n\t\t\t\tFacet( p, color, 0.32f, opacity, ghost, [top, middle, left] );\r\n\t\t\t\tFacet( p, color, 0.02f, opacity, ghost, [top, right, middle] );\r\n\t\t\t\tFacet( p, color, -0.42f, opacity, ghost, [middle, right, bottom] );\r\n\t\t\t\tFacet( p, color, -0.12f, opacity, ghost, [left, middle, bottom] );\r\n\t\t\t\tbreak;\r\n\t\t\tcase 1: // Bevel: a smaller diamond table surrounded by four broad bevels.\r\n\t\t\t\tVector2 innerTop = top * 0.48f, innerRight = right * 0.48f;\r\n\t\t\t\tVector2 innerBottom = bottom * 0.48f, innerLeft = left * 0.48f;\r\n\t\t\t\tFacet( p, color, 0.42f, opacity, ghost, [top, innerTop, innerLeft, left] );\r\n\t\t\t\tFacet( p, color, -0.12f, opacity, ghost, [top, right, innerRight, innerTop] );\r\n\t\t\t\tFacet( p, color, -0.48f, opacity, ghost, [right, bottom, innerBottom, innerRight] );\r\n\t\t\t\tFacet( p, color, -0.2f, opacity, ghost, [bottom, left, innerLeft, innerBottom] );\r\n\t\t\t\tFacet( p, color, 0.14f, opacity, ghost, [innerTop, innerRight, innerBottom, innerLeft] );\r\n\t\t\t\tbreak;\r\n\t\t\tcase 2: // Table: a long, flat hexagonal face with clipped ends and narrow sides.\r\n\t\t\t\tVector2 upperLeft = new( -w * 0.23f, -h * 0.54f ), upperRight = new( w * 0.23f, -h * 0.54f );\r\n\t\t\t\tVector2 midLeft = new( -w * 0.5f, 0 ), midRight = new( w * 0.5f, 0 );\r\n\t\t\t\tVector2 lowerLeft = new( -w * 0.23f, h * 0.54f ), lowerRight = new( w * 0.23f, h * 0.54f );\r\n\t\t\t\tFacet( p, color, 0.48f, opacity, ghost, [top, upperRight, upperLeft] );\r\n\t\t\t\tFacet( p, color, 0.24f, opacity, ghost, [top, upperLeft, midLeft, left] );\r\n\t\t\t\tFacet( p, color, -0.32f, opacity, ghost, [top, right, midRight, upperRight] );\r\n\t\t\t\tFacet( p, color, -0.12f, opacity, ghost, [left, midLeft, lowerLeft, bottom] );\r\n\t\t\t\tFacet( p, color, -0.48f, opacity, ghost, [right, bottom, lowerRight, midRight] );\r\n\t\t\t\tFacet( p, color, -0.25f, opacity, ghost, [bottom, lowerLeft, lowerRight] );\r\n\t\t\t\tFacet( p, color, 0.08f, opacity, ghost, [upperLeft, upperRight, midRight, lowerRight, lowerLeft, midLeft] );\r\n\t\t\t\tbreak;\r\n\t\t\tcase 3: // Steps: three chevron ridges cut across a central spine.\r\n\t\t\t\tVector2 a = new( -w * 0.5f, -h * 0.5f ), b = new( w * 0.5f, -h * 0.5f );\r\n\t\t\t\tVector2 c = new( 0, -h * 0.2f ), d = new( 0, h * 0.35f );\r\n\t\t\t\tVector2 e = new( -w * 0.5f, h * 0.5f ), f = new( w * 0.5f, h * 0.5f ), g = new( 0, h * 0.8f );\r\n\t\t\t\tFacet( p, color, 0.36f, opacity, ghost, [top, c, a] );\r\n\t\t\t\tFacet( p, color, -0.14f, opacity, ghost, [top, b, c] );\r\n\t\t\t\tFacet( p, color, -0.08f, opacity, ghost, [a, c, d, left] );\r\n\t\t\t\tFacet( p, color, -0.44f, opacity, ghost, [c, b, right, d] );\r\n\t\t\t\tFacet( p, color, 0.3f, opacity, ghost, [left, d, g, e] );\r\n\t\t\t\tFacet( p, color, -0.16f, opacity, ghost, [d, right, f, g] );\r\n\t\t\t\tFacet( p, color, -0.14f, opacity, ghost, [e, g, bottom] );\r\n\t\t\t\tFacet( p, color, -0.5f, opacity, ghost, [g, f, bottom] );\r\n\t\t\t\tbreak;\r\n\t\t\tcase 4: // Fan: long triangular planes radiate from the top point.\r\n\t\t\t\tVector2 fanLeft = (left + bottom) * 0.5f, fanRight = (right + bottom) * 0.5f;\r\n\t\t\t\tFacet( p, color, 0.32f, opacity, ghost, [top, fanLeft, left] );\r\n\t\t\t\tFacet( p, color, -0.34f, opacity, ghost, [top, bottom, fanLeft] );\r\n\t\t\t\tFacet( p, color, 0.12f, opacity, ghost, [top, fanRight, bottom] );\r\n\t\t\t\tFacet( p, color, -0.48f, opacity, ghost, [top, right, fanRight] );\r\n\t\t\t\tbreak;\r\n\t\t}\r\n\r\n\t\tif ( !ghost && health < DiamondBoard.MaxHealth ) DrawCracks( p, w, h, health, opacity, colorIndex, crackCorner );\r\n\t\t// The colored perimeter is drawn once around the bonded group after its facets.\r\n\t}\r\n\r\n\tstatic Vector2 CrackPoint( Vector2 point, float w, float h, float mirror, DiamondBoard.Corner corner )\r\n\t{\r\n\t\tpoint.x *= mirror;\r\n\t\t// Rotate in normalized gem coordinates before restoring its tall proportions.\r\n\t\tpoint = corner switch\r\n\t\t{\r\n\t\t\tDiamondBoard.Corner.Right => new( -point.y, point.x ),\r\n\t\t\tDiamondBoard.Corner.Bottom => new( -point.x, -point.y ),\r\n\t\t\tDiamondBoard.Corner.Left => new( point.y, -point.x ),\r\n\t\t\t_ => point\r\n\t\t};\r\n\t\treturn new( point.x * w, point.y * h );\r\n\t}\r\n\r\n\tstatic void DrawCracks( Painter p, float w, float h, int health, float opacity, int colorIndex, DiamondBoard.Corner corner )\r\n\t{\r\n\t\tfloat mirror = colorIndex % 2 == 0 ? 1 : -1;\r\n\t\tColor crackColor = Color.Lerp( Palette[colorIndex], Color.Black, health == 1 ? 0.6f : 0.45f );\r\n\t\tfloat crackAlpha = health == 1 ? 0.94f : 0.88f;\r\n\t\tvoid Crack( Painter p, float w, float h, int health, float opacity, float mirror, DiamondBoard.Corner corner, Span<Vector2> path )\r\n\t\t{\r\n\t\t\tfor ( int i = 0; i < path.Length; i++ ) path[i] = CrackPoint( path[i], w, h, mirror, corner );\r\n\t\t\tDrawGemStroke( p, path, crackColor.WithAlpha( opacity * crackAlpha ), health == 2 ? 2.2f : 3.3f, false );\r\n\t\t\tfor ( int i = 0; i < path.Length; i++ ) path[i] += new Vector2( 1, -0.4f );\r\n\t\t\tDrawGemStroke( p, path, Color.White.WithAlpha( opacity * 0.55f ), 0.65f, false );\r\n\t\t}\r\n\t\tCrack( p, w, h, health, opacity, mirror, corner, stackalloc Vector2[] { new( 0, -1 ), new( 0.12f, -0.66f ), new( -0.10f, -0.43f ), new( 0.08f, -0.21f ), new( -0.12f, 0.06f ), new( 0.05f, 0.22f ) } );\r\n\t\tCrack( p, w, h, health, opacity, mirror, corner, stackalloc Vector2[] { new( -0.10f, -0.43f ), new( -0.31f, -0.29f ), new( -0.4f, -0.06f ) } );\r\n\t\tif ( health > 1 ) return;\r\n\t\tCrack( p, w, h, health, opacity, mirror, corner, stackalloc Vector2[] { new( 0.05f, 0.22f ), new( 0.11f, 0.67f ) } );\r\n\t\tCrack( p, w, h, health, opacity, mirror, corner, stackalloc Vector2[] { new( 0.05f, 0.22f ), new( 0.34f, -0.04f ), new( 0.55f, 0.12f ), new( 0.87f, 0.1f ) } );\r\n\t\tCrack( p, w, h, health, opacity, mirror, corner, stackalloc Vector2[] { new( 0.34f, -0.04f ), new( 0.25f, -0.24f ), new( 0.55f, -0.4f ) } );\r\n\t\tCrack( p, w, h, health, opacity, mirror, corner, stackalloc Vector2[] { new( 0.11f, 0.67f ), new( -0.12f, 0.7f ), new( 0, 0.98f ) } );\r\n\t\tp.Fill = crackColor.WithAlpha( opacity * 0.65f );\r\n\t\tp.Stroke = Stroke.None;\r\n\t\tp.Polygon( [CrackPoint( new( -0.12f, 0.06f ), w, h, mirror, corner ), CrackPoint( new( -0.04f, 0.13f ), w, h, mirror, corner ), CrackPoint( new( 0.05f, 0.22f ), w, h, mirror, corner )] );\r\n\t}\r\n\r\n\tstatic void Facet( Painter p, Color color, float light, float opacity, bool ghost, ReadOnlySpan<Vector2> vertices )\r\n\t{\r\n\t\tfloat amount = MathF.Abs( light );\r\n\t\tColor target = light >= 0 ? Color.White : Color.Black;\r\n\t\tColor face = new( color.r + (target.r - color.r) * amount,\r\n\t\t\tcolor.g + (target.g - color.g) * amount, color.b + (target.b - color.b) * amount );\r\n\t\tp.Fill = ghost ? Fill.None : face.WithAlpha( opacity * 0.96f );\r\n\t\tp.Stroke = Stroke.None;\r\n\t\tp.Polygon( vertices );\r\n\t\tDrawGemStroke( p, vertices, (ghost ? color : Color.Black).WithAlpha( opacity * (ghost ? 0.2f : 0.28f) ), 0.8f, true );\r\n\t}\r\n\tstatic void Text( Painter p, string text, float x, float y, float width, float height, float size, Color color, bool center = false, string fontName = \"Roboto\" )\r\n\t{\r\n\t\tp.TextStyle = new TextStyle { FontName = fontName, FontSize = size, Color = color, Alignment = center ? TextFlag.Center : TextFlag.LeftTop };\r\n\t\tp.Text( text, new Rect( x, y, width, height ) );\r\n\t}\r\n}\r\n\r\n"
        },
        {
            "Ident": "facepunch.diamondgrinder",
            "Path": "DiamondSettings.cs",
            "FileName": "DiamondSettings.cs",
            "PackageType": "game",
            "CodeKind": "Game",
            "AssetVersionId": 398652,
            "IsPrivate": false,
            "Code": "using System;\nusing Sandbox;\n\nnamespace Diamonds;\n\n/// <summary>\n/// Player preferences from the options overlay, persisted as JSON in the game's local data folder.\n/// Volumes are slider positions from zero to one, applied as linear gain.\n/// </summary>\npublic sealed class DiamondSettings\n{\n\tpublic const string FilePath = \"diamonds_settings.json\";\n\tstatic DiamondSettings current;\n\n\t/// <summary>Use a dim static dye background instead of gameplay fluid. Keep the original JSON key for saved preferences.</summary>\n\t[System.Text.Json.Serialization.JsonPropertyName( \"HideDye\" )]\n\tpublic bool DisableFluid { get; set; }\n\n\t/// <summary>Overall level for every game sound, including music.</summary>\n\tpublic float MasterVolume { get => masterVolume; set => masterVolume = Clamp( value ); }\n\tfloat masterVolume = 1;\n\n\t/// <summary>Music level, applied on top of the master volume.</summary>\n\tpublic float MusicVolume { get => musicVolume; set => musicVolume = Clamp( value ); }\n\tfloat musicVolume = 1;\n\n\t/// <summary>Controller vibration strength, from zero (off) to one.</summary>\n\tpublic float ControllerRumble { get => controllerRumble; set => controllerRumble = Clamp( value ); }\n\tfloat controllerRumble = 1;\n\n\tpublic const float RestartDelayStep = 0.05f;\n\n\t/// <summary>Seconds the restart key must be held, from zero to one in 0.05 steps. Zero restarts instantly, without a fade.</summary>\n\tpublic float RestartDelay\n\t{\n\t\tget => restartDelay;\n\t\tset => restartDelay = float.IsFinite( value ) ? MathF.Round( Math.Clamp( value, 0, 1 ) / RestartDelayStep ) * RestartDelayStep : 0.5f;\n\t}\n\tfloat restartDelay = 0.5f;\n\n\t/// <summary>A delay as the options slider shows it: 0s, 0.05s, 0.5s, 1s.</summary>\n\tpublic static string FormatSeconds( float seconds )\n\t{\n\t\t// Whole hundredths, formatted by hand so the decimal point never follows the system culture.\n\t\tint hundredths = Math.Max( 0, (int)MathF.Round( seconds * 100 ) );\n\t\tint whole = hundredths / 100, fraction = hundredths % 100;\n\t\treturn fraction == 0 ? $\"{whole}s\" : fraction % 10 == 0 ? $\"{whole}.{fraction / 10}s\" : $\"{whole}.{fraction:00}s\";\n\t}\n\n\tpublic static DiamondSettings Current => current ??= Load();\n\n\t/// <summary>Linear gain for master-scaled sound effects.</summary>\n\tpublic static float EffectsGain => Current.MasterVolume;\n\n\t/// <summary>Linear gain for music: the master and music sliders combined.</summary>\n\tpublic static float MusicGain => Current.MasterVolume * Current.MusicVolume;\n\n\n\tstatic DiamondSettings Load()\n\t{\n\t\ttry { return FileSystem.Data?.ReadJsonOrDefault( FilePath, new DiamondSettings() ) ?? new DiamondSettings(); }\n\t\tcatch ( Exception e )\n\t\t{\n\t\t\tLog.Warning( $\"Could not read {FilePath}; using default options. {e.Message}\" );\n\t\t\treturn new DiamondSettings();\n\t\t}\n\t}\n\n\tpublic static void Save()\n\t{\n\t\ttry { FileSystem.Data?.WriteJson( FilePath, Current ); }\n\t\tcatch ( Exception e ) { Log.Warning( $\"Could not save {FilePath}. {e.Message}\" ); }\n\t}\n\n\tstatic float Clamp( float value ) => float.IsFinite( value ) ? Math.Clamp( value, 0, 1 ) : 1;\n}\n"
        },
        {
            "Ident": "facepunch.diamondgrinder",
            "Path": "DiamondGame.cs",
            "FileName": "DiamondGame.cs",
            "PackageType": "game",
            "CodeKind": "Game",
            "AssetVersionId": 398652,
            "IsPrivate": false,
            "Code": "using Sandbox;\r\nusing Sandbox.UI;\r\n\r\nnamespace Diamonds;\r\n\r\n/// <summary>Hosts the Painter canvas on a ScreenPanel and routes keyboard input.</summary>\r\npublic sealed class DiamondGame : PanelComponent\r\n{\r\n\treadonly DiamondBoard board = new();\r\n\treadonly DiamondFeedback feedback = new();\r\n\treadonly DiamondDebris debris = new();\r\n\treadonly DiamondAtmosphere atmosphere = new();\r\n\tDiamondScoreEffects scoreEffects = new();\r\n\tDiamondMusic music = new();\r\n\tDiamondPanel canvas;\r\n\tbool gameStarted;\r\n\t// B both closes overlays and moves right; a press that closed one is ignored for movement until released.\r\n\tbool closeMenuHeld;\r\n\t// A (confirming PLAY or clicking RESTART) and the sticks (aiming the virtual cursor) also play. Each one still held\r\n\t// when a game starts is ignored for play until released, so the press that started it never soft-drops or moves.\r\n\t[System.Flags] enum PadInput { None = 0, SoftDrop = 1, Left = 2, Right = 4, Up = 8, Down = 16 }\r\n\tPadInput carriedPad;\r\n\t// The board reset whose game-over score was submitted.\r\n\tint submittedResetVersion = -1;\r\n\t// The board reset started by diamonds_fill, kept off the leaderboard.\r\n\tint debugFilledResetVersion = -1;\r\n\t// Board counters already added to the lifetime achievement stats, for the board reset bankedResetVersion.\r\n\tint bankedResetVersion = -1;\r\n\tlong bankedBroken, bankedCrushed, bankedScore;\r\n\tRealTimeSince sinceAchievementBank;\r\n\t// A long game is banked now and then, so a crash or forced exit loses at most this much of its progress.\r\n\tconst float AchievementBankInterval = 60;\r\n\tMouseVisibility previousMouseVisibility;\r\n\t// Queries the engine's modal state: the s&box escape menu freezes the whole game, like Block Party.\r\n\treadonly Game.Overlay engineOverlay = new();\r\n\r\n\t/// <summary>Debug score applied when starting or restarting a game. Zero or negative values start at zero.</summary>\r\n\t[Property, Group( \"Debug\" ), Title( \"Starting Score\" )]\r\n\tpublic long StartingScore\r\n\t{\r\n\t\tget => board.StartingScore;\r\n\t\tset => board.StartingScore = value;\r\n\t}\r\n\r\n\t/// <summary>Editor only: generated players mixed into the leaderboard, to preview scrolling and a busy board. Zero shows only real scores.</summary>\r\n\t[Property, Group( \"Debug\" ), Title( \"Leaderboard Test Players\" ), Range( 0, DiamondLeaderboard.MaxEntries )]\r\n\tpublic int LeaderboardTestPlayers { get; set; }\r\n\r\n\t/// <summary>Editor only, for thumbnail screenshots: draw just the background dye (the menu dye on the title, the board dye in game) and nothing else.</summary>\r\n\t[Property, Group( \"Thumbnail\" ), Title( \"Only Render Dye\" )]\r\n\tpublic bool ThumbnailOnlyDye { get; set; }\r\n\r\n\t/// <summary>Editor only, for thumbnail screenshots: never draw any dye, including the static Disable Fluid background and the menu dye.</summary>\r\n\t[Property, Group( \"Thumbnail\" ), Title( \"Hide Dye\" )]\r\n\tpublic bool ThumbnailHideDye { get; set; }\r\n\r\n\t/// <summary>Editor only, for bright thumbnail screenshots: brighten the dye by Vibrancy.</summary>\r\n\t[Property, Group( \"Thumbnail\" ), Title( \"Vibrant Dye\" )]\r\n\tpublic bool ThumbnailVibrantDye { get; set; }\r\n\r\n\t/// <summary>How far Vibrant Dye pushes the dye from its normal faint wash (0) to opaque, fully saturated color (1).</summary>\r\n\t[Property, Group( \"Thumbnail\" ), Title( \"Vibrancy\" ), Range( 0, 1 ), ShowIf( nameof( ThumbnailVibrantDye ), true )]\r\n\tpublic float ThumbnailVibrancy { get; set; } = 0.5f;\r\n\r\n\t/// <summary>Fill both bottom corner pockets. Changing this starts a fresh game.</summary>\r\n\t[Property, Group( \"Board\" ), Title( \"Fill Corner Pockets\" )]\r\n\tpublic bool FillCornerPockets\r\n\t{\r\n\t\tget => board.FillCornerPockets;\r\n\t\tset => board.FillCornerPockets = value;\r\n\t}\r\n\r\n\t/// <summary>Size of the glowing marker at the active piece's landing contact points. One is the original size.</summary>\r\n\t[Property, Group( \"Board\" ), Title( \"Landing Indicator Scale\" ), Range( 0, 4 )]\r\n\tpublic float LandingIndicatorScale { get; set; } = 1;\r\n\r\n\t/// <summary>Seconds a sideways step takes to draw. Collision still snaps to the lattice; zero draws it instantly.</summary>\r\n\t[Property, Group( \"Controls\" ), Title( \"Move Smoothing (s)\" ), Range( 0, 0.3f )]\r\n\tpublic float MoveSmoothing\r\n\t{\r\n\t\tget => board.MoveSmoothing;\r\n\t\tset => board.MoveSmoothing = value;\r\n\t}\r\n\r\n\t/// <summary>Seconds a quarter turn takes to draw. Collision still snaps to the lattice; zero draws it instantly.</summary>\r\n\t[Property, Group( \"Controls\" ), Title( \"Rotate Smoothing (s)\" ), Range( 0, 0.3f )]\r\n\tpublic float RotateSmoothing\r\n\t{\r\n\t\tget => board.RotateSmoothing;\r\n\t\tset => board.RotateSmoothing = value;\r\n\t}\r\n\r\n\t/// <summary>How long rotate must be held before it starts repeating.</summary>\r\n\t[Property, Group( \"Controls\" ), Title( \"Rotate Repeat Delay (s)\" ), Range( 0.05f, 1 )]\r\n\tpublic float RotateRepeatDelay\r\n\t{\r\n\t\tget => board.RotateRepeatDelay;\r\n\t\tset => board.RotateRepeatDelay = value;\r\n\t}\r\n\r\n\t/// <summary>Seconds between repeated turns while rotate stays held.</summary>\r\n\t[Property, Group( \"Controls\" ), Title( \"Rotate Repeat Interval (s)\" ), Range( 0.03f, 1 )]\r\n\tpublic float RotateRepeatInterval\r\n\t{\r\n\t\tget => board.RotateRepeatInterval;\r\n\t\tset => board.RotateRepeatInterval = value;\r\n\t}\r\n\r\n\t/// <summary>Normal fall speed at the start of a game, in diamond heights per second.</summary>\r\n\t[Property, Group( \"Difficulty\" ), Title( \"Starting Fall Speed\" ), Range( 0.1f, 8 )]\r\n\tpublic float StartingFallSpeed\r\n\t{\r\n\t\tget => board.StartingFallSpeed;\r\n\t\tset => board.StartingFallSpeed = value;\r\n\t}\r\n\r\n\t/// <summary>Minutes until normal falling reaches the chosen speed multiplier.</summary>\r\n\t[Property, Group( \"Difficulty\" ), Title( \"Ramp Reference Time (min)\" ), Range( 0.5f, 60 )]\r\n\tpublic float FallRampMinutes\r\n\t{\r\n\t\tget => board.FallRampMinutes;\r\n\t\tset => board.FallRampMinutes = value;\r\n\t}\r\n\r\n\t/// <summary>Normal fall speed at the reference time, relative to the starting speed.</summary>\r\n\t[Property, Group( \"Difficulty\" ), Title( \"Speed at Reference Time (x)\" ), Range( 1, 10 )]\r\n\tpublic float FallRampMultiplier\r\n\t{\r\n\t\tget => board.FallRampMultiplier;\r\n\t\tset => board.FallRampMultiplier = value;\r\n\t}\r\n\r\n\t/// <summary>Values above one delay most of the speed increase until later in the ramp.</summary>\r\n\t[Property, Group( \"Difficulty\" ), Title( \"Ramp Curve Exponent\" ), Range( 0.5f, 4 )]\r\n\tpublic float FallRampExponent\r\n\t{\r\n\t\tget => board.FallRampExponent;\r\n\t\tset => board.FallRampExponent = value;\r\n\t}\r\n\r\n\t/// <summary>Minutes of play before pieces can gain extra distinct colors. Chance is zero at this time.</summary>\r\n\t[Property, Group( \"Difficulty - Colors\" ), Title( \"Mix Starts At (min)\" ), Range( 0, 60 )]\r\n\tpublic float ColorMixStartMinutes\r\n\t{\r\n\t\tget => board.ColorMixStartMinutes;\r\n\t\tset => board.ColorMixStartMinutes = value;\r\n\t}\r\n\r\n\t/// <summary>Minutes after the mix starts until the reference chance is reached.</summary>\r\n\t[Property, Group( \"Difficulty - Colors\" ), Title( \"Mix Reference Time (min)\" ), Range( 0.5f, 60 )]\r\n\tpublic float ColorMixRampMinutes\r\n\t{\r\n\t\tget => board.ColorMixRampMinutes;\r\n\t\tset => board.ColorMixRampMinutes = value;\r\n\t}\r\n\r\n\t/// <summary>Chance of a three- or four-color piece at the reference time. The chance keeps rising afterward, up to 100%.</summary>\r\n\t[Property, Group( \"Difficulty - Colors\" ), Title( \"Mix Chance at Reference (%)\" ), Range( 0, 100 )]\r\n\tpublic float ColorMixChanceAtReference\r\n\t{\r\n\t\tget => board.ColorMixChanceAtReference;\r\n\t\tset => board.ColorMixChanceAtReference = value;\r\n\t}\r\n\r\n\t/// <summary>Higher values keep the mix chance low for longer after it starts.</summary>\r\n\t[Property, Group( \"Difficulty - Colors\" ), Title( \"Mix Curve Exponent\" ), Range( 0.5f, 4 )]\r\n\tpublic float ColorMixRampExponent\r\n\t{\r\n\t\tget => board.ColorMixRampExponent;\r\n\t\tset => board.ColorMixRampExponent = value;\r\n\t}\r\n\r\n\t/// <summary>Among varied four-diamond pieces, the percentage with four different colors rather than three.</summary>\r\n\t[Property, Group( \"Difficulty - Colors\" ), Title( \"Four-Color Share (%)\" ), Range( 0, 100 )]\r\n\tpublic float FourColorShare\r\n\t{\r\n\t\tget => board.FourColorShare;\r\n\t\tset => board.FourColorShare = value;\r\n\t}\r\n\r\n\t/// <summary>Scales gameplay dye opacity. One preserves the original appearance.</summary>\r\n\t[Property, Group( \"Fluid\" ), Title( \"Dye Opacity\" ), Range( 0, 1 )]\r\n\tpublic float DyeOpacity\r\n\t{\r\n\t\tget => atmosphere.Opacity;\r\n\t\tset => atmosphere.Opacity = value;\r\n\t}\r\n\r\n\t/// <summary>Total dye added at each piece spawn, divided among its diamond colors.</summary>\r\n\t[Property, Group( \"Fluid\" ), Title( \"Spawn Dye Amount\" ), Range( 0, 30 )]\r\n\tpublic float SpawnDyeAmount\r\n\t{\r\n\t\tget => atmosphere.SpawnDyeAmount;\r\n\t\tset => atmosphere.SpawnDyeAmount = value;\r\n\t}\r\n\r\n\t/// <summary>Dye added in the broken diamond's color for each shatter.</summary>\r\n\t[Property, Group( \"Fluid\" ), Title( \"Shatter Dye Amount\" ), Range( 0, 15 )]\r\n\tpublic float ShatterDyeAmount\r\n\t{\r\n\t\tget => atmosphere.ShatterDyeAmount;\r\n\t\tset => atmosphere.ShatterDyeAmount = value;\r\n\t}\r\n\r\n\t/// <summary>Minimum seconds between explosion dye pushes. Each batch pushes at most eight gems; dye emission is never throttled.</summary>\r\n\t[Property, Group( \"Fluid\" ), Title( \"Shatter Push Interval (s)\" ), Range( 0, 1 )]\r\n\tpublic float ShatterPushInterval\r\n\t{\r\n\t\tget => atmosphere.ShatterPushInterval;\r\n\t\tset => atmosphere.ShatterPushInterval = value;\r\n\t}\r\n\r\n\t/// <summary>Percentage of dye faded per second, shared by every color and source.</summary>\r\n\t[Property, Group( \"Fluid\" ), Title( \"Dye Fade Rate (%/s)\" ), Range( 0, 10 )]\r\n\tpublic float DyeFadeRate\r\n\t{\r\n\t\tget => atmosphere.DyeFadeRate;\r\n\t\tset => atmosphere.DyeFadeRate = value;\r\n\t}\r\n\r\n\t/// <summary>Scales launch speed for impact, rotation, sliding, and shatter sparks. One is the original force.</summary>\r\n\t[Property, Group( \"Sparks\" ), Title( \"Force Multiplier\" ), Range( 0, 10 )]\r\n\tpublic float SparkForceMultiplier\r\n\t{\r\n\t\tget => debris.SparkForceMultiplier;\r\n\t\tset => debris.SparkForceMultiplier = value;\r\n\t}\r\n\r\n\t/// <summary>Spark bounce restitution, from zero to one. The original value was 0.24. Applies to existing sparks too.</summary>\r\n\t[Property, Group( \"Sparks\" ), Title( \"Bounciness\" ), Range( 0, 1 )]\r\n\tpublic float SparkBounciness\r\n\t{\r\n\t\tget => debris.SparkBounciness;\r\n\t\tset => debris.SparkBounciness = value;\r\n\t}\r\n\r\n\t/// <summary>Scales the original 0.5\u20130.8 second lifetime for newly emitted sparks. Four gives 2\u20133.2 seconds.</summary>\r\n\t[Property, Group( \"Sparks\" ), Title( \"Lifetime Multiplier\" ), Range( 0.1f, 10 )]\r\n\tpublic float SparkLifetimeMultiplier\r\n\t{\r\n\t\tget => debris.SparkLifetimeMultiplier;\r\n\t\tset => debris.SparkLifetimeMultiplier = value;\r\n\t}\r\n\r\n\t/// <summary>Lowest per-spark gravity scale for newly emitted sparks. One is the original world gravity.</summary>\r\n\t[Property, Group( \"Sparks\" ), Title( \"Gravity Min\" ), Range( 0, 4 )]\r\n\tpublic float SparkGravityMin\r\n\t{\r\n\t\tget => debris.SparkGravityMin;\r\n\t\tset => debris.SparkGravityMin = value;\r\n\t}\r\n\r\n\t/// <summary>Highest per-spark gravity scale for newly emitted sparks. Each spark picks uniformly between min and max.</summary>\r\n\t[Property, Group( \"Sparks\" ), Title( \"Gravity Max\" ), Range( 0, 4 )]\r\n\tpublic float SparkGravityMax\r\n\t{\r\n\t\tget => debris.SparkGravityMax;\r\n\t\tset => debris.SparkGravityMax = value;\r\n\t}\r\n\r\n\t/// <summary>Shortest lifetime of a newly created shard before it blinks out, whether moving or resting.</summary>\r\n\t[Property, Group( \"Shards\" ), Title( \"Lifetime Min (s)\" ), Range( 0, 120 )]\r\n\tpublic float ShardLifetimeMin\r\n\t{\r\n\t\tget => debris.ShardLifetimeMin;\r\n\t\tset => debris.ShardLifetimeMin = value;\r\n\t}\r\n\r\n\t/// <summary>Longest lifetime of a newly created shard before it blinks out. Each shard picks uniformly between min and max.</summary>\r\n\t[Property, Group( \"Shards\" ), Title( \"Lifetime Max (s)\" ), Range( 0, 120 )]\r\n\tpublic float ShardLifetimeMax\r\n\t{\r\n\t\tget => debris.ShardLifetimeMax;\r\n\t\tset => debris.ShardLifetimeMax = value;\r\n\t}\r\n\r\n\t/// <summary>Layered music level, on top of the Music mixer and scaled by the player's Options volumes.</summary>\r\n\t[Property, Group( \"Music\" ), Title( \"Volume\" ), Range( 0, 1 )]\r\n\tpublic float MusicVolume { get; set; } = 0.5f;\r\n\r\n\t/// <summary>Forces a music tier (0\u20133) for auditioning. -1 follows pieces placed.</summary>\r\n\t[Property, Group( \"Music\" ), Title( \"Tier Override\" ), Range( -1, 3 )]\r\n\tpublic int MusicTierOverride { get; set; } = -1;\r\n\r\n\t/// <summary>Tier the menu music holds at. Each menu visit picks a fresh random motif and clips at this tier.</summary>\r\n\t[Property, Group( \"Music\" ), Title( \"Menu Tier\" ), Range( 0, 3 )]\r\n\tpublic int MusicMenuTier { get; set; } = 2;\r\n\r\n\t/// <summary>Pieces placed before the music moves to tier 1.</summary>\r\n\t[Property, Group( \"Music\" ), Title( \"Tier 1 At (pieces)\" ), Range( 0, 200 )]\r\n\tpublic int MusicTier1Pieces { get; set; } = 4;\r\n\r\n\t/// <summary>Pieces placed before the music moves to tier 2.</summary>\r\n\t[Property, Group( \"Music\" ), Title( \"Tier 2 At (pieces)\" ), Range( 0, 200 )]\r\n\tpublic int MusicTier2Pieces { get; set; } = 12;\r\n\r\n\t/// <summary>Pieces placed before the music moves to tier 3.</summary>\r\n\t[Property, Group( \"Music\" ), Title( \"Tier 3 At (pieces)\" ), Range( 0, 200 )]\r\n\tpublic int MusicTier3Pieces { get; set; } = 28;\r\n\r\n\t/// <summary>Chance per 8-bar swap roll that a layer changes to another clip fitting the current tier. Tier changes only take effect through these rolls.</summary>\r\n\t[Property, Group( \"Music\" ), Title( \"Swap Chance\" ), Range( 0, 1 )]\r\n\tpublic float MusicSwapChance { get; set; } = 0.25f;\r\n\r\n\t/// <summary>Each swap in a roll multiplies Swap Chance for the layers still to roll (0.35: 25% for the first, then about 9%, 3%...), so several rarely change at once.</summary>\r\n\t[Property, Group( \"Music\" ), Title( \"Subsequent Swap Multiplier\" ), Range( 0, 1 )]\r\n\tpublic float MusicSwapFalloff { get; set; } = 0.35f;\r\n\r\n\t/// <summary>Editor only: overlay the live music state (tier, motif, clip per layer, mix levels).</summary>\r\n\t[Property, Group( \"Music\" ), Title( \"Show Debug\" )]\r\n\tpublic bool MusicDebug { get; set; }\r\n\r\n\t/// <summary>Looping 150 BPM clips. Each layer plays one eligible clip at a time; runs pick a motif from the clips' tags.</summary>\r\n\t[Property, Group( \"Music\" ), Title( \"Clips\" )]\r\n\tpublic List<DiamondMusicClip> MusicClips { get; set; } = new();\r\n\r\n\t// Hotload keeps old instances without running initializers for new fields, so rebuild the runtime player instead.\r\n\tDiamondMusic Music\r\n\t{\r\n\t\tget\r\n\t\t{\r\n\t\t\tif ( music is null || !music.IsIntact )\r\n\t\t\t{\r\n\t\t\t\tmusic?.Stop();\r\n\t\t\t\tmusic = new DiamondMusic();\r\n\t\t\t}\r\n\t\t\treturn music;\r\n\t\t}\r\n\t}\r\n\r\n\tprotected override void OnEnabled()\r\n\t{\r\n\t\tpreviousMouseVisibility = Mouse.Visibility;\r\n\t\tMouse.Visibility = MouseVisibility.Visible;\r\n\t\tfeedback.Prepare();\r\n\t\tDiamondDebris.Prepare();\r\n\t\tDiamondAchievements.CheckProgress();\r\n\t}\r\n\r\n\tprotected override void OnDisabled()\r\n\t{\r\n\t\tBankAchievementProgress();\r\n\t\tcanvas?.CloseTutorial();\r\n\t\tcanvas?.CloseOptions();\r\n\t\tcanvas?.CloseLeaderboard();\r\n\t\tcanvas?.CancelTransition();\r\n\t\tcanvas?.ClearRestartHold();\r\n\t\tcanvas?.StopTitleAudio();\r\n\t\tcanvas?.TitleEffects.Clear();\r\n\t\tcanvas?.TitleDye.Clear();\r\n\t\tMouse.Visibility = previousMouseVisibility;\r\n\t\tInput.EnableVirtualCursor = true;\r\n\t\tDiamondGamepad.Clear();\r\n\t\tDiamondHaptics.StopAll();\r\n\t\tfeedback.Stop();\r\n\t\tmusic?.Stop();\r\n\t\tdebris.Clear();\r\n\t\tscoreEffects?.Clear();\r\n\t\tatmosphere.Clear();\r\n\t}\r\n\r\n\tprotected override void OnUpdate()\r\n\t{\r\n\t\tMouse.Visibility = MouseVisibility.Visible;\r\n\t\tatmosphere.Enabled = true;\r\n\t\tatmosphere.DisableFluid = DiamondSettings.Current.DisableFluid;\r\n\t\t// Prepare a changed background even while Options has paused gameplay.\r\n\t\tif ( gameStarted ) atmosphere.Update( board, 0 );\r\n\t\t// PanelComponent recreates its root when disabled/enabled or hot reloaded.\r\n\t\tif ( Panel is null ) return;\r\n\t\tscoreEffects ??= new DiamondScoreEffects();\r\n\t\tif ( canvas is null || !canvas.IsValid() || canvas.Parent != Panel )\r\n\t\t{\r\n\t\t\t// Hotload can preserve the child while dropping our field reference.\r\n\t\t\tcanvas = Panel.Children.OfType<DiamondPanel>().FirstOrDefault() ?? new DiamondPanel { Parent = Panel };\r\n\t\t\tcanvas.Board = board;\r\n\t\t\tcanvas.Debris = debris;\r\n\t\t\tcanvas.Atmosphere = atmosphere;\r\n\t\t\tcanvas.ScoreEffects = scoreEffects;\r\n\t\t\tforeach ( var stale in Panel.Children.OfType<DiamondPanel>().Where( p => p != canvas ).ToArray() ) stale.Delete();\r\n\t\t}\r\n\t\tcanvas.ScoreEffects ??= scoreEffects;\r\n\t\tcanvas.Atmosphere ??= atmosphere;\r\n\t\tcanvas.TitleScreen = !gameStarted;\r\n\t\tcanvas.PlayRequested = StartGame;\r\n\t\tcanvas.QuitRequested = Game.Close;\r\n\t\tcanvas.LeaveRequested = LeaveGame;\r\n\t\t// Every gameplay, effect, title and fade clock advances from here, so skipping them freezes the whole screen.\r\n\t\tbool escapeMenuOpen = engineOverlay.IsPauseMenuOpen;\r\n\t\t// The engine's virtual cursor moves the mouse with the left stick and clicks with A, which would fight\r\n\t\t// gameplay (and could click Leave) and the title menu's stick/A selection, so it only runs on overlays and\r\n\t\t// game over. Wait for A (soft drop / confirm) to be released first, so the press that got here can't click.\r\n\t\tbool menuCursor = canvas.ModalOpen || escapeMenuOpen || gameStarted && board.GameOver;\r\n\t\tInput.EnableVirtualCursor = menuCursor && (Input.EnableVirtualCursor || !Input.Down( \"SoftDropAlt\" ) && !Input.Down( \"MenuConfirm\" ));\r\n\t\tif ( !escapeMenuOpen )\r\n\t\t{\r\n\t\t\tcanvas.UpdateTransition( Time.Delta );\r\n\t\t\t// Holding restart never pauses play; a completed hold fades through PlayRequested, and a zero delay restarts now.\r\n\t\t\tif ( canvas.UpdateRestartHold( Time.Delta, Input.Down( \"Restart\" ), DiamondSettings.Current.RestartDelay ) ) StartGame();\r\n\t\t}\r\n\t\tcanvas.IsMuted = feedback.IsMuted;\r\n\t\tcanvas.LandingPointScale = LandingIndicatorScale;\r\n\t\tcanvas.OnlyDye = Game.IsEditor && ThumbnailOnlyDye;\r\n\t\tcanvas.HideDye = Game.IsEditor && ThumbnailHideDye;\r\n\t\tcanvas.DyeVibrancy = Game.IsEditor && ThumbnailVibrantDye ? ThumbnailVibrancy : 0;\r\n\t\tvar music = Music;\r\n\t\tmusic.Clips = MusicClips;\r\n\t\tmusic.Volume = MusicVolume * DiamondSettings.MusicGain;\r\n\t\tmusic.TierOverride = MusicTierOverride;\r\n\t\tmusic.MenuTier = MusicMenuTier;\r\n\t\tmusic.SwapChance = MusicSwapChance;\r\n\t\tmusic.SwapFalloff = MusicSwapFalloff;\r\n\t\tmusic.TierPieces = (MusicTier1Pieces, MusicTier2Pieces, MusicTier3Pieces);\r\n\t\t// Options pauses play but leaves the music unducked, so its volume sliders are heard as set.\r\n\t\t// Pauses only muffle; the escape menu also lowers the volume.\r\n\t\t// A title/game switch starts the next screen's music as the old screen begins fading out.\r\n\t\tbool switching = canvas.TransitionPending;\r\n\t\tmusic.Update( board, switching ? canvas.TransitionToGame : gameStarted, feedback.IsMuted,\r\n\t\t\tescapeMenuOpen || canvas.TutorialOpen || board.Paused && !canvas.OptionsOpen, escapeMenuOpen, switching );\r\n\t\tcanvas.MusicDebugText = Game.IsEditor && MusicDebug ? music.DebugText( board ) : null;\r\n\t\tcanvas.LeaderboardTestPlayers = Game.IsEditor ? LeaderboardTestPlayers : 0;\r\n\t\tif ( escapeMenuOpen )\r\n\t\t{\r\n\t\t\tfeedback.Suspend();\r\n\t\t\tcanvas.StopTitleAudio();\r\n\t\t\tcanvas.StopTutorialAudio();\r\n\t\t\tDiamondHaptics.StopAll();\r\n\t\t\treturn;\r\n\t\t}\r\n\t\tcanvas.UpdateTitleScreen( Time.Delta );\r\n\t\tDiamondGamepad.Update();\r\n\t\tDiamondHaptics.UpdateSource();\r\n\t\t// Pulses fired later this frame start on the next tick, a frame behind their sound at most.\r\n\t\tDiamondHaptics.Tick( Time.Delta );\r\n\t\tcanvas.UpdateButtonHover();\r\n\t\t// Up/down arrows, W/S, D-pad and left stick step the title selection; Space, Enter and A confirm it.\r\n\t\tcanvas.UpdateTitleMenu(\r\n\t\t\tInput.Pressed( \"MenuUp\" ) || Input.Pressed( \"MenuUpAlt\" ) || DiamondGamepad.UpPressed,\r\n\t\t\tInput.Pressed( \"MenuDown\" ) || Input.Pressed( \"MenuDownAlt\" ) || DiamondGamepad.DownPressed,\r\n\t\t\tInput.Pressed( \"MenuConfirm\" ) || Input.Pressed( \"MenuConfirmAlt\" ) );\r\n\t\tif ( canvas.ModalOpen ) closeMenuHeld = Input.Down( \"MoveRightAlt\" );\r\n\t\telse if ( !Input.Down( \"MoveRightAlt\" ) ) closeMenuHeld = false;\r\n\t\tvar pad = HeldPad();\r\n\t\t// Out of play (title, game over, fades) everything held carries over; in play, each release clears its own.\r\n\t\tcarriedPad = !gameStarted || board.GameOver || canvas.TransitionPending ? pad : carriedPad & pad;\r\n\t\t// The modal owns input and its own clock. No gameplay/effect clock advances behind it.\r\n\t\tif ( canvas.TutorialOpen )\r\n\t\t{\r\n\t\t\tfeedback.Suspend();\r\n\t\t\tcanvas.UpdateTutorial( Time.Delta, Input.Pressed( \"CloseMenu\" ) );\r\n\t\t\treturn;\r\n\t\t}\r\n\t\tif ( canvas.OptionsOpen )\r\n\t\t{\r\n\t\t\tfeedback.Suspend();\r\n\t\t\tcanvas.UpdateOptions( Time.Delta, Input.Pressed( \"CloseMenu\" ) );\r\n\t\t\treturn;\r\n\t\t}\r\n\t\tif ( canvas.LeaderboardOpen )\r\n\t\t{\r\n\t\t\tfeedback.Suspend();\r\n\t\t\tcanvas.UpdateLeaderboard( Time.Delta, Input.Pressed( \"CloseMenu\" ) );\r\n\t\t\treturn;\r\n\t\t}\r\n\t\t// Nothing advances behind a fade-out; a new game runs once fading in.\r\n\t\tif ( canvas.TransitionPending )\r\n\t\t{\r\n\t\t\tfeedback.Suspend();\r\n\t\t\treturn;\r\n\t\t}\r\n\t\tif ( !gameStarted )\r\n\t\t{\r\n\t\t\tfeedback.Stop();\r\n\t\t\treturn;\r\n\t\t}\r\n\r\n\t\t// Each control has a primary (arrow / D-pad) and an alternate (WASD / face button) action, both rebindable,\r\n\t\t// plus either controller stick.\r\n\t\tvar playPad = pad & ~carriedPad;\r\n\t\tbool rotate = Input.Down( \"Rotate\" ) || Input.Down( \"RotateAlt\" ) || playPad.HasFlag( PadInput.Up );\r\n\t\tint direction = (Input.Down( \"MoveRight\" ) || Input.Down( \"MoveRightAlt\" ) && !closeMenuHeld || playPad.HasFlag( PadInput.Right ) ? 1 : 0)\r\n\t\t\t- (Input.Down( \"MoveLeft\" ) || Input.Down( \"MoveLeftAlt\" ) || playPad.HasFlag( PadInput.Left ) ? 1 : 0);\r\n\t\tbool softDrop = Input.Down( \"SoftDrop\" ) || playPad.HasFlag( PadInput.SoftDrop ) || playPad.HasFlag( PadInput.Down );\r\n#if STANDALONE\r\n#endif\r\n\t\tboard.Update( Time.Delta, direction, softDrop, rotate );\r\n\t\tif ( DebugGameReason() is null ) DiamondAchievements.CheckGame( board );\r\n\t\tSubmitGameOverScore();\r\n\t\tif ( sinceAchievementBank > AchievementBankInterval ) BankAchievementProgress();\r\n\t\tcanvas.UpdateNextPreview( Time.Delta );\r\n\t\tcanvas.UpdateGameOver( Time.Delta );\r\n\t\tatmosphere.Update( board, Time.Delta );\r\n\t\tdebris.Update( board, Time.Delta );\r\n\t\tscoreEffects.Update( board, debris.CrushAwards, Time.Delta );\r\n\t\tfeedback.Update( board, Time.Delta, debris.CrushedThisFrame );\r\n\t\tfeedback.ScoreArrivals( scoreEffects.Arrivals );\r\n\t\tif ( board.RotatedThisUpdate )\r\n\t\t{\r\n\t\t\tfeedback.Rotate( board );\r\n\t\t\tif ( !board.IsResolving && !board.GameOver ) debris.EmitRotationSparks( board.Active );\r\n\t\t}\r\n\t\tif ( board.RotationRefusedThisUpdate ) feedback.RotateBump();\r\n\t\tcanvas.ShakeOffset = feedback.ShakeOffset;\r\n\t\tcanvas.PieceShakeOffset = feedback.PieceShakeOffset;\r\n\t\tcanvas.IsMuted = feedback.IsMuted;\r\n\t\tcanvas.UpdateLandingPointGlows( Time.Delta );\r\n\t}\r\n\r\n\tstatic PadInput HeldPad() =>\r\n\t\t(Input.Down( \"SoftDropAlt\" ) ? PadInput.SoftDrop : 0) | (DiamondGamepad.Left ? PadInput.Left : 0)\r\n\t\t| (DiamondGamepad.Right ? PadInput.Right : 0) | (DiamondGamepad.Up ? PadInput.Up : 0) | (DiamondGamepad.Down ? PadInput.Down : 0);\r\n\r\n\t/// <summary>Submit the total score and survival time once, on the frame the game ends, and bank its achievement progress.\r\n\t/// Debug games stay off the board.</summary>\r\n\tvoid SubmitGameOverScore()\r\n\t{\r\n\t\tif ( !board.GameOver || submittedResetVersion == board.ResetVersion ) return;\r\n\t\tsubmittedResetVersion = board.ResetVersion;\r\n\t\tstring debugGame = DebugGameReason();\r\n\t\tif ( debugGame is not null ) Log.Info( $\"Diamonds: skipped leaderboard submission for {debugGame}.\" );\r\n\t\telse DiamondLeaderboard.Submit( board.Score, (long)board.ElapsedSeconds );\r\n\t\tBankAchievementProgress();\r\n\t}\r\n\r\n\t/// <summary>Why the current game stays off the leaderboard and achievements, or null for a normal game. Checked every\r\n\t/// frame, so it returns constant strings.</summary>\r\n\tstring DebugGameReason()\r\n\t{\r\n#if STANDALONE\r\n#endif\r\n\t\tif ( debugFilledResetVersion == board.ResetVersion ) return \"a diamonds_fill debug game\";\r\n\t\tif ( board.StartingScore > 0 ) return \"a game started at a debug Starting Score\";\r\n\t\treturn null;\r\n\t}\r\n\r\n\t/// <summary>Add the running game's progress since the last bank to the lifetime achievement stats: at game over, every\r\n\t/// <see cref=\"AchievementBankInterval\"/> seconds of a long game, and when a game is restarted, left or shut down before\r\n\t/// then. Only the new part of each counter is added, so banking twice in one game never counts anything twice.</summary>\r\n\tvoid BankAchievementProgress()\r\n\t{\r\n\t\tsinceAchievementBank = 0;\r\n\t\tif ( !gameStarted ) return;\r\n\t\tif ( bankedResetVersion != board.ResetVersion )\r\n\t\t{\r\n\t\t\tbankedResetVersion = board.ResetVersion;\r\n\t\t\tbankedBroken = bankedCrushed = bankedScore = 0;\r\n\t\t}\r\n\t\tif ( DebugGameReason() is null )\r\n\t\t\tDiamondAchievements.Bank( board.DiamondsBroken - bankedBroken, board.ShardsCrushed - bankedCrushed, board.Score - bankedScore );\r\n\t\tbankedBroken = board.DiamondsBroken;\r\n\t\tbankedCrushed = board.ShardsCrushed;\r\n\t\tbankedScore = board.Score;\r\n\t}\r\n\r\n\t/// <summary>Editor only: start a new game over a stack filled to near the top, for testing game over.</summary>\r\n\t[ConCmd( \"diamonds_fill\" )]\r\n\tstatic void DebugFillCommand( float freeHeights = 2 )\r\n\t{\r\n\t\tif ( !Game.IsEditor )\r\n\t\t{\r\n\t\t\tLog.Warning( \"diamonds_fill only works in the editor.\" );\r\n\t\t\treturn;\r\n\t\t}\r\n\t\tvar game = Game.ActiveScene?.GetAllComponents<DiamondGame>().FirstOrDefault();\r\n\t\tif ( game?.canvas is null )\r\n\t\t{\r\n\t\t\tLog.Warning( \"diamonds_fill: no running Diamonds game.\" );\r\n\t\t\treturn;\r\n\t\t}\r\n\t\tgame.canvas.CloseTutorial();\r\n\t\tgame.canvas.CloseOptions();\r\n\t\tgame.canvas.CloseLeaderboard();\r\n\t\tgame.canvas.CancelTransition();\r\n\t\tgame.canvas.ClearRestartHold();\r\n\t\t// Keep a running game's score; from the title, start at the usual starting score.\r\n\t\tlong score = game.board.Score;\r\n\t\tbool keepScore = game.gameStarted;\r\n\t\tgame.StartGame();\r\n\t\tgame.board.FillToNearTop( freeHeights, keepScore ? score : game.board.Score );\r\n\t\tLog.Info( $\"diamonds_fill: {game.board.Settled.Count} gems, score {game.board.Score:N0}.\" );\r\n\t\tgame.debugFilledResetVersion = game.board.ResetVersion;\r\n\t}\r\n\r\n\t/// <summary>Abandon the current game and return to the title screen.</summary>\r\n\tvoid LeaveGame()\r\n\t{\r\n\t\tBankAchievementProgress();\r\n\t\tgameStarted = false;\r\n\t\tboard.Reset();\r\n\t\tdebris.Clear();\r\n\t\tscoreEffects.Clear();\r\n\t\tatmosphere.Clear();\r\n\t\tfeedback.Stop();\r\n\t\tcanvas.ShakeOffset = Vector2.Zero;\r\n\t\tcanvas.PieceShakeOffset = Vector2.Zero;\r\n\t\tcanvas.TitleScreen = true;\r\n\t}\r\n\r\n\tvoid StartGame()\r\n\t{\r\n\t\t// A restart abandons the running game; whatever it achieved still counts.\r\n\t\tBankAchievementProgress();\r\n\t\tboard.Reset();\r\n\t\tdebris.Clear();\r\n\t\tscoreEffects.Clear();\r\n\t\tatmosphere.Clear();\r\n\t\tfeedback.Stop();\r\n\t\tcanvas.ShakeOffset = Vector2.Zero;\r\n\t\tcanvas.PieceShakeOffset = Vector2.Zero;\r\n\t\tgameStarted = true;\r\n\t\tcanvas.TitleScreen = false;\r\n\t}\r\n}\r\n"
        },
        {
            "Ident": "facepunch.diamondgrinder",
            "Path": "DiamondPanel.Leave.cs",
            "FileName": "DiamondPanel.Leave.cs",
            "PackageType": "game",
            "CodeKind": "Game",
            "AssetVersionId": 398652,
            "IsPrivate": false,
            "Code": "using System;\r\nusing Sandbox;\r\n\r\nnamespace Diamonds;\r\n\r\npublic sealed partial class DiamondPanel\r\n{\r\n\tpublic Action LeaveRequested { get; set; }\r\n\r\n\tstatic Rect LeaveButton( Vector2 size ) => new( LetterboxCenter( size, true ) - 136, Top + 876, 272, 64 );\r\n\r\n\t/// <summary>The letterbox Leave button fades straight back to the title; its flashing red hover is the warning.</summary>\r\n\tpublic bool HandleLeaveClick( Vector2 mouse, Vector2 size )\r\n\t{\r\n\t\tif ( TitleScreen || ModalOpen || TransitionPending || Board is null ) return false;\r\n\t\tfloat scale = MathF.Min( size.x / CanvasWidth, size.y / CanvasHeight );\r\n\t\tif ( scale <= 0 ) return false;\r\n\t\tvar point = (mouse - (size - new Vector2( CanvasWidth, CanvasHeight ) * scale) * 0.5f) / scale;\r\n\t\tif ( !LeaveButton( size ).IsInside( point ) ) return false;\r\n\t\tPlayClickSound();\r\n\t\tBeginTransition( toGame: false );\r\n\t\treturn true;\r\n\t}\r\n\r\n\tvoid DrawLeaveButton( Painter p, float scale )\r\n\t{\r\n\t\tvar size = Box.Rect.Size;\r\n\t\tvar mouse = (MousePosition - (size - new Vector2( CanvasWidth, CanvasHeight ) * scale) * 0.5f) / scale;\r\n\t\tDrawTitleButton( p, LeaveButton( size ), \"LEAVE\", mouse, scale, fontSize: 26, brightness: 0.7f, danger: true );\r\n\t}\r\n}\r\n"
        },
        {
            "Ident": "facepunch.diamondgrinder",
            "Path": "DiamondPanel.Options.cs",
            "FileName": "DiamondPanel.Options.cs",
            "PackageType": "game",
            "CodeKind": "Game",
            "AssetVersionId": 398652,
            "IsPrivate": false,
            "Code": "using System;\r\nusing Sandbox;\r\nusing Sandbox.UI;\r\n\r\nnamespace Diamonds;\r\n\r\npublic sealed partial class DiamondPanel\r\n{\r\n\tpublic enum OptionsSlider { None, RestartDelay, Rumble, Master, Music }\r\n\r\n\tpublic bool OptionsOpen { get; private set; }\r\n\t/// <summary>Any overlay owns input and pauses gameplay and title animation behind it.</summary>\r\n\tpublic bool ModalOpen => TutorialOpen || OptionsOpen || LeaderboardOpen;\r\n\tpublic OptionsSlider DraggingSlider { get; private set; }\r\n\tbool optionsWasPaused;\r\n\tDiamondBoard optionsBoard;\r\n\tfloat optionsColorPhase;\r\n\tfloat disableFluidKnob;\r\n\tSoundHandle optionsPreview;\r\n\r\n\tconst float OptionsWidth = 700, OptionsHeight = 604;\r\n\tconst float OptionsLabelX = 48, DisableFluidY = 150, RestartDelayY = 226, RumbleY = 302, VolumeDividerY = 351, VolumeHeadingY = 398, MasterY = 468, MusicY = 544;\r\n\t// Restart Delay and Controller Rumble have longer labels than the volume sliders, so their shared track start is farther right.\r\n\tconst float SliderRight = 568, SliderValueX = 618, VolumeTrackLeft = 196, LongLabelTrackLeft = 404;\r\n\tconst int DisableFluidColor = 0, RestartDelayColor = 2, RumbleColor = 3, MasterColor = 1, MusicColor = 4;\r\n\tstatic Rect OptionsButton( Vector2 size ) => new( LetterboxCenter( size, true ) - 136, Top + 796, 272, 64 );\r\n\t// Share the tutorial's screen-pixel fitting, so the close button and scale match it.\r\n\tstatic Rect OptionsFrame( Vector2 size )\r\n\t{\r\n\t\tfloat scale = MathF.Min( size.x * 0.5f / OptionsWidth, size.y * 0.62f / OptionsHeight );\r\n\t\tvar extent = new Vector2( OptionsWidth, OptionsHeight ) * scale;\r\n\t\treturn new Rect( (size - extent) * 0.5f, extent );\r\n\t}\r\n\tstatic Rect DisableFluidRow => new( 32, DisableFluidY - 34, OptionsWidth - 64, 68 );\r\n\tstatic Rect DisableFluidTrack => new( SliderRight - 28, DisableFluidY - 7, 112, 14 );\r\n\tstatic float SliderY( OptionsSlider id ) => id switch\r\n\t{\r\n\t\tOptionsSlider.RestartDelay => RestartDelayY, OptionsSlider.Rumble => RumbleY, OptionsSlider.Master => MasterY, _ => MusicY\r\n\t};\r\n\tstatic Rect SliderTrack( OptionsSlider id )\r\n\t{\r\n\t\tfloat left = id is OptionsSlider.RestartDelay or OptionsSlider.Rumble ? LongLabelTrackLeft : VolumeTrackLeft;\r\n\t\treturn new( left, SliderY( id ) - 4, SliderRight - left, 8 );\r\n\t}\r\n\tstatic Rect SliderHit( OptionsSlider id )\r\n\t{\r\n\t\tvar track = SliderTrack( id );\r\n\t\treturn new( track.Left - 24, track.Center.y - 34, track.Width + 48, 68 );\r\n\t}\r\n\r\n\tpublic void OpenOptions()\r\n\t{\r\n\t\tif ( ModalOpen || Board is null ) return;\r\n\t\toptionsBoard = Board;\r\n\t\toptionsWasPaused = Board.Paused;\r\n\t\tif ( !Board.Paused ) Board.TogglePause();\r\n\t\tOptionsOpen = true;\r\n\t\tDraggingSlider = OptionsSlider.None;\r\n\t\tdisableFluidKnob = DiamondSettings.Current.DisableFluid ? 1 : 0;\r\n\t\tStopTitleAudio();\r\n\t}\r\n\r\n\tpublic void CloseOptions()\r\n\t{\r\n\t\tif ( !OptionsOpen ) return;\r\n\t\tEndOptionsDrag();\r\n\t\tif ( optionsBoard is not null && optionsBoard.Paused != optionsWasPaused ) optionsBoard.TogglePause();\r\n\t\tOptionsOpen = false;\r\n\t\toptionsBoard = null;\r\n\t}\r\n\r\n\tpublic void UpdateOptions( float delta, bool backspace )\r\n\t{\r\n\t\tif ( !OptionsOpen ) return;\r\n\t\tif ( backspace ) { CloseOptions(); return; }\r\n\t\tdelta = MathF.Max( 0, delta );\r\n\t\toptionsColorPhase = (optionsColorPhase + delta * 2.4f) % Palette.Length;\r\n\t\tfloat target = DiamondSettings.Current.DisableFluid ? 1 : 0;\r\n\t\tdisableFluidKnob = target > disableFluidKnob ? MathF.Min( target, disableFluidKnob + delta / 0.14f ) : MathF.Max( target, disableFluidKnob - delta / 0.14f );\r\n\t\tif ( DraggingSlider == OptionsSlider.None ) return;\r\n\t\t// Mouse-up normally ends the drag; losing the active state covers releases we never saw.\r\n\t\tif ( !HasActive ) { EndOptionsDrag(); return; }\r\n\t\tDragOptionsSlider( MousePosition, Box.Rect.Size );\r\n\t}\r\n\r\n\t/// <summary>Opens Options from the letterbox button, or handles a press inside the open overlay.</summary>\r\n\tpublic void HandleOptionsClick( Vector2 mouse, Vector2 size )\r\n\t{\r\n\t\tif ( !OptionsOpen )\r\n\t\t{\r\n\t\t\tif ( TitleScreen || TutorialOpen ) return;\r\n\t\t\tfloat scale = MathF.Min( size.x / CanvasWidth, size.y / CanvasHeight );\r\n\t\t\tif ( scale <= 0 ) return;\r\n\t\t\tvar point = (mouse - (size - new Vector2( CanvasWidth, CanvasHeight ) * scale) * 0.5f) / scale;\r\n\t\t\tif ( OptionsButton( size ).IsInside( point ) ) { PlayClickSound(); OpenOptions(); }\r\n\t\t\treturn;\r\n\t\t}\r\n\t\tvar frame = OptionsFrame( size );\r\n\t\tif ( TutorialClose( frame, OptionsWidth ).IsInside( mouse ) ) { PlayClickSound(); CloseOptions(); return; }\r\n\t\tif ( !frame.IsInside( mouse ) ) { CloseOptions(); return; }\r\n\t\tvar local = OptionsLocal( mouse, frame );\r\n\t\tif ( DisableFluidRow.IsInside( local ) )\r\n\t\t{\r\n\t\t\tPlayClickSound();\r\n\t\t\tDiamondSettings.Current.DisableFluid = !DiamondSettings.Current.DisableFluid;\r\n\t\t\tDiamondSettings.Save();\r\n\t\t\treturn;\r\n\t\t}\r\n\t\tDraggingSlider = SliderHit( OptionsSlider.RestartDelay ).IsInside( local ) ? OptionsSlider.RestartDelay\r\n\t\t\t: SliderHit( OptionsSlider.Rumble ).IsInside( local ) ? OptionsSlider.Rumble\r\n\t\t\t: SliderHit( OptionsSlider.Master ).IsInside( local ) ? OptionsSlider.Master\r\n\t\t\t: SliderHit( OptionsSlider.Music ).IsInside( local ) ? OptionsSlider.Music : OptionsSlider.None;\r\n\t\tDragOptionsSlider( mouse, size );\r\n\t}\r\n\r\n\tpublic void DragOptionsSlider( Vector2 mouse, Vector2 size )\r\n\t{\r\n\t\tif ( DraggingSlider == OptionsSlider.None ) return;\r\n\t\tvar track = SliderTrack( DraggingSlider );\r\n\t\tfloat value = Math.Clamp( (OptionsLocal( mouse, OptionsFrame( size ) ).x - track.Left) / track.Width, 0, 1 );\r\n\t\t// The delay setter snaps to its 0.05-second steps.\r\n\t\tif ( DraggingSlider == OptionsSlider.RestartDelay ) DiamondSettings.Current.RestartDelay = value;\r\n\t\telse if ( DraggingSlider == OptionsSlider.Rumble ) DiamondSettings.Current.ControllerRumble = MathF.Round( value * 100 ) / 100;\r\n\t\telse if ( DraggingSlider == OptionsSlider.Master ) DiamondSettings.Current.MasterVolume = MathF.Round( value * 100 ) / 100;\r\n\t\telse DiamondSettings.Current.MusicVolume = MathF.Round( value * 100 ) / 100;\r\n\t}\r\n\r\n\t/// <summary>Finish a slider drag, saving it and previewing the new effects or rumble level.</summary>\r\n\tpublic void EndOptionsDrag()\r\n\t{\r\n\t\tif ( DraggingSlider == OptionsSlider.None ) return;\r\n\t\tbool master = DraggingSlider == OptionsSlider.Master;\r\n\t\tbool rumble = DraggingSlider == OptionsSlider.Rumble;\r\n\t\tDraggingSlider = OptionsSlider.None;\r\n\t\tDiamondSettings.Save();\r\n\t\t// Preview on a real cue: the hit of a slam that cracks a diamond.\r\n\t\tif ( rumble ) DiamondHaptics.DamageHit( 0.7f );\r\n\t\tif ( !master || IsMuted ) return;\r\n\t\toptionsPreview?.Stop();\r\n\t\toptionsPreview = Sound.Play( \"sfx/diamond_impact.sound\" );\r\n\t\tif ( optionsPreview is null ) return;\r\n\t\toptionsPreview.SpacialBlend = 0;\r\n\t\toptionsPreview.Volume *= 0.7f * DiamondSettings.EffectsGain;\r\n\t}\r\n\r\n\tprotected override void OnMouseUp( MousePanelEvent e )\r\n\t{\r\n\t\tif ( e.Button != \"mouseleft\" ) return;\r\n\t\tEndOptionsDrag();\r\n\t\tleaderboardDragging = false;\r\n\t}\r\n\r\n\tstatic Vector2 OptionsLocal( Vector2 mouse, Rect frame ) => (mouse - frame.Position) / (frame.Width / OptionsWidth);\r\n\r\n\tvoid DrawOptionsButton( Painter p, float scale )\r\n\t{\r\n\t\tvar size = Box.Rect.Size;\r\n\t\tvar mouse = (MousePosition - (size - new Vector2( CanvasWidth, CanvasHeight ) * scale) * 0.5f) / scale;\r\n\t\tDrawTitleButton( p, OptionsButton( size ), \"OPTIONS\", mouse, scale, fontSize: 26, brightness: 0.7f );\r\n\t}\r\n\r\n\tvoid DrawOptions( Painter p )\r\n\t{\r\n\t\tif ( !OptionsOpen ) return;\r\n\t\tusing var scope = p.Scope();\r\n\t\tp.Fill = Color.Black.WithAlpha( 0.32f );\r\n\t\tp.Stroke = Stroke.None;\r\n\t\tp.Rect( p.Bounds );\r\n\t\tvar frame = OptionsFrame( p.Bounds.Size );\r\n\t\tp.Fill = Color.Parse( \"#0d151e\" ).Value;\r\n\t\tp.Rect( frame );\r\n\t\tp.Clip( frame );\r\n\t\tfloat scale = frame.Width / OptionsWidth;\r\n\t\tif ( scale <= 0 ) return;\r\n\t\tDrawModalClose( p, frame, OptionsWidth );\r\n\t\tvar mouse = OptionsLocal( MousePosition, frame );\r\n\t\tp.Translate( frame.Position );\r\n\t\tp.Scale( scale );\r\n\t\tvar settings = DiamondSettings.Current;\r\n\t\tDrawScoreNumber( p, \"OPTIONS\", new Vector2( OptionsWidth * 0.5f, 60 ), 52, ScoreColor( optionsColorPhase ) );\r\n\t\tDrawDisableFluidToggle( p, mouse );\r\n\t\tDrawOptionsSlider( p, \"RESTART DELAY\", settings.RestartDelay, DiamondSettings.FormatSeconds( settings.RestartDelay ),\r\n\t\t\tRestartDelayColor, OptionsSlider.RestartDelay, mouse );\r\n\t\tDrawOptionsSlider( p, \"CONTROLLER RUMBLE\", settings.ControllerRumble, $\"{MathF.Round( settings.ControllerRumble * 100 )}\",\r\n\t\t\tRumbleColor, OptionsSlider.Rumble, mouse );\r\n\t\tp.Fill = Ink.WithAlpha( 0.07f );\r\n\t\tp.Stroke = Stroke.None;\r\n\t\tp.Rect( new Rect( 32, VolumeDividerY - 1, OptionsWidth - 64, 2 ) );\r\n\t\tDrawScoreNumber( p, \"VOLUME\", new Vector2( OptionsLabelX, VolumeHeadingY ), 34, Muted * 0.8f, 0 );\r\n\t\tDrawOptionsSlider( p, \"MASTER\", settings.MasterVolume, $\"{MathF.Round( settings.MasterVolume * 100 )}\", MasterColor, OptionsSlider.Master, mouse );\r\n\t\tDrawOptionsSlider( p, \"MUSIC\", settings.MusicVolume, $\"{MathF.Round( settings.MusicVolume * 100 )}\", MusicColor, OptionsSlider.Music, mouse );\r\n\t}\r\n\r\n\tvoid DrawDisableFluidToggle( Painter p, Vector2 mouse )\r\n\t{\r\n\t\tbool hover = DraggingSlider == OptionsSlider.None && DisableFluidRow.IsInside( mouse );\r\n\t\tbool pressed = hover && HasActive;\r\n\t\tp.Stroke = Stroke.None;\r\n\t\tif ( hover )\r\n\t\t{\r\n\t\t\tp.Fill = Ink.WithAlpha( pressed ? 0.07f : 0.04f );\r\n\t\t\tp.Rect( DisableFluidRow );\r\n\t\t}\r\n\t\tDrawScoreNumber( p, \"DISABLE FLUID\", new Vector2( OptionsLabelX, DisableFluidY ), 30, hover ? Ink : Ink * 0.8f, 0 );\r\n\t\tvar track = DisableFluidTrack;\r\n\t\tfloat t = disableFluidKnob * disableFluidKnob * (3 - 2 * disableFluidKnob);\r\n\t\tfloat x = track.Left + 17 + (track.Width - 34) * t;\r\n\t\tp.Fill = Ink.WithAlpha( hover ? 0.1f : 0.06f );\r\n\t\tp.Rect( track );\r\n\t\tDrawTrackFill( p, track, x, Palette[DisableFluidColor], t );\r\n\t\tDrawOptionsHandle( p, new Vector2( x, DisableFluidY ), DisableFluidColor, hover ? 0.68f : 0.62f, t );\r\n\t}\r\n\r\n\tvoid DrawOptionsSlider( Painter p, string label, float value, string valueText, int color, OptionsSlider id, Vector2 mouse, float labelSize = 26 )\r\n\t{\r\n\t\tbool active = DraggingSlider == id;\r\n\t\tbool hover = active || DraggingSlider == OptionsSlider.None && SliderHit( id ).IsInside( mouse );\r\n\t\tfloat y = SliderY( id );\r\n\t\tDrawScoreNumber( p, label, new Vector2( OptionsLabelX, y ), labelSize, hover ? Ink : Ink * 0.8f, 0 );\r\n\t\tvar track = SliderTrack( id );\r\n\t\tp.Stroke = Stroke.None;\r\n\t\tp.Fill = Ink.WithAlpha( hover ? 0.1f : 0.06f );\r\n\t\tp.Rect( track );\r\n\t\t// Faint quarter notches, cut as small diamonds into the empty track.\r\n\t\tfor ( int i = 1; i < 4; i++ )\r\n\t\t{\r\n\t\t\tvar notch = new Vector2( track.Left + track.Width * i / 4, y );\r\n\t\t\tp.Fill = Ink.WithAlpha( 0.14f );\r\n\t\t\tp.Polygon( [notch + new Vector2( 0, -7 ), notch + new Vector2( 4, 0 ), notch + new Vector2( 0, 7 ), notch + new Vector2( -4, 0 )] );\r\n\t\t}\r\n\t\tfloat x = track.Left + track.Width * value;\r\n\t\tDrawTrackFill( p, track, x, Palette[color], 1 );\r\n\t\tDrawOptionsHandle( p, new Vector2( x, y ), color, active ? 0.72f : hover ? 0.68f : 0.62f, 1 );\r\n\t\tvar valueCenter = new Vector2( SliderValueX, y );\r\n\t\tif ( valueText.EndsWith( 's' ) ) DrawSecondsValue( p, valueText[..^1], valueCenter, 26, hover ? Ink : Muted );\r\n\t\telse DrawScoreNumber( p, valueText, valueCenter, 26, hover ? Ink : Muted );\r\n\t}\r\n\r\n\t/// <summary>Score-font digits followed by a smaller score-font S, sharing their baseline so it reads as a unit, not a 5.</summary>\r\n\tstatic void DrawSecondsValue( Painter p, string number, Vector2 center, float fontSize, Color color )\r\n\t{\r\n\t\tconst float UnitScale = 0.68f, CapHeight = 0.52f;\r\n\t\tfloat unitSize = fontSize * UnitScale, gap = fontSize * 0.1f;\r\n\t\tfloat numberWidth = DiamondScoreEffects.MeasureNumber( number ).x * fontSize / DiamondScoreEffects.PickupFontSize;\r\n\t\tfloat unitWidth = DiamondScoreEffects.MeasureNumber( \"S\" ).x * unitSize / DiamondScoreEffects.PickupFontSize;\r\n\t\tfloat left = center.x - (numberWidth + gap + unitWidth) * 0.5f;\r\n\t\tDrawScoreNumber( p, number, new Vector2( left, center.y ), fontSize, color, 0 );\r\n\t\t// Caps-only glyphs sit centered on the line, so drop the smaller S by half the cap-height difference.\r\n\t\tDrawScoreNumber( p, \"S\", new Vector2( left + numberWidth + gap, center.y + (fontSize - unitSize) * CapHeight * 0.5f ), unitSize, color, 0 );\r\n\t}\r\n\r\n\tstatic void DrawTrackFill( Painter p, Rect track, float x, Color color, float opacity )\r\n\t{\r\n\t\tif ( x <= track.Left || opacity <= 0 ) return;\r\n\t\tp.Stroke = Stroke.None;\r\n\t\tp.Fill = color.WithAlpha( 0.12f * opacity );\r\n\t\tp.Rect( new Rect( track.Left, track.Center.y - 8, x - track.Left, 16 ) );\r\n\t\tp.Fill = color.WithAlpha( 0.8f * opacity );\r\n\t\tp.Rect( new Rect( track.Left, track.Top, x - track.Left, track.Height ) );\r\n\t}\r\n\r\n\t/// <summary>A small in-game gem with its bonded outline; <paramref name=\"lit\"/> fades it in from dull steel.</summary>\r\n\tvoid DrawOptionsHandle( Painter p, Vector2 center, int color, float scale, float lit )\r\n\t{\r\n\t\tif ( lit < 1 ) DrawSteelHandle( p, center, scale, 1 - lit );\r\n\t\tif ( lit <= 0 ) return;\r\n\t\tif ( lit >= 1 ) DrawDiamond( p, center, color, false, scale );\r\n\t\telse DrawDiamondVector( p, center, color, false, scale, lit );\r\n\t\tvar cells = new DiamondBoard.Diamond[] { new( 0, 0, color ) };\r\n\t\tDrawGroupOutlines( p, cells, DiamondMatching.FindGroups( cells ),\r\n\t\t\tcenter - new Vector2( DiamondBoard.LaneX( 0 ) * scale, 0 ), scale, lit );\r\n\t}\r\n\r\n\tstatic void DrawSteelHandle( Painter p, Vector2 center, float scale, float opacity )\r\n\t{\r\n\t\tusing var scope = p.Scope();\r\n\t\tp.Translate( center );\r\n\t\tp.Scale( scale );\r\n\t\tfloat w = DiamondBoard.Width * 0.5f, h = DiamondBoard.Height * 0.5f;\r\n\t\tVector2 top = new( 0, -h ), right = new( w, 0 ), bottom = new( 0, h ), left = new( -w, 0 ), middle = Vector2.Zero;\r\n\t\tvar steel = FloorInk * 0.75f;\r\n\t\tFacet( p, steel, 0.32f, opacity, false, [top, middle, left] );\r\n\t\tFacet( p, steel, 0.02f, opacity, false, [top, right, middle] );\r\n\t\tFacet( p, steel, -0.42f, opacity, false, [middle, right, bottom] );\r\n\t\tFacet( p, steel, -0.12f, opacity, false, [left, middle, bottom] );\r\n\t\tDrawGemStroke( p, [top, right, bottom, left], FloorInk.WithAlpha( opacity ), 2, true );\r\n\t}\r\n}\r\n"
        },
        {
            "Ident": "facepunch.diamondgrinder",
            "Path": "diamondgame.cs.scss",
            "FileName": "diamondgame.cs.scss",
            "PackageType": "game",
            "CodeKind": "Game",
            "AssetVersionId": 398652,
            "IsPrivate": false,
            "Code": "DiamondGame\n{\n\twidth: 100%;\n\theight: 100%;\n}\n"
        },
        {
            "Ident": "facepunch.diamondgrinder",
            "Path": "Assembly.cs",
            "FileName": "Assembly.cs",
            "PackageType": "game",
            "CodeKind": "Game",
            "AssetVersionId": 398652,
            "IsPrivate": false,
            "Code": "global using Sandbox;\r\nglobal using System.Collections.Generic;\r\nglobal using System.Linq;\r\n"
        },
        {
            "Ident": "facepunch.diamondgrinder",
            "Path": "DiamondAchievements.cs",
            "FileName": "DiamondAchievements.cs",
            "PackageType": "game",
            "CodeKind": "Game",
            "AssetVersionId": 398652,
            "IsPrivate": false,
            "Code": "using System;\nusing System.Collections.Generic;\nusing Sandbox;\n\nnamespace Diamonds;\n\n/// <summary>\n/// Backend achievements, following Block Party's scheme. Each one is defined on the s&amp;box website (ident, title, icon,\n/// unlock mode); the game only feeds stats or names manual unlocks, and the website's unlock mode decides which applies.\n///\n/// STAT achievements show a progress bar. The game submits a lifetime running total and the engine unlocks the achievement\n/// once the stat crosses the website's max, so targets can be retuned without a game update. Every source stat needs\n/// HIGHEST aggregation: totals go out whole through SetValue, never Increment, because the engine drops a failed flush\n/// without retrying. A re-sent total heals on the next submit or boot, where a lost increment would be gone for good.\n/// Stat names are unversioned, unlike the leaderboard's, so a leaderboard wipe never resets lifetime progress.\n///\n/// MANUAL achievements are one-shot deeds, unlocked the moment they are earned. Each must be MANUAL on the website: the\n/// engine silently ignores a manual unlock aimed at a stat achievement. Earned idents are persisted and re-sent every boot,\n/// since the engine never retries a lost unlock request; the backend drops repeats.\n/// <list type=\"table\">\n/// <item><term>survive_10_minutes</term><description>MANUAL \u2014 <see cref=\"Survive10Minutes\"/></description></item>\n/// <item><term>survive_12_minutes</term><description>MANUAL \u2014 <see cref=\"Survive12Minutes\"/></description></item>\n/// <item><term>survive_15_minutes</term><description>MANUAL \u2014 <see cref=\"Survive15Minutes\"/></description></item>\n/// <item><term>damage_50_diamond_piece</term><description>MANUAL \u2014 <see cref=\"DamageHugePiece\"/></description></item>\n/// <item><term>score_20000</term><description>MANUAL \u2014 <see cref=\"Score20000\"/></description></item>\n/// <item><term>score_50000</term><description>MANUAL \u2014 <see cref=\"Score50000\"/></description></item>\n/// <item><term>score_100000</term><description>MANUAL \u2014 <see cref=\"Score100000\"/></description></item>\n/// <item><term>score_10000_in_1_minute</term><description>MANUAL \u2014 <see cref=\"QuickScore\"/></description></item>\n/// <item><term>break_3_colors_in_one_turn</term><description>MANUAL \u2014 <see cref=\"BreakThreeColors\"/></description></item>\n/// <item><term>lose_with_no_group_over_3</term><description>MANUAL \u2014 <see cref=\"LoseWithSmallGroups\"/></description></item>\n/// <item><term>break_10000_diamonds</term><description>STAT \u2014 <see cref=\"BrokenStat\"/>, HIGHEST, max 10000</description></item>\n/// <item><term>crush_100000_shards</term><description>STAT \u2014 <see cref=\"CrushedStat\"/>, HIGHEST, max 100000</description></item>\n/// <item><term>score_1000000_points</term><description>STAT \u2014 <see cref=\"PointsStat\"/>, HIGHEST, max 1000000</description></item>\n/// </list>\n/// Debug games (a positive Starting Score, diamonds_fill, profiling runs) never count; DiamondGame decides that. The editor\n/// runs under the #local package, which has no achievements, so it only logs what would happen and never touches the tallies.\n/// </summary>\npublic static class DiamondAchievements\n{\n\tpublic const string FilePath = \"diamonds_achievements.json\";\n\n\t/// <summary>Lifetime diamonds shattered by play (game-over cleanup excluded).</summary>\n\tpublic const string BrokenStat = \"diamonds_broken\";\n\t/// <summary>Lifetime shards crushed for points.</summary>\n\tpublic const string CrushedStat = \"shards_crushed\";\n\t/// <summary>Lifetime points scored, summed across games.</summary>\n\tpublic const string PointsStat = \"points_scored\";\n\n\tpublic const string Survive10Minutes = \"survive_10_minutes\";\n\tpublic const string Survive12Minutes = \"survive_12_minutes\";\n\tpublic const string Survive15Minutes = \"survive_15_minutes\";\n\t/// <summary>Damage an established same-color bonded group of at least <see cref=\"HugePieceSize\"/> diamonds.</summary>\n\tpublic const string DamageHugePiece = \"damage_50_diamond_piece\";\n\tpublic const int HugePieceSize = 50;\n\n\tpublic const string Score20000 = \"score_20000\";\n\tpublic const string Score50000 = \"score_50000\";\n\tpublic const string Score100000 = \"score_100000\";\n\t/// <summary>Reach <see cref=\"QuickScoreTarget\"/> within the first <see cref=\"DiamondBoard.FirstMinuteSeconds\"/> of unpaused play.</summary>\n\tpublic const string QuickScore = \"score_10000_in_1_minute\";\n\tpublic const long QuickScoreTarget = 10_000;\n\t/// <summary>Shatter diamonds of <see cref=\"BreakColorsTarget\"/> different colors within one turn (a spawn until the next).</summary>\n\tpublic const string BreakThreeColors = \"break_3_colors_in_one_turn\";\n\tpublic const int BreakColorsTarget = 3;\n\t/// <summary>Reach game over with no bonded same-color group in the arena larger than <see cref=\"SmallGroupLimit\"/>.\n\t/// Corner-only contacts never bond, so they never join a group.</summary>\n\tpublic const string LoseWithSmallGroups = \"lose_with_no_group_over_3\";\n\tpublic const int SmallGroupLimit = 3;\n\n\t/// <summary>Unpaused game time, as the timer shows it, that unlocks each survival achievement.</summary>\n\tpublic static readonly (string Ident, float Minutes)[] Survival =\n\t\t[(Survive10Minutes, 10), (Survive12Minutes, 12), (Survive15Minutes, 15)];\n\n\t/// <summary>The running game's score that unlocks each score achievement, the moment the score passes it.</summary>\n\tpublic static readonly (string Ident, long Score)[] ScoreTargets =\n\t\t[(Score20000, 20_000), (Score50000, 50_000), (Score100000, 100_000)];\n\n\t/// <summary>The durable local copy of lifetime tallies and earned manual idents, re-sent every boot.</summary>\n\tpublic sealed class SavedProgress\n\t{\n\t\tpublic Dictionary<string, long> Tallies { get; set; } = new();\n\t\tpublic List<string> Earned { get; set; } = new();\n\t}\n\n\tstatic SavedProgress progress;\n\tstatic SavedProgress Progress => progress ??= Load();\n\t// Manual idents requested this session, so a per-frame check sends each at most once.\n\tstatic readonly HashSet<string> requested = new();\n\t// Highest value submitted per stat this session; totals only climb, so lower re-sends are dropped.\n\tstatic readonly Dictionary<string, long> sent = new();\n\tstatic bool resent;\n\n\tstatic readonly string[] LifetimeStats = [BrokenStat, CrushedStat, PointsStat];\n\n\t/// <summary>Once per session: re-send every earned manual ident and lifetime tally, healing submissions lost offline.</summary>\n\tpublic static void CheckProgress()\n\t{\n\t\tif ( Game.IsEditor || resent ) return;\n\t\tresent = true;\n\t\tforeach ( var ident in Progress.Earned.ToArray() ) Unlock( ident );\n\t\t_ = ResendTalliesAsync();\n\t}\n\n\t// Local tallies start from zero on another machine or after a data wipe, and HIGHEST aggregation would ignore every\n\t// total until it passed the backend's. Adopt the backend's best first, so new progress adds on top of it.\n\tstatic async System.Threading.Tasks.Task ResendTalliesAsync()\n\t{\n\t\ttry\n\t\t{\n\t\t\tvar stats = Sandbox.Services.Stats.LocalPlayer;\n\t\t\tif ( stats is not null )\n\t\t\t{\n\t\t\t\tvar before = new Dictionary<string, long>( Progress.Tallies );\n\t\t\t\tawait stats.Refresh();\n\t\t\t\tbool raised = false;\n\t\t\t\tforeach ( string name in LifetimeStats )\n\t\t\t\t{\n\t\t\t\t\tlong local = before.GetValueOrDefault( name );\n\t\t\t\t\tlong backend = stats.TryGet( name, out var stat ) ? (long)stat.Max : 0;\n\t\t\t\t\tif ( backend <= local ) continue;\n\t\t\t\t\t// Keep anything banked while the refresh was in flight on top of the adopted total.\n\t\t\t\t\tProgress.Tallies[name] = backend + Progress.Tallies.GetValueOrDefault( name ) - local;\n\t\t\t\t\traised = true;\n\t\t\t\t}\n\t\t\t\tif ( raised ) Save();\n\t\t\t}\n\t\t}\n\t\tcatch ( Exception e ) { Log.Warning( $\"Diamonds: could not read achievement stats; re-sending local totals. {e.Message}\" ); }\n\t\tbool submitted = false;\n\t\tforeach ( string name in LifetimeStats ) submitted |= SubmitHighest( name, Progress.Tallies.GetValueOrDefault( name ) );\n\t\tif ( submitted ) Sandbox.Services.Stats.Flush();\n\t}\n\n\t/// <summary>Unlock whichever manual achievements the running game has earned. Called every frame of a counted game.</summary>\n\tpublic static void CheckGame( DiamondBoard board )\n\t{\n\t\tforeach ( var (ident, minutes) in Survival )\n\t\t\tif ( board.ElapsedSeconds >= minutes * 60 ) Unlock( ident );\n\t\tif ( board.LargestDamagedGroup >= HugePieceSize ) Unlock( DamageHugePiece );\n\t\t// Score counts as each crush is awarded, not when its flying number reaches the displayed total.\n\t\tforeach ( var (ident, score) in ScoreTargets )\n\t\t\tif ( board.Score >= score ) Unlock( ident );\n\t\tif ( board.FirstMinuteScore >= QuickScoreTarget ) Unlock( QuickScore );\n\t\tif ( board.MostColorsBrokenInTurn >= BreakColorsTarget ) Unlock( BreakThreeColors );\n\t\tif ( board.GameOver && board.LargestGroupAtGameOver <= SmallGroupLimit ) Unlock( LoseWithSmallGroups );\n\t}\n\n\t/// <summary>Add a counted game's new progress to the lifetime tallies and submit the totals. Saved before submitting, so a\n\t/// crash can neither lose nor double-count it.</summary>\n\tpublic static void Bank( long broken, long crushed, long points )\n\t{\n\t\tif ( broken <= 0 && crushed <= 0 && points <= 0 ) return;\n\t\tif ( Game.IsEditor )\n\t\t{\n\t\t\tLog.Info( $\"Diamonds: would add {broken:N0} broken, {crushed:N0} crushed, {points:N0} points to achievement stats (editor: no backend achievements).\" );\n\t\t\treturn;\n\t\t}\n\t\tvar tallies = Progress.Tallies;\n\t\tvoid Add( string stat, long amount ) { if ( amount > 0 ) tallies[stat] = tallies.GetValueOrDefault( stat ) + amount; }\n\t\tAdd( BrokenStat, broken );\n\t\tAdd( CrushedStat, crushed );\n\t\tAdd( PointsStat, points );\n\t\tSave();\n\t\tbool submitted = SubmitHighest( BrokenStat, tallies.GetValueOrDefault( BrokenStat ) );\n\t\tsubmitted |= SubmitHighest( CrushedStat, tallies.GetValueOrDefault( CrushedStat ) );\n\t\tsubmitted |= SubmitHighest( PointsStat, tallies.GetValueOrDefault( PointsStat ) );\n\t\tif ( submitted ) Sandbox.Services.Stats.Flush();\n\t}\n\n\t/// <summary>Unlock a manual achievement. Safe to call repeatedly; only the first call per session does anything.</summary>\n\tpublic static void Unlock( string ident )\n\t{\n\t\tif ( string.IsNullOrEmpty( ident ) || !requested.Add( ident ) ) return;\n\t\tif ( Game.IsEditor )\n\t\t{\n\t\t\tLog.Info( $\"Diamonds: achievement '{ident}' would unlock (editor: no backend achievements).\" );\n\t\t\treturn;\n\t\t}\n\t\t// Persist before sending, so a request lost offline stays owed until a later boot re-sends it.\n\t\tif ( !Progress.Earned.Contains( ident ) )\n\t\t{\n\t\t\tProgress.Earned.Add( ident );\n\t\t\tSave();\n\t\t}\n\t\tSandbox.Services.Achievements.Unlock( ident );\n\t}\n\n\tstatic bool SubmitHighest( string stat, long value )\n\t{\n\t\tif ( value <= 0 || (sent.TryGetValue( stat, out long previous ) && value <= previous) ) return false;\n\t\tsent[stat] = value;\n\t\tSandbox.Services.Stats.SetValue( stat, value );\n\t\treturn true;\n\t}\n\n\tstatic SavedProgress Load()\n\t{\n\t\ttry\n\t\t{\n\t\t\tvar loaded = FileSystem.Data?.ReadJsonOrDefault( FilePath, new SavedProgress() ) ?? new SavedProgress();\n\t\t\t// A hand-edited or truncated file can null either collection.\n\t\t\tloaded.Tallies ??= new();\n\t\t\tloaded.Earned ??= new();\n\t\t\treturn loaded;\n\t\t}\n\t\tcatch ( Exception e )\n\t\t{\n\t\t\tLog.Warning( $\"Could not read {FilePath}; starting achievement progress fresh. {e.Message}\" );\n\t\t\treturn new SavedProgress();\n\t\t}\n\t}\n\n\tstatic void Save()\n\t{\n\t\ttry { FileSystem.Data?.WriteJson( FilePath, Progress ); }\n\t\tcatch ( Exception e ) { Log.Warning( $\"Could not save {FilePath}. {e.Message}\" ); }\n\t}\n}\n"
        },
        {
            "Ident": "facepunch.diamondgrinder",
            "Path": "DiamondAtmosphere.cs",
            "FileName": "DiamondAtmosphere.cs",
            "PackageType": "game",
            "CodeKind": "Game",
            "AssetVersionId": 398652,
            "IsPrivate": false,
            "Code": "using System;\r\nusing System.Collections.Generic;\r\nusing Sandbox;\r\n\r\nnamespace Diamonds;\r\n\r\n/// <summary>A small, damped velocity and five-dye field behind the playfield.</summary>\r\npublic sealed partial class DiamondAtmosphere\r\n{\r\n\tpublic const int Columns = 96;\r\n\tpublic const int Rows = 96;\r\n\tpublic const float CellSize = 15;\r\n\t// Screen-aligned cells retain the fine-grid density and ample offscreen spawn space.\r\n\tconst float FlowUnit = 30;\r\n\tconst float FlowSpeed = 1.6f;\r\n\tconst float DyeDensityScale = FlowUnit * FlowUnit / (CellSize * CellSize);\r\n\tconst float WallBounce = 0.65f;\r\n\tconst float SpawnBandWidth = 176f;\r\n\tconst float SpawnBandY = -64f;\r\n\tconst int Count = Columns * Rows;\r\n\tbool disabled, synchronizeOnEnable;\r\n\tfloat? opacity;\r\n\tpublic bool Enabled\r\n\t{\r\n\t\tget => !disabled;\r\n\t\tset\r\n\t\t{\r\n\t\t\tif ( value == Enabled ) return;\r\n\t\t\tdisabled = !value;\r\n\t\t\tpreviousBoard = null;\r\n\t\t\tsynchronizeOnEnable = true;\r\n\t\t}\r\n\t}\r\n\tpublic float Opacity { get => opacity ?? 1f; set => opacity = Math.Clamp( value, 0, 1 ); }\r\n\tfloat? spawnDyeAmount, shatterDyeAmount, dyeFadeRate;\r\n\tpublic float SpawnDyeAmount { get => spawnDyeAmount ?? 7f; set => spawnDyeAmount = MathF.Max( 0, value ); }\r\n\tpublic float ShatterDyeAmount { get => shatterDyeAmount ?? 2.5f; set => shatterDyeAmount = MathF.Max( 0, value ); }\r\n\tpublic float DyeFadeRate { get => dyeFadeRate ?? 0.3f; set => dyeFadeRate = Math.Clamp( value, 0, 100 ); }\r\n\tfloat[] velocityX = new float[Count], velocityY = new float[Count];\r\n\tfloat[] nextX = new float[Count], nextY = new float[Count];\r\n\tfloat[] dye = new float[Count * DiamondBoard.ColorCount];\r\n\tfloat[] nextDye = new float[Count * DiamondBoard.ColorCount];\r\n\tfloat[] pressure = new float[Count], nextPressure = new float[Count], divergence = new float[Count];\r\n\tfloat[] influence = new float[Count];\r\n\treadonly List<DiamondBoard.Diamond> previousSettled = new();\r\n\tRandom layoutRandom = new();\r\n\tDiamondBoard.Piece previousActive;\r\n\tDiamondBoard previousBoard;\r\n\tint resetVersion, previousPlaced, previousSpawnVersion = -1, modelRevision;\r\n\tlong previousShatter;\r\n\tbool hasPrevious;\r\n\tfloat accumulator;\r\n\t// Painter reads one contiguous view instead of calling Sample for every color of every tile.\r\n\tpublic ReadOnlySpan<float> Samples => DisableFluid && staticDye is not null ? staticDye : dye;\r\n\t// Rendering revision: velocity-only updates leave the uploaded colors reusable.\r\n\tpublic ulong DyeVersion { get; private set; }\r\n\r\n\tpublic float Sample( int x, int y, int color )\r\n\t{\r\n\t\tint index = (y * Columns + x) * DiamondBoard.ColorCount + color;\r\n\t\treturn index < Samples.Length ? Samples[index] : 0;\r\n\t}\r\n\tpublic static Vector2 GridToWorld( int x, int y )\r\n\t{\r\n\t\tfloat u = (x + 0.5f - Columns * 0.5f) * CellSize;\r\n\t\tfloat v = (y + 0.5f - Rows * 0.5f) * CellSize;\r\n\t\treturn new Vector2( DiamondBoard.BoardWidth * 0.5f + u,\r\n\t\t\tDiamondBoard.BoardHeight * 0.5f + v );\r\n\t}\r\n\r\n\tstatic (float X, float Y) WorldToGrid( float x, float y )\r\n\t{\r\n\t\tfloat dx = x - DiamondBoard.BoardWidth * 0.5f;\r\n\t\tfloat dy = y - DiamondBoard.BoardHeight * 0.5f;\r\n\t\treturn (dx / CellSize + Columns * 0.5f - 0.5f,\r\n\t\t\tdy / CellSize + Rows * 0.5f - 0.5f);\r\n\t}\r\n\r\n\tstatic float ReflectX( float x ) => x < 0 ? -x : x > DiamondBoard.BoardWidth ? 2 * DiamondBoard.BoardWidth - x : x;\r\n\tstatic float ReflectBottom( float y ) => y > DiamondBoard.BoardHeight ? 2 * DiamondBoard.BoardHeight - y : y;\r\n\r\n\t(float X, float Y) Backtrace( int x, int y, float vx, float vy, float dt )\r\n\t{\r\n\t\tVector2 point = GridToWorld( x, y );\r\n\t\t// Faster transport without extra simulation steps or faster dye fading.\r\n\t\tdt *= FlowSpeed;\r\n\t\tfloat worldX = point.x - vx * FlowUnit * dt;\r\n\t\tfloat worldY = point.y - vy * FlowUnit * dt;\r\n\t\tvar target = WorldToGrid( ReflectX( worldX ), ReflectBottom( worldY ) );\r\n\t\treturn TraceFluid( x, y, target.X, target.Y );\r\n\t}\r\n\r\n\tpublic void Clear()\r\n\t{\r\n\t\tstaticBoard = null;\r\n\t\tstaticDye = null;\r\n\t\tpreviousBoard = null;\r\n\t\tif ( DisableFluid ) { DyeVersion++; return; }\r\n\t\tClearFluid();\r\n\t}\r\n\r\n\tvoid ClearFluid()\r\n\t{\r\n\t\t// Defer even field seeding until dye is enabled again.\r\n\t\tif ( !Enabled ) { previousBoard = null; return; }\r\n\t\tDyeVersion++;\r\n\t\t// Fast hotload can retain a field created with the previous grid size.\r\n\t\tif ( velocityX.Length != Count )\r\n\t\t{\r\n\t\t\tvelocityX = new float[Count]; velocityY = new float[Count];\r\n\t\t\tnextX = new float[Count]; nextY = new float[Count];\r\n\t\t\tdye = new float[Count * DiamondBoard.ColorCount];\r\n\t\t\tnextDye = new float[dye.Length];\r\n\t\t\tpressure = new float[Count]; nextPressure = new float[Count];\r\n\t\t\tdivergence = new float[Count]; influence = new float[Count];\r\n\t\t}\r\n\t\tArray.Clear( velocityX ); Array.Clear( velocityY );\r\n\t\tArray.Clear( nextX ); Array.Clear( nextY );\r\n\t\tArray.Clear( dye ); Array.Clear( nextDye );\r\n\t\tArray.Clear( pressure ); Array.Clear( nextPressure ); Array.Clear( divergence );\r\n\t\tArray.Clear( influence );\r\n\t\tClearObstacles();\r\n\t\tpreviousSettled.Clear();\r\n\t\thasPrevious = false;\r\n\t\tpreviousSpawnVersion = -1;\r\n\t\tpreviousShatter = 0;\r\n\t\tshatterPushCooldown = 0;\r\n\t\taccumulator = 0;\r\n\t\tlayoutRandom ??= new Random();\r\n\t\tfloat foldPhase = RandomPhase(), foldBend = RandomPhase();\r\n\t\tfloat curlPhase = RandomPhase(), curlBend = RandomPhase();\r\n\t\tfloat swirlPhase = RandomPhase(), swirlBend = RandomPhase();\r\n\t\tfloat colorPhase = RandomPhase();\r\n\t\tfloat RandomPhase() => (float)(layoutRandom.NextDouble() * Math.PI * 2);\r\n\t\t// Start as one continuous mixed field. Broad, overlapping waves vary the\r\n\t\t// five dyes without placing a separate cloud at each color's location.\r\n\t\t// Cosmetic phases change on every new game without affecting the piece bag.\r\n\t\tfor ( int y = 0; y < Rows; y++ )\r\n\t\tfor ( int x = 0; x < Columns; x++ )\r\n\t\t{\r\n\t\t\tfloat u = (x + 0.5f) / Columns - 0.5f;\r\n\t\t\tfloat v = (y + 0.5f) / Rows - 0.5f;\r\n\t\t\tfloat fold = 0.5f + 0.5f * MathF.Sin( u * 9 + foldPhase + MathF.Sin( v * 6 + foldBend ) * 1.3f );\r\n\t\t\tfloat curl = 0.5f + 0.5f * MathF.Sin( v * 10 - u * 4.5f + curlPhase + MathF.Cos( u * 5 + curlBend ) * 0.9f );\r\n\t\t\tfloat cloud = Math.Clamp( (fold * 0.6f + curl * 0.4f - 0.22f) / 0.58f, 0, 1 );\r\n\t\t\tcloud = cloud * cloud * (3 - 2 * cloud);\r\n\t\t\tfloat swirl = MathF.Sin( u * 6.2f + v * 1.8f + swirlPhase ) * 1.7f +\r\n\t\t\t\tMathF.Cos( v * 6.8f - u * 2.5f + swirlBend ) * 1.4f + u * 3.1f;\r\n\t\t\tfor ( int color = 0; color < DiamondBoard.ColorCount; color++ )\r\n\t\t\t{\r\n\t\t\t\tfloat phase = color * MathF.PI * 2 / DiamondBoard.ColorCount;\r\n\t\t\t\tfloat band = MathF.Max( 0, MathF.Cos( swirl - phase + colorPhase ) );\r\n\t\t\t\tint index = (y * Columns + x) * DiamondBoard.ColorCount + color;\r\n\t\t\t\tdye[index] = cloud * (0.012f + 0.23f * band * band);\r\n\t\t\t}\r\n\t\t}\r\n\t}\r\n\r\n\tpublic void Update( DiamondBoard board, float delta )\r\n\t{\r\n\t\tif ( !Enabled ) return;\r\n\t\tif ( DisableFluid ) { PrepareStaticBackground( board ); return; }\r\n\t\tif ( board != previousBoard || board.ResetVersion != resetVersion || modelRevision != 17 )\r\n\t\t{\r\n\t\t\tClearFluid();\r\n\t\t\tpreviousBoard = board;\r\n\t\t\tresetVersion = board.ResetVersion;\r\n\t\t\tmodelRevision = 17;\r\n\t\t}\r\n\t\tif ( synchronizeOnEnable )\r\n\t\t{\r\n\t\t\t// Discard events from the disabled interval and rebuild obstacles from current positions.\r\n\t\t\tpreviousSpawnVersion = board.SpawnVersion;\r\n\t\t\tforeach ( var burst in board.ShatterBursts ) previousShatter = Math.Max( previousShatter, burst.Id );\r\n\t\t\tsynchronizeOnEnable = false;\r\n\t\t}\r\n\t\tif ( board.Paused ) return;\r\n\t\tdelta = Math.Clamp( delta, 0, 0.1f );\r\n\t\tif ( delta <= 0 ) return;\r\n\t\tshatterPushCooldown = MathF.Max( 0, shatterPushCooldown - delta );\r\n\t\tif ( !board.GameOver && board.SpawnVersion != previousSpawnVersion )\r\n\t\t{\r\n\t\t\tEmitSpawn( board.Active );\r\n\t\t\tpreviousSpawnVersion = board.SpawnVersion;\r\n\t\t}\r\n\t\tint firstShatter = board.ShatterBursts.Count;\r\n\t\tfor ( int i = 0; i < board.ShatterBursts.Count; i++ )\r\n\t\t{\r\n\t\t\tvar burst = board.ShatterBursts[i];\r\n\t\t\tif ( burst.Id <= previousShatter ) continue;\r\n\t\t\tfirstShatter = Math.Min( firstShatter, i );\r\n\t\t\tEmitShatterDye( burst.Cell );\r\n\t\t\tpreviousShatter = burst.Id;\r\n\t\t}\r\n\r\n\t\tUpdateObstacles( board, delta );\r\n\t\t// Push only after dead gems have released their fluid obstacles.\r\n\t\tPushShatterDye( board, firstShatter );\r\n\r\n\t\tpreviousSettled.Clear();\r\n\t\tpreviousSettled.AddRange( board.Settled );\r\n\t\tpreviousActive = board.Active;\r\n\t\tpreviousPlaced = board.Placed;\r\n\t\thasPrevious = true;\r\n\r\n\t\taccumulator = MathF.Min( accumulator + delta, 0.12f );\r\n\t\twhile ( accumulator >= 1f / 30 )\r\n\t\t{\r\n\t\t\tStep( 1f / 30 );\r\n\t\t\taccumulator -= 1f / 30;\r\n\t\t}\r\n\t}\r\n\r\n\tvoid EmitSpawn( DiamondBoard.Piece piece )\r\n\t{\r\n\t\tint[] counts = new int[DiamondBoard.ColorCount];\r\n\t\tint count = 0;\r\n\t\tfloat centerX = 0;\r\n\t\tforeach ( var cell in DiamondBoard.CellsOf( piece ) )\r\n\t\t{\r\n\t\t\tcounts[cell.ColorIndex]++;\r\n\t\t\tcenterX += DiamondBoard.LaneX( cell.Lane );\r\n\t\t\tcount++;\r\n\t\t}\r\n\t\tif ( count == 0 ) return;\r\n\t\tcenterX /= count;\r\n\t\tfloat left = Math.Clamp( centerX - SpawnBandWidth * 0.5f, 0, DiamondBoard.BoardWidth - SpawnBandWidth );\r\n\t\tfloat right = left + SpawnBandWidth;\r\n\t\t// Each slot carries the same dye mass. Grouping equal colors into adjacent\r\n\t\t// slots gives exact 3:1, 2:2, or four-color proportions without blending\r\n\t\t// all colors at the emission point.\r\n\t\tint[] slotColors = new int[count];\r\n\t\tint slotIndex = 0;\r\n\t\tfor ( int color = 0; color < counts.Length; color++ )\r\n\t\tfor ( int n = 0; n < counts[color]; n++ ) slotColors[slotIndex++] = color;\r\n\t\tfloat[] weightSums = new float[count];\r\n\t\tfor ( int y = 1; y < Rows - 1; y++ )\r\n\t\tfor ( int x = 1; x < Columns - 1; x++ )\r\n\t\t{\r\n\t\t\tVector2 point = GridToWorld( x, y );\r\n\t\t\tfloat weight = BandWeight( point );\r\n\t\t\tif ( weight > 0 ) weightSums[Slot( point.x )] += weight;\r\n\t\t}\r\n\t\tfloat massPerSlot = SpawnDyeAmount * DyeDensityScale / count;\r\n\t\tbool changed = false;\r\n\t\tfor ( int y = 1; y < Rows - 1; y++ )\r\n\t\tfor ( int x = 1; x < Columns - 1; x++ )\r\n\t\t{\r\n\t\t\tVector2 point = GridToWorld( x, y );\r\n\t\t\tfloat weight = BandWeight( point );\r\n\t\t\tif ( weight <= 0 ) continue;\r\n\t\t\tint slot = Slot( point.x );\r\n\t\t\tif ( weightSums[slot] > 0 )\r\n\t\t\t{\r\n\t\t\t\tint index = (y * Columns + x) * DiamondBoard.ColorCount + slotColors[slot];\r\n\t\t\t\tfloat before = dye[index];\r\n\t\t\t\tdye[index] += massPerSlot * weight / weightSums[slot];\r\n\t\t\t\tchanged |= dye[index] != before;\r\n\t\t\t}\r\n\t\t}\r\n\t\tif ( changed ) DyeVersion++;\r\n\t\tfor ( int slot = 0; slot < count; slot++ )\r\n\t\t\tSplat( left + SpawnBandWidth * (slot + 0.5f) / count, SpawnBandY, 0, 6f / count, true );\r\n\r\n\t\tint Slot( float x ) => Math.Min( count - 1, (int)((x - left) * count / SpawnBandWidth) );\r\n\t\tfloat BandWeight( Vector2 point )\r\n\t\t{\r\n\t\t\t// Keep the entire source above the visible tiles. Its downward impulse\r\n\t\t\t// carries the color through the top edge before the new piece arrives.\r\n\t\t\tif ( point.x <= left || point.x >= right || MathF.Abs( point.y - SpawnBandY ) > 41 ) return 0;\r\n\t\t\tfloat edge = Math.Clamp( MathF.Min( point.x - left, right - point.x ) / FlowUnit, 0, 1 );\r\n\t\t\tfloat dy = (point.y - SpawnBandY) / 25;\r\n\t\t\treturn edge * MathF.Exp( -0.5f * dy * dy );\r\n\t\t}\r\n\t}\r\n\r\n\tvoid EmitShatterDye( DiamondBoard.Diamond cell )\r\n\t{\r\n\t\tfloat x = DiamondBoard.LaneX( cell.Lane ), y = cell.Y;\r\n\t\tconst float radius = 84f, spread = 36f;\r\n\t\t// Only cells within this local square can receive dye; retain the radial test below and\r\n\t\t// the original summation order so the result matches a full-grid scan.\r\n\t\tvar (gx, gy) = WorldToGrid( x, y );\r\n\t\tfloat reach = radius / CellSize;\r\n\t\tint left = Math.Max( 1, (int)MathF.Floor( gx - reach ) );\r\n\t\tint right = Math.Min( Columns - 2, (int)MathF.Ceiling( gx + reach ) );\r\n\t\tint top = Math.Max( 1, (int)MathF.Floor( gy - reach ) );\r\n\t\tint bottom = Math.Min( Rows - 2, (int)MathF.Ceiling( gy + reach ) );\r\n\t\tfloat weightSum = 0;\r\n\t\tfor ( int row = top; row <= bottom; row++ )\r\n\t\tfor ( int column = left; column <= right; column++ )\r\n\t\t\tweightSum += Weight( GridToWorld( column, row ) );\r\n\t\tif ( weightSum <= 0 ) return;\r\n\t\tbool changed = false;\r\n\t\tfor ( int row = top; row <= bottom; row++ )\r\n\t\tfor ( int column = left; column <= right; column++ )\r\n\t\t{\r\n\t\t\tfloat weight = Weight( GridToWorld( column, row ) );\r\n\t\t\tif ( weight > 0 )\r\n\t\t\t{\r\n\t\t\t\tint index = (row * Columns + column) * DiamondBoard.ColorCount + cell.ColorIndex;\r\n\t\t\t\tfloat before = dye[index];\r\n\t\t\t\tdye[index] += ShatterDyeAmount * DyeDensityScale * weight / weightSum;\r\n\t\t\t\tchanged |= dye[index] != before;\r\n\t\t\t}\r\n\t\t}\r\n\t\tif ( changed ) DyeVersion++;\r\n\r\n\t\tfloat Weight( Vector2 point )\r\n\t\t{\r\n\t\t\tfloat dx = point.x - x, dy = point.y - y;\r\n\t\t\tfloat distanceSquared = dx * dx + dy * dy;\r\n\t\t\treturn distanceSquared < radius * radius\r\n\t\t\t\t? MathF.Exp( -0.5f * distanceSquared / (spread * spread) ) : 0;\r\n\t\t}\r\n\t}\r\n\r\n\tvoid Splat( float x, float y, float vx, float vy, bool spread )\r\n\t{\r\n\t\tvar (gx, gy) = WorldToGrid( x, y );\r\n\t\tint reach = (int)MathF.Ceiling( 2 * FlowUnit / CellSize );\r\n\t\tint left = Math.Max( 1, (int)MathF.Floor( gx ) - reach );\r\n\t\tint right = Math.Min( Columns - 2, (int)MathF.Ceiling( gx ) + reach );\r\n\t\tint top = Math.Max( 1, (int)MathF.Floor( gy ) - reach );\r\n\t\tint bottom = Math.Min( Rows - 2, (int)MathF.Ceiling( gy ) + reach );\r\n\t\tfor ( int j = top; j <= bottom; j++ )\r\n\t\tfor ( int i = left; i <= right; i++ )\r\n\t\t{\r\n\t\t\tfloat ox = (i - gx) * CellSize / FlowUnit, oy = (j - gy) * CellSize / FlowUnit;\r\n\t\t\tfloat weight = MathF.Exp( -(ox * ox + oy * oy) * (spread ? 0.14f : 0.275f) );\r\n\t\t\tint n = j * Columns + i;\r\n\t\t\tvelocityX[n] += vx * weight;\r\n\t\t\tvelocityY[n] += vy * weight;\r\n\t\t\tinfluence[n] = MathF.Max( influence[n], MathF.Min( 1, weight * 1.25f ) );\r\n\t\t}\r\n\t}\r\n\r\n\tfloat Bilinear( float[] field, float x, float y, int stride = 1, int channel = 0 )\r\n\t{\r\n\t\tx = Math.Clamp( x, 1, Columns - 2.001f ); y = Math.Clamp( y, 1, Rows - 2.001f );\r\n\t\tint i = (int)x, j = (int)y;\r\n\t\tfloat fx = x - i, fy = y - j;\r\n\t\tint n = (j * Columns + i) * stride + channel;\r\n\t\treturn (field[n] * (1 - fx) + field[n + stride] * fx) * (1 - fy) +\r\n\t\t\t(field[n + Columns * stride] * (1 - fx) + field[n + (Columns + 1) * stride] * fx) * fy;\r\n\t}\r\n\r\n\tvoid Step( float dt )\r\n\t{\r\n\t\t// Every color and source fades equally, including during game-over cleanup.\r\n\t\tfloat dyeRetention = MathF.Pow( 1 - DyeFadeRate * 0.01f, dt );\r\n\t\t// Damp, diffuse, then project the velocity so a push curls around nearby\r\n\t\t// gems instead of remaining a straight colored streak.\r\n\t\tfor ( int y = 1; y < Rows - 1; y++ )\r\n\t\tfor ( int x = 1; x < Columns - 1; x++ )\r\n\t\t{\r\n\t\t\tint n = y * Columns + x;\r\n\t\t\tif ( wallDistance[n] < 0 )\r\n\t\t\t{\r\n\t\t\t\tnextX[n] = wallVelocity[n].x; nextY[n] = wallVelocity[n].y;\r\n\t\t\t\tcontinue;\r\n\t\t\t}\r\n\t\t\tvar (bx, by) = Backtrace( x, y, velocityX[n], velocityY[n], dt );\r\n\t\t\tnextX[n] = (Bilinear( velocityX, bx, by ) * 0.985f +\r\n\t\t\t\t(velocityX[n - 1] + velocityX[n + 1] + velocityX[n - Columns] + velocityX[n + Columns]) * 0.0035f) * 0.992f;\r\n\t\t\tnextY[n] = (Bilinear( velocityY, bx, by ) * 0.985f +\r\n\t\t\t\t(velocityY[n - 1] + velocityY[n + 1] + velocityY[n - Columns] + velocityY[n + Columns]) * 0.0035f) * 0.992f;\r\n\t\t}\r\n\t\tfor ( int y = 1; y < Rows - 1; y++ )\r\n\t\tfor ( int x = 1; x < Columns - 1; x++ )\r\n\t\t{\r\n\t\t\tint n = y * Columns + x;\r\n\t\t\tdivergence[n] = wallDistance[n] < 0 ? 0 : -0.5f * (nextX[n + 1] - nextX[n - 1] + nextY[n + Columns] - nextY[n - Columns]);\r\n\t\t\tpressure[n] = 0;\r\n\t\t}\r\n\t\tfor ( int iteration = 0; iteration < 10; iteration++ )\r\n\t\t{\r\n\t\t\tfor ( int y = 1; y < Rows - 1; y++ )\r\n\t\t\tfor ( int x = 1; x < Columns - 1; x++ )\r\n\t\t\t{\r\n\t\t\t\tint n = y * Columns + x;\r\n\t\t\t\tnextPressure[n] = wallDistance[n] < 0 ? 0 : (divergence[n] + NeighborPressure( n - 1, n ) +\r\n\t\t\t\t\tNeighborPressure( n + 1, n ) + NeighborPressure( n - Columns, n ) + NeighborPressure( n + Columns, n )) * 0.25f;\r\n\t\t\t}\r\n\t\t\t// Both buffers have zero boundary cells; swap instead of copying the whole grid.\r\n\t\t\t(pressure, nextPressure) = (nextPressure, pressure);\r\n\t\t}\r\n\t\tfor ( int y = 1; y < Rows - 1; y++ )\r\n\t\tfor ( int x = 1; x < Columns - 1; x++ )\r\n\t\t{\r\n\t\t\tint n = y * Columns + x;\r\n\t\t\tif ( wallDistance[n] < 0 )\r\n\t\t\t{\r\n\t\t\t\tvelocityX[n] = wallVelocity[n].x; velocityY[n] = wallVelocity[n].y;\r\n\t\t\t\tcontinue;\r\n\t\t\t}\r\n\t\t\t// Project throughout the fluid, with no flow through the gem boundaries.\r\n\t\t\tfloat local = Math.Clamp( influence[n] * 1.1f, 0, 1 );\r\n\t\t\tfloat vx = nextX[n] - 0.5f * (NeighborPressure( n + 1, n ) - NeighborPressure( n - 1, n ));\r\n\t\t\t// Preserve the same upward drift after aligning the velocity axes to the screen.\r\n\t\t\tfloat vy = nextY[n] - 0.5f * (NeighborPressure( n + Columns, n ) - NeighborPressure( n - Columns, n )) - 0.0011313708f * local;\r\n\t\t\tif ( wallDistance[n] < CellSize )\r\n\t\t\t{\r\n\t\t\t\tfloat incoming = (vx - wallVelocity[n].x) * wallNormal[n].x + (vy - wallVelocity[n].y) * wallNormal[n].y;\r\n\t\t\t\tif ( incoming < 0 ) { vx -= wallNormal[n].x * incoming * 1.65f; vy -= wallNormal[n].y * incoming * 1.65f; }\r\n\t\t\t}\r\n\t\t\tfloat speed = MathF.Sqrt( vx * vx + vy * vy );\r\n\t\t\tfloat limit = speed > MaxFlowVelocity ? MaxFlowVelocity / speed : 1;\r\n\t\t\tvelocityX[n] = vx * limit; velocityY[n] = vy * limit;\r\n\t\t\tVector2 point = GridToWorld( x, y );\r\n\t\t\tfloat worldX = velocityX[n];\r\n\t\t\tfloat worldY = velocityY[n];\r\n\t\t\tif ( (point.x <= FlowUnit && worldX < 0) ||\r\n\t\t\t\t(point.x >= DiamondBoard.BoardWidth - FlowUnit && worldX > 0) ) worldX *= -WallBounce;\r\n\t\t\tif ( point.y >= DiamondBoard.BoardHeight - FlowUnit && worldY > 0 ) worldY *= -WallBounce;\r\n\t\t\tvelocityX[n] = worldX;\r\n\t\t\tvelocityY[n] = worldY;\r\n\t\t\tinfluence[n] *= 0.96f;\r\n\t\t}\r\n\t\tTransportDye( dt, dyeRetention );\r\n\t}\r\n}\r\n"
        },
        {
            "Ident": "facepunch.diamondgrinder",
            "Path": "DiamondDebris.Crushing.cs",
            "FileName": "DiamondDebris.Crushing.cs",
            "PackageType": "game",
            "CodeKind": "Game",
            "AssetVersionId": 398652,
            "IsPrivate": false,
            "Code": "using System;\r\nusing System.Collections.Generic;\r\nusing Sandbox;\r\nusing static Diamonds.DiamondBoard;\r\n\r\nnamespace Diamonds;\r\n\r\npublic sealed partial class DiamondDebris\r\n{\r\n\treadonly record struct CrushFace( Vector2 Normal, float Distance, Vector2 Velocity, int Owner );\r\n\treadonly List<CrushFace> crushFaces = new();\r\n\treadonly record struct CrushDiamond( Vector2 Center, Vector2 Velocity );\r\n\treadonly List<CrushDiamond> crushDiamonds = new();\r\n\tbool crushDiamondsMoving;\r\n\tVector2 crushPosition, crushVelocity;\r\n\tfloat crushDelta;\r\n\r\n\tvoid CacheCrushDiamonds()\r\n\t{\r\n#if STANDALONE\r\n#endif\r\n\t\t// Cross the native boundary once per diamond per step, not once for\r\n\t\t// every shard/face pair. Refresh after stepping so enclosure stays exact.\r\n\t\tcrushDiamonds.Clear();\r\n\t\tcrushDiamondsMoving = false;\r\n\t\tforeach ( var body in settledBodies ) Cache( body );\r\n\t\tforeach ( var body in activeBodies ) Cache( body );\r\n\t\tvoid Cache( PhysicsBody body )\r\n\t\t{\r\n\t\t\tvar position = body.Position;\r\n\t\t\tvar velocity = body.Velocity;\r\n\t\t\tcrushDiamonds.Add( new( new( position.x, position.y ), new( velocity.x, velocity.y ) ) );\r\n\t\t\tcrushDiamondsMoving |= velocity.LengthSquared > 1;\r\n\t\t}\r\n\t}\r\n\r\n\tvoid CrushTrappedChips( float delta )\r\n\t{\r\n#if STANDALONE\r\n#endif\r\n\t\t// Rebuilt lock/shatter colliders can already enclose a fragment before\r\n\t\t// their first step, with no closing velocity left for the seam test.\r\n\t\tCrushEmbeddedShards();\r\n\t\t// Stationary gems/walls cannot close a press, even in a dense shard pile.\r\n\t\tif ( !crushDiamondsMoving ) return;\r\n\t\tcrushDelta = delta;\r\n\t\tfor ( int i = particles.Count - 1; i >= 0; i-- )\r\n\t\t{\r\n\t\t\tvar chip = particles[i];\r\n\t\t\tif ( chip.IsSpark ) continue;\r\n\t\t\tcrushPosition = chip.Position;\r\n\t\t\tvar velocity = chip.Body.Velocity;\r\n\t\t\tcrushVelocity = new Vector2( velocity.x, velocity.y );\r\n\t\t\tcrushFaces.Clear();\r\n\t\t\tint owner = 0;\r\n\t\t\tforeach ( var diamond in crushDiamonds ) AddDiamondFaces( chip, diamond, ++owner );\r\n\t\t\tfor ( int edge = 0; edge < Valleys * 2; edge++ )\r\n\t\t\t{\r\n\t\t\t\tfloat x = edge * Width * 0.5f;\r\n\t\t\t\tAddFace( chip, new Vector2( x, BoardHeight - (edge % 2 == 0 ? Height * 0.5f : 0) ),\r\n\t\t\t\t\tnew Vector2( x + Width * 0.5f, BoardHeight - (edge % 2 == 0 ? 0 : Height * 0.5f) ), Vector2.Zero, -1 );\r\n\t\t\t}\r\n\t\t\tforeach ( var tooth in SideWallTeeth() )\r\n\t\t\t{\r\n\t\t\t\tfloat x = LaneX( tooth.Lane );\r\n\t\t\t\tbool left = tooth.Lane < 0;\r\n\t\t\t\tvar top = new Vector2( x, tooth.Y - Height * 0.5f );\r\n\t\t\t\tvar tip = new Vector2( x + (left ? 1 : -1) * Width * 0.5f, tooth.Y );\r\n\t\t\t\tvar bottom = new Vector2( x, tooth.Y + Height * 0.5f );\r\n\t\t\t\tAddFace( chip, left ? top : bottom, tip, Vector2.Zero, left ? -2 : -3 );\r\n\t\t\t\tAddFace( chip, tip, left ? bottom : top, Vector2.Zero, left ? -2 : -3 );\r\n\t\t\t}\r\n\t\t\tforeach ( var corner in CornerFills( FillCornerPockets ) )\r\n\t\t\t\tAddDiamondFaces( chip, new( new Vector2( LaneX( corner.Lane ), corner.Y ), Vector2.Zero ), corner.Lane == 0 ? -2 : -3 );\r\n\t\t\tAddFace( chip, new Vector2( BoardWidth, 0 ), new Vector2( 0, 0 ), Vector2.Zero, -4 );\r\n\t\t\tif ( !IsPressedFlat( chip, delta ) ) continue;\r\n\t\t\tAwardCrush( chip );\r\n\t\t\tEmitSmoke( chip, true );\r\n\t\t\tchip.Body.Remove();\r\n\t\t\tparticles.RemoveAt( i );\r\n\t\t}\r\n\t}\r\n\r\n\tvoid CrushEmbeddedShards()\r\n\t{\r\n#if STANDALONE\r\n#endif\r\n\t\tSpan<Vector2> polygon = stackalloc Vector2[8];\r\n\t\tfor ( int i = particles.Count - 1; i >= 0; i-- )\r\n\t\t{\r\n\t\t\tvar shard = particles[i];\r\n\t\t\tif ( shard.IsSpark ) continue;\r\n\t\t\tvar position = shard.Position;\r\n\t\t\tvar vertices = polygon[..shard.Vertices.Length];\r\n\t\t\tbool transformed = false;\r\n\t\t\tfloat coveredArea = 0;\r\n\t\t\tbool embedded = false;\r\n\t\t\tforeach ( var diamond in crushDiamonds )\r\n\t\t\t{\r\n\t\t\t\tif ( !NearDiamond( position, shard.Radius, diamond.Center ) ) continue;\r\n\t\t\t\tif ( EnclosesShard( diamond.Center, position, shard.Radius ) ) { embedded = true; break; }\r\n\t\t\t\tif ( !transformed ) { TransformShard( shard, vertices ); transformed = true; }\r\n\t\t\t\tcoveredArea += CoveredArea( vertices, diamond.Center );\r\n\t\t\t}\r\n\t\t\t// Board diamonds have disjoint interiors: adding their clipped areas\r\n\t\t\t// detects a fragment buried across shared faces or corner junctions.\r\n\t\t\t// A centroid-only check misses precisely those internal seams.\r\n\t\t\tif ( transformed && coveredArea > PolygonArea( vertices ) * 0.55f ) embedded = true;\r\n\t\t\tif ( !embedded )\r\n\t\t\t{\r\n\t\t\t\tif ( !transformed || ClearOfDiamonds( vertices, position, shard.Radius ) )\r\n\t\t\t\t{\r\n\t\t\t\t\tshard.LastClearTransform = shard.Body.Transform;\r\n\t\t\t\t\tshard.HasClearTransform = true;\r\n\t\t\t\t}\r\n\t\t\t\tcontinue;\r\n\t\t\t}\r\n\t\t\t// Other shards can force a solver overlap without a diamond closing\r\n\t\t\t// the pocket. Recover the last clear pose if it is still available.\r\n\t\t\t// A newly placed/moving gem covering that pose still causes a crush.\r\n\t\t\tif ( RestoreClearShardPose( shard, vertices ) ) continue;\r\n\t\t\tAwardCrush( shard );\r\n\t\t\tEmitSmoke( shard, true );\r\n\t\t\tshard.Body.Remove();\r\n\t\t\tparticles.RemoveAt( i );\r\n\t\t}\r\n\t}\r\n\r\n\tvoid AwardCrush( Particle shard )\r\n\t{\r\n\t\tif ( currentBoard.GameOver ) return;\r\n\t\tEmitCrushSunburst( shard );\r\n\t\tcrushAwards.Add( new( shard.Position, shard.ColorIndex, currentBoard.NextShardPoints ) );\r\n\t\tcurrentBoard.AwardShardCrush( 1 );\r\n\t\tCrushedThisFrame++;\r\n\t}\r\n\r\n\tstatic bool EnclosesShard( Vector2 center, Vector2 position, float radius )\r\n\t{\r\n\t\tvar offset = position - center;\r\n\t\t// Distance from the centroid to the nearest diamond face. Tolerate\r\n\t\t// shallow solver overlap, but never let a gem contain a shard's core.\r\n\t\tfloat penetration = (1 - MathF.Abs( offset.x ) / (Width / 2) - MathF.Abs( offset.y ) / (Height / 2))\r\n\t\t\t/ MathF.Sqrt( 4 / (Width * Width) + 4 / (Height * Height) );\r\n\t\treturn penetration > MathF.Max( 1.5f, radius * 0.2f );\r\n\t}\r\n\r\n\tvoid AddDiamondFaces( Particle chip, CrushDiamond diamond, int owner )\r\n\t{\r\n\t\tvar center = diamond.Center;\r\n\t\tvar difference = crushPosition - center;\r\n\t\tfloat reach = chip.Radius * 2.5f + (diamond.Velocity - crushVelocity).Length * crushDelta;\r\n\t\tif ( MathF.Abs( difference.x ) > Width * 0.5f + reach ||\r\n\t\t\tMathF.Abs( difference.y ) > Height * 0.5f + reach ) return;\r\n\t\tvar velocity = diamond.Velocity;\r\n\t\tvar top = center + new Vector2( 0, -Height * 0.5f );\r\n\t\tvar right = center + new Vector2( Width * 0.5f, 0 );\r\n\t\tvar bottom = center + new Vector2( 0, Height * 0.5f );\r\n\t\tvar left = center + new Vector2( -Width * 0.5f, 0 );\r\n\t\tAddFace( chip, top, right, velocity, owner );\r\n\t\tAddFace( chip, right, bottom, velocity, owner );\r\n\t\tAddFace( chip, bottom, left, velocity, owner );\r\n\t\tAddFace( chip, left, top, velocity, owner );\r\n\t}\r\n\r\n\tvoid AddFace( Particle chip, Vector2 a, Vector2 b, Vector2 velocity, int owner )\r\n\t{\r\n\t\tvar edge = b - a;\r\n\t\tfloat length = edge.Length;\r\n\t\tvar tangent = edge / length;\r\n\t\tvar offset = crushPosition - a;\r\n\t\tfloat along = Vector2.Dot( offset, tangent );\r\n\t\t// Stay on the actual segment; extrapolated planes at tips aren't presses.\r\n\t\tif ( along < 0 || along > length ) return;\r\n\t\tvar normal = new Vector2( tangent.y, -tangent.x );\r\n\t\tfloat distance = Vector2.Dot( offset, normal );\r\n\t\tfloat reach = chip.Radius * 2.5f + (velocity - crushVelocity).Length * crushDelta;\r\n\t\tif ( distance < -chip.Radius || distance > reach ) return;\r\n\t\tcrushFaces.Add( new( normal, distance, velocity, owner ) );\r\n\t}\r\n\r\n\tbool IsPressedFlat( Particle chip, float delta )\r\n\t{\r\n\t\tfor ( int a = 0; a < crushFaces.Count; a++ )\r\n\t\tfor ( int b = a + 1; b < crushFaces.Count; b++ )\r\n\t\t{\r\n\t\t\tvar first = crushFaces[a];\r\n\t\t\tvar second = crushFaces[b];\r\n\t\t\t// Adjacent faces of one gem and zigzag valley supports cannot crush.\r\n\t\t\tif ( first.Owner == second.Owner || Vector2.Dot( first.Normal, second.Normal ) > -0.95f ) continue;\r\n\t\t\tfloat min = float.PositiveInfinity, max = float.NegativeInfinity;\r\n\t\t\tvar rotation = chip.Body.Rotation;\r\n\t\t\tforeach ( var vertex in chip.Vertices )\r\n\t\t\t{\r\n\t\t\t\tvar rotated = rotation * new Vector3( vertex.x, vertex.y, 0 );\r\n\t\t\t\tfloat projection = Vector2.Dot( new Vector2( rotated.x, rotated.y ), first.Normal );\r\n\t\t\t\tmin = MathF.Min( min, projection ); max = MathF.Max( max, projection );\r\n\t\t\t}\r\n\t\t\tfloat thickness = max - min;\r\n\t\t\tfloat gap = first.Distance + second.Distance;\r\n\t\t\tfloat closing = Vector2.Dot( first.Velocity - second.Velocity, first.Normal );\r\n\t\t\t// Test the substep's approaching surfaces before Box2D ejects a trapped\r\n\t\t\t// chip. Small solver overlaps are harmless; require >55% compression.\r\n\t\t\tif ( closing > 1 && gap - closing * delta < thickness * 0.45f ) return true;\r\n\t\t}\r\n\t\treturn false;\r\n\t}\r\n}\r\n"
        }
    ]
}