// Match-3 — drag a gem onto a neighbour, or tap one then the other. Cascades and refill.
// Every gem is a SkiaSvg control: point Source at a file (or swap SkiaSvg for SkiaImage)
// and the game logic below does not change.
const int N = 8, kinds = 6;
const double cell = 40, pad = 8, top = 30, gem = 37;
const double swapDur = 0.14, popDur = 0.16, fallDur = 0.22;
const double W = pad * 2 + N * cell;
const double H = top + pad * 2 + N * cell;
var pal = new string[] { "#FF5C7C", "#FFC65C", "#5CE1A0", "#63D7FF", "#B07CFF", "#FF8A3D" };
// @ is the gem colour, filled in below. Body only — the svg element is added below.
var art = new string[]
{
"<circle cx='50' cy='50' r='44' fill='@'/><circle cx='50' cy='50' r='44' fill='none' stroke='#00000033' stroke-width='5'/><ellipse cx='37' cy='33' rx='17' ry='11' fill='#ffffff66' transform='rotate(-28 37 33)'/>",
"<rect x='7' y='7' width='86' height='86' rx='19' fill='@'/><rect x='7' y='7' width='86' height='86' rx='19' fill='none' stroke='#00000033' stroke-width='5'/><rect x='17' y='17' width='40' height='21' rx='10' fill='#ffffff5e'/>",
"<polygon points='50,3 97,50 50,97 3,50' fill='@'/><polygon points='50,3 97,50 50,50 3,50' fill='#ffffff40'/><polygon points='50,3 97,50 50,97 3,50' fill='none' stroke='#00000033' stroke-width='5'/>",
"<polygon points='50,7 95,88 5,88' fill='@'/><polygon points='50,7 73,48 27,48' fill='#ffffff4d'/><polygon points='50,7 95,88 5,88' fill='none' stroke='#00000033' stroke-width='5' stroke-linejoin='round'/>",
"<polygon points='50,4 92,27 92,73 50,96 8,73 8,27' fill='@'/><polygon points='50,4 92,27 50,50 8,27' fill='#ffffff40'/><polygon points='50,4 92,27 92,73 50,96 8,73 8,27' fill='none' stroke='#00000033' stroke-width='5' stroke-linejoin='round'/>",
"<path d='M35 5 h30 v30 h30 v30 h-30 v30 h-30 v-30 h-30 v-30 h30 z' fill='@'/><path d='M35 5 h30 v30 h30 v30 h-30 v30 h-30 v-30 h-30 v-30 h30 z' fill='none' stroke='#00000033' stroke-width='5' stroke-linejoin='round'/><rect x='41' y='12' width='13' height='23' rx='6' fill='#ffffff5e'/>",
};
var svgs = Enumerable.Range(0, kinds)
.Select(k => "<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'>" + art[k].Replace("@", pal[k]) + "</svg>")
.ToArray();
var rnd = new Random();
var g = new int[N, N];
var shown = new int[N, N];
var dead = new bool[N, N];
var fromX = new double[N, N];
var fromY = new double[N, N];
var tiles = new SkiaSvg[N, N];
int phase = 0; // 0 idle, 1 swap, 2 revert, 3 pop, 4 fall
double t = 0, pop = 1;
int cascade = 0, score = 0, moves = 0;
int selC = -1, selR = -1;
int aC = 0, aR = 0, bC = 0, bR = 0;
int downC = -1, downR = -1;
double downX = 0, downY = 0;
bool dragged = false;
SkiaLabel hud = null;
double CellX(int c) => pad + c * cell + cell / 2;
double CellY(int r) => top + pad + r * cell + cell / 2;
void Sw(int c1, int r1, int c2, int r2)
{
var x = g[c1, r1]; g[c1, r1] = g[c2, r2]; g[c2, r2] = x;
}
int Pick(int c, int r)
{
while (true)
{
var k = rnd.Next(kinds);
if (c >= 2 && g[c - 1, r] == k && g[c - 2, r] == k) continue;
if (r >= 2 && g[c, r - 1] == k && g[c, r - 2] == k) continue;
return k;
}
}
bool AnyMatch()
{
for (int r = 0; r < N; r++)
for (int c = 0; c < N - 2; c++)
if (g[c, r] == g[c + 1, r] && g[c, r] == g[c + 2, r]) return true;
for (int c = 0; c < N; c++)
for (int r = 0; r < N - 2; r++)
if (g[c, r] == g[c, r + 1] && g[c, r] == g[c, r + 2]) return true;
return false;
}
int Mark()
{
for (int c = 0; c < N; c++)
for (int r = 0; r < N; r++) dead[c, r] = false;
for (int r = 0; r < N; r++)
for (int c = 0; c < N - 2; c++)
if (g[c, r] == g[c + 1, r] && g[c, r] == g[c + 2, r])
{ dead[c, r] = true; dead[c + 1, r] = true; dead[c + 2, r] = true; }
for (int c = 0; c < N; c++)
for (int r = 0; r < N - 2; r++)
if (g[c, r] == g[c, r + 1] && g[c, r] == g[c, r + 2])
{ dead[c, r] = true; dead[c, r + 1] = true; dead[c, r + 2] = true; }
var n = 0;
for (int c = 0; c < N; c++)
for (int r = 0; r < N; r++) if (dead[c, r]) n++;
return n;
}
bool HasMove()
{
for (int c = 0; c < N; c++)
for (int r = 0; r < N; r++)
{
if (c + 1 < N) { Sw(c, r, c + 1, r); var m = AnyMatch(); Sw(c, r, c + 1, r); if (m) return true; }
if (r + 1 < N) { Sw(c, r, c, r + 1); var m = AnyMatch(); Sw(c, r, c, r + 1); if (m) return true; }
}
return false;
}
void FillFresh()
{
do
{
for (int c = 0; c < N; c++)
for (int r = 0; r < N; r++) g[c, r] = Pick(c, r);
}
while (!HasMove());
}
void Collapse()
{
for (int c = 0; c < N; c++)
{
var write = N - 1;
for (int r = N - 1; r >= 0; r--)
{
if (dead[c, r]) continue;
g[c, write] = g[c, r];
fromY[c, write] = (r - write) * cell;
write--;
}
var born = write + 1; // rows 0..write are new, they drop in as one column block
for (int r = write; r >= 0; r--)
{
g[c, r] = rnd.Next(kinds);
fromY[c, r] = -born * cell;
}
}
for (int c = 0; c < N; c++)
for (int r = 0; r < N; r++) dead[c, r] = false;
}
// pushes the model onto the controls: art only when the kind changed, offset and scale always
void Sync(double e)
{
for (int c = 0; c < N; c++)
for (int r = 0; r < N; r++)
{
var v = tiles[c, r];
if (v == null) continue;
if (shown[c, r] != g[c, r])
{
shown[c, r] = g[c, r];
v.SvgString = svgs[g[c, r]];
}
v.TranslationX = fromX[c, r] * (1 - e);
v.TranslationY = fromY[c, r] * (1 - e);
var k = (phase == 3 && dead[c, r]) ? pop : 1;
v.ScaleX = k;
v.ScaleY = k;
}
}
void BeginSwap(int c1, int r1, int c2, int r2, int next)
{
aC = c1; aR = r1; bC = c2; bR = r2;
Sw(c1, r1, c2, r2);
fromX[c1, r1] = CellX(c2) - CellX(c1);
fromY[c1, r1] = CellY(r2) - CellY(r1);
fromX[c2, r2] = CellX(c1) - CellX(c2);
fromY[c2, r2] = CellY(r1) - CellY(r2);
phase = next;
t = 0;
}
void Try(int c1, int r1, int c2, int r2)
{
if (phase != 0) return;
if (c2 < 0 || c2 >= N || r2 < 0 || r2 >= N) return;
selC = selR = -1;
moves++;
BeginSwap(c1, r1, c2, r2, 1);
}
void Step(double dt)
{
var e = 1.0;
if (phase != 0)
{
var dur = phase == 3 ? popDur : phase == 4 ? fallDur : swapDur;
t += dt / dur;
e = t >= 1 ? 1 : t * t * (3 - 2 * t);
pop = phase == 3 ? 1 - e : 1;
}
Sync(e);
if (phase != 0 && t >= 1)
{
t = 0;
for (int c = 0; c < N; c++)
for (int r = 0; r < N; r++) { fromX[c, r] = 0; fromY[c, r] = 0; }
if (phase == 1)
{
var n = Mark();
if (n > 0) { cascade = 1; score += n * 10; phase = 3; }
else BeginSwap(aC, aR, bC, bR, 2); // nothing matched, slide them back
}
else if (phase == 2) phase = 0;
else if (phase == 3) { Collapse(); phase = 4; }
else
{
var n = Mark();
if (n > 0) { cascade++; score += n * 10 * cascade; phase = 3; }
else
{
cascade = 0;
phase = 0;
if (!HasMove()) FillFresh();
}
}
}
if (hud != null)
hud.Text = cascade > 1 ? $"score {score} combo x{cascade}" : $"score {score} moves {moves}";
}
FillFresh();
return new SkiaShape
{
CornerRadius = 12,
HorizontalOptions = LayoutOptions.Center,
VerticalOptions = LayoutOptions.Center,
BackgroundColor = Color.Parse("#141A28"),
WidthRequest = W,
HeightRequest = H,
Children = new List<SkiaControl>
{
new SkiaShape
{
CornerRadius = 8,
BackgroundColor = Color.Parse("#0C111C"),
HorizontalOptions = LayoutOptions.Start,
VerticalOptions = LayoutOptions.Start,
WidthRequest = W - pad,
HeightRequest = W - pad,
Margin = new Thickness(pad * 0.5, top + pad * 0.5, 0, 0),
},
new SkiaLayer
{
Children = Enumerable.Range(0, N * N).Select(i => (SkiaControl)new SkiaSvg
{
SvgString = svgs[0],
WidthRequest = gem,
HeightRequest = gem,
HorizontalOptions = LayoutOptions.Start,
VerticalOptions = LayoutOptions.Start,
UseCache = SkiaCacheType.Image,
Margin = new Thickness(CellX(i % N) - gem / 2, CellY(i / N) - gem / 2, 0, 0),
}.Assign(out tiles[i % N, i / N])).ToList()
}.Fill(),
new SkiaLayer()
.Fill()
.WhenPaint((me, ctx) =>
{
if (selC < 0) return;
var s = (float)ctx.Scale;
var x = (float)(ctx.Destination.Left + (pad + selC * cell) * s);
var y = (float)(ctx.Destination.Top + (top + pad + selR * cell) * s);
using var stroke = new SKPaint
{
IsAntialias = true,
Style = SKPaintStyle.Stroke,
StrokeWidth = 2f * s,
Color = SKColors.White,
};
ctx.Context.Canvas.DrawRoundRect(
new SKRect(x, y, x + (float)(cell * s), y + (float)(cell * s)), 6 * s, 6 * s, stroke);
})
.WithGestures((me, args, apply) =>
{
var px = (args.Event.Location.X - me.DrawingRect.Left) / me.RenderingScale;
var py = (args.Event.Location.Y - me.DrawingRect.Top) / me.RenderingScale;
var c = (int)Math.Floor((px - pad) / cell);
var r = (int)Math.Floor((py - top - pad) / cell);
var inside = c >= 0 && c < N && r >= 0 && r < N;
if (args.Type == TouchActionResult.Down)
{
downC = inside ? c : -1;
downR = inside ? r : -1;
downX = px; downY = py;
dragged = false;
return me;
}
if (args.Type == TouchActionResult.Panning)
{
if (dragged || downC < 0 || phase != 0) return me;
var dx = px - downX;
var dy = py - downY;
if (Math.Abs(dx) < cell * 0.45 && Math.Abs(dy) < cell * 0.45) return me;
dragged = true;
if (Math.Abs(dx) > Math.Abs(dy)) Try(downC, downR, downC + (dx > 0 ? 1 : -1), downR);
else Try(downC, downR, downC, downR + (dy > 0 ? 1 : -1));
return me;
}
if (args.Type == TouchActionResult.Up)
{
if (dragged || downC < 0 || phase != 0) { downC = -1; return me; }
if (selC < 0) { selC = downC; selR = downR; } // first tap selects
else if (selC == downC && selR == downR) { selC = selR = -1; } // tapping it again clears
else if (Math.Abs(selC - downC) + Math.Abs(selR - downR) == 1) Try(selC, selR, downC, downR);
else { selC = downC; selR = downR; }
downC = -1;
return me;
}
return null;
})
.Animate(1, (me, animator, value, dt) =>
{
Step(Math.Min(dt, 0.05));
me.Update();
}, repeat: -1),
new SkiaLabel("0")
{
FontSize = 13,
TextColor = Color.Parse("#8AA0C0"),
Margin = new Thickness(0, 9, 0, 0),
}.CenterX().Assign(out hud),
}
};