// GAME ETUDE
SkiaLabel movesLabel = null;
SkiaControl winLabel = null; //game over dlg
var rnd = new Random();
var board = new int[4, 4]; // value in each cell; 0 = the empty space (no tile there)
var tiles = new SkiaShape[16]; // 15 physical tiles indexed by value 1..15 — there is NO tile 0
int moves = 0;
bool won = false;
const double pitch = 68; // tile 66 + 2 gap between tiles
(int r, int c) Cell(int v)
{
for (int r = 0; r < 4; r++)
for (int c = 0; c < 4; c++)
if (board[r, c] == v) return (r, c);
return (3, 3);
}
// Snap every tile to its slot with no animation, and refresh the HUD.
void Snap()
{
for (int v = 1; v < 16; v++)
{
var (r, c) = Cell(v);
tiles[v].Left = c * pitch; // Left/Top are the tile's absolute position
tiles[v].Top = r * pitch;
tiles[v].TranslationX = 0; // translation is only a transient slide offset
tiles[v].TranslationY = 0;
}
movesLabel.Text = $"MOVES {moves}";
winLabel.IsVisible = won;
}
bool Solved()
{
int n = 1;
for (int r = 0; r < 4; r++)
for (int c = 0; c < 4; c++)
{
int expect = (r == 3 && c == 3) ? 0 : n++;
if (board[r, c] != expect) return false;
}
return true;
}
// Move the tile in cell (fr,fc) into the empty cell (tr,tc). Its own translation slides.
void MoveTile(int fr, int fc, int tr, int tc, bool animate)
{
int v = board[fr, fc];
board[tr, tc] = v;
board[fr, fc] = 0;
tiles[v].Left = tc * pitch; // final position via Left/Top
tiles[v].Top = tr * pitch;
if (animate)
{
// start visually at the old slot (translation offset) and slide the offset to 0
tiles[v].AnimateTranslationX((fc - tc) * pitch, 0, 0.11, easing: Easing.Linear);
tiles[v].AnimateTranslationY((fr - tr) * pitch, 0, 0.11, easing: Easing.Linear);
}
else
{
tiles[v].TranslationX = 0;
tiles[v].TranslationY = 0;
}
}
// Arrow: pull the neighbour on that side into the empty cell. dir 0 left,1 right,2 up,3 down.
bool Slide(int dir, bool animate)
{
var (gr, gc) = Cell(0);
int nr = gr, nc = gc;
if (dir == 0) nc = gc + 1;
else if (dir == 1) nc = gc - 1;
else if (dir == 2) nr = gr + 1;
else nr = gr - 1;
if (nr < 0 || nr > 3 || nc < 0 || nc > 3) return false;
MoveTile(nr, nc, gr, gc, animate);
return true;
}
// Tap a tile by its value: if it sits next to the empty space, slide it in.
void Tap(int v)
{
if (won) return;
var (tr, tc) = Cell(v);
var (gr, gc) = Cell(0);
bool adjacent = (Math.Abs(gr - tr) == 1 && gc == tc) || (Math.Abs(gc - tc) == 1 && gr == tr);
if (!adjacent) return;
MoveTile(tr, tc, gr, gc, true);
moves++;
if (Solved()) won = true;
movesLabel.Text = $"MOVES {moves}";
winLabel.IsVisible = won;
}
void Reset()
{
// 15 evenly-spaced hues (every tile a distinct hue), one sane lightness, shuffled onto values.
var hue = new int[15];
for (int i = 0; i < 15; i++) hue[i] = i;
for (int i = 14; i > 0; i--) { int j = rnd.Next(i + 1); (hue[i], hue[j]) = (hue[j], hue[i]); }
for (int v = 1; v < 16; v++) tiles[v].BackgroundColor = Color.FromHsla(hue[v - 1] / 15f, 0.6f, 0.6f, 1f);
int n = 1;
for (int r = 0; r < 4; r++)
for (int c = 0; c < 4; c++)
board[r, c] = (r == 3 && c == 3) ? 0 : n++;
for (int i = 0; i < 400; i++) Slide(rnd.Next(4), false); // legal moves keep it solvable
if (Solved()) Slide(rnd.Next(4), false); // never start already solved
moves = 0;
won = false;
Snap();
}
// Build the 15 physical tiles once, absolute-positioned by their own TranslationX/Y.
// The 16th slot is simply never occupied — a real hole in the absolute layout.
var tileViews = new List<SkiaControl>();
for (int v = 1; v < 16; v++)
{
int val = v; // copy loop var so each tap closure keeps its own value
tileViews.Add(new SkiaShape
{
UseCache = SkiaCacheType.GPU,
AnimationTapped = SkiaTouchAnimation.Shimmer,
TouchEffectColor = Colors.White.WithAlpha(0.5),
AnimationTappedSpeed = 200,
Type = ShapeType.Rectangle,
CornerRadius = 8,
WidthRequest = 66, HeightRequest = 66,
BevelType = BevelType.Bevel, // raised candy tile: light top-left, shadow bottom-right
Bevel = new SkiaBevel
{
Depth = 3,
LightColor = Color.FromHex("#F0F0F0"),
ShadowColor = Color.FromHex("#202020"),
Opacity = 0.45
},
Children =
{
new SkiaLabel(val.ToString())
{
FontSize = 27, FontAttributes = FontAttributes.Bold,
TextColor = Color.FromHex("#CC000000"),
// engraved/embossed digits: soft light offset under the dark glyph
DropShadowColor = Color.FromHex("#55FFFFFF"),
DropShadowOffsetX = 1, DropShadowOffsetY = 1, DropShadowSize = 1,
HorizontalOptions = LayoutOptions.Center,
VerticalOptions = LayoutOptions.Center,
},
},
}
.Assign(out tiles[val])
.OnTapped(me =>
{
//sync with shimmer
Tasks.StartDelayed(TimeSpan.FromMilliseconds(10),()=>
{
Tap(val);
});
}));
}
return new SkiaLayout
{
UseCache = SkiaCacheType.Operations,
Type = LayoutType.Column,
Spacing = 12,
HorizontalOptions = LayoutOptions.Center,
VerticalOptions = LayoutOptions.Center,
Children =
{
new SkiaLabel("15 Puzzle")
{
FontSize = 30, FontAttributes = FontAttributes.Bold,
TextColor = Color.FromHex("#E2E8F0"),
HorizontalOptions = LayoutOptions.Center,
},
new SkiaLabel("MOVES 0") { FontSize = 14, TextColor = Color.FromHex("#94A3B8"), HorizontalOptions = LayoutOptions.Center }.Assign(out movesLabel),
new SkiaLayout
{
HorizontalOptions = LayoutOptions.Center,
Children =
{
new SkiaShape
{
UseCache = SkiaCacheType.Image,
BevelType = BevelType.Emboss,
Bevel = new SkiaBevel
{
Depth = 3,
ShadowColor = Colors.Black,
LightColor = Colors.White,
Opacity = 0.35
},
Type = ShapeType.Rectangle,
CornerRadius = 8,
BackgroundColor = Color.FromHex("#F3E5D9"),
Padding = 4,
HorizontalOptions = LayoutOptions.Fill,
VerticalOptions = LayoutOptions.Fill,
Children =
{
}
},
// Absolute playfield: tiles place themselves by translation.
new SkiaLayout
{
Margin=4,
UseCache = SkiaCacheType.Operations,
Type = LayoutType.Absolute,
WidthRequest = 269,
LockRatio = 1,
HorizontalOptions = LayoutOptions.Center,
VerticalOptions = LayoutOptions.Center,
Children = tileViews,
},
new SkiaShape()
{
IsVisible = false,
UseCache = SkiaCacheType.Operations,
HorizontalOptions = LayoutOptions.Center,
VerticalOptions = LayoutOptions.Center,
BackgroundColor = Color.FromHex("#99000000"),
CornerRadius = 8,
Children =
{
new SkiaLabel("SOLVED!!!")
{
Padding = 16,
FontSize = 24, FontAttributes = FontAttributes.Bold,
TextColor = Color.FromHex("#FFD9D0"),
HorizontalTextAlignment = DrawTextAlignment.Center,
},
}
}.Assign(out winLabel).OnTapped(me => Reset()),
}
},
new SkiaRichLabel("← → ↑ ↓ slide · tap a tile · Space shuffle")
{
FontSize = 12, TextColor = Color.FromHex("#64748B"),
HorizontalOptions = LayoutOptions.Center,
},
}
}
.OnKeyDown((me, key) =>
{
if (won)
{
if (key == InputKey.Space || key == InputKey.Enter) Reset();
return;
}
int dir = key switch
{
InputKey.ArrowLeft => 0, InputKey.ArrowRight => 1,
InputKey.ArrowUp => 2, InputKey.ArrowDown => 3, _ => -1,
};
if (dir < 0) return;
if (Slide(dir, true))
{
moves++;
if (Solved()) won = true;
movesLabel.Text = $"MOVES {moves}";
winLabel.IsVisible = won;
}
})
.Adapt(me => Reset());