// Blackjack vs the house. Tap Deal, then Hit or Stand. Dealer stands on 17.
// Card faces are real SVGs from github.com/MattCain/svg-playing-cards (MIT), pulled off the
// CDN the first time a card appears and cached, so a round downloads only what it deals.
const string cdn = "https://cdn.jsdelivr.net/gh/MattCain/svg-playing-cards@master/imgs/";
const double W = 380, H = 470;
// the deck's svgs draw their own white body + keyline; we strip that and draw the card here,
// so cardPad is ours to set. artH keeps the remaining art at its native 167.09 x 242.67.
const double cardW = 70, cardPad = 3;
const double artW = cardW - cardPad * 2;
const double artH = artW * 242.6669922 / 167.0869141;
const double cardH = artH + cardPad * 2, spread = 38;
const double discardX = -cardW - 20, discardY = -cardH * 0.3; // spent cards leave past the top-left
const double dealerY = 60, playerY = 210, deckX = W - 62, deckY = 18, sideX = 32;
const double dealDur = 0.26, flipDur = 0.18, outDur = 0.34;
const double sweepStagger = 0.01; // gap between one card leaving and the next in the same row
const double sweepRowGap = 0.0; // extra pause before the dealer's row follows the player's
const int pool = 12;
// the three button rectangles, used both to draw them and to hit-test taps
const double btnY = 358, btnH = 42;
const double btnAX = W / 2 - 60, btnAW = 120; // Deal / Hit
const double btnBX = W / 2 + 70, btnBW = 100; // bet +25 / Stand
const double btnCX = W / 2 - 170, btnCW = 100; // bet -25
var rankName = new string[] { "ace", "2", "3", "4", "5", "6", "7", "8", "9", "10", "jack", "queen", "king" };
var suitName = new string[] { "hearts", "diamonds", "clubs", "spades" };
// file name for a card id, e.g. 10 of hearts -> "10_of_hearts"
string Name(int card) => rankName[card % 13] + "_of_" + suitName[card / 13];
var art = new Dictionary<int, string>(); // card id (-1 = back) -> svg markup
var pending = new HashSet<int>();
var http = new System.Net.Http.HttpClient();
// SkiaSvg parses with Encoding.ASCII, so non-ascii bytes (this deck's metadata has plenty)
// would turn into '?' inside the markup — strip them before they ever get there
string Ascii(string svg)
{
var sb = new System.Text.StringBuilder(svg.Length);
foreach (var ch in svg) if (ch < 128) sb.Append(ch);
return sb.ToString();
}
// drops the card outline the deck bakes in, leaving pips and figures on transparency
string StripFrame(string svg)
{
const string key = "fill:#FFFFFF;stroke-width:0.5;";
var k = svg.IndexOf(key);
if (k < 0) return svg;
var start = svg.LastIndexOf("<path", k);
var end = svg.IndexOf("/>", k);
if (start < 0 || end < 0) return svg;
return svg.Remove(start, end + 2 - start);
}
// downloads one card's svg once and caches it; -1 is the shared back
async void Fetch(int card)
{
if (art.ContainsKey(card) || !pending.Add(card)) return;
try
{
var svg = await http.GetStringAsync(cdn + (card < 0 ? "back" : Name(card)) + ".svg");
art[card] = card < 0 ? svg : Ascii(StripFrame(svg));
}
catch (Exception e)
{
Console.WriteLine("card art failed: " + e.Message);
}
finally
{
pending.Remove(card);
}
}
var rnd = new Random();
var deck = new List<int>();
var dealer = new List<int>();
var player = new List<int>();
int bank = 500, bet = 50, phase = 0; // 0 bet, 1 dealing, 2 player, 3 dealer, 4 payout
double wait = 0;
string msg = "place your bet";
var qOwner = new List<int>(); // pending deals: 0 dealer, 1 player
var qUp = new List<bool>();
var slotCard = new int[pool];
var slotOwner = new int[pool];
var slotSeat = new int[pool];
var slotAnim = new double[pool];
var slotFlip = new double[pool];
var slotUp = new bool[pool];
var slotUsed = new bool[pool];
var shown = new int[pool];
var backSet = new bool[pool];
var slotOut = new double[pool];
var slotLeaving = new bool[pool]; // on its way off the table
var slotDelay = new double[pool]; // its turn in the right-to-left sweep
var slotFromX = new double[pool]; // where it sat when the sweep began
var slotFromY = new double[pool];
int dealSeq = 0; // every dealt card gets a higher z than the one before it // 0 = on the table, otherwise how far it has been swept away
var slotJitterX = new double[pool]; // a dip of scatter so a hand never looks machine-stacked
var slotJitterY = new double[pool];
var vBody = new SkiaShape[pool];
var vFace = new SkiaSvg[pool];
var vBack = new SkiaSvg[pool];
SkiaLabel hudMsg = null, hudBank = null, hudBet = null, hudDealer = null, hudPlayer = null;
SkiaShape btnA = null, btnB = null, btnC = null;
SkiaLabel labA = null, labB = null, labC = null;
// best total for a hand: aces start at 11 and drop to 1 while it would bust
int Val(List<int> h)
{
int sum = 0, aces = 0;
foreach (var c in h)
{
var r = c % 13;
sum += r == 0 ? 11 : r >= 9 ? 10 : r + 1;
if (r == 0) aces++;
}
while (sum > 21 && aces > 0) { sum -= 10; aces--; }
return sum;
}
// a two-card 21, which pays 3:2 and ends the hand at once
bool Natural(List<int> h) => h.Count == 2 && Val(h) == 21;
// where the seat-th card of a hand sits, fanned around the table centre
double SeatX(int seat, int count) => W / 2 + (seat - (count - 1) / 2.0) * spread - cardW / 2;
// dealer row or player row
double SeatY(int owner) => owner == 0 ? dealerY : playerY;
// deals the top card into the first free slot and starts its slide from the deck
void Give(int owner, bool up)
{
for (int i = 0; i < pool; i++)
{
if (slotUsed[i]) continue;
var card = deck[deck.Count - 1];
deck.RemoveAt(deck.Count - 1);
(owner == 0 ? dealer : player).Add(card);
slotUsed[i] = true;
slotCard[i] = card;
slotOwner[i] = owner;
slotSeat[i] = (owner == 0 ? dealer.Count : player.Count) - 1;
slotAnim[i] = 0;
slotFlip[i] = 0;
slotUp[i] = up;
slotOut[i] = 0;
slotLeaving[i] = false;
slotDelay[i] = 0;
vBody[i].Opacity = 1;
vBody[i].ZIndex = ++dealSeq; // pooled slots are reused out of order, so z follows the deal
slotJitterX[i] = rnd.NextDouble() * 2 - 1;
slotJitterY[i] = rnd.NextDouble() * 4 - 2;
Fetch(card);
return;
}
}
// sends every card on the table off past the top-left; slots free themselves when they arrive.
// returns true if anything was actually on the table, so the deal can wait for it to clear
double Sweep()
{
var going = new List<int>();
for (int i = 0; i < pool; i++)
if (slotUsed[i] && !slotLeaving[i]) going.Add(i);
if (going.Count == 0) return 0;
// the player's row goes first (they exit upward), and within a row rightmost leads
going.Sort((a, b) =>
{
var row = slotOwner[b].CompareTo(slotOwner[a]); // owner 1 = player, so it sorts ahead of the dealer
return row != 0 ? row : vBody[b].TranslationX.CompareTo(vBody[a].TranslationX);
});
var at = 0.0;
for (int k = 0; k < going.Count; k++)
{
var i = going[k];
if (k > 0 && slotOwner[i] != slotOwner[going[k - 1]]) at += sweepRowGap; // row change
slotLeaving[i] = true;
slotDelay[i] = at;
slotUp[i] = false; // turns face down before it goes
slotOut[i] = 0;
slotFromX[i] = vBody[i].TranslationX;
slotFromY[i] = vBody[i].TranslationY;
at += sweepStagger;
}
return at - sweepStagger + flipDur + outDur;
}
// turns the dealer's hole card face up
void Reveal()
{
for (int i = 0; i < pool; i++)
if (slotUsed[i] && slotOwner[i] == 0) slotUp[i] = true;
}
// true while anything is still queued, sliding or flipping — the phase machine waits on it
bool Busy()
{
if (qOwner.Count > 0) return true;
for (int i = 0; i < pool; i++)
{
if (!slotUsed[i]) continue;
if (slotAnim[i] < 1) return true;
if (slotUp[i] && slotFlip[i] < 1) return true;
}
return false;
}
// settles the hand; the stake already left the stack, so a win pays it back doubled
void Payout()
{
var p = Val(player);
var d = Val(dealer);
if (p > 21) msg = $"bust — you lose {bet}";
else if (Natural(player) && !Natural(dealer)) { bank += bet + bet * 3 / 2; msg = $"blackjack! +{bet * 3 / 2}"; }
else if (d > 21) { bank += bet * 2; msg = $"dealer busts — you win {bet}"; }
else if (p > d) { bank += bet * 2; msg = $"you win {bet}"; }
else if (p < d) msg = $"dealer wins {bet}";
else { bank += bet; msg = "push"; }
phase = 4;
wait = 2.0;
}
// takes the stake, shuffles, clears the table and queues the opening four cards
void NewRound()
{
if (bank < 25) { msg = "out of chips — tap Deal to rebuy"; bank = 500; bet = 50; return; }
if (bet > bank) bet = bank;
bank -= bet; // the wager sits on the table now
deck.Clear();
for (int i = 0; i < 52; i++) deck.Add(i);
for (int i = deck.Count - 1; i > 0; i--)
{
var j = rnd.Next(i + 1);
(deck[i], deck[j]) = (deck[j], deck[i]);
}
dealer.Clear();
player.Clear();
var clearing = Sweep(); // last hand leaves first, the deal waits for the table
qOwner.Clear(); qUp.Clear();
qOwner.Add(1); qUp.Add(true);
qOwner.Add(0); qUp.Add(true);
qOwner.Add(1); qUp.Add(true);
qOwner.Add(0); qUp.Add(false); // hole card
phase = 1;
wait = clearing; // Sweep tells us how long the table takes to clear
msg = "";
}
// pushes the model onto the controls: art, position, flip state and the hud text
void Sync()
{
var dCount = dealer.Count;
var pCount = player.Count;
for (int i = 0; i < pool; i++)
{
var body = vBody[i];
if (body == null) continue;
if (!slotUsed[i]) { body.IsVisible = false; continue; }
var card = slotCard[i];
if (shown[i] != card)
{
if (art.TryGetValue(card, out var svg))
{
shown[i] = card;
vFace[i].SvgString = svg;
if (vFace[i].Svg?.Picture == null) // parsed to nothing: blank card, refetch once
{
Console.WriteLine($"art did not parse: {Name(card)}");
art.Remove(card);
shown[i] = -2;
}
}
else Fetch(card);
}
if (!backSet[i] && art.TryGetValue(-1, out var backSvg)) { backSet[i] = true; vBack[i].SvgString = backSvg; }
body.IsVisible = true;
if (slotLeaving[i]) // holds its old spot, then slides off past the top-left
{
var o = Math.Min(1, slotOut[i]);
o *= o;
body.TranslationX = slotFromX[i] + (discardX - slotFromX[i]) * o;
body.TranslationY = slotFromY[i] + (discardY - slotFromY[i]) * o;
body.Opacity = 1 - o;
}
else
{
var e = slotAnim[i] >= 1 ? 1 : slotAnim[i] * slotAnim[i] * (3 - 2 * slotAnim[i]);
var tx = SeatX(slotSeat[i], slotOwner[i] == 0 ? dCount : pCount) + slotJitterX[i];
var ty = SeatY(slotOwner[i]) + slotJitterY[i];
body.TranslationX = deckX + (tx - deckX) * e;
body.TranslationY = deckY + (ty - deckY) * e;
body.Opacity = 1;
}
var f = slotFlip[i];
var face = f > 0.5;
body.ScaleX = Math.Max(0.04, Math.Abs(1 - 2 * f));
vBack[i].IsVisible = !face;
vFace[i].IsVisible = face && shown[i] == card; // stays blank until its art has landed
}
if (hudMsg != null) hudMsg.Text = msg;
// while betting the pending wager is already taken off the displayed stack
if (hudBank != null) hudBank.Text = $"chips {(phase == 0 ? bank - bet : bank)}";
if (hudBet != null) hudBet.Text = $"bet {bet}";
if (hudDealer != null)
{
var reveal = true;
for (int i = 0; i < pool; i++) if (slotUsed[i] && slotOwner[i] == 0 && slotFlip[i] <= 0.5) reveal = false;
hudDealer.Text = dealer.Count == 0 ? "" : reveal ? $"{Val(dealer)}" : "?";
}
if (hudPlayer != null) hudPlayer.Text = player.Count == 0 ? "" : $"{Val(player)}";
var betting = phase == 0;
var playing = phase == 2; // stay on screen while a card lands, so the ripple is visible
btnA.IsVisible = betting || playing;
btnB.IsVisible = betting || playing;
btnC.IsVisible = betting;
labA.Text = betting ? "Deal" : "Hit";
labB.Text = betting ? "bet +25" : "Stand";
labC.Text = "bet -25";
}
// one frame: advance animations, tick the pause timer, then run the phase machine
void Step(double dt)
{
for (int i = 0; i < pool; i++)
{
if (!slotUsed[i]) continue;
if (slotLeaving[i])
{
// leaving is the deal in reverse: wait your turn, turn face down, then slide off
if (slotDelay[i] > 0) { slotDelay[i] -= dt; continue; }
if (slotFlip[i] > 0) { slotFlip[i] = Math.Max(0, slotFlip[i] - dt / flipDur); continue; }
slotOut[i] += dt / outDur;
if (slotOut[i] >= 1) { slotUsed[i] = false; slotLeaving[i] = false; slotOut[i] = 0; shown[i] = -2; }
continue;
}
if (slotAnim[i] < 1) slotAnim[i] = Math.Min(1, slotAnim[i] + dt / dealDur);
else if (slotUp[i] && slotFlip[i] < 1 && art.ContainsKey(slotCard[i])) slotFlip[i] = Math.Min(1, slotFlip[i] + dt / flipDur);
else if (!slotUp[i] && slotFlip[i] > 0) slotFlip[i] = Math.Max(0, slotFlip[i] - dt / flipDur);
}
if (wait > 0)
{
wait -= dt;
if (wait > 0) { Sync(); return; }
wait = 0;
if (phase == 4) { phase = 0; msg = "place your bet"; }
}
if (phase == 1)
{
if (qOwner.Count > 0)
{
if (wait <= 0)
{
Give(qOwner[0], qUp[0]);
qOwner.RemoveAt(0);
qUp.RemoveAt(0);
wait = 0.24;
}
}
else if (!Busy())
{
if (Natural(player) || Natural(dealer)) { Reveal(); phase = 3; wait = 0.5; }
else { phase = 2; msg = "hit or stand"; }
}
}
else if (phase == 2)
{
if (!Busy() && Val(player) > 21) { Reveal(); Payout(); }
}
else if (phase == 3)
{
if (!Busy())
{
Reveal();
if (Val(player) <= 21 && Val(dealer) < 17 && !Natural(player)) { Give(0, true); wait = 0.35; }
else Payout();
}
}
Sync();
}
// ripple from where the finger landed, in the button's own coordinates
void Ripple(SkiaShape btn, double left, double x, double y)
{
if (btn != null && btn.IsVisible) btn.PlayRippleAnimation(Color.Parse("#FFFFFF66"), x - left, y - btnY);
}
// hit-tests the button row in table coordinates
void Tap(double x, double y)
{
if (y < btnY || y > btnY + btnH) return;
var onA = x > btnAX && x < btnAX + btnAW;
var onB = x > btnBX && x < btnBX + btnBW;
var onC = x > btnCX && x < btnCX + btnCW;
if (phase == 0)
{
if (onA) { Ripple(btnA, btnAX, x, y); NewRound(); return; }
if (onB && bet + 25 <= bank) { Ripple(btnB, btnBX, x, y); bet += 25; return; }
if (onC && bet > 25) { Ripple(btnC, btnCX, x, y); bet -= 25; return; }
}
else if (phase == 2 && !Busy())
{
if (onA) { Ripple(btnA, btnAX, x, y); Give(1, true); return; }
if (onB) { Ripple(btnB, btnBX, x, y); phase = 3; wait = 0.4; msg = ""; }
}
}
// one pooled card view: white body carrying a face svg and a back svg
SkiaControl Card(int i) => new SkiaShape
{
CornerRadius = 4,
StrokeColor = Color.Parse("#8F9791"),
StrokeWidth = 1,
BackgroundColor = Colors.White,
WidthRequest = cardW,
HeightRequest = cardH,
HorizontalOptions = LayoutOptions.Start,
VerticalOptions = LayoutOptions.Start,
IsVisible = false,
UseCache = SkiaCacheType.Image,
Children = new List<SkiaControl>
{
new SkiaSvg
{
Margin = cardPad,
WidthRequest = artW,
HeightRequest = artH,
}.Assign(out vFace[i]),
new SkiaSvg
{
WidthRequest = cardW,
HeightRequest = cardH,
}.Assign(out vBack[i]),
}
}.Assign(out vBody[i]);
// a button visual only — the overlay below owns the input
SkiaControl Button(double left, double width, string caption, out SkiaShape shape, out SkiaLabel label) => new SkiaShape
{
CornerRadius = 24,
BackgroundColor = Color.Parse("#19563E"),
WidthRequest = width,
HeightRequest = btnH,
HorizontalOptions = LayoutOptions.Start,
VerticalOptions = LayoutOptions.Start,
Margin = new Thickness(left, btnY, 0, 0),
Children = new List<SkiaControl>
{
new SkiaLabel(caption)
{
FontSize = 14,
FontWeight = 600,
TextColor = Colors.White,
}.Center().Assign(out label),
}
}.Assign(out shape);
// our own back instead of the deck's plain blue hatch
string Back()
{
const string ink = "#14493A", trim = "#FDFDFB", emblem = "#E9C46A";
var s = "<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 140'>";
s += "<rect width='100' height='140' rx='7' fill='" + trim + "'/>";
s += "<clipPath id='c'><rect x='3' y='3' width='94' height='134' rx='5'/></clipPath>";
s += "<g clip-path='url(#c)'><rect x='3' y='3' width='94' height='134' fill='" + ink + "'/>";
s += "<g stroke='#ffffff2e' stroke-width='2'>";
for (int i = -140; i <= 100; i += 9) s += "<line x1='" + i + "' y1='0' x2='" + (i + 140) + "' y2='140'/>";
for (int i = 0; i <= 240; i += 9) s += "<line x1='" + i + "' y1='0' x2='" + (i - 140) + "' y2='140'/>";
s += "</g></g>";
s += "<rect x='3' y='3' width='94' height='134' rx='5' fill='none' stroke='" + trim + "' stroke-width='2'/>";
s += "<ellipse cx='50' cy='70' rx='21' ry='29' fill='" + ink + "' stroke='" + emblem + "' stroke-width='2'/>";
s += "<path d='M50 50 L63 70 L50 90 L37 70 Z' fill='" + emblem + "'/>";
s += "<circle cx='50' cy='70' r='5' fill='" + ink + "'/>";
return s + "</svg>";
}
art[-1] = Back();
// prefetch the whole deck so a card never reaches its flip before its art does
for (int i = 0; i < 52; i++) Fetch(i);
// main layout
return new SkiaShape
{
CornerRadius = 14,
HorizontalOptions = LayoutOptions.Center,
VerticalOptions = LayoutOptions.Center,
BackgroundColor = Color.Parse("#123A2C"),
WidthRequest = W,
HeightRequest = H,
Children = new List<SkiaControl>
{
// table
new SkiaShape
{
CornerRadius = 24,
BackgroundColor = Color.Parse("#174A38"),
StrokeColor = Color.Parse("#0B2A20"),
StrokeWidth = 3,
WidthRequest = W - 24,
HeightRequest = 300,
Margin = new Thickness(12, 26, 0, 0),
HorizontalOptions = LayoutOptions.Start,
VerticalOptions = LayoutOptions.Start,
},
new SkiaLabel("")
{
FontSize = 15,
FontWeight = 600,
TextColor = Color.Parse("#CFE8DC"),
Margin = new Thickness(0, 176, 0, 0),
}.CenterX().Assign(out hudMsg),
new SkiaLabel("")
{
FontSize = 15,
FontWeight = 700,
TextColor = Color.Parse("#FFD98A"),
Margin = new Thickness(sideX, dealerY, 0, 0),
HorizontalOptions = LayoutOptions.Start,
}.Assign(out hudDealer),
new SkiaLabel("")
{
FontSize = 15,
FontWeight = 700,
TextColor = Color.Parse("#FFD98A"),
Margin = new Thickness(sideX, playerY, 0, 0),
HorizontalOptions = LayoutOptions.Start,
}.Assign(out hudPlayer),
new SkiaLayer
{
Children = Enumerable.Range(0, pool).Select(i => Card(i)).ToList()
}.Fill(),
Button(btnAX, btnAW, "Deal", out btnA, out labA),
Button(btnBX, btnBW, "bet +25", out btnB, out labB),
Button(btnCX, btnCW, "bet -25", out btnC, out labC),
new SkiaLabel("chips 500")
{
FontSize = 13,
TextColor = Color.Parse("#CFE8DC"),
Margin = new Thickness(sideX, 420, 0, 0),
HorizontalOptions = LayoutOptions.Start,
}.Assign(out hudBank),
new SkiaLabel("bet 50")
{
FontSize = 13,
TextColor = Color.Parse("#CFE8DC"),
Margin = new Thickness(0, 420, sideX, 0),
HorizontalOptions = LayoutOptions.End,
}.Assign(out hudBet),
new SkiaLayer()
.Fill()
.WithGestures((me, args, apply) =>
{
if (args.Type == TouchActionResult.Tapped)
{
Tap((args.Event.Location.X - me.DrawingRect.Left) / me.RenderingScale,
(args.Event.Location.Y - me.DrawingRect.Top) / me.RenderingScale);
return me;
}
if (args.Type == TouchActionResult.Down) return me;
return null;
})
.Animate(1, (me, animator, value, dt) =>
{
Step(Math.Min(dt, 0.05));
me.Update();
}, repeat: -1),
}
};