DiamondDamage.cs
using System;
using System.Collections.Generic;
using static Diamonds.DiamondBoard;
namespace Diamonds;
/// <summary>One damage wave per collision, propagated only through pre-existing face bonds.</summary>
public static class DiamondDamage
{
const float Tolerance = 0.02f;
public const float SlideDamageSteps = 3;
public readonly record struct Contact( float Lane, float Y, int ColorIndex );
public readonly record struct FaceContact( float StartLane, float StartY, float EndLane, float EndY, int ColorIndex );
public static Dictionary<int, Corner> Targets( IReadOnlyList<Diamond> cells,
IReadOnlyList<DiamondMatching.CellMove> moving, IReadOnlyList<IReadOnlyList<int>> bonds,
float velocityX, float velocityY, List<Contact> contacts = null, List<FaceContact> faceContacts = null )
{
var hit = new Dictionary<int, Corner>();
if ( velocityY <= 0 ) return hit;
// Bottom-to-top points damage only on straight vertical falls. Diagonal
// slides must qualify through their full leading faces, even at high speed.
if ( velocityX == 0 && velocityY > DamageSpeedThreshold )
hit = PointTargets( cells, moving, contacts );
SlideFaceTargets( cells, moving, hit, faceContacts );
var movingIndices = new HashSet<int>();
foreach ( var move in moving ) movingIndices.Add( move.Index );
var damaged = new Dictionary<int, Corner>( hit );
foreach ( var group in bonds )
{
foreach ( int index in group )
{
if ( !hit.TryGetValue( index, out var corner ) ) continue;
// Indirect fractures follow the initiating strike. Direct contacts retain
// their own corner, including simultaneous hits on the same bonded group.
foreach ( int member in group ) if ( !movingIndices.Contains( member ) ) damaged.TryAdd( member, corner );
break;
}
}
return damaged;
}
// SettlementMove spans one uninterrupted slide, already coalesced across lattice
// steps. A stop, turn, group change or replan starts a new From, so distance from
// an earlier slide can never survive a landing or the destruction of a support.
static void SlideFaceTargets( IReadOnlyList<Diamond> cells, IReadOnlyList<DiamondMatching.CellMove> moving,
Dictionary<int, Corner> hit, List<FaceContact> contacts )
{
var movingIndices = new HashSet<int>();
foreach ( var move in moving ) movingIndices.Add( move.Index );
var seen = new HashSet<(int, int)>();
foreach ( var move in moving )
{
float lanes = move.To.Lane - move.From.Lane;
float dy = move.To.Y - move.From.Y;
if ( lanes == 0 || dy <= 0 || MathF.Abs( dy - MathF.Abs( lanes ) * Height * 0.5f ) > Tolerance ||
MathF.Abs( lanes ) < SlideDamageSteps ) continue;
int direction = Math.Sign( lanes );
for ( int other = 0; other < cells.Count; other++ )
{
if ( movingIndices.Contains( other ) || cells[other].Health <= 0 ) continue;
var receiver = cells[other];
// Only the lower face facing into the slide closes onto a full opposing
// face. The supporting slope is tangential; tips and partial faces don't count.
if ( MathF.Abs( receiver.Lane - move.To.Lane - direction ) * Width * 0.5f >= Tolerance ||
MathF.Abs( receiver.Y - move.To.Y - Height * 0.5f ) >= Tolerance ) continue;
hit.TryAdd( other, Corner.Top );
if ( contacts is not null && seen.Add( (other, direction) ) )
contacts.Add( new( receiver.Lane, receiver.Y - Height * 0.5f,
receiver.Lane - direction, receiver.Y, receiver.ColorIndex ) );
}
}
}
/// <summary>Exposed point contacts for a downward preview, regardless of speed, including floor peaks.</summary>
public static IReadOnlyList<Contact> PointContacts( IReadOnlyList<Diamond> cells, IReadOnlyList<DiamondMatching.CellMove> moving,
bool fillCornerPockets = true )
{
var contacts = new List<Contact>();
PointTargets( cells, moving, contacts, includeFloor: true, fillCornerPockets: fillCornerPockets );
return contacts;
}
// Caller-owned storage: previews reuse this without sharing state with real damage waves.
internal sealed class PointContactScratch
{
public readonly Dictionary<int, Corner> Hit = new();
public readonly HashSet<int> Moving = new();
public readonly HashSet<(int, Corner)> Corners = new();
}
internal static void CopyPointContacts( IReadOnlyList<Diamond> cells, IReadOnlyList<DiamondMatching.CellMove> moving,
List<Contact> contacts, PointContactScratch scratch, bool fillCornerPockets = true )
{
contacts.Clear();
PointTargets( cells, moving, contacts, includeFloor: true, scratch: scratch, fillCornerPockets: fillCornerPockets );
}
/// <summary>New exposed tip collisions for cosmetic sparks, including harmless side tips.</summary>
public static IReadOnlyList<Contact> SparkContacts( IReadOnlyList<Diamond> cells, IReadOnlyList<DiamondMatching.CellMove> moving,
bool fillCornerPockets = true )
{
var contacts = new List<Contact>( PointContacts( cells, moving, fillCornerPockets ) );
var movingIndices = new HashSet<int>( moving.Select( m => m.Index ) );
foreach ( var move in moving )
for ( int other = 0; other < cells.Count; other++ )
{
if ( movingIndices.Contains( other ) || cells[other].Health <= 0 ) continue;
var receiver = cells[other];
float dx = (receiver.Lane - move.To.Lane) * Width * 0.5f;
if ( MathF.Abs( MathF.Abs( dx ) - Width ) > Tolerance || MathF.Abs( receiver.Y - move.To.Y ) > Tolerance ) continue;
int side = Math.Sign( dx );
if ( (move.To.Lane - move.From.Lane) * side <= 0 ||
!ExposedPoint( cells, move.Index, side, 0, movingIndices ) ||
!ExposedPoint( cells, other, -side, 0, movingIndices ) ) continue;
var contact = new Contact( (move.To.Lane + receiver.Lane) * 0.5f, receiver.Y, receiver.ColorIndex );
if ( !contacts.Contains( contact ) ) contacts.Add( contact );
}
return contacts;
}
static Dictionary<int, Corner> PointTargets( IReadOnlyList<Diamond> cells,
IReadOnlyList<DiamondMatching.CellMove> moving,
List<Contact> contacts, bool includeFloor = false, PointContactScratch scratch = null, bool fillCornerPockets = true )
{
var hit = scratch?.Hit ?? new Dictionary<int, Corner>();
var movingIndices = scratch?.Moving ?? new HashSet<int>();
var contactCorners = scratch?.Corners ?? new HashSet<(int, Corner)>();
hit.Clear(); movingIndices.Clear(); contactCorners.Clear();
for ( int i = 0; i < moving.Count; i++ ) movingIndices.Add( moving[i].Index );
for ( int i = 0; i < moving.Count; i++ )
{
var move = moving[i];
var cell = move.To;
if ( cell.Y <= move.From.Y ) continue;
// Odd lanes sit over upward floor peaks; even lanes nest between two faces.
if ( includeFloor && MathF.Abs( cell.Lane % 2 - 1 ) * Width * 0.5f < Tolerance &&
MathF.Abs( cell.Y - FloorY( cell.Lane ) ) < Tolerance &&
ExposedPoint( cells, move.Index, 0, 1, movingIndices ) && ExposedFloorPeak( cells, cell, movingIndices, fillCornerPockets ) &&
contactCorners.Add( (move.Index, Corner.Bottom) ) )
contacts.Add( new( cell.Lane, cell.Y + Height * 0.5f, cell.ColorIndex ) );
// Only receiving diamonds take damage; the floor cannot receive gem damage.
for ( int other = 0; other < cells.Count; other++ )
{
if ( movingIndices.Contains( other ) ) continue;
var receiver = cells[other];
// Only a moving bottom tip striking a receiving top tip can damage.
// Side tips meeting at the end of a downhill slide are harmless.
if ( !BottomToTopContact( cell, receiver ) || BottomToTopContact( move.From, receiver, 0.0001f ) ) continue;
if ( !ExposedPoint( cells, move.Index, 0, 1, movingIndices ) ||
!ExposedPoint( cells, other, 0, -1, movingIndices ) ) continue;
const Corner corner = Corner.Top;
hit.TryAdd( other, corner );
if ( contacts is not null && contactCorners.Add( (other, corner) ) )
contacts.Add( new( (cell.Lane + receiver.Lane) * 0.5f, (cell.Y + receiver.Y) * 0.5f, receiver.ColorIndex ) );
}
}
return hit;
}
// Either face adjoining the struck point can extend it into a smooth edge.
// Check the whole physical assembly, including other colors, but exclude the
// opposite collision side: new contacts must not hide the points being tested.
static bool ExposedPoint( IReadOnlyList<Diamond> cells, int index, int pointX, int pointY, HashSet<int> moving )
{
var cell = cells[index];
bool isMoving = moving.Contains( index );
for ( int other = 0; other < cells.Count; other++ )
{
if ( other == index || moving.Contains( other ) != isMoving || cells[other].Health <= 0 ) continue;
float dx = (cells[other].Lane - cell.Lane) * Width * 0.5f;
float dy = cells[other].Y - cell.Y;
if ( MathF.Abs( MathF.Abs( dx ) - Width * 0.5f ) >= Tolerance ||
MathF.Abs( MathF.Abs( dy ) - Height * 0.5f ) >= Tolerance ) continue;
if ( (pointX != 0 && Math.Sign( dx ) == pointX) ||
(pointY != 0 && Math.Sign( dy ) == pointY) ) return false;
}
return true;
}
// A peak flanked by a resting gem or filled corner slope is part of a smooth
// edge: the mover's lower face lands flush on that neighbor's upper face.
static bool ExposedFloorPeak( IReadOnlyList<Diamond> cells, Diamond cell, HashSet<int> moving, bool fillCornerPockets )
{
for ( int other = 0; other < cells.Count; other++ )
if ( !moving.Contains( other ) && cells[other].Health > 0 && FlanksPeak( cells[other] ) ) return false;
// Matches DiamondBoard.CornerFills without allocating an iterator per preview frame.
return !fillCornerPockets || (!FlanksPeak( new( 0, BoardHeight - Height * 0.5f, 0 ) ) &&
!FlanksPeak( new( LaneCount - 1, BoardHeight - Height * 0.5f, 0 ) ));
bool FlanksPeak( Diamond flank ) => MathF.Abs( MathF.Abs( flank.Lane - cell.Lane ) * Width * 0.5f - Width * 0.5f ) < Tolerance &&
MathF.Abs( flank.Y - cell.Y - Height * 0.5f ) < Tolerance;
}
static bool BottomToTopContact( Diamond mover, Diamond receiver, float tolerance = Tolerance )
{
float dx = MathF.Abs( mover.Lane - receiver.Lane ) * Width * 0.5f;
return dx < tolerance && MathF.Abs( receiver.Y - mover.Y - Height ) < tolerance;
}
public static bool Apply( List<Diamond> cells, IReadOnlyDictionary<int, Corner> targets )
{
bool destroyed = false;
foreach ( var target in targets )
{
int index = target.Key;
cells[index] = cells[index] with
{
Health = Math.Max( 0, cells[index].Health - 1 ),
FirstCrack = cells[index].Health == MaxHealth ? target.Value : cells[index].FirstCrack
};
destroyed |= cells[index].Health == 0;
}
return destroyed;
}
}