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
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 calledSuit()
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!
Let’s start with the card itself. I found an image that contains all the cards in a deck, including the back.
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
orMain.Card().CardText()
(This took me waaaay too long to figure out! 😒 )
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()
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
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
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()
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
}
<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>
<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>
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;
}