FCards - On the Web

26. The beautiful game

By the end of the last chapter we had a working game; model, view, and updates.

But it started getting pretty ugly with lots of square brackets and a mixing of concerns between the program data vs how it looks. So this chapter we are going to add the idea of HTML templates

HTML Templates

The blazor/bolero system allows us to define a template webpage using HTML, because that’s what it’s good for! In the template we can put in some “Holes” for the data that are marked as ${...}.

<h3>Person ${Name}</h3>
<p>Age: ${Age}</p>
<p>Height: ${Height}</p>

We can even have little snippets of HTML that we can re-use

<p>Here are my cards:</p>
<ul>
    ${Cards}
    <template id="Card">
        <li><img src="${Suit}${Number}.png"/></li>
    </template>
</ul>

In this example ${Cards} is a hole that may be filled with list items from the template Card, that itself has a hole for the image source of each card.

In our view.fs code we can fill in the templates with the data from our Game. The templates can be accessed in the code using the Template type provider.

A Type Provider is a special feature of F# that creates a dynamic type at compile-time based on an external data source (in this case the template HTML).

type Main = Template<"wwwroot/main.html">

This line creates the type Main that changes as you change the referenced html file so that it contains parts that reflect it.

For instance, the Main type has the function Card() because there is a template with the id of “Card”, and a function called Suit() because there is a Hole called “${Suit}”.

Type providers can be used on all sorts of data such as databases (with fields for tables that contain fields for their columns) or even JSON data sources on the internet. All before you even write the rest of the program! Just hit “.” in your editor after the type / variable and let the code-completion show you what’s available!

Moving the HTML into templates

Let’s start with the card itself. I found an image that contains all the cards in a deck, including the back.

Cards

We can display a bit of this image at a time by setting the background image and position and therefore create a template like this. It includes holes for data and also an event hole to allow us to get a callback when the player clicks the card.

<template id="Card">
  <li onclick="${CardClicked}" class="">
    <div 
      class="card ${Selected}" 
      title="${CardText}" 
      alt="${CardText}" 
      style="
        background-position-x: calc( ${NumberOffset} * -45px);
        background-position-y: calc( ${SuitOffset} * -63px);
        "
    ></div>
  </li>
</template>

Note that the we put the amount of pixels that the image needs to offset into the html template itself, so that the calling code doesn’t need to know how big a card image is (separating concerns).

Our view code can now just fill in the holes

type Main = Template<"wwwroot/main.html">

let SuitNumber card =
  match card with 
  // Note the image isn't in the same order
  //  but we can easily deal with that in this matcher
  | Hearts _ -> 1
  | Diamonds _ -> 3
  | Clubs _ -> 0
  | Spades _ -> 2
  | Joker -> 4

let viewCard dispatch cardDisplay =
  match cardDisplay with 
  | { CardDisplay.isFaceUp=false } -> Main.CardBack().Elt()
  | { card=card; isSelected=isSelected; selection=selection } -> 
    Main.Card()
      .NumberOffset(card.Number.Ordinal - 1 |> string) // holes expect a string value
      .SuitOffset(SuitNumber card |> string)
      .CardText(card.ToString())
      .Selected(if isSelected then "selected" else "notselected")
      .CardClicked(fun _ -> selection |> SelectCard |> dispatch )
      .Elt()

Once the template has been completed we call the function Elt(), which compiles the template into a single element node that can be returned for display.

TIP: The templates are types, and so are accessed as a sub-type of main: Main.Card() (no brackets after “Main”)
The holes are fields of a template (incl the main template) and so are accessed as: Main().Deck or Main.Card().CardText() (This took me waaaay too long to figure out! 😒 )

Exercise: Convert to using template HTML

Given the main view function, create the views and templates to make it all look beautiful

let mainPage webgame dispatch = 
  Main()
    .SelectionMode(if webgame.selectedCard = NoSelection then "mode_unselected" else "mode_selected")
    .Deck(viewDeck dispatch webgame)
    .Table(viewTable dispatch webgame)
    .Aces(viewAces dispatch webgame)
    .Stacks(viewStacks dispatch webgame)
    .DrawSomeCards(fun _ -> DrawCards |> dispatch)
    .Elt()

Code so far

Website/Startup.fs

namespace Solitaire.Website

open System
open System.Net.Http
open Microsoft.AspNetCore.Components.WebAssembly.Hosting
open Microsoft.Extensions.DependencyInjection

module Program =

  [<EntryPoint>]
  let Main args =
    let builder = WebAssemblyHostBuilder.CreateDefault(args)
    builder.Services.AddScoped<HttpClient>(
      fun _ -> new HttpClient(BaseAddress = Uri builder.HostEnvironment.BaseAddress)
      ) 
      |> ignore
    builder.RootComponents.Add<Main.MyApp>("#main")
    builder.Build().RunAsync() |> ignore
    0

Website/Main.fs

module Solitaire.Website.Main

open Elmish
open Bolero
open Bolero.Html
open Cards
open Solitaire.Model
open Solitaire.Website.Update
open Solitaire.Website.Views


