FCards - On the Web

25. Playing the game on the Web

We have the data in the model, and the view to display it, now we need to interact with the player to allow them to play the game.

Dispatching messages from our View

You may remember that one of the inputs passed to the viewMain function was called “dispatch”.

TIP: dispatch() is a function that takes a command message and passes it to our update() function, so that we can change the state of the model and therefore make progress in our game

It’s passed into the view function so that we can create links/buttons/interactions that can generate messages that we can then dispatch to the bolero MVU system.

We need to pass the dispatch function to our sub-view functions. For instance, in the viewDeck function we can generate a DrawCards message when the deck cards are clicked.

let viewDeck dispatch webgame =
  List.init webgame.game.deck.Length ( fun _ -> viewCardBack |> wrap |> li [] )
  |> ul [ on.click( fun _ -> DrawCards |> dispatch  ) ]
  |> fun a -> [ h3 [] [text "Deck"]; a]
  |> div [ Attr.Classes ["deck"] ]

let viewMain webgame dispatch = 
  let stacks = viewStacks dispatch webgame
  let aces = viewAces dispatch webgame
  let table = viewTable dispatch webgame
  let deck = viewDeck dispatch webgame
  ...

The on.click() bolero function has a MouseEventArgs input (which we’ll just ignore), and then we “dispatch” a DrawCards message.

Dealing with messages

Similarly to our terminal game we are going to need a couple of phases in the interaction with the player to generate a single move.

This time rather than multiple keystrokes, we can pretty much get away with two clicks:

  1. select a card to move
  2. select the destination for the card

We can model this by wrapping the base Game in a WebGame record and using a DU for the messages. The exception is the DrawCards message, which is really only needs to be a single click.

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
}

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

  override this.Program =
    Program.mkSimple initialise update viewMain

Responding to messages

Now that we have the commands/messages sorted we can respond to the messages to update the game

let update message webgame =
  printfn "%A" message   // actually quite useful for debugging.  It prints the messages in the browser's console
  match message with
  | DrawCards -> 
      {webgame with         // Note how we deal with two DU's with the same names
        game = applyCommand Solitaire.Actions.DrawCards webgame.game
        selectedCard = NoSelection
      }
  | SelectCard selection -> 
      {webgame with selectedCard = selection}
  | PlaceCard target -> 
      match target, webgame.selectedCard with   // matching on a combo of values
      | _, 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       // Needs some maths!
                  numCards=(webgame.game.stacks[selection.stacknum-1].Length - selection.cardnum) 
                  targetStack=toStack
                }
                |> MoveCards
              ) webgame.game
            selectedCard = NoSelection 
          } 

Exercise: Click cards & highlight the selected one

There actually three parts to this exercise:

  1. Add a click dispatcher to cards
  2. Add “drop target” options for the selected card (with its own dispatch) at the bottom of each stack and ace stack
  3. Add a “selected” css class to the card that is selected

TIP: I extended the CardDisplay record to include selection so that a lot of the work can be done in the ViewCard function

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

As an example I have done the viewTable function for you

let viewTable dispatch webgame =
  match webgame.game.table with 
    | [] -> []
    | [card] -> 
        [
          {
            card=card
            isFaceUp=true
            isSelected=(webgame.selectedCard=TableCardSelected)
            selection=NoSelection
          } 
          |> viewCard dispatch
        ]
    | topcard::rest -> 
        let facedowns = List.init (rest.Length) (fun _ -> viewCardBack)
        let faceup = 
          {
            card=topcard
            isFaceUp=true
            isSelected=(webgame.selectedCard=TableCardSelected)
            selection=TableCardSelected
          }
          |> viewCard dispatch
        facedowns @ [ faceup ]
  |> List.map ( fun a -> li [] [a])
  |> ul []
  |> fun a -> [ h3 [] [text "Table"]; a]
  |> div [ Attr.Classes ["table"] ]

See the code below for an answer

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 viewMain

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
}

let viewCardBack = span [ ] [ text "[###]"] 

