{"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/// \u003Csummary\u003EOne damage wave per collision, propagated only through pre-existing face bonds.\u003C/summary\u003E\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\u003Cint, Corner\u003E Targets( IReadOnlyList\u003CDiamond\u003E cells,\r\n\t\tIReadOnlyList\u003CDiamondMatching.CellMove\u003E moving, IReadOnlyList\u003CIReadOnlyList\u003Cint\u003E\u003E bonds,\r\n\t\tfloat velocityX, float velocityY, List\u003CContact\u003E contacts = null, List\u003CFaceContact\u003E faceContacts = null )\r\n\t{\r\n\t\tvar hit = new Dictionary\u003Cint, Corner\u003E();\r\n\t\tif ( velocityY \u003C= 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 \u0026\u0026 velocityY \u003E 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\u003Cint\u003E();\r\n\t\tforeach ( var move in moving ) movingIndices.Add( move.Index );\r\n\t\tvar damaged = new Dictionary\u003Cint, Corner\u003E( 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\u003CDiamond\u003E cells, IReadOnlyList\u003CDiamondMatching.CellMove\u003E moving,\r\n\t\tDictionary\u003Cint, Corner\u003E hit, List\u003CFaceContact\u003E contacts )\r\n\t{\r\n\t\tvar movingIndices = new HashSet\u003Cint\u003E();\r\n\t\tforeach ( var move in moving ) movingIndices.Add( move.Index );\r\n\t\tvar seen = new HashSet\u003C(int, int)\u003E();\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 \u003C= 0 || MathF.Abs( dy - MathF.Abs( lanes ) * Height * 0.5f ) \u003E Tolerance ||\r\n\t\t\t\tMathF.Abs( lanes ) \u003C SlideDamageSteps ) continue;\r\n\t\t\tint direction = Math.Sign( lanes );\r\n\t\t\tfor ( int other = 0; other \u003C cells.Count; other\u002B\u002B )\r\n\t\t\t{\r\n\t\t\t\tif ( movingIndices.Contains( other ) || cells[other].Health \u003C= 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\u0027t count.\r\n\t\t\t\tif ( MathF.Abs( receiver.Lane - move.To.Lane - direction ) * Width * 0.5f \u003E= Tolerance ||\r\n\t\t\t\t\tMathF.Abs( receiver.Y - move.To.Y - Height * 0.5f ) \u003E= Tolerance ) continue;\r\n\t\t\t\thit.TryAdd( other, Corner.Top );\r\n\t\t\t\tif ( contacts is not null \u0026\u0026 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/// \u003Csummary\u003EExposed point contacts for a downward preview, regardless of speed, including floor peaks.\u003C/summary\u003E\r\n\tpublic static IReadOnlyList\u003CContact\u003E PointContacts( IReadOnlyList\u003CDiamond\u003E cells, IReadOnlyList\u003CDiamondMatching.CellMove\u003E moving,\r\n\t\tbool fillCornerPockets = true )\r\n\t{\r\n\t\tvar contacts = new List\u003CContact\u003E();\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\u003Cint, Corner\u003E Hit = new();\r\n\t\tpublic readonly HashSet\u003Cint\u003E Moving = new();\r\n\t\tpublic readonly HashSet\u003C(int, Corner)\u003E Corners = new();\r\n\t}\r\n\r\n\tinternal static void CopyPointContacts( IReadOnlyList\u003CDiamond\u003E cells, IReadOnlyList\u003CDiamondMatching.CellMove\u003E moving,\r\n\t\tList\u003CContact\u003E 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/// \u003Csummary\u003ENew exposed tip collisions for cosmetic sparks, including harmless side tips.\u003C/summary\u003E\r\n\tpublic static IReadOnlyList\u003CContact\u003E SparkContacts( IReadOnlyList\u003CDiamond\u003E cells, IReadOnlyList\u003CDiamondMatching.CellMove\u003E moving,\r\n\t\tbool fillCornerPockets = true )\r\n\t{\r\n\t\tvar contacts = new List\u003CContact\u003E( PointContacts( cells, moving, fillCornerPockets ) );\r\n\t\tvar movingIndices = new HashSet\u003Cint\u003E( moving.Select( m =\u003E m.Index ) );\r\n\t\tforeach ( var move in moving )\r\n\t\tfor ( int other = 0; other \u003C cells.Count; other\u002B\u002B )\r\n\t\t{\r\n\t\t\tif ( movingIndices.Contains( other ) || cells[other].Health \u003C= 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 ) \u003E Tolerance || MathF.Abs( receiver.Y - move.To.Y ) \u003E Tolerance ) continue;\r\n\t\t\tint side = Math.Sign( dx );\r\n\t\t\tif ( (move.To.Lane - move.From.Lane) * side \u003C= 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 \u002B 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\u003Cint, Corner\u003E PointTargets( IReadOnlyList\u003CDiamond\u003E cells,\r\n\t\tIReadOnlyList\u003CDiamondMatching.CellMove\u003E moving,\r\n\t\tList\u003CContact\u003E contacts, bool includeFloor = false, PointContactScratch scratch = null, bool fillCornerPockets = true )\r\n\t{\r\n\t\tvar hit = scratch?.Hit ?? new Dictionary\u003Cint, Corner\u003E();\r\n\t\tvar movingIndices = scratch?.Moving ?? new HashSet\u003Cint\u003E();\r\n\t\tvar contactCorners = scratch?.Corners ?? new HashSet\u003C(int, Corner)\u003E();\r\n\t\thit.Clear(); movingIndices.Clear(); contactCorners.Clear();\r\n\t\tfor ( int i = 0; i \u003C moving.Count; i\u002B\u002B ) movingIndices.Add( moving[i].Index );\r\n\t\tfor ( int i = 0; i \u003C moving.Count; i\u002B\u002B )\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 \u003C= 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 \u0026\u0026 MathF.Abs( cell.Lane % 2 - 1 ) * Width * 0.5f \u003C Tolerance \u0026\u0026\r\n\t\t\t\tMathF.Abs( cell.Y - FloorY( cell.Lane ) ) \u003C Tolerance \u0026\u0026\r\n\t\t\t\tExposedPoint( cells, move.Index, 0, 1, movingIndices ) \u0026\u0026 ExposedFloorPeak( cells, cell, movingIndices, fillCornerPockets ) \u0026\u0026\r\n\t\t\t\tcontactCorners.Add( (move.Index, Corner.Bottom) ) )\r\n\t\t\t\tcontacts.Add( new( cell.Lane, cell.Y \u002B 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 \u003C cells.Count; other\u002B\u002B )\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 \u0026\u0026 contactCorners.Add( (other, corner) ) )\r\n\t\t\t\t\tcontacts.Add( new( (cell.Lane \u002B receiver.Lane) * 0.5f, (cell.Y \u002B 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\u003CDiamond\u003E cells, int index, int pointX, int pointY, HashSet\u003Cint\u003E 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 \u003C cells.Count; other\u002B\u002B )\r\n\t\t{\r\n\t\t\tif ( other == index || moving.Contains( other ) != isMoving || cells[other].Health \u003C= 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 ) \u003E= Tolerance ||\r\n\t\t\t\tMathF.Abs( MathF.Abs( dy ) - Height * 0.5f ) \u003E= Tolerance ) continue;\r\n\t\t\tif ( (pointX != 0 \u0026\u0026 Math.Sign( dx ) == pointX) ||\r\n\t\t\t\t(pointY != 0 \u0026\u0026 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\u0027s lower face lands flush on that neighbor\u0027s upper face.\r\n\tstatic bool ExposedFloorPeak( IReadOnlyList\u003CDiamond\u003E cells, Diamond cell, HashSet\u003Cint\u003E moving, bool fillCornerPockets )\r\n\t{\r\n\t\tfor ( int other = 0; other \u003C cells.Count; other\u002B\u002B )\r\n\t\t\tif ( !moving.Contains( other ) \u0026\u0026 cells[other].Health \u003E 0 \u0026\u0026 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 ) ) \u0026\u0026\r\n\t\t\t!FlanksPeak( new( LaneCount - 1, BoardHeight - Height * 0.5f, 0 ) ));\r\n\t\tbool FlanksPeak( Diamond flank ) =\u003E MathF.Abs( MathF.Abs( flank.Lane - cell.Lane ) * Width * 0.5f - Width * 0.5f ) \u003C Tolerance \u0026\u0026\r\n\t\t\tMathF.Abs( flank.Y - cell.Y - Height * 0.5f ) \u003C 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 \u003C tolerance \u0026\u0026 MathF.Abs( receiver.Y - mover.Y - Height ) \u003C tolerance;\r\n\t}\r\n\r\n\tpublic static bool Apply( List\u003CDiamond\u003E cells, IReadOnlyDictionary\u003Cint, Corner\u003E 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/// \u003Csummary\u003EBox2D debris in Painter coordinates (\u002BY down). Only crushed shards award points.\u003C/summary\u003E\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 =\u003E IsSpark || Age \u003C 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 =\u003E 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 =\u003E Body.Rotation.Yaw();\r\n\t\tpublic bool Visible =\u003E IsSpark ? Age \u003C MaxAge : BlinkTime \u003C 0 || (int)(BlinkTime / (BlinkTime \u003C 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 ) =\u003E random = seed.HasValue ? new Random( seed.Value ) : new Random();\r\n\treadonly List\u003CParticle\u003E particles = new();\r\n\treadonly List\u003CPhysicsBody\u003E settledBodies = new();\r\n\treadonly List\u003CPhysicsBody\u003E activeBodies = new();\r\n\treadonly List\u003CDiamond\u003E 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 =\u003E 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 =\u003E 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\u003CCrushAward\u003E crushAwards = new();\r\n\tpublic IReadOnlyList\u003CCrushAward\u003E CrushAwards =\u003E crushAwards;\r\n\tpublic IReadOnlyList\u003CParticle\u003E Particles =\u003E particles;\r\n#if STANDALONE\r\n#endif\r\n\tPhysicsWorld World =\u003E physicsScene.PhysicsWorld;\r\n\r\n\tstatic bool prepared;\r\n\t/// \u003Csummary\u003ECompile and initialize particle/physics paths before play, in an isolated disposable world.\u003C/summary\u003E\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\u0027s Box2D backend. It isn\u0027t 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[(\u0022spark\u0022, \u0022spark\u0022)] = Sandbox.Physics.CollisionRules.Result.Ignore;\r\n\t\trules.Pairs[(\u0022shard\u0022, \u0022spark\u0022)] = 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 \u003C Valleys * 2; i\u002B\u002B )\r\n\t\t{\r\n\t\t\tfloat x = i * Width * 0.5f, nextX = x \u002B 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\u003CVector3\u003E\r\n\t\t\t{\r\n\t\t\t\tnew( x, y, 0 ), new( nextX, nextY, 0 ), new( nextX, BoardHeight \u002B 32, 0 ), new( x, BoardHeight \u002B 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 \u002B 64, 1 ) ).Surface = surface;\r\n\t\tfloor.AddBoxShape( new Vector3( BoardWidth \u002B 32, BoardHeight * 0.5f, 0 ), Rotation.Identity, new Vector3( 32, BoardHeight * 0.5f \u002B 64, 1 ) ).Surface = surface;\r\n\t\tfloor.AddBoxShape( new Vector3( BoardWidth * 0.5f, -32, 0 ), Rotation.Identity, new Vector3( BoardWidth * 0.5f \u002B 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 \u003C 0 ? 1 : -1;\r\n\t\t\tfloor.AddHullShape( Vector3.Zero, Rotation.Identity, new List\u003CVector3\u003E\r\n\t\t\t{\r\n\t\t\t\tnew( x, tooth.Y - Height * 0.5f, 0 ), new( x \u002B inward * Width * 0.5f, tooth.Y, 0 ),\r\n\t\t\t\tnew( x, tooth.Y \u002B 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\u003CVector3\u003E\r\n\t\t\t{\r\n\t\t\t\tnew( x, y - Height * 0.5f, 0 ), new( x \u002B Width * 0.5f, y, 0 ),\r\n\t\t\t\tnew( x, y \u002B 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\u003CVector3\u003E\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\u003CPhysicsBody\u003E bodies, IReadOnlyList\u003CDiamond\u003E 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 \u003C diamonds.Count; i\u002B\u002B )\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 \u003E 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 \u003C= 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 \u0026\u0026 !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 \u003C= 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 \u003C= lastCornerSpark ) continue;\r\n\t\t\tbool gentle = contact.Speed \u003C= 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 \u003C= 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 \u003C= 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 =\u003E b.Velocity.LengthSquared \u003E 480 * 480 ) ||\r\n\t\t\tactiveBodies.Any( b =\u003E b.Velocity.LengthSquared \u003E 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 \u003C steps; step\u002B\u002B )\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 \u003E= 0; i-- )\r\n\t\t{\r\n\t\t\tvar chip = particles[i];\r\n\t\t\tchip.Age \u002B= 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 \u0026\u0026 chip.BlinkTime \u003C 0 \u0026\u0026 chip.Age \u003E chip.MaxAge ) chip.BlinkTime = 0;\r\n\t\t\tif ( chip.BlinkTime \u003E= 0 ) chip.BlinkTime \u002B= delta;\r\n\t\t\tif ( (chip.IsSpark \u0026\u0026 chip.Age \u003E= chip.MaxAge) || chip.BlinkTime \u003E= BlinkDuration || chip.Position.y \u003E BoardHeight \u002B 64 )\r\n\t\t\t{\r\n\t\t\t\tif ( chip.BlinkTime \u003E= BlinkDuration \u0026\u0026 chip.Position.y \u003C= 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 ) =\u003E min \u002B (max - min) * (float)random.NextDouble();\r\n\r\n\tbool HasRoom( Vector2 position, float radius )\r\n\t{\r\n\t\tif ( position.x \u003C radius || position.x \u003E BoardWidth - radius || position.y \u003C radius ) return false;\r\n\t\tfloat wall = SideWallInset( position.y, FillCornerPockets ) \u002B radius * MathF.Sqrt( 1 \u002B Width * Width / (Height * Height) );\r\n\t\tif ( position.x \u003C wall || position.x \u003E 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 \u003E floor - radius * MathF.Sqrt( 1 \u002B 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) \u002B dy / (Height * 0.5f) \u003C\r\n\t\t\t\t1 \u002B radius * MathF.Sqrt( 4 / (Width * Width) \u002B 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 \u003C count; i\u002B\u002B )\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\u0027t 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 \u003C 24 \u0026\u0026 !found; attempt\u002B\u002B )\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 \u002B 1; distance \u003C= 12; distance \u002B= 2 )\r\n\t\t\t\t{\r\n\t\t\t\t\tposition = origin \u002B direction * distance;\r\n\t\t\t\t\tif ( HasRoom( position, radius \u002B 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 \u002B 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 ) =\u003E\r\n\t\t(1 \u002B 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 \u002B left) / 2;\r\n\t\t\tvar rightMid = (tip \u002B 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 \u002B b \u002B 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 ) \u002B new Vector2( -outward.y, outward.x ) * MathF.Sin( angle );\r\n\t\t\tAddParticle( center \u002B 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 \u003C count; i\u002B\u002B )\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 \u003C 24; attempt\u002B\u002B )\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 ) \u002B new Vector2( -outward.y, outward.x ) * MathF.Sin( angle );\r\n\t\t\t\tvar position = origin \u002B direction * Range( 3, 14 );\r\n\t\t\t\tif ( !HasRoom( position, radius \u002B 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() \u003C 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 \u003C particles.Count; i\u002B\u002B )\r\n\t\t{\r\n\t\t\tif ( particles[i].IsSpark != spark ) continue;\r\n\t\t\tif ( oldest \u003C 0 ) oldest = i;\r\n\t\t\tcount\u002B\u002B;\r\n\t\t}\r\n\t\tif ( count \u003E= (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 \u003C vertices.Length; i\u002B\u002B ) radius = MathF.Max( radius, vertices[i].Length );\r\n\t\t// Sub-unit spark polygons fall below Box2D\u0027s 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\u003CVector3\u003E( vertices.Length );\r\n\t\t\tfor ( int i = 0; i \u003C vertices.Length; i\u002B\u002B ) 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 ? \u0022spark\u0022 : \u0022shard\u0022 );\r\n\t\tbody.Rotation = spark ? Rotation.FromYaw( Range( 0, 360 ) ) : Rotation.Identity;\r\n\t\tif ( spark \u0026\u0026 velocity.LengthSquared \u003E 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\u0027s 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\u003CVector2\u003E points, Color color, float width, bool closed )\n\t{\n\t\tif ( drawingReferenceStrokes || (!closed \u0026\u0026 points.Length \u003E 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\u003CVector2\u003E offsets = stackalloc Vector2[points.Length];\n\t\tfor ( int i = 0; i \u003C points.Length; i\u002B\u002B )\n\t\t{\n\t\t\tvar incoming = (points[i] - points[(i \u002B points.Length - 1) % points.Length]).Normal;\n\t\t\tvar outgoing = (points[(i \u002B 1) % points.Length] - points[i]).Normal;\n\t\t\tif ( !closed \u0026\u0026 i == 0 ) incoming = outgoing;\n\t\t\tif ( !closed \u0026\u0026 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 \u002B Vector2.Dot( n0, n1 );\n\t\t\tvar miter = denominator \u003E 0.00001f ? (n0 \u002B 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 \u003C= 0.00001f || miter.Length \u003E 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\u0027s\n\t\t\t// stroke path rather than splitting them across independently antialiased edges.\n\t\t\tSpan\u003CVector2\u003E polygon = stackalloc Vector2[points.Length * 2];\n\t\t\tfor ( int i = 0; i \u003C points.Length; i\u002B\u002B )\n\t\t\t{\n\t\t\t\tpolygon[i] = points[i] \u002B 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 \u003C points.Length; i\u002B\u002B )\n\t\t{\n\t\t\tint next = (i \u002B 1) % points.Length;\n\t\t\tp.Quad( points[i] \u002B offsets[i], points[next] \u002B 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\u003CShatterSunburst\u003E shatterSunbursts = new();\n\tpublic IReadOnlyList\u003CShatterSunburst\u003E ShatterSunbursts =\u003E shatterSunbursts;\n\treadonly List\u003CShatterSunburst\u003E crushSunbursts = new();\n\tpublic IReadOnlyList\u003CShatterSunburst\u003E CrushSunbursts =\u003E 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 \u003E= 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\u003CShatterSunburst\u003E bursts, float delta )\n\t{\n\t\tfor ( int i = bursts.Count - 1; i \u003E= 0; i-- )\n\t\t{\n\t\t\tvar burst = bursts[i];\n\t\t\tburst.Age \u002B= delta;\n\t\t\tif ( burst.Age \u003E= burst.Lifetime ) { bursts.RemoveAt( i ); continue; }\n\t\t\tburst.Position \u002B= 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 \u003C 3; i\u002B\u002B )\n\t\t{\n\t\t\tif ( shatterSunbursts.Count \u003E= 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 \u002B 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 \u003C count; i\u002B\u002B )\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 \u002B outward * Range( 2, 18 );\n\t\t\tif ( !HasRoom( position, radius \u002B 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/// \u003Csummary\u003EExposed perimeter edges of actual bonded groups, in board coordinates.\u003C/summary\u003E\npublic static class DiamondOutline\n{\n\tpublic readonly record struct Edge( float X1, float Y1, float X2, float Y2, int ColorIndex );\n\n\t/// \u003Csummary\u003EWhether a traced perimeter can be drawn as a closed polygon.\u003C/summary\u003E\n\tpublic static bool IsClosed( IReadOnlyList\u003CEdge\u003E contour ) =\u003E contour.Count \u003E= 3\n\t\t\u0026\u0026 MathF.Abs( contour[^1].X2 - contour[0].X1 ) \u003C= 0.01f\n\t\t\u0026\u0026 MathF.Abs( contour[^1].Y2 - contour[0].Y1 ) \u003C= 0.01f;\n\n\t/// \u003Csummary\u003EOrder one group\u0027s 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.\u003C/summary\u003E\n\tpublic static List\u003CList\u003CEdge\u003E\u003E Contours( IReadOnlyList\u003CEdge\u003E edges )\n\t{\n\t\tvar contours = new List\u003CList\u003CEdge\u003E\u003E();\n\t\tvar used = new bool[edges.Count];\n\t\tfor ( int start = 0; start \u003C edges.Count; start\u002B\u002B )\n\t\t{\n\t\t\tif ( used[start] ) continue;\n\t\t\tvar contour = new List\u003CEdge\u003E();\n\t\t\tint current = start;\n\t\t\twhile ( current \u003E= 0 \u0026\u0026 !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 \u003C edges.Count; candidate\u002B\u002B )\n\t\t\t\t{\n\t\t\t\t\tvar other = edges[candidate];\n\t\t\t\t\tif ( (used[candidate] \u0026\u0026 candidate != start) || other.ColorIndex != edge.ColorIndex ||\n\t\t\t\t\t\tMathF.Abs( other.X1 - edge.X2 ) \u003E 0.01f || MathF.Abs( other.Y1 - edge.Y2 ) \u003E 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 \u002B dy * oy );\n\t\t\t\t\tif ( turn \u003E 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\u003CEdge\u003E Build( IReadOnlyList\u003CDiamond\u003E cells, IReadOnlyList\u003CIReadOnlyList\u003Cint\u003E\u003E groups )\n\t{\n\t\tvar edges = new List\u003CEdge\u003E();\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 \u003E cell.Lane ? (other.Y \u003C cell.Y ? 0 : 1) : (other.Y \u003E cell.Y ? 2 : 3);\n\t\t\t\tshared |= 1 \u003C\u003C 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 \u0026 1) == 0 ) edges.Add( new( x, y - h, x \u002B w, y, cell.ColorIndex ) );\n\t\t\tif ( (shared \u0026 2) == 0 ) edges.Add( new( x \u002B w, y, x, y \u002B h, cell.ColorIndex ) );\n\t\t\tif ( (shared \u0026 4) == 0 ) edges.Add( new( x, y \u002B h, x - w, y, cell.ColorIndex ) );\n\t\t\tif ( (shared \u0026 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 \u002B 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 \u002B 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 \u003C layout.Rows.Count; i\u002B\u002B )\n\t\t\tDrawScoreNumber( p, layout.Rows[i].Text, target \u002B 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 \u002B 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 \u002B 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 \u003E 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, $\u0022\u002B{pickup.Points}\u0022, 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 \u002B 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/// \u003Csummary\u003EDraw at \u003Cparamref name=\u0022center\u0022/\u003E; \u003Cparamref name=\u0022align\u0022/\u003E 0 places the text\u0027s left edge there instead.\u003C/summary\u003E\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 ) \u002B 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/// \u003Csummary\u003E\n\t/// A character\u0027s cell width in a time. Enixe\u0027s numerals are proportional only because 1 is narrow,\n\t/// so every digit takes the widest numeral\u0027s cell and a ticking time keeps its width and positions.\n\t/// \u003C/summary\u003E\n\tstatic float TimeAdvance( char c )\n\t{\n\t\tif ( c \u003E= 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 = \u00270\u0027; d \u003C= \u00279\u0027; d\u002B\u002B ) digit = MathF.Max( digit, DiamondScoreEffects.MeasureNumber( d.ToString() ).x );\n\t\t\t// Don\u0027t keep widths measured before the font is available.\n\t\t\tif ( digit \u003C= 0 ) return 0;\n\t\t\ttimeAdvances = new float[128];\n\t\t\tfor ( char d = \u00270\u0027; d \u003C= \u00279\u0027; d\u002B\u002B ) timeAdvances[d] = digit;\n\t\t}\n\t\tif ( timeAdvances[c] \u003C= 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 \u002B= TimeAdvance( c );\n\t\treturn width * fontSize / DiamondScoreEffects.PickupFontSize;\n\t}\n\n\t/// \u003Csummary\u003EDraw a time like \u003Csee cref=\u0022DrawScoreNumber\u0022/\u003E, but with each glyph centered in a fixed-width cell.\u003C/summary\u003E\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 \u002B advance * 0.5f, center.y ), fontSize, color, outline: outline );\n\t\t\tx \u002B= 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 =\u003E 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 =\u003E titleEffects;\r\n\tpublic DiamondMenuDye TitleDye =\u003E titleDye;\r\n\treadonly DiamondSlideAudio titleSlideAudio = new();\r\n\tSoundHandle titleImpact;\r\n\t// Stacked below the title\u0027s lowest glyph (y ~650), ending above the canvas bottom.\r\n\tstatic Rect TitleMenuButton( int index ) =\u003E new( (CanvasWidth - 360) * 0.5f, 688 \u002B index * 74, 360, 64 );\r\n\tstatic Rect PlayButton =\u003E TitleMenuButton( 0 );\r\n\tstatic Rect LeaderboardButton =\u003E TitleMenuButton( 1 );\r\n\tstatic Rect InstructionsButton =\u003E TitleMenuButton( 2 );\r\n\tstatic Rect QuitButton =\u003E TitleMenuButton( 3 );\r\n\t// Refilled on use: hotload keeps static values and leaves new fields null.\r\n\tRect[] titleButtons;\r\n\t/// \u003Csummary\u003ESolid walls for the menu dye and sparks.\u003C/summary\u003E\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 \u003C titleButtons.Length; i\u002B\u002B ) titleButtons[i] = TitleMenuButton( i );\r\n\t\t\treturn titleButtons;\r\n\t\t}\r\n\t}\r\n\tVector2 TitleShakeOffset =\u003E new Vector2( -0.6293f, 0.7771f ) *\r\n\t\t(TitleShake( TitleAnimationTime - TitleSlideDuration ) \u002B 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 \u003E 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 \u002B 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 \u003C 2; word\u002B\u002B )\r\n\t\t\t{\r\n\t\t\t\tfloat start = word * TitleSlideDuration, hit = start \u002B 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 \u002B= sliding * 190;\r\n\t\t\t\twhile ( titleSparkAccumulator \u003E= 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 \u002B TitleAnimationTime - previous ), speed );\r\n\t\t\t\t}\r\n\t\t\t\tif ( before \u003C hit \u0026\u0026 TitleAnimationTime \u003E= hit )\r\n\t\t\t\t{\r\n\t\t\t\t\tvar color = TitleWordColor( word, titleColorTime \u002B 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 \u003E 0.0001f ) UpdateTitleDye( idle );\r\n\t\ttitleColorTime = (titleColorTime \u002B delta) % 100;\r\n\t\tfor ( int word = 1; word \u003C= 2; word\u002B\u002B )\r\n\t\t{\r\n\t\t\tfloat landing = TitleSlideDuration * word;\r\n\t\t\tif ( previous \u003E= landing || TitleAnimationTime \u003C 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( \u0022sfx/diamond_impact.sound\u0022 );\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 \u003C 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 \u003C= 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 \u003C TitleButtonCount; i\u002B\u002B )\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, \u0022DIAMOND\u0022, 0 );\r\n\t\tDrawTitleWord( p, \u0022GRINDER\u0022, 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( \u0022#0b1017\u0022 ).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, \u0022PLAY\u0022, mouse, scale, selected: titleSelection == 0, labelColor: TitleButtonLabel( 0 ) );\r\n\t\tDrawTitleButton( p, LeaderboardButton, \u0022LEADERBOARDS\u0022, mouse, scale, selected: titleSelection == 1, labelColor: TitleButtonLabel( 1 ) );\r\n\t\tDrawTitleButton( p, InstructionsButton, \u0022INSTRUCTIONS\u0022, mouse, scale, selected: titleSelection == 2, labelColor: TitleButtonLabel( 2 ) );\r\n\t\tDrawTitleButton( p, QuitButton, \u0022QUIT\u0022, 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 ) =\u003E age \u003C 0 || age \u003E 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 \u002B (time - TitleSlideDuration) * 0.85f : 3 - time * 1.05f;\r\n\t\t// Wrap the reverse cycle into the palette\u0027s positive range before interpolating.\r\n\t\treturn ScoreColor( (phase % Palette.Length \u002B 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 \u003E 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 \u002B MathF.Max( 0, -TitleViewport.Top ) / 0.777146f) * (1 - MathF.Pow( progress, 2.8f ));\r\n\t\t// Compensate for D/G\u0027s different lowest vertices, aligning the visible tips.\r\n\t\tlocal \u002B= new Vector2( slide \u002B (word == 0 ? -9.72f : 45), word == 0 ? -54 : 54 );\r\n\t\treturn new Vector2( CanvasWidth * 0.5f, 374 ) \u002B new Vector2( local.x * 0.62932f \u002B local.y * 0.777146f, -local.x * 0.777146f \u002B local.y * 0.62932f );\r\n\t}\r\n\r\n\tVector2 TitleContactPoint( int word ) =\u003E 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 \u003C TitleSlideDuration ? 0 : 12 * (1 - damage) * (1 - damage);\r\n\t\tfloat angle = MathF.Floor( damage * 18 ) * 2.39996f \u002B index * 1.7f;\r\n\t\treturn TitleWordPoint( index, Vector2.Zero, TitleAnimationTime ) \u002B new Vector2( MathF.Cos( angle ), MathF.Sin( angle ) ) * magnitude;\r\n\t}\r\n\r\n\tvoid UpdateTitleDye( float delta ) =\u003E 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( \u0022#0b1017\u0022 ).Value, \u0022diamonds-menu-dye\u0022, 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( \u0022#0b1017\u0022 ).Value;\r\n\t\tp.Stroke = Stroke.None;\r\n\t\tfor ( int y = 0; y \u003C DiamondMenuDye.Rows; y\u002B\u002B )\r\n\t\tfor ( int x = 0; x \u003C DiamondMenuDye.Columns; x\u002B\u002B )\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 \u003C 5; color\u002B\u002B )\r\n\t\t\t{\r\n\t\t\t\tfloat amount = titleDye.Sample( x, y, color );\r\n\t\t\t\ttotal \u002B= amount; weight \u002B= amount * amount;\r\n\t\t\t\trgb \u002B= new Vector3( Palette[color].r, Palette[color].g, Palette[color].b ) * (amount * amount);\r\n\t\t\t}\r\n\t\t\tif ( weight \u003C= 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 \u002B 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 \u003C= 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 \u003C= 0 || bounds.y \u003C= 0 ) return;\r\n\t\tp.Scale( 700 / bounds.x, 1.8f );\r\n\t\tvar rect = new Rect( -(bounds \u002B new Vector2( 4 )) * 0.5f, bounds \u002B new Vector2( 4 ) );\r\n\t\tp.Text( word, rect );\r\n\t\tfloat shine = (age - TitleSlideDuration) / TitleShineDuration;\r\n\t\tif ( shine \u003C 0 || shine \u003E= 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 \u002B 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 \u003C 25; band\u002B\u002B )\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 \u002B (band - 12.5f) * width, -bounds.y, width \u002B 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 \u002B jolt, burst.FaceEnd.Value \u002B 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 \u002B 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 \u003C titleButtonBlend.Length; i\u002B\u002B )\r\n\t\t\ttitleButtonBlend[i] \u002B= ((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 \u0026\u0026 index \u003C values.Length ? values[index] : 0;\r\n\t\t// Offset each button\u0027s phase so neighbours don\u0027t flash the same color.\r\n\t\treturn Color.Lerp( Palette[TitleButtonPalette[index]], ScoreColor( titleButtonRainbowTime \u002B 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 \u0026\u0026 rect.IsInside( mouse );\r\n\t\tbool lit = selected ?? hover;\r\n\t\tbool pressed = lit \u0026\u0026 hover \u0026\u0026 HasActive;\r\n\t\tif ( lit \u0026\u0026 danger ) p.Fill = Palette[0].WithAlpha( RealTime.Now * 5 % 1 \u003C 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/// \u003Csummary\u003E\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/// \u003C/summary\u003E\r\npublic static class DiamondLeaderboard\r\n{\r\n\t/// \u003Csummary\u003EBump to start fresh boards: it is baked into \u003Csee cref=\u0022StatName\u0022/\u003E, so older entries stop showing.\u003C/summary\u003E\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 ) =\u003E mode == Mode.Time ? $\u0022time_v{LEADERBOARD_NUM}\u0022 : $\u0022score_v{LEADERBOARD_NUM}\u0022;\r\n\r\n\t/// \u003Csummary\u003EA player\u0027s best: a score, or whole seconds survived.\u003C/summary\u003E\r\n\tpublic readonly record struct Row( long SteamId, string Name, long Value );\r\n\r\n\t// This session\u0027s 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 ) =\u003E ref mode == Mode.Time ? ref sessionBestSeconds : ref sessionBestScore;\r\n\r\n\t/// \u003Csummary\u003EThe in-game timer\u0027s MM:SS, which the time board shows too.\u003C/summary\u003E\r\n\tpublic static string FormatTime( long seconds ) =\u003E $\u0022{seconds / 60:00}:{seconds % 60:00}\u0022;\r\n\tpublic static string Format( Mode mode, long value ) =\u003E\r\n\t\tmode == Mode.Time ? FormatTime( value ) : value.ToString( \u0022N0\u0022, CultureInfo.InvariantCulture );\r\n\r\n\t/// \u003Csummary\u003ESubmit a finished game\u0027s total score and whole seconds survived. Max aggregation keeps only each player\u0027s best.\u003C/summary\u003E\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 \u003C= 0 )\r\n\t\t{\r\n\t\t\tLog.Info( $\u0022Diamonds: skipped \u0027{stat}\u0027 submission for {Format( mode, value )}.\u0022 );\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\u0027s 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 ? $\u0022Diamonds: submitted {Format( mode, value )} to \u0027{stat}\u0027.\u0022\r\n\t\t\t: $\u0022Diamonds: submitted session best {Format( mode, best )} to \u0027{stat}\u0027 for a game with {Format( mode, value )}.\u0022 );\r\n\t}\r\n\r\n\t/// \u003Csummary\u003EThe top \u003Csee cref=\u0022MaxEntries\u0022/\u003E players on \u003Cparamref name=\u0022mode\u0022/\u003E\u0027s board, including this session\u0027s best when the\r\n\t/// response predates it. \u003Cparamref name=\u0022testPlayers\u0022/\u003E mixes generated players in by value, for previewing a busy board.\u003C/summary\u003E\r\n\tpublic static async Task\u003CList\u003CRow\u003E\u003E FetchTop( Mode mode, int testPlayers = 0 )\r\n\t{\r\n\t\tvar rows = new List\u003CRow\u003E();\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( $\u0022Diamonds: leaderboard empty or unavailable ({e.Message}) [stat \u0027{stat}\u0027].\u0022 );\r\n\t\t\trows.Clear();\r\n\t\t}\r\n\t\tif ( testPlayers \u003E 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 =\u003E 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 ), () =\u003E new Friend( me ).Name );\r\n\t\treturn rows;\r\n\t}\r\n\r\n\t/// \u003Csummary\u003E\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\u0027s fake range, which draws generated avatars instead of asking Steam.\r\n\t/// \u003C/summary\u003E\r\n\tpublic static List\u003CRow\u003E TestRows( int count, Mode mode = Mode.Score )\r\n\t{\r\n\t\tstring[] names = [\u0022crystalfan\u0022, \u0022An Extremely Long Steam Username That Must Be Truncated\u0022, \u0022Garry\u0022, \u0022\u30C0\u30A4\u30E4\u30E2\u30F3\u30C9\u8077\u4EBA\u0022, \u0022x\u0022,\r\n\t\t\t\u0022GemGrinder_2000\u0022, \u0022\u03A9mega \u0394iamond\u0022, \u0022lowercase only name\u0022, \u0022WWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWW\u0022, \u0022\u0428\u043B\u0438\u0444\u043E\u0432\u0449\u0438\u043A\u0022,\r\n\t\t\t\u0022prism\u0022, \u0022Sapphire Queen\u0022, \u0022\uD83D\uDC8E facet \uD83D\uDC8E\u0022, \u0022not_a_bot\u0022, \u0022Tetrahedron\u0022, \u0022Obsidian\u0022];\r\n\t\tcount = Math.Clamp( count, 0, MaxEntries );\r\n\t\tvar rows = new List\u003CRow\u003E( 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 \u003C count; i\u002B\u002B )\r\n\t\t{\r\n\t\t\tfloat t = count \u003E 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 \u002B count) / top, t )) );\r\n\t\t\tstring name = names[i % names.Length] \u002B (i \u003E= names.Length ? $\u0022 {i / names.Length \u002B 1}\u0022 : \u0022\u0022);\r\n\t\t\trows.Add( new Row( 90071996842377216 \u002B 1000 \u002B i, name, value ) );\r\n\t\t}\r\n\t\treturn rows;\r\n\t}\r\n\r\n\t/// \u003Csummary\u003E\r\n\t/// Show \u003Cparamref name=\u0022best\u0022/\u003E for \u003Cparamref name=\u0022me\u0022/\u003E 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/// \u003C/summary\u003E\r\n\tpublic static void Splice( List\u003CRow\u003E rows, long me, long best, Func\u003Cstring\u003E localName )\r\n\t{\r\n\t\tif ( best \u003C= 0 ) return;\r\n\t\tint existing = rows.FindIndex( r =\u003E r.SteamId == me );\r\n\t\tif ( existing \u003E= 0 \u0026\u0026 rows[existing].Value \u003E= best ) return;\r\n\t\tstring name = existing \u003E= 0 ? rows[existing].Name : localName();\r\n\t\tif ( existing \u003E= 0 ) rows.RemoveAt( existing );\r\n\t\tint index = rows.FindIndex( r =\u003E r.Value \u003C best );\r\n\t\tif ( index \u003C 0 ) index = rows.Count;\r\n\t\tif ( existing \u003C 0 \u0026\u0026 index \u003E= MaxEntries ) return;\r\n\t\trows.Insert( index, new Row( me, name, best ) );\r\n\t\tif ( rows.Count \u003E 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/// \u003Csummary\u003EA title/game switch is fading the old screen out or the new one in.\u003C/summary\u003E\r\n\tpublic bool Transitioning =\u003E transitionActive;\r\n\t/// \u003Csummary\u003EThe old screen is still fading out; the switch has not happened yet.\u003C/summary\u003E\r\n\tpublic bool TransitionPending =\u003E transitionActive \u0026\u0026 !transitionSwitched;\r\n\t/// \u003Csummary\u003EThe pending or current switch leads into a game rather than the title.\u003C/summary\u003E\r\n\tpublic bool TransitionToGame =\u003E transitionToGame;\r\n\r\n\t/// \u003Csummary\u003EFade to black, then switch between the title and a new game and fade back in.\u003C/summary\u003E\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() =\u003E 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 \u002B= delta;\r\n\t\t\tif ( transitionTime \u003C TransitionFadeOut ) return;\r\n\t\t\t// Start the fade-in fully black; the switch frame\u0027s 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 \u002B= MathF.Min( delta, 1f / 30 );\r\n\t\tif ( transitionTime \u003E= TransitionFadeOut \u002B TransitionFadeIn ) transitionActive = false;\r\n\t}\r\n\r\n\tfloat TransitionDarkness =\u003E !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 \u003C= 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/// \u003Csummary\u003EDebris crushing foley, synthesized once as mono PCM.\u003C/summary\u003E\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 \u003C samples.Length; i\u002B\u002B )\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 \u002B= (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 \u003E= start ) envelope \u002B= 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 \u002B 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/// \u003Csummary\u003EA short, deep error buzz for a refused turn.\u003C/summary\u003E\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 \u003C samples.Length; i\u002B\u002B )\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 \u002B= MathF.Tau * frequency / Rate;\r\n\t\t\tsub \u002B= MathF.Tau * frequency * 0.5f / Rate;\r\n\t\t\tfloat buzz = MathF.Tanh( 3 * MathF.Sin( phase ) ) \u002B 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 \u002B= (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 \u002B 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 \u003C samples.Length; i\u002B\u002B )\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 \u0026 255);\r\n\t\t\tbytes[i * 2 \u002B 1] = (byte)((value \u003E\u003E 8) \u0026 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/// \u003Csummary\u003EEvery visible element is drawn with the new Painter API; no sprite assets.\u003C/summary\u003E\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/// \u003Csummary\u003EEditor-only music state, drawn in the bottom-left screen corner when set.\u003C/summary\u003E\r\n\tpublic string MusicDebugText { get; set; }\r\n\tpublic bool ShowLandingIndicator { get; set; } = false;\r\n\t/// \u003Csummary\u003EMultiplies the landing point marker\u0027s original size.\u003C/summary\u003E\r\n\tpublic float LandingPointScale { get; set; } = 1;\r\n\t/// \u003Csummary\u003EThumbnail capture: draw only the background dye, nothing else.\u003C/summary\u003E\r\n\tpublic bool OnlyDye { get; set; }\r\n\t/// \u003Csummary\u003EThumbnail capture: skip every dye layer, including the static and menu dye.\u003C/summary\u003E\r\n\tpublic bool HideDye { get; set; }\r\n\t/// \u003Csummary\u003EThumbnail capture: blends every dye layer from its normal faint wash (0) to opaque, fully saturated color (1).\u003C/summary\u003E\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 \u002B (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\u003CLandingPointGlow\u003E landingPointGlows = new();\r\n\treadonly List\u003CDiamondDamage.Contact\u003E 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 \u002B 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 \u002B 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( \u0022#e4eeef\u0022 ).Value;\r\n\tstatic readonly Color Muted = Color.Parse( \u0022#82959d\u0022 ).Value;\r\n\tstatic readonly Color TimerInk = Color.Parse( \u0022#50636d\u0022 ).Value;\r\n\tstatic void DrawTimer( Painter p, string value, Vector2 center ) =\u003E\r\n\t\tDrawTimeNumber( p, value, center, 32, TimerInk.WithAlpha( 0.42f ), outline: false );\r\n\t[SkipHotload] static readonly Color ToothFill = Color.Parse( \u0022#1c2933\u0022 ).Value;\r\n\t[SkipHotload] static readonly Color GameOverToothFill = Color.Parse( \u0022#451e25\u0022 ).Value;\r\n\t[SkipHotload]\r\n\tstatic readonly Color FloorInk = Color.Parse( \u0022#70818c\u0022 ).Value;\r\n\t[SkipHotload]\r\n\tstatic readonly Color GameOverSpikeInk = Color.Parse( \u0022#e34343\u0022 ).Value;\r\n\t[SkipHotload]\r\n\tstatic readonly Color GameOverFlashRed = Color.Parse( \u0022#ef3238\u0022 ).Value;\r\n\t[SkipHotload]\r\n\tstatic readonly Color GameOverFlashYellow = Color.Parse( \u0022#ffe04a\u0022 ).Value;\r\n\tColor SpikeInk =\u003E 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( \u0022#f23d3d\u0022 ).Value, Color.Parse( \u0022#2298f0\u0022 ).Value,\r\n\t\tColor.Parse( \u0022#32cc59\u0022 ).Value, Color.Parse( \u0022#ffdc32\u0022 ).Value, Color.Parse( \u0022#b63fc9\u0022 ).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 \u002B 212;\r\n\tconst float CanvasWidth = DiamondBoard.BoardWidth \u002B Left * 2;\r\n\tconst float CanvasHeight = Top \u002B DiamondBoard.BoardHeight \u002B 8;\r\n\tfloat boardRenderTop;\r\n\tfloat boardOffsetY;\r\n\tVector2 BoardOffset =\u003E ShakeOffset \u002B new Vector2( 0, boardOffsetY );\r\n\tRect BoardRenderBounds =\u003E new( Left, boardRenderTop, DiamondBoard.BoardWidth, Top \u002B 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 \u003E 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 \u002B 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\u0027s 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 \u0026\u0026\r\n\t\t\t\t\tMathF.Abs( candidate.Contact.Lane - contact.Lane ) \u003C 0.001f \u0026\u0026 MathF.Abs( candidate.Contact.Y - contact.Y ) \u003C 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 \u003E= 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 \u002B= ((glow.Visible ? 1 : 0) - glow.Opacity) * blend;\r\n\t\t\tglow.Rotation = (glow.Rotation \u002B delta * 0.3f) % (MathF.PI * 2);\r\n\t\t\tglow.Age = (glow.Age \u002B delta) % 1000;\r\n\t\t\tif ( glow.Visible ) glow.Appear = MathF.Min( 1, glow.Appear \u002B delta / LandingPointAppearTime );\r\n\t\t\tif ( !glow.Visible \u0026\u0026 glow.Opacity \u003C 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( \u0022#0b1017\u0022 ).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 \u003C= 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 \u002B 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 \u002B 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 =\u003E c.Lane ) \u002B next.Cells.Max( c =\u003E c.Lane )) * 0.5f;\r\n\t\t\tfloat previewRow = (next.Cells.Min( c =\u003E c.Row ) \u002B next.Cells.Max( c =\u003E 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( \u0022#080d13\u0022 ).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 \u0026\u0026 !Board.GameOver \u0026\u0026 !Board.IsSliding \u0026\u0026 !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 \u0026\u0026 !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\u003CVector2\u003E floor = stackalloc Vector2[DiamondBoard.Valleys * 2 \u002B 1 - cornerSkip * 2];\r\n\t\t\t\tfor ( int i = 0; i \u003C floor.Length; i\u002B\u002B )\r\n\t\t\t\t\tfloor[i] = new Vector2( Left \u002B (i \u002B cornerSkip) * DiamondBoard.Width * 0.5f,\r\n\t\t\t\t\t\tTop \u002B DiamondBoard.BoardHeight - (i % 2 == 0 ? DiamondBoard.Height * 0.5f : 0) );\r\n\t\t\t\tp.Stroke = Stroke.Solid( SpikeInk.WithAlpha( 0.08f \u002B 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 ) \u002B 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 ) \u002B 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, \u0022NEXT\u0022, new Vector2( rightCenter, Top \u002B 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 \u002B DiamondBoard.BoardWidth * 0.5f, Top \u002B boardOffsetY \u002B 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/// \u003Csummary\u003EThumbnail capture: the current screen\u0027s dye over its plain background, without the board, HUD or overlays.\u003C/summary\u003E\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( \u0022#080d13\u0022 ).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/// \u003Csummary\u003EScreen-space debug readout above everything, including the tutorial.\u003C/summary\u003E\r\n\tvoid DrawMusicDebug( Painter p )\r\n\t{\r\n\t\tif ( string.IsNullOrEmpty( MusicDebugText ) ) return;\r\n\t\tvar lines = MusicDebugText.Split( \u0027\\n\u0027 );\r\n\t\tconst float size = 14, lineHeight = 18, padding = 10, width = 600;\r\n\t\tfloat height = lines.Length * lineHeight \u002B 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 \u003C lines.Length; i\u002B\u002B )\r\n\t\t\t// Painter\u0027s text rect includes the font\u0027s ascent, so give each line generous height.\r\n\t\t\tText( p, lines[i].TrimEnd( \u0027\\r\u0027 ), 14 \u002B padding, top \u002B padding \u002B i * lineHeight - size * 0.4f, width - padding * 2, lineHeight * 2,\r\n\t\t\t\tsize, Color.White.WithAlpha( 0.9f ), fontName: \u0022Roboto Mono\u0022 );\r\n\t}\r\n\r\n\tstatic void DrawControls( Painter p, float center )\r\n\t{\r\n\t\tDrawScoreNumber( p, \u0022ROTATE\u0022, new Vector2( center, Top \u002B 374 ), 26, Muted * 0.65f );\r\n\t\tDrawControlKey( p, new Vector2( center, Top \u002B 428 ), \u0022\u2191\u0022 );\r\n\t\tstring[] arrows = [\u0022\u2190\u0022, \u0022\u2193\u0022, \u0022\u2192\u0022];\r\n\t\tstring[] labels = [\u0022LEFT\u0022, \u0022DROP\u0022, \u0022RIGHT\u0022];\r\n\t\tfor ( int i = 0; i \u003C 3; i\u002B\u002B )\r\n\t\t{\r\n\t\t\tfloat x = center \u002B (i - 1) * 84;\r\n\t\t\tDrawControlKey( p, new Vector2( x, Top \u002B 506 ), arrows[i] );\r\n\t\t\tDrawScoreNumber( p, labels[i], new Vector2( x, Top \u002B 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 \u002B DiamondBoard.BoardWidth;\r\n\t\tfloat bottom = Top \u002B 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\u003CVector2\u003E points =\r\n\t\t[\r\n\t\t\tnew( Left, boardRenderTop ), new( Left, bottom - cornerHeight ),\r\n\t\t\tnew( Left \u002B 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 \u0026\u0026 Board.Shattering.Count == 0 ) return;\r\n\t\tfloat yellow = 0.5f \u002B 0.5f * MathF.Sin( boundaryTime * MathF.PI * 2 / 0.9f );\r\n\t\tp.Fill = new Color(\r\n\t\t\tGameOverFlashRed.r \u002B (GameOverFlashYellow.r - GameOverFlashRed.r) * yellow,\r\n\t\t\tGameOverFlashRed.g \u002B (GameOverFlashYellow.g - GameOverFlashRed.g) * yellow,\r\n\t\t\tGameOverFlashRed.b \u002B (GameOverFlashYellow.b - GameOverFlashRed.b) * yellow )\r\n\t\t\t.WithAlpha( 0.055f \u002B 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 \u002B CanvasHeight \u002B 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 \u002B (side \u003E 0 ? DiamondBoard.BoardWidth : 0), Top \u002B 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 \u003C 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 \u003C= 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( \u0022#080d13\u0022 ).Value,\r\n\t\t\t\u0022diamonds-gameplay-dye\u0022, Math.Clamp( DyeVibrancy, 0, 1 ), ref atmosphereTexture, ref atmospherePixels, ref atmosphereTextureStamp );\r\n\t\tvar origin = DiamondAtmosphere.GridToWorld( 0, 0 ) \u002B 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\u003Cfloat\u003E 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 \u0026\u0026 pixels?.Length == columns * rows \u0026\u0026 ReferenceEquals( stamp.Source, source ) \u0026\u0026\r\n\t\t\tstamp.Version == version \u0026\u0026 ReferenceEquals( stamp.Palette, Palette ) \u0026\u0026 stamp.Background.Equals( background ) \u0026\u0026 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 \u003C pixels.Length; n\u002B\u002B )\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 \u003C DiamondBoard.ColorCount; color\u002B\u002B )\r\n\t\t\t{\r\n\t\t\t\tfloat amount = samples[n * DiamondBoard.ColorCount \u002B color];\r\n\t\t\t\ttotal \u002B= amount;\r\n\t\t\t\tfloat vividAmount = amount * amount;\r\n\t\t\t\tweightedTotal \u002B= vividAmount;\r\n\t\t\t\tvar ink = Palette[color];\r\n\t\t\t\tred \u002B= ink.r * vividAmount; green \u002B= ink.g * vividAmount; blue \u002B= 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 \u003C= 0 || coverage \u003C= 0 ? background :\r\n\t\t\t\tnew Color( background.r \u002B (red / weightedTotal - background.r) * coverage,\r\n\t\t\t\t\tbackground.g \u002B (green / weightedTotal - background.g) * coverage,\r\n\t\t\t\t\tbackground.b \u002B (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 \u003C= 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( \u0022#080d13\u0022 ).Value;\r\n\t\tp.Stroke = Stroke.None;\r\n\t\tvar samples = Atmosphere.Samples;\r\n\t\tfor ( int y = 0; y \u003C DiamondAtmosphere.Rows; y\u002B\u002B )\r\n\t\tfor ( int x = 0; x \u003C DiamondAtmosphere.Columns; x\u002B\u002B )\r\n\t\t{\r\n\t\t\tvar tile = atmosphereTiles[y * DiamondAtmosphere.Columns \u002B x];\r\n\t\t\tif ( tile.Width \u003C= 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 \u002B x) * DiamondBoard.ColorCount;\r\n\t\t\tfor ( int color = 0; color \u003C DiamondBoard.ColorCount; color\u002B\u002B )\r\n\t\t\t{\r\n\t\t\t\tfloat amount = samples[sampleIndex \u002B color];\r\n\t\t\t\ttotal \u002B= amount;\r\n\t\t\t\tfloat vividAmount = amount * amount;\r\n\t\t\t\tweightedTotal \u002B= vividAmount;\r\n\t\t\t\tvar ink = Palette[color];\r\n\t\t\t\tred \u002B= ink.r * vividAmount;\r\n\t\t\t\tgreen \u002B= ink.g * vividAmount;\r\n\t\t\t\tblue \u002B= 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 \u003C= 0 || coverage \u003C= 0 ) continue;\r\n\t\t\tp.Fill = new Color( background.r \u002B (red / weightedTotal - background.r) * coverage,\r\n\t\t\t\tbackground.g \u002B (green / weightedTotal - background.g) * coverage,\r\n\t\t\t\tbackground.b \u002B (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 \u002B 3 * 0.70710678f;\r\n\t\tfor ( int y = 0; y \u003C DiamondAtmosphere.Rows; y\u002B\u002B )\r\n\t\tfor ( int x = 0; x \u003C DiamondAtmosphere.Columns; x\u002B\u002B )\r\n\t\t{\r\n\t\t\tVector2 center = DiamondAtmosphere.GridToWorld( x, y );\r\n\t\t\tif ( center.x \u003C -radius || center.x \u003E DiamondBoard.BoardWidth \u002B radius ||\r\n\t\t\t\tcenter.y \u003C -radius || center.y \u003E DiamondBoard.BoardHeight \u002B radius ) continue;\r\n\t\t\tcenter \u002B= new Vector2( Left, Top );\r\n\t\t\ttiles[y * DiamondAtmosphere.Columns \u002B 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 \u003C= DiamondBoard.Valleys; i\u002B\u002B )\r\n\t\t{\r\n\t\t\tvar center = new Vector2( Left \u002B i * DiamondBoard.Width, Top \u002B 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 \u002B new Vector2( -DiamondBoard.Width * 0.5f, 0 );\r\n\t\tvar tip = center \u002B new Vector2( 0, -DiamondBoard.Height * 0.5f );\r\n\t\tvar right = center \u002B 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\u003CVector2\u003E 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 \u002B y \u002B DiamondBoard.Height * 0.5f \u003E boardRenderTop; y -= DiamondBoard.Height )\r\n\t\tfor ( int side = 0; side \u003C 2; side\u002B\u002B )\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 \u0026\u0026 tooth.Y == DiamondBoard.BoardHeight ) continue;\r\n\t\t\tvar center = Position( tooth.Lane, tooth.Y );\r\n\t\t\tfloat inward = tooth.Lane \u003C 0 ? 1 : -1;\r\n\t\t\tedge[0] = center \u002B new Vector2( 0, -DiamondBoard.Height * 0.5f );\r\n\t\t\tedge[1] = center \u002B new Vector2( inward * DiamondBoard.Width * 0.5f, 0 );\r\n\t\t\tedge[2] = center \u002B new Vector2( 0, DiamondBoard.Height * 0.5f );\r\n\t\t\tbool corner = Board.FillCornerPockets \u0026\u0026 tooth.Y == DiamondBoard.BoardHeight - DiamondBoard.Height;\r\n\t\t\tif ( corner ) edge[2] \u002B= 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 \u002B 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 ) \u002B 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 \u002B (chip.IsSpark ? 6 : 7.5f)) * (chip.IsSpark ? 1.6f : 1) \u002B 2;\r\n\t\t\tif ( !drawingReferenceGeometry \u0026\u0026 (position.x \u002B radius \u003C BoardRenderBounds.Left ||\r\n\t\t\t\tposition.x - radius \u003E BoardRenderBounds.Right || position.y \u002B radius \u003C BoardRenderBounds.Top ||\r\n\t\t\t\tposition.y - radius \u003E 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 \u002B chip.Brightness * 0.25f;\r\n\t\t\tvar tint = new Color( color.r \u002B (1 - color.r) * highlight,\r\n\t\t\t\tcolor.g \u002B (1 - color.g) * highlight, color.b \u002B (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] \u002B vertices[1] \u002B vertices[2]) / 3;\r\n\t\tfor ( int facet = 0; facet \u003C 3; facet\u002B\u002B )\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 \u002B 1) % 3], center] );\r\n\t\t}\r\n\t}\r\n\r\n\tstatic void DrawParticleGlow( Painter p, ReadOnlySpan\u003CVector2\u003E 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 \u003C layers.Length; i\u002B\u002B )\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\u0027s 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\u003CVector2\u003E corners = stackalloc Vector2[4];\r\n\t\tSpan\u003CVector2\u003E expanded = stackalloc Vector2[4];\r\n\t\tParticleGlowCorners( vertices, corners );\r\n\t\tfor ( int layer = 3; layer \u003E= 1; layer-- )\r\n\t\t{\r\n\t\t\tfor ( int i = 0; i \u003C vertices.Length; i\u002B\u002B ) expanded[i] = vertices[i] \u002B 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\u003CVector2\u003E vertices, Span\u003CVector2\u003E corners )\r\n\t{\r\n\t\tfloat winding = 0;\r\n\t\tfor ( int i = 0; i \u003C vertices.Length; i\u002B\u002B )\r\n\t\t{\r\n\t\t\tvar a = vertices[i]; var b = vertices[(i \u002B 1) % vertices.Length];\r\n\t\t\twinding \u002B= a.x * b.y - a.y * b.x;\r\n\t\t}\r\n\t\tfor ( int i = 0; i \u003C vertices.Length; i\u002B\u002B )\r\n\t\t{\r\n\t\t\tvar incoming = (vertices[i] - vertices[(i \u002B vertices.Length - 1) % vertices.Length]).Normal;\r\n\t\t\tvar outgoing = (vertices[(i \u002B 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 \u002B 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 \u002B t * 1.4f);\r\n\t\t\tvar center = new Vector2( Left, Top ) \u002B 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 \u003E= 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 ) =\u003E ray switch { 0 =\u003E 9, 1 =\u003E 5.5f, 2 =\u003E 7, 3 =\u003E 8, 4 =\u003E 5, _ =\u003E 6.5f };\r\n\r\n\tstatic void DrawLandingPointGlow( Painter p, LandingPointGlow glow, float scale )\r\n\t{\r\n\t\tif ( scale \u003C= 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 \u002B 2.2f * t * t * t \u002B 1.2f * t * t;\r\n\t\tp.Scale( 1.6f * scale * (0.25f \u002B 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 \u002B 1) * 0.5f, (color.g \u002B 1) * 0.5f, (color.b \u002B 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\u003Cfloat\u003E pulses = stackalloc float[6], coreReach = stackalloc float[6];\r\n\t\tfor ( int ray = 0; ray \u003C 6; ray\u002B\u002B )\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 \u002B 1) * 1.618f;\r\n\t\t\tfloat w = glow.Age * LandingRayPulseRates[ray] * MathF.PI * 2;\r\n\t\t\tpulses[ray] = 1 \u002B 0.1f * MathF.Sin( w \u002B phase ) \u002B 0.05f * MathF.Sin( w * 1.73f \u002B phase * 0.5f );\r\n\t\t\t// The core\u0027s 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 \u002B 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 \u002B 0.1f * MathF.Sin( coreW \u002B corePhase ) \u002B 0.05f * MathF.Sin( coreW * 1.61f \u002B 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 \u003C 6; ray\u002B\u002B )\r\n\t\t{\r\n\t\t\tfloat angle = glow.Rotation \u002B ray * MathF.PI / 3;\r\n\t\t\tfloat reach = LandingRayLength( ray ) * pulses[ray];\r\n\t\t\tfloat along = reach * 0.6f \u002B 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 \u003C 6; ray\u002B\u002B )\r\n\t\t{\r\n\t\t\tfloat angle = glow.Rotation \u002B 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 \u002B side * 1.2f * pulse, center \u002B 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 \u002B 3) * 0.25f, (tint.g \u002B 3) * 0.25f, (tint.b \u002B 3) * 0.25f );\r\n\t\tfor ( int layer = 0; layer \u003C LandingCoreLayers.Length; layer\u002B\u002B )\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 \u003C 6; ray\u002B\u002B )\r\n\t\t\t{\r\n\t\t\t\tfloat angle = glow.Rotation \u002B 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 \u002B side * width * pulse, center \u002B 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=\u003E 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=\u003E 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 \u002B 28 * (1 - MathF.Pow( 1 - t, 3 ));\r\n\t\t// Lift the receiver\u0027s color toward white while retaining its hue. Brightness\r\n\t\t// is chosen once per burst so fading doesn\u0027t flicker between random shades.\r\n\t\tvar tint = new Color( color.r \u002B (1 - color.r) * brightness,\r\n\t\t\tcolor.g \u002B (1 - color.g) * brightness, color.b \u002B (1 - color.b) * brightness );\r\n\t\tp.Stroke = Stroke.None;\r\n\t\tfor ( int layer = 3; layer \u003E= 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 \u003C 12; ray\u002B\u002B )\r\n\t\t{\r\n\t\t\tfloat angle = ray * MathF.PI / 6 \u002B 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 \u002B 1) * 0.5f, (tint.g \u002B 1) * 0.5f, (tint.b \u002B 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=\u003E 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 \u002B 0.5f * Math.Max( 0, damageCount - 1 );\r\n\t\tvar tint = new Color( color.r \u002B (1 - color.r) * brightness,\r\n\t\t\tcolor.g \u002B (1 - color.g) * brightness, color.b \u002B (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 \u003E= 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 \u003C 11; ray\u002B\u002B )\r\n\t\t{\r\n\t\t\tfloat along = (ray \u002B 0.5f) / 11;\r\n\t\t\tvar origin = start \u002B (end - start) * along;\r\n\t\t\tfor ( int side = -1; side \u003C= 1; side \u002B= 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 \u002B ray * 2.399f \u002B side * 1.71f ) * 1.25f;\r\n\t\t\t\tvar direction = normal * (side * MathF.Cos( angle )) \u002B 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 \u002B 0.5f * MathF.Sin( seed \u002B ray * 4.13f \u002B side * 2.57f );\r\n\t\t\t\tfloat length = (13.5f \u002B 31 * (1 - MathF.Pow( 1 - t, 3 ))) * (0.45f \u002B variation * 0.55f) * scale;\r\n\t\t\t\tfloat width = (1.15f \u002B variation * 1.15f) * (1 - t * 0.6f);\r\n\t\t\t\tp.Polygon( [origin - across * width, origin \u002B direction * length, origin \u002B across * width] );\r\n\t\t\t}\r\n\t\t}\r\n\t\tvar core = new Color( (tint.r \u002B 1) * 0.5f, (tint.g \u002B 1) * 0.5f, (tint.b \u002B 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 ) =\u003E new( Left \u002B DiamondBoard.LaneX( lane ), Top \u002B y );\r\n\r\n\t/// \u003Csummary\u003EDraw the controlled piece trailing its lattice position while a step or turn animates.\u003C/summary\u003E\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 \u002B trail.Lane, piece.Y \u002B trail.Y ) \u002B PieceShakeOffset;\r\n\t\tfloat turns = Board.ControlTurns;\r\n\t\tif ( MathF.Abs( turns ) \u003C 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) \u0026 3 };\r\n\t\tvar pivotOffset = DiamondShapes.All[piece.ShapeIndex].Pivot;\r\n\t\tvar pivot = anchor \u002B 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 \u002B 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 \u002B 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\u003CDiamondBoard.Diamond\u003E cells,\r\n\t\tIReadOnlyList\u003CIReadOnlyList\u003Cint\u003E\u003E groups, Vector2 origin, float scale = 1, float opacity = 1, bool glow = true,\r\n\t\tIReadOnlyList\u003CDiamondBoard.BondPulse\u003E pulses = null, OutlineGeometryCache geometryCache = null )\r\n\t{\r\n\t\tif ( cells.Count == 0 || opacity \u003C= 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 \u003C cells.Count; i\u002B\u002B )\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 \u002B 20; maxX \u002B= DiamondBoard.Width * 0.5f \u002B 20;\r\n\t\tminY -= DiamondBoard.Height * 0.5f \u002B 20; maxY \u002B= DiamondBoard.Height * 0.5f \u002B 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 \u0026\u0026 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 =\u003E BuildPerimeterMiters( c.Points, c.Closed ) );\r\n\t\t\tvar firstEdge = group.FirstEdge with { X1 = group.FirstEdge.X1 \u002B group.Offset.x, Y1 = group.FirstEdge.Y1 \u002B group.Offset.y,\r\n\t\t\t\tX2 = group.FirstEdge.X2 \u002B group.Offset.x, Y2 = group.FirstEdge.Y2 \u002B 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 \u002B (1 - color.r) * lift,\r\n\t\t\t\tcolor.g \u002B (1 - color.g) * lift, color.b \u002B (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 \u003C group.Contours.Length; i\u002B\u002B )\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 \u002B breathing * 0.06f : 1 ),\r\n\t\t\t\t\tglow ? 4.5f \u002B breathing * 0.25f \u002B 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 \u003E= 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 \u0026\u0026 !drawingReferenceStrokes \u0026\u0026 !ghost \u0026\u0026 opacity == 1 \u0026\u0026 scale \u003E 0 \u0026\u0026\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 \u002B bottom) * 0.5f, fanRight = (right \u002B 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 \u0026\u0026 health \u003C 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 =\u003E new( -point.y, point.x ),\r\n\t\t\tDiamondBoard.Corner.Bottom =\u003E new( -point.x, -point.y ),\r\n\t\t\tDiamondBoard.Corner.Left =\u003E new( point.y, -point.x ),\r\n\t\t\t_ =\u003E 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\u003CVector2\u003E path )\r\n\t\t{\r\n\t\t\tfor ( int i = 0; i \u003C path.Length; i\u002B\u002B ) 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 \u003C path.Length; i\u002B\u002B ) path[i] \u002B= 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 \u003E 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\u003CVector2\u003E vertices )\r\n\t{\r\n\t\tfloat amount = MathF.Abs( light );\r\n\t\tColor target = light \u003E= 0 ? Color.White : Color.Black;\r\n\t\tColor face = new( color.r \u002B (target.r - color.r) * amount,\r\n\t\t\tcolor.g \u002B (target.g - color.g) * amount, color.b \u002B (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 = \u0022Roboto\u0022 )\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/// \u003Csummary\u003E\n/// Player preferences from the options overlay, persisted as JSON in the game\u0027s local data folder.\n/// Volumes are slider positions from zero to one, applied as linear gain.\n/// \u003C/summary\u003E\npublic sealed class DiamondSettings\n{\n\tpublic const string FilePath = \u0022diamonds_settings.json\u0022;\n\tstatic DiamondSettings current;\n\n\t/// \u003Csummary\u003EUse a dim static dye background instead of gameplay fluid. Keep the original JSON key for saved preferences.\u003C/summary\u003E\n\t[System.Text.Json.Serialization.JsonPropertyName( \u0022HideDye\u0022 )]\n\tpublic bool DisableFluid { get; set; }\n\n\t/// \u003Csummary\u003EOverall level for every game sound, including music.\u003C/summary\u003E\n\tpublic float MasterVolume { get =\u003E masterVolume; set =\u003E masterVolume = Clamp( value ); }\n\tfloat masterVolume = 1;\n\n\t/// \u003Csummary\u003EMusic level, applied on top of the master volume.\u003C/summary\u003E\n\tpublic float MusicVolume { get =\u003E musicVolume; set =\u003E musicVolume = Clamp( value ); }\n\tfloat musicVolume = 1;\n\n\t/// \u003Csummary\u003EController vibration strength, from zero (off) to one.\u003C/summary\u003E\n\tpublic float ControllerRumble { get =\u003E controllerRumble; set =\u003E controllerRumble = Clamp( value ); }\n\tfloat controllerRumble = 1;\n\n\tpublic const float RestartDelayStep = 0.05f;\n\n\t/// \u003Csummary\u003ESeconds the restart key must be held, from zero to one in 0.05 steps. Zero restarts instantly, without a fade.\u003C/summary\u003E\n\tpublic float RestartDelay\n\t{\n\t\tget =\u003E restartDelay;\n\t\tset =\u003E 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/// \u003Csummary\u003EA delay as the options slider shows it: 0s, 0.05s, 0.5s, 1s.\u003C/summary\u003E\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 ? $\u0022{whole}s\u0022 : fraction % 10 == 0 ? $\u0022{whole}.{fraction / 10}s\u0022 : $\u0022{whole}.{fraction:00}s\u0022;\n\t}\n\n\tpublic static DiamondSettings Current =\u003E current ??= Load();\n\n\t/// \u003Csummary\u003ELinear gain for master-scaled sound effects.\u003C/summary\u003E\n\tpublic static float EffectsGain =\u003E Current.MasterVolume;\n\n\t/// \u003Csummary\u003ELinear gain for music: the master and music sliders combined.\u003C/summary\u003E\n\tpublic static float MusicGain =\u003E 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( $\u0022Could not read {FilePath}; using default options. {e.Message}\u0022 );\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( $\u0022Could not save {FilePath}. {e.Message}\u0022 ); }\n\t}\n\n\tstatic float Clamp( float value ) =\u003E 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/// \u003Csummary\u003EHosts the Painter canvas on a ScreenPanel and routes keyboard input.\u003C/summary\u003E\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\u0027s modal state: the s\u0026box escape menu freezes the whole game, like Block Party.\r\n\treadonly Game.Overlay engineOverlay = new();\r\n\r\n\t/// \u003Csummary\u003EDebug score applied when starting or restarting a game. Zero or negative values start at zero.\u003C/summary\u003E\r\n\t[Property, Group( \u0022Debug\u0022 ), Title( \u0022Starting Score\u0022 )]\r\n\tpublic long StartingScore\r\n\t{\r\n\t\tget =\u003E board.StartingScore;\r\n\t\tset =\u003E board.StartingScore = value;\r\n\t}\r\n\r\n\t/// \u003Csummary\u003EEditor only: generated players mixed into the leaderboard, to preview scrolling and a busy board. Zero shows only real scores.\u003C/summary\u003E\r\n\t[Property, Group( \u0022Debug\u0022 ), Title( \u0022Leaderboard Test Players\u0022 ), Range( 0, DiamondLeaderboard.MaxEntries )]\r\n\tpublic int LeaderboardTestPlayers { get; set; }\r\n\r\n\t/// \u003Csummary\u003EEditor only, for thumbnail screenshots: draw just the background dye (the menu dye on the title, the board dye in game) and nothing else.\u003C/summary\u003E\r\n\t[Property, Group( \u0022Thumbnail\u0022 ), Title( \u0022Only Render Dye\u0022 )]\r\n\tpublic bool ThumbnailOnlyDye { get; set; }\r\n\r\n\t/// \u003Csummary\u003EEditor only, for thumbnail screenshots: never draw any dye, including the static Disable Fluid background and the menu dye.\u003C/summary\u003E\r\n\t[Property, Group( \u0022Thumbnail\u0022 ), Title( \u0022Hide Dye\u0022 )]\r\n\tpublic bool ThumbnailHideDye { get; set; }\r\n\r\n\t/// \u003Csummary\u003EEditor only, for bright thumbnail screenshots: brighten the dye by Vibrancy.\u003C/summary\u003E\r\n\t[Property, Group( \u0022Thumbnail\u0022 ), Title( \u0022Vibrant Dye\u0022 )]\r\n\tpublic bool ThumbnailVibrantDye { get; set; }\r\n\r\n\t/// \u003Csummary\u003EHow far Vibrant Dye pushes the dye from its normal faint wash (0) to opaque, fully saturated color (1).\u003C/summary\u003E\r\n\t[Property, Group( \u0022Thumbnail\u0022 ), Title( \u0022Vibrancy\u0022 ), Range( 0, 1 ), ShowIf( nameof( ThumbnailVibrantDye ), true )]\r\n\tpublic float ThumbnailVibrancy { get; set; } = 0.5f;\r\n\r\n\t/// \u003Csummary\u003EFill both bottom corner pockets. Changing this starts a fresh game.\u003C/summary\u003E\r\n\t[Property, Group( \u0022Board\u0022 ), Title( \u0022Fill Corner Pockets\u0022 )]\r\n\tpublic bool FillCornerPockets\r\n\t{\r\n\t\tget =\u003E board.FillCornerPockets;\r\n\t\tset =\u003E board.FillCornerPockets = value;\r\n\t}\r\n\r\n\t/// \u003Csummary\u003ESize of the glowing marker at the active piece\u0027s landing contact points. One is the original size.\u003C/summary\u003E\r\n\t[Property, Group( \u0022Board\u0022 ), Title( \u0022Landing Indicator Scale\u0022 ), Range( 0, 4 )]\r\n\tpublic float LandingIndicatorScale { get; set; } = 1;\r\n\r\n\t/// \u003Csummary\u003ESeconds a sideways step takes to draw. Collision still snaps to the lattice; zero draws it instantly.\u003C/summary\u003E\r\n\t[Property, Group( \u0022Controls\u0022 ), Title( \u0022Move Smoothing (s)\u0022 ), Range( 0, 0.3f )]\r\n\tpublic float MoveSmoothing\r\n\t{\r\n\t\tget =\u003E board.MoveSmoothing;\r\n\t\tset =\u003E board.MoveSmoothing = value;\r\n\t}\r\n\r\n\t/// \u003Csummary\u003ESeconds a quarter turn takes to draw. Collision still snaps to the lattice; zero draws it instantly.\u003C/summary\u003E\r\n\t[Property, Group( \u0022Controls\u0022 ), Title( \u0022Rotate Smoothing (s)\u0022 ), Range( 0, 0.3f )]\r\n\tpublic float RotateSmoothing\r\n\t{\r\n\t\tget =\u003E board.RotateSmoothing;\r\n\t\tset =\u003E board.RotateSmoothing = value;\r\n\t}\r\n\r\n\t/// \u003Csummary\u003EHow long rotate must be held before it starts repeating.\u003C/summary\u003E\r\n\t[Property, Group( \u0022Controls\u0022 ), Title( \u0022Rotate Repeat Delay (s)\u0022 ), Range( 0.05f, 1 )]\r\n\tpublic float RotateRepeatDelay\r\n\t{\r\n\t\tget =\u003E board.RotateRepeatDelay;\r\n\t\tset =\u003E board.RotateRepeatDelay = value;\r\n\t}\r\n\r\n\t/// \u003Csummary\u003ESeconds between repeated turns while rotate stays held.\u003C/summary\u003E\r\n\t[Property, Group( \u0022Controls\u0022 ), Title( \u0022Rotate Repeat Interval (s)\u0022 ), Range( 0.03f, 1 )]\r\n\tpublic float RotateRepeatInterval\r\n\t{\r\n\t\tget =\u003E board.RotateRepeatInterval;\r\n\t\tset =\u003E board.RotateRepeatInterval = value;\r\n\t}\r\n\r\n\t/// \u003Csummary\u003ENormal fall speed at the start of a game, in diamond heights per second.\u003C/summary\u003E\r\n\t[Property, Group( \u0022Difficulty\u0022 ), Title( \u0022Starting Fall Speed\u0022 ), Range( 0.1f, 8 )]\r\n\tpublic float StartingFallSpeed\r\n\t{\r\n\t\tget =\u003E board.StartingFallSpeed;\r\n\t\tset =\u003E board.StartingFallSpeed = value;\r\n\t}\r\n\r\n\t/// \u003Csummary\u003EMinutes until normal falling reaches the chosen speed multiplier.\u003C/summary\u003E\r\n\t[Property, Group( \u0022Difficulty\u0022 ), Title( \u0022Ramp Reference Time (min)\u0022 ), Range( 0.5f, 60 )]\r\n\tpublic float FallRampMinutes\r\n\t{\r\n\t\tget =\u003E board.FallRampMinutes;\r\n\t\tset =\u003E board.FallRampMinutes = value;\r\n\t}\r\n\r\n\t/// \u003Csummary\u003ENormal fall speed at the reference time, relative to the starting speed.\u003C/summary\u003E\r\n\t[Property, Group( \u0022Difficulty\u0022 ), Title( \u0022Speed at Reference Time (x)\u0022 ), Range( 1, 10 )]\r\n\tpublic float FallRampMultiplier\r\n\t{\r\n\t\tget =\u003E board.FallRampMultiplier;\r\n\t\tset =\u003E board.FallRampMultiplier = value;\r\n\t}\r\n\r\n\t/// \u003Csummary\u003EValues above one delay most of the speed increase until later in the ramp.\u003C/summary\u003E\r\n\t[Property, Group( \u0022Difficulty\u0022 ), Title( \u0022Ramp Curve Exponent\u0022 ), Range( 0.5f, 4 )]\r\n\tpublic float FallRampExponent\r\n\t{\r\n\t\tget =\u003E board.FallRampExponent;\r\n\t\tset =\u003E board.FallRampExponent = value;\r\n\t}\r\n\r\n\t/// \u003Csummary\u003EMinutes of play before pieces can gain extra distinct colors. Chance is zero at this time.\u003C/summary\u003E\r\n\t[Property, Group( \u0022Difficulty - Colors\u0022 ), Title( \u0022Mix Starts At (min)\u0022 ), Range( 0, 60 )]\r\n\tpublic float ColorMixStartMinutes\r\n\t{\r\n\t\tget =\u003E board.ColorMixStartMinutes;\r\n\t\tset =\u003E board.ColorMixStartMinutes = value;\r\n\t}\r\n\r\n\t/// \u003Csummary\u003EMinutes after the mix starts until the reference chance is reached.\u003C/summary\u003E\r\n\t[Property, Group( \u0022Difficulty - Colors\u0022 ), Title( \u0022Mix Reference Time (min)\u0022 ), Range( 0.5f, 60 )]\r\n\tpublic float ColorMixRampMinutes\r\n\t{\r\n\t\tget =\u003E board.ColorMixRampMinutes;\r\n\t\tset =\u003E board.ColorMixRampMinutes = value;\r\n\t}\r\n\r\n\t/// \u003Csummary\u003EChance of a three- or four-color piece at the reference time. The chance keeps rising afterward, up to 100%.\u003C/summary\u003E\r\n\t[Property, Group( \u0022Difficulty - Colors\u0022 ), Title( \u0022Mix Chance at Reference (%)\u0022 ), Range( 0, 100 )]\r\n\tpublic float ColorMixChanceAtReference\r\n\t{\r\n\t\tget =\u003E board.ColorMixChanceAtReference;\r\n\t\tset =\u003E board.ColorMixChanceAtReference = value;\r\n\t}\r\n\r\n\t/// \u003Csummary\u003EHigher values keep the mix chance low for longer after it starts.\u003C/summary\u003E\r\n\t[Property, Group( \u0022Difficulty - Colors\u0022 ), Title( \u0022Mix Curve Exponent\u0022 ), Range( 0.5f, 4 )]\r\n\tpublic float ColorMixRampExponent\r\n\t{\r\n\t\tget =\u003E board.ColorMixRampExponent;\r\n\t\tset =\u003E board.ColorMixRampExponent = value;\r\n\t}\r\n\r\n\t/// \u003Csummary\u003EAmong varied four-diamond pieces, the percentage with four different colors rather than three.\u003C/summary\u003E\r\n\t[Property, Group( \u0022Difficulty - Colors\u0022 ), Title( \u0022Four-Color Share (%)\u0022 ), Range( 0, 100 )]\r\n\tpublic float FourColorShare\r\n\t{\r\n\t\tget =\u003E board.FourColorShare;\r\n\t\tset =\u003E board.FourColorShare = value;\r\n\t}\r\n\r\n\t/// \u003Csummary\u003EScales gameplay dye opacity. One preserves the original appearance.\u003C/summary\u003E\r\n\t[Property, Group( \u0022Fluid\u0022 ), Title( \u0022Dye Opacity\u0022 ), Range( 0, 1 )]\r\n\tpublic float DyeOpacity\r\n\t{\r\n\t\tget =\u003E atmosphere.Opacity;\r\n\t\tset =\u003E atmosphere.Opacity = value;\r\n\t}\r\n\r\n\t/// \u003Csummary\u003ETotal dye added at each piece spawn, divided among its diamond colors.\u003C/summary\u003E\r\n\t[Property, Group( \u0022Fluid\u0022 ), Title( \u0022Spawn Dye Amount\u0022 ), Range( 0, 30 )]\r\n\tpublic float SpawnDyeAmount\r\n\t{\r\n\t\tget =\u003E atmosphere.SpawnDyeAmount;\r\n\t\tset =\u003E atmosphere.SpawnDyeAmount = value;\r\n\t}\r\n\r\n\t/// \u003Csummary\u003EDye added in the broken diamond\u0027s color for each shatter.\u003C/summary\u003E\r\n\t[Property, Group( \u0022Fluid\u0022 ), Title( \u0022Shatter Dye Amount\u0022 ), Range( 0, 15 )]\r\n\tpublic float ShatterDyeAmount\r\n\t{\r\n\t\tget =\u003E atmosphere.ShatterDyeAmount;\r\n\t\tset =\u003E atmosphere.ShatterDyeAmount = value;\r\n\t}\r\n\r\n\t/// \u003Csummary\u003EMinimum seconds between explosion dye pushes. Each batch pushes at most eight gems; dye emission is never throttled.\u003C/summary\u003E\r\n\t[Property, Group( \u0022Fluid\u0022 ), Title( \u0022Shatter Push Interval (s)\u0022 ), Range( 0, 1 )]\r\n\tpublic float ShatterPushInterval\r\n\t{\r\n\t\tget =\u003E atmosphere.ShatterPushInterval;\r\n\t\tset =\u003E atmosphere.ShatterPushInterval = value;\r\n\t}\r\n\r\n\t/// \u003Csummary\u003EPercentage of dye faded per second, shared by every color and source.\u003C/summary\u003E\r\n\t[Property, Group( \u0022Fluid\u0022 ), Title( \u0022Dye Fade Rate (%/s)\u0022 ), Range( 0, 10 )]\r\n\tpublic float DyeFadeRate\r\n\t{\r\n\t\tget =\u003E atmosphere.DyeFadeRate;\r\n\t\tset =\u003E atmosphere.DyeFadeRate = value;\r\n\t}\r\n\r\n\t/// \u003Csummary\u003EScales launch speed for impact, rotation, sliding, and shatter sparks. One is the original force.\u003C/summary\u003E\r\n\t[Property, Group( \u0022Sparks\u0022 ), Title( \u0022Force Multiplier\u0022 ), Range( 0, 10 )]\r\n\tpublic float SparkForceMultiplier\r\n\t{\r\n\t\tget =\u003E debris.SparkForceMultiplier;\r\n\t\tset =\u003E debris.SparkForceMultiplier = value;\r\n\t}\r\n\r\n\t/// \u003Csummary\u003ESpark bounce restitution, from zero to one. The original value was 0.24. Applies to existing sparks too.\u003C/summary\u003E\r\n\t[Property, Group( \u0022Sparks\u0022 ), Title( \u0022Bounciness\u0022 ), Range( 0, 1 )]\r\n\tpublic float SparkBounciness\r\n\t{\r\n\t\tget =\u003E debris.SparkBounciness;\r\n\t\tset =\u003E debris.SparkBounciness = value;\r\n\t}\r\n\r\n\t/// \u003Csummary\u003EScales the original 0.5\u20130.8 second lifetime for newly emitted sparks. Four gives 2\u20133.2 seconds.\u003C/summary\u003E\r\n\t[Property, Group( \u0022Sparks\u0022 ), Title( \u0022Lifetime Multiplier\u0022 ), Range( 0.1f, 10 )]\r\n\tpublic float SparkLifetimeMultiplier\r\n\t{\r\n\t\tget =\u003E debris.SparkLifetimeMultiplier;\r\n\t\tset =\u003E debris.SparkLifetimeMultiplier = value;\r\n\t}\r\n\r\n\t/// \u003Csummary\u003ELowest per-spark gravity scale for newly emitted sparks. One is the original world gravity.\u003C/summary\u003E\r\n\t[Property, Group( \u0022Sparks\u0022 ), Title( \u0022Gravity Min\u0022 ), Range( 0, 4 )]\r\n\tpublic float SparkGravityMin\r\n\t{\r\n\t\tget =\u003E debris.SparkGravityMin;\r\n\t\tset =\u003E debris.SparkGravityMin = value;\r\n\t}\r\n\r\n\t/// \u003Csummary\u003EHighest per-spark gravity scale for newly emitted sparks. Each spark picks uniformly between min and max.\u003C/summary\u003E\r\n\t[Property, Group( \u0022Sparks\u0022 ), Title( \u0022Gravity Max\u0022 ), Range( 0, 4 )]\r\n\tpublic float SparkGravityMax\r\n\t{\r\n\t\tget =\u003E debris.SparkGravityMax;\r\n\t\tset =\u003E debris.SparkGravityMax = value;\r\n\t}\r\n\r\n\t/// \u003Csummary\u003EShortest lifetime of a newly created shard before it blinks out, whether moving or resting.\u003C/summary\u003E\r\n\t[Property, Group( \u0022Shards\u0022 ), Title( \u0022Lifetime Min (s)\u0022 ), Range( 0, 120 )]\r\n\tpublic float ShardLifetimeMin\r\n\t{\r\n\t\tget =\u003E debris.ShardLifetimeMin;\r\n\t\tset =\u003E debris.ShardLifetimeMin = value;\r\n\t}\r\n\r\n\t/// \u003Csummary\u003ELongest lifetime of a newly created shard before it blinks out. Each shard picks uniformly between min and max.\u003C/summary\u003E\r\n\t[Property, Group( \u0022Shards\u0022 ), Title( \u0022Lifetime Max (s)\u0022 ), Range( 0, 120 )]\r\n\tpublic float ShardLifetimeMax\r\n\t{\r\n\t\tget =\u003E debris.ShardLifetimeMax;\r\n\t\tset =\u003E debris.ShardLifetimeMax = value;\r\n\t}\r\n\r\n\t/// \u003Csummary\u003ELayered music level, on top of the Music mixer and scaled by the player\u0027s Options volumes.\u003C/summary\u003E\r\n\t[Property, Group( \u0022Music\u0022 ), Title( \u0022Volume\u0022 ), Range( 0, 1 )]\r\n\tpublic float MusicVolume { get; set; } = 0.5f;\r\n\r\n\t/// \u003Csummary\u003EForces a music tier (0\u20133) for auditioning. -1 follows pieces placed.\u003C/summary\u003E\r\n\t[Property, Group( \u0022Music\u0022 ), Title( \u0022Tier Override\u0022 ), Range( -1, 3 )]\r\n\tpublic int MusicTierOverride { get; set; } = -1;\r\n\r\n\t/// \u003Csummary\u003ETier the menu music holds at. Each menu visit picks a fresh random motif and clips at this tier.\u003C/summary\u003E\r\n\t[Property, Group( \u0022Music\u0022 ), Title( \u0022Menu Tier\u0022 ), Range( 0, 3 )]\r\n\tpublic int MusicMenuTier { get; set; } = 2;\r\n\r\n\t/// \u003Csummary\u003EPieces placed before the music moves to tier 1.\u003C/summary\u003E\r\n\t[Property, Group( \u0022Music\u0022 ), Title( \u0022Tier 1 At (pieces)\u0022 ), Range( 0, 200 )]\r\n\tpublic int MusicTier1Pieces { get; set; } = 4;\r\n\r\n\t/// \u003Csummary\u003EPieces placed before the music moves to tier 2.\u003C/summary\u003E\r\n\t[Property, Group( \u0022Music\u0022 ), Title( \u0022Tier 2 At (pieces)\u0022 ), Range( 0, 200 )]\r\n\tpublic int MusicTier2Pieces { get; set; } = 12;\r\n\r\n\t/// \u003Csummary\u003EPieces placed before the music moves to tier 3.\u003C/summary\u003E\r\n\t[Property, Group( \u0022Music\u0022 ), Title( \u0022Tier 3 At (pieces)\u0022 ), Range( 0, 200 )]\r\n\tpublic int MusicTier3Pieces { get; set; } = 28;\r\n\r\n\t/// \u003Csummary\u003EChance per 8-bar swap roll that a layer changes to another clip fitting the current tier. Tier changes only take effect through these rolls.\u003C/summary\u003E\r\n\t[Property, Group( \u0022Music\u0022 ), Title( \u0022Swap Chance\u0022 ), Range( 0, 1 )]\r\n\tpublic float MusicSwapChance { get; set; } = 0.25f;\r\n\r\n\t/// \u003Csummary\u003EEach 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.\u003C/summary\u003E\r\n\t[Property, Group( \u0022Music\u0022 ), Title( \u0022Subsequent Swap Multiplier\u0022 ), Range( 0, 1 )]\r\n\tpublic float MusicSwapFalloff { get; set; } = 0.35f;\r\n\r\n\t/// \u003Csummary\u003EEditor only: overlay the live music state (tier, motif, clip per layer, mix levels).\u003C/summary\u003E\r\n\t[Property, Group( \u0022Music\u0022 ), Title( \u0022Show Debug\u0022 )]\r\n\tpublic bool MusicDebug { get; set; }\r\n\r\n\t/// \u003Csummary\u003ELooping 150 BPM clips. Each layer plays one eligible clip at a time; runs pick a motif from the clips\u0027 tags.\u003C/summary\u003E\r\n\t[Property, Group( \u0022Music\u0022 ), Title( \u0022Clips\u0022 )]\r\n\tpublic List\u003CDiamondMusicClip\u003E 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\u003CDiamondPanel\u003E().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\u003CDiamondPanel\u003E().Where( p =\u003E 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\u0027s 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\u0027s 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\u0027t click.\r\n\t\tbool menuCursor = canvas.ModalOpen || escapeMenuOpen || gameStarted \u0026\u0026 board.GameOver;\r\n\t\tInput.EnableVirtualCursor = menuCursor \u0026\u0026 (Input.EnableVirtualCursor || !Input.Down( \u0022SoftDropAlt\u0022 ) \u0026\u0026 !Input.Down( \u0022MenuConfirm\u0022 ));\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( \u0022Restart\u0022 ), 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 \u0026\u0026 ThumbnailOnlyDye;\r\n\t\tcanvas.HideDye = Game.IsEditor \u0026\u0026 ThumbnailHideDye;\r\n\t\tcanvas.DyeVibrancy = Game.IsEditor \u0026\u0026 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\u0027s 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 \u0026\u0026 !canvas.OptionsOpen, escapeMenuOpen, switching );\r\n\t\tcanvas.MusicDebugText = Game.IsEditor \u0026\u0026 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( \u0022MenuUp\u0022 ) || Input.Pressed( \u0022MenuUpAlt\u0022 ) || DiamondGamepad.UpPressed,\r\n\t\t\tInput.Pressed( \u0022MenuDown\u0022 ) || Input.Pressed( \u0022MenuDownAlt\u0022 ) || DiamondGamepad.DownPressed,\r\n\t\t\tInput.Pressed( \u0022MenuConfirm\u0022 ) || Input.Pressed( \u0022MenuConfirmAlt\u0022 ) );\r\n\t\tif ( canvas.ModalOpen ) closeMenuHeld = Input.Down( \u0022MoveRightAlt\u0022 );\r\n\t\telse if ( !Input.Down( \u0022MoveRightAlt\u0022 ) ) 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 \u0026 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( \u0022CloseMenu\u0022 ) );\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( \u0022CloseMenu\u0022 ) );\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( \u0022CloseMenu\u0022 ) );\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 \u0026 ~carriedPad;\r\n\t\tbool rotate = Input.Down( \u0022Rotate\u0022 ) || Input.Down( \u0022RotateAlt\u0022 ) || playPad.HasFlag( PadInput.Up );\r\n\t\tint direction = (Input.Down( \u0022MoveRight\u0022 ) || Input.Down( \u0022MoveRightAlt\u0022 ) \u0026\u0026 !closeMenuHeld || playPad.HasFlag( PadInput.Right ) ? 1 : 0)\r\n\t\t\t- (Input.Down( \u0022MoveLeft\u0022 ) || Input.Down( \u0022MoveLeftAlt\u0022 ) || playPad.HasFlag( PadInput.Left ) ? 1 : 0);\r\n\t\tbool softDrop = Input.Down( \u0022SoftDrop\u0022 ) || 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 \u003E 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 \u0026\u0026 !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() =\u003E\r\n\t\t(Input.Down( \u0022SoftDropAlt\u0022 ) ? 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/// \u003Csummary\u003ESubmit 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.\u003C/summary\u003E\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( $\u0022Diamonds: skipped leaderboard submission for {debugGame}.\u0022 );\r\n\t\telse DiamondLeaderboard.Submit( board.Score, (long)board.ElapsedSeconds );\r\n\t\tBankAchievementProgress();\r\n\t}\r\n\r\n\t/// \u003Csummary\u003EWhy 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.\u003C/summary\u003E\r\n\tstring DebugGameReason()\r\n\t{\r\n#if STANDALONE\r\n#endif\r\n\t\tif ( debugFilledResetVersion == board.ResetVersion ) return \u0022a diamonds_fill debug game\u0022;\r\n\t\tif ( board.StartingScore \u003E 0 ) return \u0022a game started at a debug Starting Score\u0022;\r\n\t\treturn null;\r\n\t}\r\n\r\n\t/// \u003Csummary\u003EAdd the running game\u0027s progress since the last bank to the lifetime achievement stats: at game over, every\r\n\t/// \u003Csee cref=\u0022AchievementBankInterval\u0022/\u003E 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.\u003C/summary\u003E\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/// \u003Csummary\u003EEditor only: start a new game over a stack filled to near the top, for testing game over.\u003C/summary\u003E\r\n\t[ConCmd( \u0022diamonds_fill\u0022 )]\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( \u0022diamonds_fill only works in the editor.\u0022 );\r\n\t\t\treturn;\r\n\t\t}\r\n\t\tvar game = Game.ActiveScene?.GetAllComponents\u003CDiamondGame\u003E().FirstOrDefault();\r\n\t\tif ( game?.canvas is null )\r\n\t\t{\r\n\t\t\tLog.Warning( \u0022diamonds_fill: no running Diamonds game.\u0022 );\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\u0027s 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( $\u0022diamonds_fill: {game.board.Settled.Count} gems, score {game.board.Score:N0}.\u0022 );\r\n\t\tgame.debugFilledResetVersion = game.board.ResetVersion;\r\n\t}\r\n\r\n\t/// \u003Csummary\u003EAbandon the current game and return to the title screen.\u003C/summary\u003E\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 ) =\u003E new( LetterboxCenter( size, true ) - 136, Top \u002B 876, 272, 64 );\r\n\r\n\t/// \u003Csummary\u003EThe letterbox Leave button fades straight back to the title; its flashing red hover is the warning.\u003C/summary\u003E\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 \u003C= 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 ), \u0022LEAVE\u0022, 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/// \u003Csummary\u003EAny overlay owns input and pauses gameplay and title animation behind it.\u003C/summary\u003E\r\n\tpublic bool ModalOpen =\u003E 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 ) =\u003E new( LetterboxCenter( size, true ) - 136, Top \u002B 796, 272, 64 );\r\n\t// Share the tutorial\u0027s 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 =\u003E new( 32, DisableFluidY - 34, OptionsWidth - 64, 68 );\r\n\tstatic Rect DisableFluidTrack =\u003E new( SliderRight - 28, DisableFluidY - 7, 112, 14 );\r\n\tstatic float SliderY( OptionsSlider id ) =\u003E id switch\r\n\t{\r\n\t\tOptionsSlider.RestartDelay =\u003E RestartDelayY, OptionsSlider.Rumble =\u003E RumbleY, OptionsSlider.Master =\u003E MasterY, _ =\u003E 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 \u002B 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 \u0026\u0026 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 \u002B delta * 2.4f) % Palette.Length;\r\n\t\tfloat target = DiamondSettings.Current.DisableFluid ? 1 : 0;\r\n\t\tdisableFluidKnob = target \u003E disableFluidKnob ? MathF.Min( target, disableFluidKnob \u002B 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/// \u003Csummary\u003EOpens Options from the letterbox button, or handles a press inside the open overlay.\u003C/summary\u003E\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 \u003C= 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/// \u003Csummary\u003EFinish a slider drag, saving it and previewing the new effects or rumble level.\u003C/summary\u003E\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( \u0022sfx/diamond_impact.sound\u0022 );\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 != \u0022mouseleft\u0022 ) return;\r\n\t\tEndOptionsDrag();\r\n\t\tleaderboardDragging = false;\r\n\t}\r\n\r\n\tstatic Vector2 OptionsLocal( Vector2 mouse, Rect frame ) =\u003E (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 ), \u0022OPTIONS\u0022, 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( \u0022#0d151e\u0022 ).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 \u003C= 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, \u0022OPTIONS\u0022, new Vector2( OptionsWidth * 0.5f, 60 ), 52, ScoreColor( optionsColorPhase ) );\r\n\t\tDrawDisableFluidToggle( p, mouse );\r\n\t\tDrawOptionsSlider( p, \u0022RESTART DELAY\u0022, settings.RestartDelay, DiamondSettings.FormatSeconds( settings.RestartDelay ),\r\n\t\t\tRestartDelayColor, OptionsSlider.RestartDelay, mouse );\r\n\t\tDrawOptionsSlider( p, \u0022CONTROLLER RUMBLE\u0022, settings.ControllerRumble, $\u0022{MathF.Round( settings.ControllerRumble * 100 )}\u0022,\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, \u0022VOLUME\u0022, new Vector2( OptionsLabelX, VolumeHeadingY ), 34, Muted * 0.8f, 0 );\r\n\t\tDrawOptionsSlider( p, \u0022MASTER\u0022, settings.MasterVolume, $\u0022{MathF.Round( settings.MasterVolume * 100 )}\u0022, MasterColor, OptionsSlider.Master, mouse );\r\n\t\tDrawOptionsSlider( p, \u0022MUSIC\u0022, settings.MusicVolume, $\u0022{MathF.Round( settings.MusicVolume * 100 )}\u0022, 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 \u0026\u0026 DisableFluidRow.IsInside( mouse );\r\n\t\tbool pressed = hover \u0026\u0026 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, \u0022DISABLE FLUID\u0022, 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 \u002B 17 \u002B (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 \u0026\u0026 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 \u003C 4; i\u002B\u002B )\r\n\t\t{\r\n\t\t\tvar notch = new Vector2( track.Left \u002B track.Width * i / 4, y );\r\n\t\t\tp.Fill = Ink.WithAlpha( 0.14f );\r\n\t\t\tp.Polygon( [notch \u002B new Vector2( 0, -7 ), notch \u002B new Vector2( 4, 0 ), notch \u002B new Vector2( 0, 7 ), notch \u002B new Vector2( -4, 0 )] );\r\n\t\t}\r\n\t\tfloat x = track.Left \u002B 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( \u0027s\u0027 ) ) 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/// \u003Csummary\u003EScore-font digits followed by a smaller score-font S, sharing their baseline so it reads as a unit, not a 5.\u003C/summary\u003E\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( \u0022S\u0022 ).x * unitSize / DiamondScoreEffects.PickupFontSize;\r\n\t\tfloat left = center.x - (numberWidth \u002B gap \u002B 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, \u0022S\u0022, new Vector2( left \u002B numberWidth \u002B gap, center.y \u002B (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 \u003C= track.Left || opacity \u003C= 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/// \u003Csummary\u003EA small in-game gem with its bonded outline; \u003Cparamref name=\u0022lit\u0022/\u003E fades it in from dull steel.\u003C/summary\u003E\r\n\tvoid DrawOptionsHandle( Painter p, Vector2 center, int color, float scale, float lit )\r\n\t{\r\n\t\tif ( lit \u003C 1 ) DrawSteelHandle( p, center, scale, 1 - lit );\r\n\t\tif ( lit \u003C= 0 ) return;\r\n\t\tif ( lit \u003E= 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/// \u003Csummary\u003E\n/// Backend achievements, following Block Party\u0027s scheme. Each one is defined on the s\u0026amp;box website (ident, title, icon,\n/// unlock mode); the game only feeds stats or names manual unlocks, and the website\u0027s 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\u0027s 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\u0027s, 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/// \u003Clist type=\u0022table\u0022\u003E\n/// \u003Citem\u003E\u003Cterm\u003Esurvive_10_minutes\u003C/term\u003E\u003Cdescription\u003EMANUAL \u2014 \u003Csee cref=\u0022Survive10Minutes\u0022/\u003E\u003C/description\u003E\u003C/item\u003E\n/// \u003Citem\u003E\u003Cterm\u003Esurvive_12_minutes\u003C/term\u003E\u003Cdescription\u003EMANUAL \u2014 \u003Csee cref=\u0022Survive12Minutes\u0022/\u003E\u003C/description\u003E\u003C/item\u003E\n/// \u003Citem\u003E\u003Cterm\u003Esurvive_15_minutes\u003C/term\u003E\u003Cdescription\u003EMANUAL \u2014 \u003Csee cref=\u0022Survive15Minutes\u0022/\u003E\u003C/description\u003E\u003C/item\u003E\n/// \u003Citem\u003E\u003Cterm\u003Edamage_50_diamond_piece\u003C/term\u003E\u003Cdescription\u003EMANUAL \u2014 \u003Csee cref=\u0022DamageHugePiece\u0022/\u003E\u003C/description\u003E\u003C/item\u003E\n/// \u003Citem\u003E\u003Cterm\u003Escore_20000\u003C/term\u003E\u003Cdescription\u003EMANUAL \u2014 \u003Csee cref=\u0022Score20000\u0022/\u003E\u003C/description\u003E\u003C/item\u003E\n/// \u003Citem\u003E\u003Cterm\u003Escore_50000\u003C/term\u003E\u003Cdescription\u003EMANUAL \u2014 \u003Csee cref=\u0022Score50000\u0022/\u003E\u003C/description\u003E\u003C/item\u003E\n/// \u003Citem\u003E\u003Cterm\u003Escore_100000\u003C/term\u003E\u003Cdescription\u003EMANUAL \u2014 \u003Csee cref=\u0022Score100000\u0022/\u003E\u003C/description\u003E\u003C/item\u003E\n/// \u003Citem\u003E\u003Cterm\u003Escore_10000_in_1_minute\u003C/term\u003E\u003Cdescription\u003EMANUAL \u2014 \u003Csee cref=\u0022QuickScore\u0022/\u003E\u003C/description\u003E\u003C/item\u003E\n/// \u003Citem\u003E\u003Cterm\u003Ebreak_3_colors_in_one_turn\u003C/term\u003E\u003Cdescription\u003EMANUAL \u2014 \u003Csee cref=\u0022BreakThreeColors\u0022/\u003E\u003C/description\u003E\u003C/item\u003E\n/// \u003Citem\u003E\u003Cterm\u003Elose_with_no_group_over_3\u003C/term\u003E\u003Cdescription\u003EMANUAL \u2014 \u003Csee cref=\u0022LoseWithSmallGroups\u0022/\u003E\u003C/description\u003E\u003C/item\u003E\n/// \u003Citem\u003E\u003Cterm\u003Ebreak_10000_diamonds\u003C/term\u003E\u003Cdescription\u003ESTAT \u2014 \u003Csee cref=\u0022BrokenStat\u0022/\u003E, HIGHEST, max 10000\u003C/description\u003E\u003C/item\u003E\n/// \u003Citem\u003E\u003Cterm\u003Ecrush_100000_shards\u003C/term\u003E\u003Cdescription\u003ESTAT \u2014 \u003Csee cref=\u0022CrushedStat\u0022/\u003E, HIGHEST, max 100000\u003C/description\u003E\u003C/item\u003E\n/// \u003Citem\u003E\u003Cterm\u003Escore_1000000_points\u003C/term\u003E\u003Cdescription\u003ESTAT \u2014 \u003Csee cref=\u0022PointsStat\u0022/\u003E, HIGHEST, max 1000000\u003C/description\u003E\u003C/item\u003E\n/// \u003C/list\u003E\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/// \u003C/summary\u003E\npublic static class DiamondAchievements\n{\n\tpublic const string FilePath = \u0022diamonds_achievements.json\u0022;\n\n\t/// \u003Csummary\u003ELifetime diamonds shattered by play (game-over cleanup excluded).\u003C/summary\u003E\n\tpublic const string BrokenStat = \u0022diamonds_broken\u0022;\n\t/// \u003Csummary\u003ELifetime shards crushed for points.\u003C/summary\u003E\n\tpublic const string CrushedStat = \u0022shards_crushed\u0022;\n\t/// \u003Csummary\u003ELifetime points scored, summed across games.\u003C/summary\u003E\n\tpublic const string PointsStat = \u0022points_scored\u0022;\n\n\tpublic const string Survive10Minutes = \u0022survive_10_minutes\u0022;\n\tpublic const string Survive12Minutes = \u0022survive_12_minutes\u0022;\n\tpublic const string Survive15Minutes = \u0022survive_15_minutes\u0022;\n\t/// \u003Csummary\u003EDamage an established same-color bonded group of at least \u003Csee cref=\u0022HugePieceSize\u0022/\u003E diamonds.\u003C/summary\u003E\n\tpublic const string DamageHugePiece = \u0022damage_50_diamond_piece\u0022;\n\tpublic const int HugePieceSize = 50;\n\n\tpublic const string Score20000 = \u0022score_20000\u0022;\n\tpublic const string Score50000 = \u0022score_50000\u0022;\n\tpublic const string Score100000 = \u0022score_100000\u0022;\n\t/// \u003Csummary\u003EReach \u003Csee cref=\u0022QuickScoreTarget\u0022/\u003E within the first \u003Csee cref=\u0022DiamondBoard.FirstMinuteSeconds\u0022/\u003E of unpaused play.\u003C/summary\u003E\n\tpublic const string QuickScore = \u0022score_10000_in_1_minute\u0022;\n\tpublic const long QuickScoreTarget = 10_000;\n\t/// \u003Csummary\u003EShatter diamonds of \u003Csee cref=\u0022BreakColorsTarget\u0022/\u003E different colors within one turn (a spawn until the next).\u003C/summary\u003E\n\tpublic const string BreakThreeColors = \u0022break_3_colors_in_one_turn\u0022;\n\tpublic const int BreakColorsTarget = 3;\n\t/// \u003Csummary\u003EReach game over with no bonded same-color group in the arena larger than \u003Csee cref=\u0022SmallGroupLimit\u0022/\u003E.\n\t/// Corner-only contacts never bond, so they never join a group.\u003C/summary\u003E\n\tpublic const string LoseWithSmallGroups = \u0022lose_with_no_group_over_3\u0022;\n\tpublic const int SmallGroupLimit = 3;\n\n\t/// \u003Csummary\u003EUnpaused game time, as the timer shows it, that unlocks each survival achievement.\u003C/summary\u003E\n\tpublic static readonly (string Ident, float Minutes)[] Survival =\n\t\t[(Survive10Minutes, 10), (Survive12Minutes, 12), (Survive15Minutes, 15)];\n\n\t/// \u003Csummary\u003EThe running game\u0027s score that unlocks each score achievement, the moment the score passes it.\u003C/summary\u003E\n\tpublic static readonly (string Ident, long Score)[] ScoreTargets =\n\t\t[(Score20000, 20_000), (Score50000, 50_000), (Score100000, 100_000)];\n\n\t/// \u003Csummary\u003EThe durable local copy of lifetime tallies and earned manual idents, re-sent every boot.\u003C/summary\u003E\n\tpublic sealed class SavedProgress\n\t{\n\t\tpublic Dictionary\u003Cstring, long\u003E Tallies { get; set; } = new();\n\t\tpublic List\u003Cstring\u003E Earned { get; set; } = new();\n\t}\n\n\tstatic SavedProgress progress;\n\tstatic SavedProgress Progress =\u003E progress ??= Load();\n\t// Manual idents requested this session, so a per-frame check sends each at most once.\n\tstatic readonly HashSet\u003Cstring\u003E requested = new();\n\t// Highest value submitted per stat this session; totals only climb, so lower re-sends are dropped.\n\tstatic readonly Dictionary\u003Cstring, long\u003E sent = new();\n\tstatic bool resent;\n\n\tstatic readonly string[] LifetimeStats = [BrokenStat, CrushedStat, PointsStat];\n\n\t/// \u003Csummary\u003EOnce per session: re-send every earned manual ident and lifetime tally, healing submissions lost offline.\u003C/summary\u003E\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\u0027s. Adopt the backend\u0027s 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\u003Cstring, long\u003E( 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 \u003C= 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 \u002B 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( $\u0022Diamonds: could not read achievement stats; re-sending local totals. {e.Message}\u0022 ); }\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/// \u003Csummary\u003EUnlock whichever manual achievements the running game has earned. Called every frame of a counted game.\u003C/summary\u003E\n\tpublic static void CheckGame( DiamondBoard board )\n\t{\n\t\tforeach ( var (ident, minutes) in Survival )\n\t\t\tif ( board.ElapsedSeconds \u003E= minutes * 60 ) Unlock( ident );\n\t\tif ( board.LargestDamagedGroup \u003E= 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 \u003E= score ) Unlock( ident );\n\t\tif ( board.FirstMinuteScore \u003E= QuickScoreTarget ) Unlock( QuickScore );\n\t\tif ( board.MostColorsBrokenInTurn \u003E= BreakColorsTarget ) Unlock( BreakThreeColors );\n\t\tif ( board.GameOver \u0026\u0026 board.LargestGroupAtGameOver \u003C= SmallGroupLimit ) Unlock( LoseWithSmallGroups );\n\t}\n\n\t/// \u003Csummary\u003EAdd a counted game\u0027s 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.\u003C/summary\u003E\n\tpublic static void Bank( long broken, long crushed, long points )\n\t{\n\t\tif ( broken \u003C= 0 \u0026\u0026 crushed \u003C= 0 \u0026\u0026 points \u003C= 0 ) return;\n\t\tif ( Game.IsEditor )\n\t\t{\n\t\t\tLog.Info( $\u0022Diamonds: would add {broken:N0} broken, {crushed:N0} crushed, {points:N0} points to achievement stats (editor: no backend achievements).\u0022 );\n\t\t\treturn;\n\t\t}\n\t\tvar tallies = Progress.Tallies;\n\t\tvoid Add( string stat, long amount ) { if ( amount \u003E 0 ) tallies[stat] = tallies.GetValueOrDefault( stat ) \u002B 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/// \u003Csummary\u003EUnlock a manual achievement. Safe to call repeatedly; only the first call per session does anything.\u003C/summary\u003E\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( $\u0022Diamonds: achievement \u0027{ident}\u0027 would unlock (editor: no backend achievements).\u0022 );\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 \u003C= 0 || (sent.TryGetValue( stat, out long previous ) \u0026\u0026 value \u003C= 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( $\u0022Could not read {FilePath}; starting achievement progress fresh. {e.Message}\u0022 );\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( $\u0022Could not save {FilePath}. {e.Message}\u0022 ); }\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/// \u003Csummary\u003EA small, damped velocity and five-dye field behind the playfield.\u003C/summary\u003E\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 =\u003E !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 =\u003E opacity ?? 1f; set =\u003E opacity = Math.Clamp( value, 0, 1 ); }\r\n\tfloat? spawnDyeAmount, shatterDyeAmount, dyeFadeRate;\r\n\tpublic float SpawnDyeAmount { get =\u003E spawnDyeAmount ?? 7f; set =\u003E spawnDyeAmount = MathF.Max( 0, value ); }\r\n\tpublic float ShatterDyeAmount { get =\u003E shatterDyeAmount ?? 2.5f; set =\u003E shatterDyeAmount = MathF.Max( 0, value ); }\r\n\tpublic float DyeFadeRate { get =\u003E dyeFadeRate ?? 0.3f; set =\u003E 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\u003CDiamondBoard.Diamond\u003E 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\u003Cfloat\u003E Samples =\u003E DisableFluid \u0026\u0026 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 \u002B x) * DiamondBoard.ColorCount \u002B color;\r\n\t\treturn index \u003C 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 \u002B 0.5f - Columns * 0.5f) * CellSize;\r\n\t\tfloat v = (y \u002B 0.5f - Rows * 0.5f) * CellSize;\r\n\t\treturn new Vector2( DiamondBoard.BoardWidth * 0.5f \u002B u,\r\n\t\t\tDiamondBoard.BoardHeight * 0.5f \u002B 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 \u002B Columns * 0.5f - 0.5f,\r\n\t\t\tdy / CellSize \u002B Rows * 0.5f - 0.5f);\r\n\t}\r\n\r\n\tstatic float ReflectX( float x ) =\u003E x \u003C 0 ? -x : x \u003E DiamondBoard.BoardWidth ? 2 * DiamondBoard.BoardWidth - x : x;\r\n\tstatic float ReflectBottom( float y ) =\u003E y \u003E 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\u002B\u002B; 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\u002B\u002B;\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() =\u003E (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\u0027s 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 \u003C Rows; y\u002B\u002B )\r\n\t\tfor ( int x = 0; x \u003C Columns; x\u002B\u002B )\r\n\t\t{\r\n\t\t\tfloat u = (x \u002B 0.5f) / Columns - 0.5f;\r\n\t\t\tfloat v = (y \u002B 0.5f) / Rows - 0.5f;\r\n\t\t\tfloat fold = 0.5f \u002B 0.5f * MathF.Sin( u * 9 \u002B foldPhase \u002B MathF.Sin( v * 6 \u002B foldBend ) * 1.3f );\r\n\t\t\tfloat curl = 0.5f \u002B 0.5f * MathF.Sin( v * 10 - u * 4.5f \u002B curlPhase \u002B MathF.Cos( u * 5 \u002B curlBend ) * 0.9f );\r\n\t\t\tfloat cloud = Math.Clamp( (fold * 0.6f \u002B 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 \u002B v * 1.8f \u002B swirlPhase ) * 1.7f \u002B\r\n\t\t\t\tMathF.Cos( v * 6.8f - u * 2.5f \u002B swirlBend ) * 1.4f \u002B u * 3.1f;\r\n\t\t\tfor ( int color = 0; color \u003C DiamondBoard.ColorCount; color\u002B\u002B )\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 \u002B colorPhase ) );\r\n\t\t\t\tint index = (y * Columns \u002B x) * DiamondBoard.ColorCount \u002B color;\r\n\t\t\t\tdye[index] = cloud * (0.012f \u002B 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 \u003C= 0 ) return;\r\n\t\tshatterPushCooldown = MathF.Max( 0, shatterPushCooldown - delta );\r\n\t\tif ( !board.GameOver \u0026\u0026 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 \u003C board.ShatterBursts.Count; i\u002B\u002B )\r\n\t\t{\r\n\t\t\tvar burst = board.ShatterBursts[i];\r\n\t\t\tif ( burst.Id \u003C= 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 \u002B delta, 0.12f );\r\n\t\twhile ( accumulator \u003E= 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]\u002B\u002B;\r\n\t\t\tcenterX \u002B= DiamondBoard.LaneX( cell.Lane );\r\n\t\t\tcount\u002B\u002B;\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 \u002B 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 \u003C counts.Length; color\u002B\u002B )\r\n\t\tfor ( int n = 0; n \u003C counts[color]; n\u002B\u002B ) slotColors[slotIndex\u002B\u002B] = color;\r\n\t\tfloat[] weightSums = new float[count];\r\n\t\tfor ( int y = 1; y \u003C Rows - 1; y\u002B\u002B )\r\n\t\tfor ( int x = 1; x \u003C Columns - 1; x\u002B\u002B )\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 \u003E 0 ) weightSums[Slot( point.x )] \u002B= 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 \u003C Rows - 1; y\u002B\u002B )\r\n\t\tfor ( int x = 1; x \u003C Columns - 1; x\u002B\u002B )\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 \u003C= 0 ) continue;\r\n\t\t\tint slot = Slot( point.x );\r\n\t\t\tif ( weightSums[slot] \u003E 0 )\r\n\t\t\t{\r\n\t\t\t\tint index = (y * Columns \u002B x) * DiamondBoard.ColorCount \u002B slotColors[slot];\r\n\t\t\t\tfloat before = dye[index];\r\n\t\t\t\tdye[index] \u002B= 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\u002B\u002B;\r\n\t\tfor ( int slot = 0; slot \u003C count; slot\u002B\u002B )\r\n\t\t\tSplat( left \u002B SpawnBandWidth * (slot \u002B 0.5f) / count, SpawnBandY, 0, 6f / count, true );\r\n\r\n\t\tint Slot( float x ) =\u003E 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 \u003C= left || point.x \u003E= right || MathF.Abs( point.y - SpawnBandY ) \u003E 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 \u002B 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 \u002B reach ) );\r\n\t\tfloat weightSum = 0;\r\n\t\tfor ( int row = top; row \u003C= bottom; row\u002B\u002B )\r\n\t\tfor ( int column = left; column \u003C= right; column\u002B\u002B )\r\n\t\t\tweightSum \u002B= Weight( GridToWorld( column, row ) );\r\n\t\tif ( weightSum \u003C= 0 ) return;\r\n\t\tbool changed = false;\r\n\t\tfor ( int row = top; row \u003C= bottom; row\u002B\u002B )\r\n\t\tfor ( int column = left; column \u003C= right; column\u002B\u002B )\r\n\t\t{\r\n\t\t\tfloat weight = Weight( GridToWorld( column, row ) );\r\n\t\t\tif ( weight \u003E 0 )\r\n\t\t\t{\r\n\t\t\t\tint index = (row * Columns \u002B column) * DiamondBoard.ColorCount \u002B cell.ColorIndex;\r\n\t\t\t\tfloat before = dye[index];\r\n\t\t\t\tdye[index] \u002B= 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\u002B\u002B;\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 \u002B dy * dy;\r\n\t\t\treturn distanceSquared \u003C 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 ) \u002B 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 ) \u002B reach );\r\n\t\tfor ( int j = top; j \u003C= bottom; j\u002B\u002B )\r\n\t\tfor ( int i = left; i \u003C= right; i\u002B\u002B )\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 \u002B oy * oy) * (spread ? 0.14f : 0.275f) );\r\n\t\t\tint n = j * Columns \u002B i;\r\n\t\t\tvelocityX[n] \u002B= vx * weight;\r\n\t\t\tvelocityY[n] \u002B= 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 \u002B i) * stride \u002B channel;\r\n\t\treturn (field[n] * (1 - fx) \u002B field[n \u002B stride] * fx) * (1 - fy) \u002B\r\n\t\t\t(field[n \u002B Columns * stride] * (1 - fx) \u002B field[n \u002B (Columns \u002B 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 \u003C Rows - 1; y\u002B\u002B )\r\n\t\tfor ( int x = 1; x \u003C Columns - 1; x\u002B\u002B )\r\n\t\t{\r\n\t\t\tint n = y * Columns \u002B x;\r\n\t\t\tif ( wallDistance[n] \u003C 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 \u002B\r\n\t\t\t\t(velocityX[n - 1] \u002B velocityX[n \u002B 1] \u002B velocityX[n - Columns] \u002B velocityX[n \u002B Columns]) * 0.0035f) * 0.992f;\r\n\t\t\tnextY[n] = (Bilinear( velocityY, bx, by ) * 0.985f \u002B\r\n\t\t\t\t(velocityY[n - 1] \u002B velocityY[n \u002B 1] \u002B velocityY[n - Columns] \u002B velocityY[n \u002B Columns]) * 0.0035f) * 0.992f;\r\n\t\t}\r\n\t\tfor ( int y = 1; y \u003C Rows - 1; y\u002B\u002B )\r\n\t\tfor ( int x = 1; x \u003C Columns - 1; x\u002B\u002B )\r\n\t\t{\r\n\t\t\tint n = y * Columns \u002B x;\r\n\t\t\tdivergence[n] = wallDistance[n] \u003C 0 ? 0 : -0.5f * (nextX[n \u002B 1] - nextX[n - 1] \u002B nextY[n \u002B Columns] - nextY[n - Columns]);\r\n\t\t\tpressure[n] = 0;\r\n\t\t}\r\n\t\tfor ( int iteration = 0; iteration \u003C 10; iteration\u002B\u002B )\r\n\t\t{\r\n\t\t\tfor ( int y = 1; y \u003C Rows - 1; y\u002B\u002B )\r\n\t\t\tfor ( int x = 1; x \u003C Columns - 1; x\u002B\u002B )\r\n\t\t\t{\r\n\t\t\t\tint n = y * Columns \u002B x;\r\n\t\t\t\tnextPressure[n] = wallDistance[n] \u003C 0 ? 0 : (divergence[n] \u002B NeighborPressure( n - 1, n ) \u002B\r\n\t\t\t\t\tNeighborPressure( n \u002B 1, n ) \u002B NeighborPressure( n - Columns, n ) \u002B NeighborPressure( n \u002B 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 \u003C Rows - 1; y\u002B\u002B )\r\n\t\tfor ( int x = 1; x \u003C Columns - 1; x\u002B\u002B )\r\n\t\t{\r\n\t\t\tint n = y * Columns \u002B x;\r\n\t\t\tif ( wallDistance[n] \u003C 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 \u002B 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 \u002B Columns, n ) - NeighborPressure( n - Columns, n )) - 0.0011313708f * local;\r\n\t\t\tif ( wallDistance[n] \u003C CellSize )\r\n\t\t\t{\r\n\t\t\t\tfloat incoming = (vx - wallVelocity[n].x) * wallNormal[n].x \u002B (vy - wallVelocity[n].y) * wallNormal[n].y;\r\n\t\t\t\tif ( incoming \u003C 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 \u002B vy * vy );\r\n\t\t\tfloat limit = speed \u003E 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 \u003C= FlowUnit \u0026\u0026 worldX \u003C 0) ||\r\n\t\t\t\t(point.x \u003E= DiamondBoard.BoardWidth - FlowUnit \u0026\u0026 worldX \u003E 0) ) worldX *= -WallBounce;\r\n\t\t\tif ( point.y \u003E= DiamondBoard.BoardHeight - FlowUnit \u0026\u0026 worldY \u003E 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\u003CCrushFace\u003E crushFaces = new();\r\n\treadonly record struct CrushDiamond( Vector2 Center, Vector2 Velocity );\r\n\treadonly List\u003CCrushDiamond\u003E 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 \u003E 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 \u003E= 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, \u002B\u002Bowner );\r\n\t\t\tfor ( int edge = 0; edge \u003C Valleys * 2; edge\u002B\u002B )\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 \u002B 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 \u003C 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 \u002B (left ? 1 : -1) * Width * 0.5f, tooth.Y );\r\n\t\t\t\tvar bottom = new Vector2( x, tooth.Y \u002B 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\u003CVector2\u003E polygon = stackalloc Vector2[8];\r\n\t\tfor ( int i = particles.Count - 1; i \u003E= 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 \u002B= 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 \u0026\u0026 coveredArea \u003E 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\u002B\u002B;\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\u0027s 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) \u002B 4 / (Height * Height) );\r\n\t\treturn penetration \u003E 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 \u002B (diamond.Velocity - crushVelocity).Length * crushDelta;\r\n\t\tif ( MathF.Abs( difference.x ) \u003E Width * 0.5f \u002B reach ||\r\n\t\t\tMathF.Abs( difference.y ) \u003E Height * 0.5f \u002B reach ) return;\r\n\t\tvar velocity = diamond.Velocity;\r\n\t\tvar top = center \u002B new Vector2( 0, -Height * 0.5f );\r\n\t\tvar right = center \u002B new Vector2( Width * 0.5f, 0 );\r\n\t\tvar bottom = center \u002B new Vector2( 0, Height * 0.5f );\r\n\t\tvar left = center \u002B 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\u0027t presses.\r\n\t\tif ( along \u003C 0 || along \u003E 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 \u002B (velocity - crushVelocity).Length * crushDelta;\r\n\t\tif ( distance \u003C -chip.Radius || distance \u003E 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 \u003C crushFaces.Count; a\u002B\u002B )\r\n\t\tfor ( int b = a \u002B 1; b \u003C crushFaces.Count; b\u002B\u002B )\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 ) \u003E -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 \u002B second.Distance;\r\n\t\t\tfloat closing = Vector2.Dot( first.Velocity - second.Velocity, first.Normal );\r\n\t\t\t// Test the substep\u0027s approaching surfaces before Box2D ejects a trapped\r\n\t\t\t// chip. Small solver overlaps are harmless; require \u003E55% compression.\r\n\t\t\tif ( closing \u003E 1 \u0026\u0026 gap - closing * delta \u003C thickness * 0.45f ) return true;\r\n\t\t}\r\n\t\treturn false;\r\n\t}\r\n}\r\n"}]}