type MyApp() =
  inherit ProgramComponent<WebGame, WebCommands>()

  override this.Program =
    Program.mkSimple initialise update mainPage

Website/views.fs

module Solitaire.Website.Views

open Bolero
open Bolero.Html
open Cards
open Solitaire.Model
open Solitaire.Website.Update

let suits = [SYMBOL_HEART; SYMBOL_DIAMOND; SYMBOL_CLUB; SYMBOL_SPADE]

let wrap = List.singleton  // shortcut to wrap a thing in a list

type CardDisplay = {
  card: Card
  isFaceUp: bool
  isSelected: bool
  selection: CardSelection
}

type Main = Template<"wwwroot/main.html">
let main = Main()

let SuitNumber card =
  match card with 
  | Hearts _ -> 1
  | Diamonds _ -> 3
  | Clubs _ -> 0
  | Spades _ -> 2
  | Joker -> 4

let viewCard dispatch cardDisplay =
  match cardDisplay with 
  | { CardDisplay.isFaceUp=false } -> Main.CardBack().Elt()
  | { card=card; isSelected=isSelected; selection=selection } -> 
    Main.Card()
      .NumberOffset(card.Number.Ordinal - 1 |> string)
      .SuitOffset(SuitNumber card |> string)
      .CardText(card.ToString())
      .Selected(if isSelected then "selected" else "notselected")
      .CardClicked(fun _ -> selection |> SelectCard |> dispatch )
      .Elt()

let viewStack dispatch webgame stackNum =
  Main.Stack()
    .StackNum(stackNum.ToString())
    .StackCards(
      webgame.game.stacks[stackNum - 1]
      |> List.mapi ( fun cardnum card -> 
          let location={stacknum=stackNum; cardnum=cardnum}
          { 
            card=card.card
            isFaceUp=card.isFaceUp
            isSelected=(webgame.selectedCard=StackCardSelected location)
            selection=StackCardSelected location
          }
          |> viewCard dispatch
      )
      |> concat
    )
    .DropClicked(fun _ -> stackNum |> StackTarget |> PlaceCard |> dispatch )
    .Elt()

let viewStacks dispatch webgame =
  [1..6]
  |> List.map (viewStack dispatch webgame)
  |> concat

let private viewAceStack dispatch webgame aceStackNum =
  Main.AceStack()
    .Symbol(suits[aceStackNum - 1])
    .AceCards(
      webgame.game.aces[aceStackNum - 1]
      |> List.map ( fun card -> 
          {
            card=card
            isFaceUp=true
            isSelected=false
            selection=NoSelection
          } 
          |> viewCard dispatch
      )
      |> concat
    )
    .DropClicked(fun _ -> aceStackNum |>  AceTarget |> PlaceCard |> dispatch)
    .Elt()

let private viewAces dispatch webgame =
  [1..4]
  |> List.map (viewAceStack dispatch webgame)
  |> concat

let private viewTable dispatch webgame =
  match webgame.game.table with 
  | [] -> text ""
  | [card] -> 
      {
        card=card
        isFaceUp=true
        isSelected=(webgame.selectedCard=TableCardSelected)
        selection=NoSelection
      } 
      |> viewCard dispatch
    | topcard::rest -> 
        let facedowns = 
          rest
          |> List.map (fun card -> 
            {
              card=card
              isFaceUp=false
              isSelected=false
              selection=NoSelection
            }
            |> viewCard dispatch
          )
        let faceup = 
          {
            card=topcard
            isFaceUp=true
            isSelected=(webgame.selectedCard=TableCardSelected)
            selection=TableCardSelected
          }
          |> viewCard dispatch
        facedowns @ [ faceup ]
        |> concat

let private viewDeck dispatch webgame : Node =
  webgame.game.deck
  |> List.map (fun _ -> Main.CardBack().Elt() )
  |> concat

let mainPage webgame dispatch = 
  main
    .SelectionMode(if webgame.selectedCard = NoSelection then "mode_unselected" else "mode_selected")
    .Deck(viewDeck dispatch webgame)
    .Table(viewTable dispatch webgame)
    .Aces(viewAces dispatch webgame)
    .Stacks(viewStacks dispatch webgame)
    .DrawSomeCards(fun _ -> DrawCards |> dispatch)
    .Elt()

Website/update.fs

module Solitaire.Website.Update
open Cards
open Solitaire.Model
open Solitaire.Actions

type StackCardLocation = {
  stacknum: int;
  cardnum: int;
}

type CardSelection =
  | NoSelection
  | TableCardSelected
  | StackCardSelected of StackCardLocation

type TargetSelection = 
  | StackTarget of int
  | AceTarget of int

type WebCommands =  // must be a DU
  | SelectCard of CardSelection
  | PlaceCard of TargetSelection
  | DrawCards

type WebGame = {
  selectedCard: CardSelection
  game: Game
}


let initialise args = 
  newDeck 
  |> shuffle 
  |> deal
  |> fun x -> {game = x; selectedCard = NoSelection}

  