let viewCard dispatch cardDisplay =
  match cardDisplay with 
  | { CardDisplay.isFaceUp=false } -> viewCardBack
  | { card=card; isSelected=isSelected; selection=selection } -> 
    let txt = text $"[{card}]"
    let colorAttr = 
      match card with 
      | IsRed _ -> ["red"]
      | IsBlack _ -> ["black"]
      | _ -> []
    let selectionAttr = if isSelected then ["selected"] else []
    let classAttr = colorAttr @ selectionAttr |> Attr.Classes
    span [ classAttr; on.click( fun _ -> selection |> SelectCard |> dispatch ) ] [txt]

let viewStacks dispatch webgame =
  webgame.game.stacks
  |> List.mapi (fun stacknum stack -> 
    stack
    |> List.mapi ( fun cardnum card -> 
        let location={stacknum=stacknum+1; cardnum=cardnum}
        { 
          card=card.card
          isFaceUp=card.isFaceUp
          isSelected=(webgame.selectedCard=StackCardSelected location)
          selection=StackCardSelected location
        }
        |> viewCard dispatch
    )
    |> fun cards -> cards @ [ span [ Classes ["dropTarget"]; on.click(fun _ -> StackTarget (stacknum + 1) |> PlaceCard |> dispatch ) ] [ text "[___]" ]  ]
    |> List.map ( fun x -> x |> wrap |> li [] )
    |> ul []
    |> fun x -> [h4 [] [(stacknum + 1).ToString() |> text]; x]
    |> div []
  )
  |> div [ Attr.Classes ["stacks"] ]

let viewAces dispatch webgame =
  webgame.game.aces
  |> List.mapi (fun stacknum stack -> 
    stack
    |> List.map ( fun card -> 
        {
          card=card
          isFaceUp=true
          isSelected=false
          selection=NoSelection
        } 
        |> viewCard dispatch
    )
    |> fun cards -> cards @ [ a [ Classes ["dropTarget"]; on.click(fun _ -> AceTarget (stacknum + 1) |> PlaceCard |> dispatch ) ] [ text "[___]" ]  ]
    |> List.map ( fun x -> x |> wrap |> li [] )
    |> ul []
    |> fun x -> [h4 [] [suits[stacknum] |> text]; x]
    |> div []
  )
  |> div [ Attr.Classes ["aces"] ]

let viewTable dispatch webgame =
  match webgame.game.table with 
    | [] -> []
    | [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 ]
  |> List.map ( fun a -> li [] [a])
  |> ul []
  |> fun a -> [ h3 [] [text "Table"]; a]
  |> div [ Attr.Classes ["table"] ]

let viewDeck dispatch webgame =
  List.init webgame.game.deck.Length ( fun _ -> viewCardBack |> wrap |> li [] )
  |> ul [ on.click( fun _ -> DrawCards |> dispatch  ) ]
  |> fun a -> [ h3 [] [text "Deck"]; a]
  |> div [ Attr.Classes ["deck"] ]

let viewMain webgame dispatch = 
  let stacks = viewStacks dispatch webgame
  let aces = viewAces dispatch webgame
  let table = viewTable dispatch webgame
  let deck = viewDeck dispatch webgame
  let topHalf = div [Attr.Classes ["topHalf"]] [stacks; aces]
  let mode = 
    match webgame.selectedCard with 
    | NoSelection -> "mode_unselected"
    | _ -> "mode_selected" 
  div [Attr.Classes ["game"; mode]] [topHalf; table; deck]

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/solitaire.css

h2, h3, h4 {
  color: #777;
}
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: 1em;
  align-content: space-between;
  flex-grow: 1;
  text-align: center;
}
.stacks > *, .aces > * {
  flex-grow: 1;
}

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

.deck ul, .table ul{
  margin-left: 1.2em;
}
.deck li, .table li {
  margin-left: -1.2em;
  background: white;
}


.red {
  color: red;
}
.black {
  color: black;
}
.dropTarget{
  opacity: 0.2;
  transition: opacity 0.5s;
  cursor:default;
  color: transparent;
}
.dropTarget:hover {
  opacity: 1.0;
}
.mode_selected .dropTarget{
  color: green;
  cursor:cell;
}
.selected {
  border: 3px solid gold;
  border-radius: 35%;
  padding: 3px;
}