DiamondPanel.cs
using System;
using System.Collections.Generic;
using Sandbox;
using Sandbox.UI;
namespace Diamonds;
/// <summary>Every visible element is drawn with the new Painter API; no sprite assets.</summary>
public sealed partial class DiamondPanel : Panel
{
public DiamondBoard Board { get; set; }
public DiamondDebris Debris { get; set; }
public DiamondAtmosphere Atmosphere { get; set; }
public DiamondScoreEffects ScoreEffects { get; set; }
public Vector2 ShakeOffset { get; set; }
public Vector2 PieceShakeOffset { get; set; }
public bool IsMuted { get; set; }
/// <summary>Editor-only music state, drawn in the bottom-left screen corner when set.</summary>
public string MusicDebugText { get; set; }
public bool ShowLandingIndicator { get; set; } = false;
/// <summary>Multiplies the landing point marker's original size.</summary>
public float LandingPointScale { get; set; } = 1;
/// <summary>Thumbnail capture: draw only the background dye, nothing else.</summary>
public bool OnlyDye { get; set; }
/// <summary>Thumbnail capture: skip every dye layer, including the static and menu dye.</summary>
public bool HideDye { get; set; }
/// <summary>Thumbnail capture: blends every dye layer from its normal faint wash (0) to opaque, fully saturated color (1).</summary>
public float DyeVibrancy { get; set; }
float DyeLayerOpacity( float opacity )
{
float vibrancy = Math.Clamp( DyeVibrancy, 0, 1 );
return 0.22f * opacity + (1 - 0.22f * opacity) * vibrancy;
}
sealed class LandingPointGlow
{
public DiamondDamage.Contact Contact;
public float Opacity;
public float Rotation;
public bool Visible;
// Seconds since appearing, driving the uneven per-ray pulse; Appear eases the pop-in scale.
public float Age;
public float Appear;
public float PulsePhase;
}
readonly List<LandingPointGlow> landingPointGlows = new();
readonly List<DiamondDamage.Contact> landingPointContacts = new( 4 );
Rect[] atmosphereTiles;
int atmosphereTileVersion;
Texture atmosphereTexture;
Color32[] atmospherePixels;
DyeTextureStamp atmosphereTextureStamp;
struct DyeTextureStamp
{
public object Source;
public ulong Version;
public Color[] Palette;
public Color Background;
public float Vibrancy;
}
// Only the editor comparison tools enable the retained rectangle reference.
bool atmosphereReferenceTiles = false;
// Editor GPU checks compare this against full resolution on an identical frame.
float dyeLayerResolution = 0.5f;
public override void OnDeleted()
{
ReleaseGemAtlas();
ReleaseSparkAtlas();
ReleaseWallMasks();
atmosphereTexture?.Dispose();
atmosphereTexture = null;
titleDyeTexture?.Dispose();
titleDyeTexture = null;
base.OnDeleted();
}
readonly Random glowRandom = new();
DiamondBoard glowBoard;
int glowResetVersion;
float boundaryTime;
DiamondBoard previewBoard;
int previewResetVersion, previewSpawnVersion;
float previewBounceTime = 0.45f;
public void UpdateNextPreview( float delta )
{
if ( Board is null ) return;
if ( previewBoard != Board || previewResetVersion != Board.ResetVersion || previewSpawnVersion != Board.SpawnVersion )
{
previewBoard = Board;
previewResetVersion = Board.ResetVersion;
previewSpawnVersion = Board.SpawnVersion;
previewBounceTime = 0;
}
else if ( !Board.Paused ) previewBounceTime = MathF.Min( 0.45f, previewBounceTime + MathF.Max( 0, delta ) );
}
float NextPreviewScale
{
get
{
float t = previewBounceTime / 0.45f;
return 1 + 0.09f * MathF.Sin( t * MathF.PI * 2 ) * (1 - t) * (1 - t);
}
}
static readonly Color Ink = Color.Parse( "#e4eeef" ).Value;
static readonly Color Muted = Color.Parse( "#82959d" ).Value;
static readonly Color TimerInk = Color.Parse( "#50636d" ).Value;
static void DrawTimer( Painter p, string value, Vector2 center ) =>
DrawTimeNumber( p, value, center, 32, TimerInk.WithAlpha( 0.42f ), outline: false );
[SkipHotload] static readonly Color ToothFill = Color.Parse( "#1c2933" ).Value;
[SkipHotload] static readonly Color GameOverToothFill = Color.Parse( "#451e25" ).Value;
[SkipHotload]
static readonly Color FloorInk = Color.Parse( "#70818c" ).Value;
[SkipHotload]
static readonly Color GameOverSpikeInk = Color.Parse( "#e34343" ).Value;
[SkipHotload]
static readonly Color GameOverFlashRed = Color.Parse( "#ef3238" ).Value;
[SkipHotload]
static readonly Color GameOverFlashYellow = Color.Parse( "#ffe04a" ).Value;
Color SpikeInk => Board.GameOver ? GameOverSpikeInk : FloorInk;
// Saturated per-diamond colors, shared by the active piece, ghost and next preview.
// These are presentation constants: refresh their initializers when hotloading.
[SkipHotload]
static readonly Color[] Palette = [Color.Parse( "#f23d3d" ).Value, Color.Parse( "#2298f0" ).Value,
Color.Parse( "#32cc59" ).Value, Color.Parse( "#ffdc32" ).Value, Color.Parse( "#b63fc9" ).Value];
// Preserve the 1592-wide canvas, keeping gem scale identical at every viewport size.
const float Left = (1592 - DiamondBoard.BoardWidth) * 0.5f;
// Fit the expanded board to the screen, scaling gems and boundary teeth together.
// Keep gameplay and HUD placement stable; the board rendering extends above this margin.
const float Top = 8;
const float NextPreviewCenterY = Top + 212;
const float CanvasWidth = DiamondBoard.BoardWidth + Left * 2;
const float CanvasHeight = Top + DiamondBoard.BoardHeight + 8;
float boardRenderTop;
float boardOffsetY;
Vector2 BoardOffset => ShakeOffset + new Vector2( 0, boardOffsetY );
Rect BoardRenderBounds => new( Left, boardRenderTop, DiamondBoard.BoardWidth, Top + DiamondBoard.BoardHeight - boardRenderTop );
// Include any extra horizontal margin outside the fitted canvas on wide screens.
static float LetterboxCenter( Vector2 size, bool right = false )
{
float scale = MathF.Min( size.x / CanvasWidth, size.y / CanvasHeight );
float extra = scale > 0 ? MathF.Max( 0, (size.x / scale - CanvasWidth) * 0.5f ) : 0;
float center = (Left - extra) * 0.5f;
return right ? CanvasWidth - center : center;
}
public DiamondPanel()
{
Style.Width = Length.Percent( 100 );
Style.Height = Length.Percent( 100 );
Style.PointerEvents = PointerEvents.All;
}
public void UpdateLandingPointGlows( float delta )
{
if ( Board != glowBoard || Board?.ResetVersion != glowResetVersion )
{
landingPointGlows.Clear();
boundaryTime = 0;
glowBoard = Board;
glowResetVersion = Board?.ResetVersion ?? 0;
}
if ( Board is null || Board.Paused ) return;
boundaryTime = (boundaryTime + MathF.Max( 0, delta )) % 3.6f;
foreach ( var glow in landingPointGlows ) glow.Visible = false;
Board.CopyLandingPointContacts( landingPointContacts );
foreach ( var contact in landingPointContacts )
{
// Keep each marker's fade and rotation when projection rounding changes slightly.
LandingPointGlow glow = null;
foreach ( var candidate in landingPointGlows )
if ( candidate.Contact.ColorIndex == contact.ColorIndex &&
MathF.Abs( candidate.Contact.Lane - contact.Lane ) < 0.001f && MathF.Abs( candidate.Contact.Y - contact.Y ) < 0.02f )
{ glow = candidate; break; }
if ( glow is null )
{
glow = new LandingPointGlow { Contact = contact, Rotation = (float)glowRandom.NextDouble() * MathF.PI * 2,
PulsePhase = (float)glowRandom.NextDouble() * MathF.PI * 2 };
landingPointGlows.Add( glow );
}
glow.Visible = true;
}
delta = MathF.Max( 0, delta );
for ( int i = landingPointGlows.Count - 1; i >= 0; i-- )
{
var glow = landingPointGlows[i];
// Frame-rate independent lerp; retain old contact points until they fade away.
float blend = 1 - MathF.Exp( -delta / (glow.Visible ? 0.09f : 0.07f) );
glow.Opacity += ((glow.Visible ? 1 : 0) - glow.Opacity) * blend;
glow.Rotation = (glow.Rotation + delta * 0.3f) % (MathF.PI * 2);
glow.Age = (glow.Age + delta) % 1000;
if ( glow.Visible ) glow.Appear = MathF.Min( 1, glow.Appear + delta / LandingPointAppearTime );
if ( !glow.Visible && glow.Opacity < 0.01f ) landingPointGlows.RemoveAt( i );
}
}
public override void OnDraw( Painter p )
{
#if STANDALONE
#endif
var screenTransform = p.Transform;
p.Fill = Color.Parse( "#0b1017" ).Value;
p.Stroke = Stroke.None;
p.Rect( p.Bounds );
if ( Board is null ) return;
float scale = MathF.Min( p.Bounds.Width / CanvasWidth, p.Bounds.Height / CanvasHeight );
if ( scale <= 0 ) return;
// Halve the full bottom gap, including any vertical letterboxing, without moving the HUD.
float verticalMargin = MathF.Max( 0, (p.Bounds.Height / scale - CanvasHeight) * 0.5f );
boardOffsetY = (verticalMargin + 8) * 0.5f;
// Reach the actual screen edge even after lowering the board or shaking it downward.
boardRenderTop = -verticalMargin - boardOffsetY - MathF.Abs( ShakeOffset.y ) - 8;
if ( OnlyDye )
{
DrawDyeOnly( p, scale );
return;
}
if ( TitleScreen )
{
using ( p.Scope() )
{
p.Translate( (p.Bounds.Size - new Vector2( CanvasWidth, CanvasHeight ) * scale) * 0.5f );
p.Scale( scale );
DrawTitleScreen( p, scale );
}
DrawTutorial( p );
DrawOptions( p );
DrawLeaderboard( p );
DrawTransition( p );
DrawMusicDebug( p );
return;
}
p.Translate( (p.Bounds.Size - new Vector2( CanvasWidth, CanvasHeight ) * scale) * 0.5f );
p.Scale( scale );
float rightCenter = LetterboxCenter( p.Bounds.Size, true );
DrawTimer( p, DiamondLeaderboard.FormatTime( (long)Board.ElapsedSeconds ), new Vector2( LetterboxCenter( p.Bounds.Size ), Top + 40 ) );
if ( !Board.GameOver )
{
var next = DiamondShapes.All[Board.NextShapeIndex];
float previewLane = (next.Cells.Min( c => c.Lane ) + next.Cells.Max( c => c.Lane )) * 0.5f;
float previewRow = (next.Cells.Min( c => c.Row ) + next.Cells.Max( c => c.Row )) * 0.5f;
// Scale the entire mixed-color piece about its visual center, including its outlines.
using var previewScope = p.Scope();
var previewCenter = new Vector2( rightCenter, NextPreviewCenterY );
p.Translate( previewCenter );
p.Scale( NextPreviewScale );
p.Translate( -previewCenter );
DrawShape( p, Board.Next,
new Vector2( rightCenter - previewLane * DiamondBoard.Width * 0.5f, NextPreviewCenterY - previewRow * DiamondBoard.Height * 0.5f ), false );
}
using ( p.Scope() )
{
p.Translate( BoardOffset );
using ( p.Scope() )
{
ClipBoard( p );
p.Fill = Color.Parse( "#080d13" ).Value;
p.Stroke = Stroke.None;
p.Rect( BoardRenderBounds );
DrawAtmosphere( p );
DrawFloorFill( p );
DrawSideWalls( p );
if ( ShowLandingIndicator && !Board.GameOver && !Board.IsSliding && !Board.IsResolving )
DrawShape( p, Board.Active, Position( Board.Active.Lane, Board.LandingY ), true );
var displayCells = DamageShakeCells();
foreach ( var piece in displayCells )
DrawDiamond( p, Position( piece.Lane, piece.Y ), piece.ColorIndex, false, health: piece.Health, crackCorner: piece.FirstCrack );
DrawGroupOutlines( p, displayCells, Board.BondedGroups, new Vector2( Left, Top ), pulses: Board.BondPulses );
if ( !Board.GameOver && !Board.IsResolving )
DrawActivePiece( p );
foreach ( var cell in Board.Shattering )
{
if ( Board.IsDamagePaused )
DrawDiamond( p, Position( cell.Lane, cell.Y ), cell.ColorIndex, false, health: 1, crackCorner: cell.FirstCrack );
else if ( Board.GameOver )
DrawDiamond( p, Position( cell.Lane, cell.Y ), cell.ColorIndex, false,
scale: 1 - Board.ShatterProgress * 0.75f, opacity: 1 - Board.ShatterProgress,
health: cell.Health, crackCorner: cell.FirstCrack );
}
DrawDamageShines( p );
// The floor slopes exactly match the lower half of a diamond.
int cornerSkip = Board.FillCornerPockets ? 2 : 0;
Span<Vector2> floor = stackalloc Vector2[DiamondBoard.Valleys * 2 + 1 - cornerSkip * 2];
for ( int i = 0; i < floor.Length; i++ )
floor[i] = new Vector2( Left + (i + cornerSkip) * DiamondBoard.Width * 0.5f,
Top + DiamondBoard.BoardHeight - (i % 2 == 0 ? DiamondBoard.Height * 0.5f : 0) );
p.Stroke = Stroke.Solid( SpikeInk.WithAlpha( 0.08f + Board.LockFlash * 0.18f ), 9 );
p.Line( floor );
p.Stroke = Stroke.Solid( SpikeInk, 2 );
p.Line( floor );
foreach ( var glow in landingPointGlows ) DrawLandingPointGlow( p, glow, LandingPointScale );
foreach ( var flash in Board.DamageFlashes ) DrawDamageFlash( p, flash );
foreach ( var flash in Board.FaceFlashes ) DrawFaceFlash( p, flash );
DrawDebris( p );
if ( Debris is not null )
{
foreach ( var burst in Debris.ShatterSunbursts )
DrawSunburst( p, new Vector2( Left, Top ) + burst.Position, burst.ColorIndex,
burst.Age / burst.Lifetime, burst.Rotation, burst.Brightness, burst.Scale );
foreach ( var burst in Debris.CrushSunbursts )
DrawSunburst( p, new Vector2( Left, Top ) + burst.Position, burst.ColorIndex,
burst.Age / burst.Lifetime, burst.Rotation, burst.Brightness, burst.Scale );
}
DrawSmoke( p );
DrawGameOverFlash( p );
}
}
if ( !Board.GameOver ) DrawScoreNumber( p, "NEXT", new Vector2( rightCenter, Top + 72 ), 48, Muted * 0.6f );
DrawScore( p, LetterboxCenter( p.Bounds.Size ) );
DrawControls( p, rightCenter );
DrawTutorialButton( p, scale );
DrawOptionsButton( p, scale );
DrawLeaveButton( p, scale );
if ( Board.GameOver ) DrawGameOver( p, scale );
DrawBoardBorder( p, scale );
// After game over, the RESTARTING frame covers the RESTART button instead of the board center.
DrawRestartHold( p, screenTransform, Board.GameOver ? GameOverRestartButton.Center
: new Vector2( Left + DiamondBoard.BoardWidth * 0.5f, Top + boardOffsetY + DiamondBoard.BoardHeight * 0.5f ), scale );
p.Transform = screenTransform;
DrawTutorial( p );
DrawOptions( p );
DrawLeaderboard( p );
DrawTransition( p );
DrawMusicDebug( p );
}
/// <summary>Thumbnail capture: the current screen's dye over its plain background, without the board, HUD or overlays.</summary>
void DrawDyeOnly( Painter p, float scale )
{
using var scope = p.Scope();
p.Translate( (p.Bounds.Size - new Vector2( CanvasWidth, CanvasHeight ) * scale) * 0.5f );
p.Scale( scale );
if ( TitleScreen )
{
DrawTitleDye( p );
return;
}
p.Translate( BoardOffset );
ClipBoard( p );
p.Fill = Color.Parse( "#080d13" ).Value;
p.Stroke = Stroke.None;
p.Rect( BoardRenderBounds );
DrawAtmosphere( p );
}
/// <summary>Screen-space debug readout above everything, including the tutorial.</summary>
void DrawMusicDebug( Painter p )
{
if ( string.IsNullOrEmpty( MusicDebugText ) ) return;
var lines = MusicDebugText.Split( '\n' );
const float size = 14, lineHeight = 18, padding = 10, width = 600;
float height = lines.Length * lineHeight + padding * 2;
float top = p.Bounds.Height - 14 - height;
p.Fill = Color.Black.WithAlpha( 0.62f );
p.Stroke = Stroke.None;
p.Rect( new Rect( 14, top, width, height ) );
for ( int i = 0; i < lines.Length; i++ )
// Painter's text rect includes the font's ascent, so give each line generous height.
Text( p, lines[i].TrimEnd( '\r' ), 14 + padding, top + padding + i * lineHeight - size * 0.4f, width - padding * 2, lineHeight * 2,
size, Color.White.WithAlpha( 0.9f ), fontName: "Roboto Mono" );
}
static void DrawControls( Painter p, float center )
{
DrawScoreNumber( p, "ROTATE", new Vector2( center, Top + 374 ), 26, Muted * 0.65f );
DrawControlKey( p, new Vector2( center, Top + 428 ), "↑" );
string[] arrows = ["←", "↓", "→"];
string[] labels = ["LEFT", "DROP", "RIGHT"];
for ( int i = 0; i < 3; i++ )
{
float x = center + (i - 1) * 84;
DrawControlKey( p, new Vector2( x, Top + 506 ), arrows[i] );
DrawScoreNumber( p, labels[i], new Vector2( x, Top + 560 ), 20, Muted * 0.65f );
}
}
static void DrawControlKey( Painter p, Vector2 center, string arrow )
{
p.Fill = Ink.WithAlpha( 0.025f );
p.Stroke = Stroke.None;
p.Rect( new Rect( center - new Vector2( 32 ), new Vector2( 64 ) ) );
DrawScoreNumber( p, arrow, center, 58, Ink.WithAlpha( 0.5f ) );
}
void DrawBoardBorder( Painter p, float scale )
{
using var scope = p.Scope();
p.Translate( BoardOffset );
float cornerScale = Board.FillCornerPockets ? 1.5f : 0.5f;
float cornerWidth = DiamondBoard.Width * cornerScale;
float cornerHeight = DiamondBoard.Height * cornerScale;
float right = Left + DiamondBoard.BoardWidth;
float bottom = Top + DiamondBoard.BoardHeight;
// Keep a 3.5-pixel outline at every canvas scale, with sharp joins and an open top.
ReadOnlySpan<Vector2> points =
[
new( Left, boardRenderTop ), new( Left, bottom - cornerHeight ),
new( Left + cornerWidth, bottom ), new( right - cornerWidth, bottom ),
new( right, bottom - cornerHeight ), new( right, boardRenderTop )
];
if ( borderPoints is null || !points.SequenceEqual( borderPoints ) )
{
borderPoints = points.ToArray();
borderMiters = BuildPerimeterMiters( points, false );
}
DrawPerimeter( p, points, borderMiters, SpikeInk, 3.5f / scale, false );
}
void DrawGameOverFlash( Painter p )
{
if ( !Board.GameOver || Board.Settled.Count == 0 && Board.Shattering.Count == 0 ) return;
float yellow = 0.5f + 0.5f * MathF.Sin( boundaryTime * MathF.PI * 2 / 0.9f );
p.Fill = new Color(
GameOverFlashRed.r + (GameOverFlashYellow.r - GameOverFlashRed.r) * yellow,
GameOverFlashRed.g + (GameOverFlashYellow.g - GameOverFlashRed.g) * yellow,
GameOverFlashRed.b + (GameOverFlashYellow.b - GameOverFlashRed.b) * yellow )
.WithAlpha( 0.055f + 0.035f * yellow );
p.Stroke = Stroke.None;
// Draw inside ClipBoard so the wash follows the cut corners and never enters the letterboxes.
p.Rect( BoardRenderBounds );
}
void ClipBoard( Painter p )
{
p.Clip( BoardRenderBounds );
// Intersect with two diagonal half-planes. The background, effects, and
// game-over wash all stop at the actual end slopes instead of a rectangular box.
float cornerHeight = DiamondBoard.Height * (Board.FillCornerPockets ? 1.5f : 0.5f);
float extent = CanvasWidth + CanvasHeight + MathF.Abs( boardRenderTop );
var transform = p.Transform;
foreach ( int side in new[] { -1, 1 } )
{
p.Translate( Left + (side > 0 ? DiamondBoard.BoardWidth : 0), Top + DiamondBoard.BoardHeight - cornerHeight );
p.Rotate( MathF.Atan2( DiamondBoard.Height, -side * DiamondBoard.Width ) * 180 / MathF.PI );
p.Clip( new Rect( -extent, side < 0 ? -extent : 0, extent * 2, extent ) );
// Clips capture their transform; restoring only the drawing transform keeps them active.
p.Transform = transform;
}
}
void DrawAtmosphere( Painter p )
{
if ( HideDye || Atmosphere is null || !Atmosphere.Enabled || Atmosphere.Opacity <= 0 ) return;
if ( atmosphereReferenceTiles ) { DrawAtmosphereTiles( p ); return; }
const int columns = DiamondAtmosphere.Columns, rows = DiamondAtmosphere.Rows;
UpdateDyeTexture( Atmosphere, Atmosphere.DyeVersion, Atmosphere.Samples, columns, rows, Color.Parse( "#080d13" ).Value,
"diamonds-gameplay-dye", Math.Clamp( DyeVibrancy, 0, 1 ), ref atmosphereTexture, ref atmospherePixels, ref atmosphereTextureStamp );
var origin = DiamondAtmosphere.GridToWorld( 0, 0 ) + new Vector2( Left, Top ) - new Vector2( DiamondAtmosphere.CellSize * 0.5f );
DrawDyeLayer( p, atmosphereTexture, BoardRenderBounds,
new Rect( origin, new Vector2( columns, rows ) * DiamondAtmosphere.CellSize ), DyeLayerOpacity( Atmosphere.RenderOpacity ) );
}
void DrawDyeLayer( Painter p, Texture texture, Rect bounds, Rect image, float opacity )
{
float resolution = dyeLayerResolution;
using var scope = p.Scope();
p.Scale( 1 / resolution );
var smallBounds = new Rect( bounds.Position * resolution, bounds.Size * resolution );
var smallImage = new Rect( image.Position * resolution, image.Size * resolution );
using var blur = p.BeginLayer( smallBounds, opacity, new Painter.Filter { Blur = 6 * resolution } );
p.Texture( texture, smallImage );
}
// Both screens share color packing, linear texture format and reusable upload storage.
static void UpdateDyeTexture( object source, ulong version, ReadOnlySpan<float> samples, int columns, int rows, Color background,
string name, float vibrancy, ref Texture texture, ref Color32[] pixels, ref DyeTextureStamp stamp )
{
bool recreate = texture is null || !texture.IsValid || texture.Width != columns || texture.Height != rows;
// Opacity and placement belong to Painter, so changing either needs no pixel upload.
if ( !recreate && pixels?.Length == columns * rows && ReferenceEquals( stamp.Source, source ) &&
stamp.Version == version && ReferenceEquals( stamp.Palette, Palette ) && stamp.Background.Equals( background ) && stamp.Vibrancy == vibrancy ) return;
if ( recreate )
{
texture?.Dispose();
// UI solid fills use these raw color values; avoid an extra sRGB decode.
texture = Texture.Create( columns, rows, ImageFormat.RGBA8888_LINEAR )
.WithDynamicUsage().WithName( name ).Finish();
}
if ( pixels is null || pixels.Length != columns * rows ) pixels = new Color32[columns * rows];
for ( int n = 0; n < pixels.Length; n++ )
{
float total = 0, weightedTotal = 0, red = 0, green = 0, blue = 0;
for ( int color = 0; color < DiamondBoard.ColorCount; color++ )
{
float amount = samples[n * DiamondBoard.ColorCount + color];
total += amount;
float vividAmount = amount * amount;
weightedTotal += vividAmount;
var ink = Palette[color];
red += ink.r * vividAmount; green += ink.g * vividAmount; blue += ink.b * vividAmount;
}
// Vibrant dye saturates from a smaller amount; at full vibrancy only truly empty space stays dark.
float coverage = Math.Clamp( (total - (0.04f - 0.035f * vibrancy)) / (0.28f - 0.22f * vibrancy), 0, 1 );
coverage = coverage * coverage * (3 - 2 * coverage);
pixels[n] = weightedTotal <= 0 || coverage <= 0 ? background :
new Color( background.r + (red / weightedTotal - background.r) * coverage,
background.g + (green / weightedTotal - background.g) * coverage,
background.b + (blue / weightedTotal - background.b) * coverage );
}
texture.Update( pixels.AsSpan(), width: columns, height: rows );
stamp = new DyeTextureStamp { Source = source, Version = version, Palette = Palette, Background = background, Vibrancy = vibrancy };
}
void DrawAtmosphereTiles( Painter p )
{
if ( Atmosphere is null || !Atmosphere.Enabled || Atmosphere.Opacity <= 0 ) return;
if ( atmosphereTiles is null || atmosphereTiles.Length != DiamondAtmosphere.Columns * DiamondAtmosphere.Rows ||
atmosphereTileVersion != 9 )
{
atmosphereTiles = BuildAtmosphereTiles();
atmosphereTileVersion = 9;
}
using var layer = p.BeginLayer( BoardRenderBounds,
0.22f * Atmosphere.RenderOpacity, new Painter.Filter { Blur = 6 } );
var background = Color.Parse( "#080d13" ).Value;
p.Stroke = Stroke.None;
var samples = Atmosphere.Samples;
for ( int y = 0; y < DiamondAtmosphere.Rows; y++ )
for ( int x = 0; x < DiamondAtmosphere.Columns; x++ )
{
var tile = atmosphereTiles[y * DiamondAtmosphere.Columns + x];
if ( tile.Width <= 0 ) continue;
float total = 0, weightedTotal = 0, red = 0, green = 0, blue = 0;
int sampleIndex = (y * DiamondAtmosphere.Columns + x) * DiamondBoard.ColorCount;
for ( int color = 0; color < DiamondBoard.ColorCount; color++ )
{
float amount = samples[sampleIndex + color];
total += amount;
float vividAmount = amount * amount;
weightedTotal += vividAmount;
var ink = Palette[color];
red += ink.r * vividAmount;
green += ink.g * vividAmount;
blue += ink.b * vividAmount;
}
float coverage = Math.Clamp( (total - 0.04f) / 0.28f, 0, 1 );
coverage = coverage * coverage * (3 - 2 * coverage);
if ( weightedTotal <= 0 || coverage <= 0 ) continue;
p.Fill = new Color( background.r + (red / weightedTotal - background.r) * coverage,
background.g + (green / weightedTotal - background.g) * coverage,
background.b + (blue / weightedTotal - background.b) * coverage );
p.Rect( tile );
}
}
static Rect[] BuildAtmosphereTiles()
{
var tiles = new Rect[DiamondAtmosphere.Columns * DiamondAtmosphere.Rows];
// Screen-aligned squares keep the previous overlap to hide seams under the blur.
float radius = DiamondAtmosphere.CellSize * 0.5f + 3 * 0.70710678f;
for ( int y = 0; y < DiamondAtmosphere.Rows; y++ )
for ( int x = 0; x < DiamondAtmosphere.Columns; x++ )
{
Vector2 center = DiamondAtmosphere.GridToWorld( x, y );
if ( center.x < -radius || center.x > DiamondBoard.BoardWidth + radius ||
center.y < -radius || center.y > DiamondBoard.BoardHeight + radius ) continue;
center += new Vector2( Left, Top );
tiles[y * DiamondAtmosphere.Columns + x] = new Rect( center - new Vector2( radius ), new Vector2( radius * 2 ) );
}
return tiles;
}
void DrawFloorFill( Painter p )
{
p.Stroke = Stroke.None;
for ( int i = 0; i <= DiamondBoard.Valleys; i++ )
{
var center = new Vector2( Left + i * DiamondBoard.Width, Top + DiamondBoard.BoardHeight );
DrawFloorTooth( p, center, SpikeInk, Board.GameOver );
}
}
static void DrawFloorTooth( Painter p, Vector2 center, Color ink, bool gameOver = false )
{
var left = center + new Vector2( -DiamondBoard.Width * 0.5f, 0 );
var tip = center + new Vector2( 0, -DiamondBoard.Height * 0.5f );
var right = center + new Vector2( DiamondBoard.Width * 0.5f, 0 );
p.Stroke = Stroke.None;
p.Fill = gameOver ? GameOverToothFill : ToothFill;
p.Polygon( [left, tip, right] );
p.Fill = ink.WithAlpha( 0.12f );
p.Polygon( [left, tip, center] );
}
void DrawSideWalls( Painter p )
{
Span<Vector2> edge = stackalloc Vector2[3];
// Continue the same tooth pattern above the gameplay bounds to the screen edge.
for ( float y = DiamondBoard.BoardHeight; Top + y + DiamondBoard.Height * 0.5f > boardRenderTop; y -= DiamondBoard.Height )
for ( int side = 0; side < 2; side++ )
{
var tooth = new DiamondBoard.Diamond( side == 0 ? -1 : DiamondBoard.LaneCount, y, 0 );
if ( Board.FillCornerPockets && tooth.Y == DiamondBoard.BoardHeight ) continue;
var center = Position( tooth.Lane, tooth.Y );
float inward = tooth.Lane < 0 ? 1 : -1;
edge[0] = center + new Vector2( 0, -DiamondBoard.Height * 0.5f );
edge[1] = center + new Vector2( inward * DiamondBoard.Width * 0.5f, 0 );
edge[2] = center + new Vector2( 0, DiamondBoard.Height * 0.5f );
bool corner = Board.FillCornerPockets && tooth.Y == DiamondBoard.BoardHeight - DiamondBoard.Height;
if ( corner ) edge[2] += new Vector2( inward * DiamondBoard.Width, 0 );
p.Stroke = Stroke.None;
if ( !corner )
{
p.Fill = Board.GameOver ? GameOverToothFill : ToothFill;
p.Polygon( edge );
p.Fill = SpikeInk.WithAlpha( 0.12f );
p.Polygon( [edge[0], edge[1], center] );
}
if ( DrawWallStrokeMask( p, center, inward, corner ) ) continue;
p.Stroke = Stroke.Solid( SpikeInk.WithAlpha( 0.08f + Board.LockFlash * 0.18f ), 9 );
p.Line( edge );
p.Stroke = Stroke.Solid( SpikeInk, 2 );
p.Line( edge );
}
}
void DrawDebris( Painter p )
{
if ( Debris is null ) return;
p.Stroke = Stroke.None;
foreach ( var chip in Debris.Particles )
{
if ( !chip.Visible ) continue;
var position = new Vector2( Left, Top ) + chip.Position;
// Include rotation, the widest mitered glow, and an antialiasing margin.
float radius = (chip.Radius + (chip.IsSpark ? 6 : 7.5f)) * (chip.IsSpark ? 1.6f : 1) + 2;
if ( !drawingReferenceGeometry && (position.x + radius < BoardRenderBounds.Left ||
position.x - radius > BoardRenderBounds.Right || position.y + radius < BoardRenderBounds.Top ||
position.y - radius > BoardRenderBounds.Bottom) ) continue;
float opacity = chip.Opacity;
using var scope = p.Scope();
p.Translate( position );
p.Rotate( chip.Angle );
var color = GemColor( chip.ColorIndex, chip.IsSpark ? DiamondBoard.MaxHealth : 1 );
float highlight = chip.IsSpark ? chip.Brightness : 0.12f + chip.Brightness * 0.25f;
var tint = new Color( color.r + (1 - color.r) * highlight,
color.g + (1 - color.g) * highlight, color.b + (1 - color.b) * highlight );
if ( chip.IsSpark )
{
// Enlarge the light without changing the small physical collision fixture.
p.Scale( 1.6f );
if ( !DrawSparkGlow( p, chip, opacity ) )
DrawParticleGlow( p, chip.Vertices, color, opacity * 0.24f, 1, ParticleGlowLayers( chip ) );
p.Fill = tint.WithAlpha( opacity );
p.Polygon( chip.Vertices );
p.Polygon( [chip.Vertices[0], chip.Vertices[1], Vector2.Zero] );
continue;
}
DrawShardFacets( p, chip.Vertices, color, tint, opacity, ParticleGlowLayers( chip ) );
DrawShardShine( p, chip );
DrawGemStroke( p, chip.Vertices, tint.WithAlpha( opacity * 0.45f ), 0.8f, true );
}
}
static void DrawShardFacets( Painter p, Vector2[] vertices, Color color, Color tint, float opacity = 1, Vector2[][] glowLayers = null )
{
DrawParticleGlow( p, vertices, color, opacity * 0.12f, 1.25f, glowLayers );
var shadow = new Color( color.r * 0.68f, color.g * 0.68f, color.b * 0.68f );
var center = (vertices[0] + vertices[1] + vertices[2]) / 3;
for ( int facet = 0; facet < 3; facet++ )
{
p.Fill = (facet == 0 ? tint : facet == 1 ? shadow : color).WithAlpha( opacity );
p.Polygon( [vertices[facet], vertices[(facet + 1) % 3], center] );
}
}
static void DrawParticleGlow( Painter p, ReadOnlySpan<Vector2> vertices, Color color, float opacity, float spread, Vector2[][] layers = null )
{
p.Stroke = Stroke.None;
if ( layers is not null )
{
for ( int i = 0; i < layers.Length; i++ )
{
p.Fill = color.WithAlpha( opacity / (3 - i) );
p.Polygon( layers[i] );
}
return;
}
// Tiny filled polygons use Painter's inline shape path. Stroked polygons
// rebuild path primitives and a BVH for every layer of every particle.
// Expand the convex triangle/quad along its face normals, retaining an
// angular halo with the same three widths and falloff. Facets cover its core.
Span<Vector2> corners = stackalloc Vector2[4];
Span<Vector2> expanded = stackalloc Vector2[4];
ParticleGlowCorners( vertices, corners );
for ( int layer = 3; layer >= 1; layer-- )
{
for ( int i = 0; i < vertices.Length; i++ ) expanded[i] = vertices[i] + corners[i] * (layer * spread);
p.Fill = color.WithAlpha( opacity / layer );
p.Polygon( expanded[..vertices.Length] );
}
}
static void ParticleGlowCorners( ReadOnlySpan<Vector2> vertices, Span<Vector2> corners )
{
float winding = 0;
for ( int i = 0; i < vertices.Length; i++ )
{
var a = vertices[i]; var b = vertices[(i + 1) % vertices.Length];
winding += a.x * b.y - a.y * b.x;
}
for ( int i = 0; i < vertices.Length; i++ )
{
var incoming = (vertices[i] - vertices[(i + vertices.Length - 1) % vertices.Length]).Normal;
var outgoing = (vertices[(i + 1) % vertices.Length] - vertices[i]).Normal;
var normal = new Vector2( incoming.y, -incoming.x ) * MathF.Sign( winding );
var nextNormal = new Vector2( outgoing.y, -outgoing.x ) * MathF.Sign( winding );
var bisector = (normal + nextNormal).Normal;
corners[i] = bisector / MathF.Max( 0.5f, Vector2.Dot( bisector, normal ) );
}
}
void DrawSmoke( Painter p )
{
if ( Debris is null ) return;
p.Stroke = Stroke.None;
foreach ( var puff in Debris.Smoke )
{
float t = Math.Clamp( puff.Age / puff.Lifetime, 0, 1 );
float opacity = MathF.Min( 1, puff.Age / 0.035f ) * (1 - t) * (1 - t);
float radius = puff.Radius * (1 + t * 1.4f);
var center = new Vector2( Left, Top ) + puff.Position;
// Soft, faint grey dust is composited last, over gems and chip fragments.
for ( int layer = 4; layer >= 1; layer-- )
{
p.Fill = new Color( 0.70f, 0.74f, 0.78f ).WithAlpha( opacity * 0.05f );
p.Circle( center, radius * layer / 4 );
}
}
}
const float LandingPointAppearTime = 0.14f;
// Unrelated per-ray pulse rates (Hz), so the spokes never breathe in step.
static readonly float[] LandingRayPulseRates = [1.31f, 2.07f, 1.63f, 2.53f, 1.12f, 1.87f];
// Spoke core blur, widest and faintest first; stacked, the middle reaches about 92% opacity.
[SkipHotload] static readonly (float Width, float Alpha)[] LandingCoreLayers = [(1.5f, 0.15f), (1.2f, 0.2f), (0.95f, 0.3f), (0.72f, 0.45f), (0.5f, 0.7f)];
static float LandingRayLength( int ray ) => ray switch { 0 => 9, 1 => 5.5f, 2 => 7, 3 => 8, 4 => 5, _ => 6.5f };
static void DrawLandingPointGlow( Painter p, LandingPointGlow glow, float scale )
{
if ( scale <= 0 ) return;
using var scope = p.Scope();
p.Translate( Position( glow.Contact.Lane, glow.Contact.Y ) );
// Pop in from a quarter size with a slight ease-out-back overshoot; fading out keeps full size.
float t = glow.Appear - 1;
float grow = 1 + 2.2f * t * t * t + 1.2f * t * t;
p.Scale( 1.6f * scale * (0.25f + 0.75f * grow) );
var center = Vector2.Zero;
var color = Palette[glow.Contact.ColorIndex];
var tint = new Color( (color.r + 1) * 0.5f, (color.g + 1) * 0.5f, (color.b + 1) * 0.5f );
p.Stroke = Stroke.None;
p.Fill = Fill.RadialGradient()
.WithStop( 0, tint.WithAlpha( 0.26f * glow.Opacity ) )
.WithStop( 0.25f, tint.WithAlpha( 0.2f * glow.Opacity ) )
.WithStop( 0.5f, tint.WithAlpha( 0.1f * glow.Opacity ) )
.WithStop( 0.75f, tint.WithAlpha( 0.04f * glow.Opacity ) )
.WithStop( 1, tint.WithAlpha( 0 ) );
p.Circle( center, 13 );
Span<float> pulses = stackalloc float[6], coreReach = stackalloc float[6];
for ( int ray = 0; ray < 6; ray++ )
{
// Two detuned sines per ray give an irregular, subtle breathing.
float phase = glow.PulsePhase * (ray + 1) * 1.618f;
float w = glow.Age * LandingRayPulseRates[ray] * MathF.PI * 2;
pulses[ray] = 1 + 0.1f * MathF.Sin( w + phase ) + 0.05f * MathF.Sin( w * 1.73f + phase * 0.5f );
// The core's reach, as a fraction of its spoke, pulses on its own unrelated clock (about 0.45-0.75).
float corePhase = glow.PulsePhase * (ray + 2) * 2.414f;
float coreW = glow.Age * LandingRayPulseRates[5 - ray] * 1.37f * MathF.PI * 2;
coreReach[ray] = 0.6f + 0.1f * MathF.Sin( coreW + corePhase ) + 0.05f * MathF.Sin( coreW * 1.61f + corePhase * 0.7f );
}
// Blurry white halos first: a soft radial gradient stretched into an ellipse along each spoke.
var halo = Fill.RadialGradient()
.WithStop( 0, Color.White.WithAlpha( 0.42f * glow.Opacity ) )
.WithStop( 0.4f, Color.White.WithAlpha( 0.2f * glow.Opacity ) )
.WithStop( 0.75f, Color.White.WithAlpha( 0.06f * glow.Opacity ) )
.WithStop( 1, Color.White.WithAlpha( 0 ) );
for ( int ray = 0; ray < 6; ray++ )
{
float angle = glow.Rotation + ray * MathF.PI / 3;
float reach = LandingRayLength( ray ) * pulses[ray];
float along = reach * 0.6f + 3, across = 3.4f * pulses[ray];
using var haloScope = p.Scope();
p.Translate( new Vector2( MathF.Cos( angle ), MathF.Sin( angle ) ) * reach * 0.45f );
p.Rotate( angle * 180 / MathF.PI );
p.Scale( 1, across / along );
p.Fill = halo;
p.Circle( Vector2.Zero, along );
}
p.Fill = tint.WithAlpha( glow.Opacity );
for ( int ray = 0; ray < 6; ray++ )
{
float angle = glow.Rotation + ray * MathF.PI / 3;
var direction = new Vector2( MathF.Cos( angle ), MathF.Sin( angle ) );
var side = new Vector2( -direction.y, direction.x );
float pulse = pulses[ray];
p.Polygon( [center + side * 1.2f * pulse, center + direction * LandingRayLength( ray ) * pulse, center - side * 1.2f * pulse] );
}
// A thin near-white core partway down each spoke, instead of a round hotspot in the middle. Layers
// share the tip but widen toward the center, so the core is blurred inside and sharp at the point.
var core = new Color( (tint.r + 3) * 0.25f, (tint.g + 3) * 0.25f, (tint.b + 3) * 0.25f );
for ( int layer = 0; layer < LandingCoreLayers.Length; layer++ )
{
var (width, alpha) = LandingCoreLayers[layer];
p.Fill = core.WithAlpha( alpha * glow.Opacity );
for ( int ray = 0; ray < 6; ray++ )
{
float angle = glow.Rotation + ray * MathF.PI / 3;
var direction = new Vector2( MathF.Cos( angle ), MathF.Sin( angle ) );
var side = new Vector2( -direction.y, direction.x );
float pulse = pulses[ray];
p.Polygon( [center + side * width * pulse, center + direction * LandingRayLength( ray ) * coreReach[ray] * pulse, center - side * width * pulse] );
}
}
}
static void DrawDamageFlash( Painter p, DiamondBoard.DamageFlash flash )
=> DrawSunburst( p, Position( flash.Lane, flash.Y ), flash.ColorIndex,
flash.Age / DiamondBoard.DamageFlashDuration, flash.Rotation, flash.Brightness, flash.Scale );
static void DrawSunburst( Painter p, Vector2 position, int colorIndex, float progress, float rotation, float brightness, float scale )
=> DrawSunburst( p, position, Palette[colorIndex], progress, rotation, brightness, scale );
static void DrawSunburst( Painter p, Vector2 position, Color color, float progress, float rotation, float brightness, float scale )
{
using var scope = p.Scope();
p.Translate( position );
p.Scale( scale );
float t = Math.Clamp( progress, 0, 1 );
float fade = (1 - t) * (1 - t);
float radius = 17 + 28 * (1 - MathF.Pow( 1 - t, 3 ));
// Lift the receiver's color toward white while retaining its hue. Brightness
// is chosen once per burst so fading doesn't flicker between random shades.
var tint = new Color( color.r + (1 - color.r) * brightness,
color.g + (1 - color.g) * brightness, color.b + (1 - color.b) * brightness );
p.Stroke = Stroke.None;
for ( int layer = 3; layer >= 1; layer-- )
{
p.Fill = tint.WithAlpha( fade * 0.07f );
p.Circle( Vector2.Zero, radius * layer * 0.45f );
}
// Fixed ray orientation throughout the expanding, fading burst.
for ( int ray = 0; ray < 12; ray++ )
{
float angle = ray * MathF.PI / 6 + rotation;
var direction = new Vector2( MathF.Cos( angle ), MathF.Sin( angle ) );
var side = new Vector2( -direction.y, direction.x );
float length = radius * (ray % 3 == 0 ? 1 : ray % 2 == 0 ? 0.76f : 0.52f);
float width = (ray % 3 == 0 ? 2.8f : 1.6f) * (1 - t * 0.6f);
p.Fill = tint.WithAlpha( fade * 0.9f );
p.Polygon( [side * width, direction * length, -side * width] );
}
p.Fill = new Color( (tint.r + 1) * 0.5f, (tint.g + 1) * 0.5f, (tint.b + 1) * 0.5f ).WithAlpha( fade );
p.Circle( Vector2.Zero, 4.2f * (1 - t) );
}
static void DrawFaceFlash( Painter p, DiamondBoard.FaceFlash flash )
{
var face = flash.Contact;
DrawFaceFlash( p, Position( face.StartLane, face.StartY ), Position( face.EndLane, face.EndY ),
face.ColorIndex, flash.Age, flash.Seed, flash.Brightness, flash.DamageCount );
}
static void DrawFaceFlash( Painter p, Vector2 start, Vector2 end, int colorIndex,
float age, float seed, float brightness, int damageCount )
=> DrawFaceFlash( p, start, end, Palette[colorIndex], age, seed, brightness, damageCount );
static void DrawFaceFlash( Painter p, Vector2 start, Vector2 end, Color color,
float age, float seed, float brightness, int damageCount )
{
using var scope = p.Scope();
var tangent = (end - start).Normal;
var normal = new Vector2( -tangent.y, tangent.x );
float t = Math.Clamp( age / DiamondBoard.DamageFlashDuration, 0, 1 );
float fade = (1 - t) * (1 - t);
float scale = 1 + 0.5f * Math.Max( 0, damageCount - 1 );
var tint = new Color( color.r + (1 - color.r) * brightness,
color.g + (1 - color.g) * brightness, color.b + (1 - color.b) * brightness );
// Keep the seam anchored to the receiving face; only the escaping light grows.
for ( int layer = 3; layer >= 1; layer-- )
{
p.Stroke = Stroke.Solid( tint.WithAlpha( fade * 0.10f ), layer * 4 * scale );
p.Line( start, end );
}
p.Stroke = Stroke.None;
p.Fill = tint.WithAlpha( fade * 0.9f );
for ( int ray = 0; ray < 11; ray++ )
{
float along = (ray + 0.5f) / 11;
var origin = start + (end - start) * along;
for ( int side = -1; side <= 1; side += 2 )
{
// Each spoke fans out independently, with a fixed angle for its lifetime.
float angle = MathF.Sin( seed * 1.37f + ray * 2.399f + side * 1.71f ) * 1.25f;
var direction = normal * (side * MathF.Cos( angle )) + tangent * MathF.Sin( angle );
var across = new Vector2( -direction.y, direction.x );
float variation = 0.5f + 0.5f * MathF.Sin( seed + ray * 4.13f + side * 2.57f );
float length = (13.5f + 31 * (1 - MathF.Pow( 1 - t, 3 ))) * (0.45f + variation * 0.55f) * scale;
float width = (1.15f + variation * 1.15f) * (1 - t * 0.6f);
p.Polygon( [origin - across * width, origin + direction * length, origin + across * width] );
}
}
var core = new Color( (tint.r + 1) * 0.5f, (tint.g + 1) * 0.5f, (tint.b + 1) * 0.5f );
p.Stroke = Stroke.Solid( core.WithAlpha( fade ), 2.8f * (1 - t) );
p.Line( start, end );
}
static Vector2 Position( float lane, float y ) => new( Left + DiamondBoard.LaneX( lane ), Top + y );
/// <summary>Draw the controlled piece trailing its lattice position while a step or turn animates.</summary>
void DrawActivePiece( Painter p )
{
var piece = Board.Active;
var trail = Board.ControlOffset;
var anchor = Position( piece.Lane + trail.Lane, piece.Y + trail.Y ) + PieceShakeOffset;
float turns = Board.ControlTurns;
if ( MathF.Abs( turns ) < 0.0005f )
{
DrawShape( p, piece, anchor, false );
return;
}
// Show the nearest whole rotation, turned the rest of the way on the lattice:
// a rotation in half-width/half-height units, so each endpoint matches the
// lattice exactly and the facets swap mid-turn while motion is fastest.
int whole = (int)MathF.Round( turns );
var shown = piece with { Rotation = (piece.Rotation - whole) & 3 };
var pivotOffset = DiamondShapes.All[piece.ShapeIndex].Pivot;
var pivot = anchor + new Vector2( pivotOffset.Lane * DiamondBoard.Width * 0.5f, pivotOffset.Row * DiamondBoard.Height * 0.5f );
using var scope = p.Scope();
p.Translate( pivot );
p.Scale( DiamondBoard.Width * 0.5f, DiamondBoard.Height * 0.5f );
p.Rotate( (whole - turns) * 90 );
p.Scale( 2 / DiamondBoard.Width, 2 / DiamondBoard.Height );
p.Translate( -pivot );
DrawShape( p, shown, anchor, false );
}
void DrawShape( Painter p, DiamondBoard.Piece piece, Vector2 anchor, bool ghost, float scale = 1 )
{
if ( drawingReferenceGeometry )
{
var referenceCells = DiamondBoard.CellsOf( piece ).ToArray();
foreach ( var cell in referenceCells )
DrawDiamond( p, anchor + new Vector2( (cell.Lane - piece.Lane) * DiamondBoard.Width * 0.5f, cell.Y - piece.Y ) * scale,
cell.ColorIndex, ghost, scale, health: cell.Health, crackCorner: cell.FirstCrack );
DrawGroupOutlines( p, referenceCells, DiamondMatching.FindGroups( referenceCells ),
anchor - new Vector2( DiamondBoard.LaneX( piece.Lane ), piece.Y ) * scale, scale, ghost ? 0.35f : 1, !ghost );
return;
}
// Reuse cell/group storage while preserving the original layer bounds and pixel alignment.
var cache = ghost ? ghostShapeGeometry : piece == Board.Next ? nextShapeGeometry : activeShapeGeometry;
cache.Update( piece );
var cells = cache.Cells;
foreach ( var cell in cells )
DrawDiamond( p, anchor + new Vector2( (cell.Lane - piece.Lane) * DiamondBoard.Width * 0.5f, cell.Y - piece.Y ) * scale,
cell.ColorIndex, ghost, scale, health: cell.Health, crackCorner: cell.FirstCrack );
DrawGroupOutlines( p, cells, cache.Groups,
anchor - new Vector2( DiamondBoard.LaneX( piece.Lane ), piece.Y ) * scale, scale, ghost ? 0.35f : 1, !ghost,
geometryCache: cache.Outlines );
}
void DrawGroupOutlines( Painter p, IReadOnlyList<DiamondBoard.Diamond> cells,
IReadOnlyList<IReadOnlyList<int>> groups, Vector2 origin, float scale = 1, float opacity = 1, bool glow = true,
IReadOnlyList<DiamondBoard.BondPulse> pulses = null, OutlineGeometryCache geometryCache = null )
{
if ( cells.Count == 0 || opacity <= 0 ) return;
using var scope = p.Scope();
p.Translate( origin );
p.Scale( scale );
// One filtered layer for the whole collection. Drawing coordinates inside
// BeginLayer are local; the parent transform is applied when compositing.
float minX = float.PositiveInfinity, maxX = float.NegativeInfinity;
float minY = float.PositiveInfinity, maxY = float.NegativeInfinity;
for ( int i = 0; i < cells.Count; i++ )
{
var cell = cells[i];
float x = DiamondBoard.LaneX( cell.Lane );
minX = MathF.Min( minX, x ); maxX = MathF.Max( maxX, x );
minY = MathF.Min( minY, cell.Y ); maxY = MathF.Max( maxY, cell.Y );
}
minX -= DiamondBoard.Width * 0.5f + 20; maxX += DiamondBoard.Width * 0.5f + 20;
minY -= DiamondBoard.Height * 0.5f + 20; maxY += DiamondBoard.Height * 0.5f + 20;
using var layer = p.BeginLayer( new Rect( minX, minY, maxX - minX, maxY - minY ),
opacity, new Painter.Filter { Blur = glow ? 3 : 0 } );
p.Fill = Fill.None;
geometryCache ??= ReferenceEquals( groups, Board?.BondedGroups ) ? settledOutlineGeometry : null;
var geometry = !drawingReferenceGeometry && geometryCache is not null
? geometryCache.Get( cells, groups ) : BuildOutlineGeometry( cells, groups );
foreach ( var group in geometry )
{
if ( group is null ) continue;
// Existing contour caches can survive hotload without the newly added offsets.
group.Miters ??= Array.ConvertAll( group.Contours, c => BuildPerimeterMiters( c.Points, c.Closed ) );
var firstEdge = group.FirstEdge with { X1 = group.FirstEdge.X1 + group.Offset.x, Y1 = group.FirstEdge.Y1 + group.Offset.y,
X2 = group.FirstEdge.X2 + group.Offset.x, Y2 = group.FirstEdge.Y2 + group.Offset.y };
float connection = 0;
if ( pulses is not null )
foreach ( var pulse in pulses )
if ( pulse.Edges.Contains( firstEdge ) )
{
float t = Math.Clamp( pulse.Age / DiamondBoard.BondPulseDuration, 0, 1 );
connection = MathF.Sin( MathF.PI * t ) * (1 - t);
break;
}
float breathing = MathF.Sin( boundaryTime * MathF.PI * 2 / 3.6f );
var color = Palette[group.FirstEdge.ColorIndex];
float lift = glow ? 0.32f : 0;
color = new Color( color.r + (1 - color.r) * lift,
color.g + (1 - color.g) * lift, color.b + (1 - color.b) * lift );
// A single continuous, softly filtered stroke: no stacked bands, end caps,
// bright corner discs or detached rings. New bonds swell this same glow.
for ( int i = 0; i < group.Contours.Length; i++ )
{
var contour = group.Contours[i];
DrawPerimeter( p, contour.Points, group.Miters?[i], color.WithAlpha( glow ? 0.88f + breathing * 0.06f : 1 ),
glow ? 4.5f + breathing * 0.25f + connection * 5 : 1, contour.Closed );
}
}
}
static Color GemColor( int colorIndex, int health )
{
var color = Palette[colorIndex];
float brightness = health >= DiamondBoard.MaxHealth ? 1 : health == 2 ? 0.93f : 0.78f;
return new Color( color.r * brightness, color.g * brightness, color.b * brightness );
}
void DrawDiamond( Painter p, Vector2 center, int colorIndex, bool ghost, float scale = 1, float opacity = 1, int health = DiamondBoard.MaxHealth, DiamondBoard.Corner crackCorner = DiamondBoard.Corner.Top )
{
// Ghosts and shatter fades have per-facet opacity, which must not be flattened.
if ( !drawingReferenceGems && !drawingReferenceStrokes && !ghost && opacity == 1 && scale > 0 &&
DrawAtlasGem( p, center, colorIndex, scale, health, crackCorner ) ) return;
DrawDiamondVector( p, center, colorIndex, ghost, scale, opacity, health, crackCorner );
}
static void DrawDiamondVector( Painter p, Vector2 center, int colorIndex, bool ghost, float scale = 1, float opacity = 1, int health = DiamondBoard.MaxHealth, DiamondBoard.Corner crackCorner = DiamondBoard.Corner.Top )
{
using var scope = p.Scope();
p.Translate( center );
p.Scale( scale );
Color color = GemColor( colorIndex, health );
float w = DiamondBoard.Width * 0.5f;
float h = DiamondBoard.Height * 0.5f;
Vector2 top = new( 0, -h ), right = new( w, 0 ), bottom = new( 0, h ), left = new( -w, 0 );
Vector2 middle = Vector2.Zero;
// Every cut tiles the same diamond. Broad light/dark faces provide the visual
// identity; there are no icons or marks floating above the gem surface.
p.Fill = color.WithAlpha( opacity * (ghost ? 0.045f : 0.94f) );
p.Stroke = Stroke.None;
p.Quad( top, right, bottom, left );
switch ( colorIndex )
{
case 0: // Classic: four facets meet at the center, retaining the original cut.
Facet( p, color, 0.32f, opacity, ghost, [top, middle, left] );
Facet( p, color, 0.02f, opacity, ghost, [top, right, middle] );
Facet( p, color, -0.42f, opacity, ghost, [middle, right, bottom] );
Facet( p, color, -0.12f, opacity, ghost, [left, middle, bottom] );
break;
case 1: // Bevel: a smaller diamond table surrounded by four broad bevels.
Vector2 innerTop = top * 0.48f, innerRight = right * 0.48f;
Vector2 innerBottom = bottom * 0.48f, innerLeft = left * 0.48f;
Facet( p, color, 0.42f, opacity, ghost, [top, innerTop, innerLeft, left] );
Facet( p, color, -0.12f, opacity, ghost, [top, right, innerRight, innerTop] );
Facet( p, color, -0.48f, opacity, ghost, [right, bottom, innerBottom, innerRight] );
Facet( p, color, -0.2f, opacity, ghost, [bottom, left, innerLeft, innerBottom] );
Facet( p, color, 0.14f, opacity, ghost, [innerTop, innerRight, innerBottom, innerLeft] );
break;
case 2: // Table: a long, flat hexagonal face with clipped ends and narrow sides.
Vector2 upperLeft = new( -w * 0.23f, -h * 0.54f ), upperRight = new( w * 0.23f, -h * 0.54f );
Vector2 midLeft = new( -w * 0.5f, 0 ), midRight = new( w * 0.5f, 0 );
Vector2 lowerLeft = new( -w * 0.23f, h * 0.54f ), lowerRight = new( w * 0.23f, h * 0.54f );
Facet( p, color, 0.48f, opacity, ghost, [top, upperRight, upperLeft] );
Facet( p, color, 0.24f, opacity, ghost, [top, upperLeft, midLeft, left] );
Facet( p, color, -0.32f, opacity, ghost, [top, right, midRight, upperRight] );
Facet( p, color, -0.12f, opacity, ghost, [left, midLeft, lowerLeft, bottom] );
Facet( p, color, -0.48f, opacity, ghost, [right, bottom, lowerRight, midRight] );
Facet( p, color, -0.25f, opacity, ghost, [bottom, lowerLeft, lowerRight] );
Facet( p, color, 0.08f, opacity, ghost, [upperLeft, upperRight, midRight, lowerRight, lowerLeft, midLeft] );
break;
case 3: // Steps: three chevron ridges cut across a central spine.
Vector2 a = new( -w * 0.5f, -h * 0.5f ), b = new( w * 0.5f, -h * 0.5f );
Vector2 c = new( 0, -h * 0.2f ), d = new( 0, h * 0.35f );
Vector2 e = new( -w * 0.5f, h * 0.5f ), f = new( w * 0.5f, h * 0.5f ), g = new( 0, h * 0.8f );
Facet( p, color, 0.36f, opacity, ghost, [top, c, a] );
Facet( p, color, -0.14f, opacity, ghost, [top, b, c] );
Facet( p, color, -0.08f, opacity, ghost, [a, c, d, left] );
Facet( p, color, -0.44f, opacity, ghost, [c, b, right, d] );
Facet( p, color, 0.3f, opacity, ghost, [left, d, g, e] );
Facet( p, color, -0.16f, opacity, ghost, [d, right, f, g] );
Facet( p, color, -0.14f, opacity, ghost, [e, g, bottom] );
Facet( p, color, -0.5f, opacity, ghost, [g, f, bottom] );
break;
case 4: // Fan: long triangular planes radiate from the top point.
Vector2 fanLeft = (left + bottom) * 0.5f, fanRight = (right + bottom) * 0.5f;
Facet( p, color, 0.32f, opacity, ghost, [top, fanLeft, left] );
Facet( p, color, -0.34f, opacity, ghost, [top, bottom, fanLeft] );
Facet( p, color, 0.12f, opacity, ghost, [top, fanRight, bottom] );
Facet( p, color, -0.48f, opacity, ghost, [top, right, fanRight] );
break;
}
if ( !ghost && health < DiamondBoard.MaxHealth ) DrawCracks( p, w, h, health, opacity, colorIndex, crackCorner );
// The colored perimeter is drawn once around the bonded group after its facets.
}
static Vector2 CrackPoint( Vector2 point, float w, float h, float mirror, DiamondBoard.Corner corner )
{
point.x *= mirror;
// Rotate in normalized gem coordinates before restoring its tall proportions.
point = corner switch
{
DiamondBoard.Corner.Right => new( -point.y, point.x ),
DiamondBoard.Corner.Bottom => new( -point.x, -point.y ),
DiamondBoard.Corner.Left => new( point.y, -point.x ),
_ => point
};
return new( point.x * w, point.y * h );
}
static void DrawCracks( Painter p, float w, float h, int health, float opacity, int colorIndex, DiamondBoard.Corner corner )
{
float mirror = colorIndex % 2 == 0 ? 1 : -1;
Color crackColor = Color.Lerp( Palette[colorIndex], Color.Black, health == 1 ? 0.6f : 0.45f );
float crackAlpha = health == 1 ? 0.94f : 0.88f;
void Crack( Painter p, float w, float h, int health, float opacity, float mirror, DiamondBoard.Corner corner, Span<Vector2> path )
{
for ( int i = 0; i < path.Length; i++ ) path[i] = CrackPoint( path[i], w, h, mirror, corner );
DrawGemStroke( p, path, crackColor.WithAlpha( opacity * crackAlpha ), health == 2 ? 2.2f : 3.3f, false );
for ( int i = 0; i < path.Length; i++ ) path[i] += new Vector2( 1, -0.4f );
DrawGemStroke( p, path, Color.White.WithAlpha( opacity * 0.55f ), 0.65f, false );
}
Crack( p, w, h, health, opacity, mirror, corner, stackalloc Vector2[] { new( 0, -1 ), new( 0.12f, -0.66f ), new( -0.10f, -0.43f ), new( 0.08f, -0.21f ), new( -0.12f, 0.06f ), new( 0.05f, 0.22f ) } );
Crack( p, w, h, health, opacity, mirror, corner, stackalloc Vector2[] { new( -0.10f, -0.43f ), new( -0.31f, -0.29f ), new( -0.4f, -0.06f ) } );
if ( health > 1 ) return;
Crack( p, w, h, health, opacity, mirror, corner, stackalloc Vector2[] { new( 0.05f, 0.22f ), new( 0.11f, 0.67f ) } );
Crack( p, w, h, health, opacity, mirror, corner, stackalloc Vector2[] { new( 0.05f, 0.22f ), new( 0.34f, -0.04f ), new( 0.55f, 0.12f ), new( 0.87f, 0.1f ) } );
Crack( p, w, h, health, opacity, mirror, corner, stackalloc Vector2[] { new( 0.34f, -0.04f ), new( 0.25f, -0.24f ), new( 0.55f, -0.4f ) } );
Crack( p, w, h, health, opacity, mirror, corner, stackalloc Vector2[] { new( 0.11f, 0.67f ), new( -0.12f, 0.7f ), new( 0, 0.98f ) } );
p.Fill = crackColor.WithAlpha( opacity * 0.65f );
p.Stroke = Stroke.None;
p.Polygon( [CrackPoint( new( -0.12f, 0.06f ), w, h, mirror, corner ), CrackPoint( new( -0.04f, 0.13f ), w, h, mirror, corner ), CrackPoint( new( 0.05f, 0.22f ), w, h, mirror, corner )] );
}
static void Facet( Painter p, Color color, float light, float opacity, bool ghost, ReadOnlySpan<Vector2> vertices )
{
float amount = MathF.Abs( light );
Color target = light >= 0 ? Color.White : Color.Black;
Color face = new( color.r + (target.r - color.r) * amount,
color.g + (target.g - color.g) * amount, color.b + (target.b - color.b) * amount );
p.Fill = ghost ? Fill.None : face.WithAlpha( opacity * 0.96f );
p.Stroke = Stroke.None;
p.Polygon( vertices );
DrawGemStroke( p, vertices, (ghost ? color : Color.Black).WithAlpha( opacity * (ghost ? 0.2f : 0.28f) ), 0.8f, true );
}
static void Text( Painter p, string text, float x, float y, float width, float height, float size, Color color, bool center = false, string fontName = "Roboto" )
{
p.TextStyle = new TextStyle { FontName = fontName, FontSize = size, Color = color, Alignment = center ? TextFlag.Center : TextFlag.LeftTop };
p.Text( text, new Rect( x, y, width, height ) );
}
}