let update message webgame =
  printfn "%A" message
  match message with
  | DrawCards -> 
      {webgame with 
        game = applyCommand Solitaire.Actions.DrawCards webgame.game
        selectedCard = NoSelection
      }
  | SelectCard selection -> 
      {webgame with selectedCard = selection}
  | PlaceCard target -> 
      match target, webgame.selectedCard with 
      | _, NoSelection -> webgame
      | AceTarget _, TableCardSelected ->
          {webgame with 
            game = applyCommand TableToAce webgame.game
            selectedCard = NoSelection 
          }
      | AceTarget _, StackCardSelected selection -> 
          {webgame with 
            game = applyCommand (StackToAce selection.stacknum) webgame.game
            selectedCard = NoSelection 
          } 
      | StackTarget toStack, TableCardSelected ->       
          {webgame with 
            game = applyCommand (TableToStack toStack) webgame.game
            selectedCard = NoSelection 
          } 
      | StackTarget toStack, StackCardSelected selection ->       
          {webgame with 
            game = 
              applyCommand (
                {
                  sourceStack=selection.stacknum
                  numCards=(webgame.game.stacks[selection.stacknum-1].Length - selection.cardnum) 
                  targetStack=toStack
                }
                |> MoveCards
              ) webgame.game
            selectedCard = NoSelection 
          } 

Website/wwwroot/index.html

<html>
  <head>
    <title>Solitaire</title>
    <link rel="stylesheet" href="solitaire.css">
  </head>
  <body>
    <h1>Solitaire</h1>
    <div id="main">Loading...</div>

    <script src="_framework/blazor.webassembly.js"></script>    
  </body>
</html>

Website/wwwroot/main.html

<div class="game ${SelectionMode}">
  <div class="topHalf">
    <div class="stacks">
      ${Stacks}
      <template id="Stack">
        <div>
          <h4>${StackNum}</h4>
          <ul>
            ${StackCards}
            <li onclick="${DropClicked}">
              <div class="card dropTarget" title="Place card here" style="background-position-x: -45px; background-position-y: -252px"></div>
            </li>
          </ul>
        </div>
      </template>
    </div>
    <div class="aces">
      ${Aces}
      <template id="AceStack">
        <div>
          <h4>${Symbol}</h4>
          <ul>
            ${AceCards}
            <li onclick="${DropClicked}">
              <div class="card dropTarget" title="Place card here" style="background-position-x: -45px; background-position-y: -252px"></div>
            </li>
          </ul>
        </div>
      </template>      
    </div>
  </div>
  <div class="table">
    <h3>Table</h3>
    <ul>
      ${Table}
    </ul>
  </div>
  <div class="deck">
    <h3>Deck</h3>
    <ul onclick="${DrawSomeCards}" title="Deal 3 cards">
      ${Deck}
    </ul>
  </div>

  <template id="CardBack">
    <li>
      <div class="card" style="background-position-x: 0px; background-position-y: -252px"></div>
    </li>
  </template>
  <template id="Card">
    <li onclick="${CardClicked}" class="">
      <div class="card ${Selected}" title="${CardText}" alt="${CardText}" style="background-position-x: calc( ${NumberOffset} * -45px); background-position-y: calc( ${SuitOffset} * -63px)"></div>
    </li>
  </template>
</div>

Website/wwwroot/solitaire.css

h2, h3, h4 {
  color: #777;
  margin-top: 0;
}
ul{
  margin: 0;
  padding: 0;
}
li{
  list-style-type: none;
}

.game{
  display: flex;
  flex-direction: column;
  color: #777;
}
.topHalf{
  display: flex;
  align-content: space-between;

}
.stacks, .aces{
  display: flex;
  margin: 0 1em 2em;
  align-content: space-between;
  flex-grow: 1;
  text-align: center;
}
.stacks > *, .aces > * {
  flex-grow: 1;
}
.stacks ul, .aces ul {
  padding-top: 40px;
}
.stacks li, .aces li {
  margin-top: -40px;
}

.table ul, .deck ul {
  display: flex;
  flex-direction: row;
}

.deck ul, .table ul{
  margin-left: 1.2em;
  height: 70px;
  width: 90%;
  background-color: green;
  border: 1px solid darkgreen;
  border-radius: 10px;
  padding: 20px 20px 20px 40px;
}
.deck ul {
  cursor: pointer;
}
.deck li, .table li {
  margin-left: -1.2em;

}


.red {
  color: red;
}
.black {
  color: black;
}

.card{
  height: 62px; 
  width:44px; 
  display: inline-block; 
  background-color: white;
  background-image: url('cards.png');
  background-size: 585px 315px;
  border: 1px solid #777;
  border-radius: 4px;
  cursor: pointer;
}

.dropTarget{
  transition: opacity 0.5s, width 0.1s;
}
.mode_unselected .dropTarget {
  cursor:default;
  color: transparent;
  opacity: 0;
  width: 0;
  border: 0; 
}
.mode_selected .dropTarget{
  opacity: 0.2;
  display: inline-block;
  color: green;
  cursor:cell;
}
.mode_selected .dropTarget:hover{
  opacity: 1.0;
}

.selected {
  box-shadow: 0px 0px 10px gold;
}