├── tests
├── .gitignore
├── js
│ └── talk-to-ports.test.js
├── Main.elm
├── Tests.elm
├── Tests
│ ├── Utils.elm
│ └── Data
│ │ ├── Player.elm
│ │ ├── OutgoingMessage.elm
│ │ └── Room.elm
└── elm-package.json
├── examples
├── fast-and-moebius
│ ├── README.md
│ ├── index.html
│ └── elm-package.json
├── lettero
│ ├── index.html
│ ├── elm-package.json
│ └── Main.elm
├── the-capitalist
│ ├── index.html
│ ├── elm-package.json
│ └── Main.elm
├── spacecraterball
│ ├── index.html
│ └── elm-package.json
└── counterclockwooze
│ ├── index.html
│ ├── elm-package.json
│ └── Main.elm
├── bin
├── lint-js.sh
├── test-js.sh
├── example.sh
├── run-webrtc-test-server.js
└── site.sh
├── site
├── .firebaserc
├── database.rules.json
├── firebase.json
├── README.md
├── src
│ ├── index.html
│ ├── index.js
│ ├── Styles.elm
│ └── Main.elm
└── elm-package.json
├── src
├── Data
│ ├── RoundResult.elm
│ ├── Ports.elm
│ ├── Route.elm
│ ├── Guess.elm
│ ├── Round.elm
│ ├── IncomingMessage.elm
│ ├── RoundTime.elm
│ ├── OutgoingMessage.elm
│ ├── Player.elm
│ ├── Spec.elm
│ └── Room.elm
├── Views
│ ├── Loader.elm
│ ├── Footer.elm
│ ├── Header.elm
│ ├── Link.elm
│ ├── Attribution.elm
│ ├── NoMultiplayer.elm
│ ├── Timer.elm
│ ├── NoMultiplayer
│ │ └── Styles.elm
│ ├── Scoreboard.elm
│ ├── Footer
│ │ └── Styles.elm
│ ├── Timer
│ │ └── Styles.elm
│ ├── Notification.elm
│ ├── Scoreboard
│ │ └── Styles.elm
│ ├── Header
│ │ └── Styles.elm
│ ├── Attribution
│ │ └── Styles.elm
│ ├── Notification
│ │ └── Styles.elm
│ ├── Layout.elm
│ └── Logo.elm
├── Models.elm
├── Page
│ ├── Tutorial
│ │ ├── Messages.elm
│ │ ├── Models.elm
│ │ ├── Update.elm
│ │ ├── Views
│ │ │ └── Styles.elm
│ │ └── Views.elm
│ ├── NewRoom
│ │ ├── Messages.elm
│ │ ├── Models.elm
│ │ ├── Views
│ │ │ └── Styles.elm
│ │ ├── Update.elm
│ │ └── Views.elm
│ ├── Game
│ │ ├── Messages.elm
│ │ ├── Views
│ │ │ └── Styles.elm
│ │ ├── Helpers.elm
│ │ ├── Models.elm
│ │ ├── Update.elm
│ │ └── Views.elm
│ ├── NotFound
│ │ ├── Views.elm
│ │ └── Views
│ │ │ └── Styles.elm
│ └── Home
│ │ ├── Views.elm
│ │ └── Views
│ │ └── Styles.elm
├── Utils.elm
├── Messages.elm
├── Styles
│ ├── Constants.elm
│ ├── Shared.elm
│ └── Mixins.elm
├── Copy.elm
├── Gameroom
│ └── Context.elm
├── js
│ ├── db
│ │ ├── firebase.js
│ │ ├── local-storage.js
│ │ └── webrtc.js
│ ├── talk-to-ports.js
│ └── README.md
├── Subscriptions.elm
├── Router.elm
├── Views.elm
├── Update.elm
└── Gameroom.elm
├── .gitignore
├── elm-package.json
├── LICENSE
└── README.md
/tests/.gitignore:
--------------------------------------------------------------------------------
1 | /elm-stuff/
2 |
--------------------------------------------------------------------------------
/examples/fast-and-moebius/README.md:
--------------------------------------------------------------------------------
1 | Incomplete example :).
2 |
--------------------------------------------------------------------------------
/bin/lint-js.sh:
--------------------------------------------------------------------------------
1 | #!/bin/sh
2 |
3 | standard src/**/*.js --fix
4 |
--------------------------------------------------------------------------------
/bin/test-js.sh:
--------------------------------------------------------------------------------
1 | #!/bin/sh
2 |
3 | jest tests/**/*.js --notify
4 |
--------------------------------------------------------------------------------
/site/.firebaserc:
--------------------------------------------------------------------------------
1 | {
2 | "projects": {
3 | "default": "elm-gameroom"
4 | }
5 | }
6 |
--------------------------------------------------------------------------------
/site/database.rules.json:
--------------------------------------------------------------------------------
1 | {
2 | "rules": {
3 | ".read": true,
4 | ".write": true
5 | }
6 | }
7 |
--------------------------------------------------------------------------------
/src/Data/RoundResult.elm:
--------------------------------------------------------------------------------
1 | module Data.RoundResult exposing (..)
2 |
3 |
4 | type RoundResult
5 | = Pending
6 | | Winner String
7 | | Tie
8 |
--------------------------------------------------------------------------------
/src/Views/Loader.elm:
--------------------------------------------------------------------------------
1 | module Views.Loader exposing (view)
2 |
3 | import Html exposing (Html, text)
4 |
5 |
6 | view : Html msg
7 | view =
8 | text "Loading.."
9 |
--------------------------------------------------------------------------------
/tests/js/talk-to-ports.test.js:
--------------------------------------------------------------------------------
1 | const talkToPorts = require('../../src/js/talk-to-ports.js')
2 |
3 | test('talkToPorts is a function', () => {
4 | expect(typeof talkToPorts).toBe('function')
5 | })
6 |
--------------------------------------------------------------------------------
/src/Models.elm:
--------------------------------------------------------------------------------
1 | module Models exposing (..)
2 |
3 | import Data.Route exposing (Route)
4 | import Window
5 |
6 |
7 | type alias Model problem guess =
8 | { route : Route problem guess
9 | , windowSize : Window.Size
10 | }
11 |
--------------------------------------------------------------------------------
/src/Page/Tutorial/Messages.elm:
--------------------------------------------------------------------------------
1 | module Page.Tutorial.Messages exposing (..)
2 |
3 | import Time
4 |
5 |
6 | type Msg problem guess
7 | = RequestNewProblem
8 | | ReceiveProblem problem
9 | | Guess guess
10 | | Tick Time.Time
11 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | elm-stuff
2 | node_modules
3 | repl-temp-*
4 | .DS_Store
5 | .env
6 | examples/**/elm.js
7 | examples/**/talk-to-ports.js
8 | examples/**/db
9 | examples/**/200.html
10 | site/dist
11 | site/src/home.js
12 | documentation.json
13 | tmp
14 | *.log
15 |
--------------------------------------------------------------------------------
/bin/example.sh:
--------------------------------------------------------------------------------
1 | #!/bin/sh
2 |
3 | # Copy JS boilerplate
4 | cp -r src/js/db examples/$1
5 | cp src/js/talk-to-ports.js examples/$1
6 |
7 | # Start elm-live server
8 | cd examples/$1
9 | elm-live Main.elm --dir=. --output elm.js --debug --open --pushstate
10 |
--------------------------------------------------------------------------------
/site/firebase.json:
--------------------------------------------------------------------------------
1 | {
2 | "database": {
3 | "rules": "database.rules.json"
4 | },
5 | "hosting": {
6 | "public": "dist",
7 | "rewrites": [
8 | {
9 | "source": "**",
10 | "destination": "/index.html"
11 | }
12 | ]
13 | }
14 | }
15 |
--------------------------------------------------------------------------------
/src/Views/Footer.elm:
--------------------------------------------------------------------------------
1 | module Views.Footer exposing (view)
2 |
3 | import Html exposing (Html, div, footer)
4 | import Views.Footer.Styles exposing (CssClasses(..), localClass)
5 |
6 |
7 | view : List (Html msg) -> Html msg
8 | view children =
9 | div [ localClass [ Root ] ] children
10 |
--------------------------------------------------------------------------------
/tests/Main.elm:
--------------------------------------------------------------------------------
1 | port module Main exposing (..)
2 |
3 | import Tests
4 | import Test.Runner.Node exposing (run, TestProgram)
5 | import Json.Encode exposing (Value)
6 |
7 |
8 | main : TestProgram
9 | main =
10 | run emit Tests.all
11 |
12 |
13 | port emit : ( String, Value ) -> Cmd msg
14 |
--------------------------------------------------------------------------------
/src/Page/NewRoom/Messages.elm:
--------------------------------------------------------------------------------
1 | module Page.NewRoom.Messages exposing (..)
2 |
3 |
4 | type Msg
5 | = ChangeRoomId String
6 | | ChangePlayerId Int String
7 | | AddPlayer
8 | | CreateRequest
9 | | CreateResponse String
10 | | RemovePlayer Int
11 | | DismissUrlizeNotification
12 |
--------------------------------------------------------------------------------
/src/Page/Game/Messages.elm:
--------------------------------------------------------------------------------
1 | module Page.Game.Messages exposing (..)
2 |
3 | import Time
4 | import Data.Room as Room
5 |
6 |
7 | type Msg problem guess
8 | = Guess guess
9 | | Tick Time.Time
10 | | ReceiveUpdate (Room.Room problem guess)
11 | | MarkReady
12 | | ReceiveNewProblem problem
13 |
--------------------------------------------------------------------------------
/src/Data/Ports.elm:
--------------------------------------------------------------------------------
1 | module Data.Ports exposing (..)
2 |
3 | import Json.Encode as JE
4 |
5 |
6 | type alias Ports msg =
7 | { incoming : (JE.Value -> msg) -> Sub msg
8 | , outgoing : JE.Value -> Cmd msg
9 | }
10 |
11 |
12 | init : Ports msg
13 | init =
14 | { incoming = always Sub.none
15 | , outgoing = always Cmd.none
16 | }
17 |
--------------------------------------------------------------------------------
/src/Data/Route.elm:
--------------------------------------------------------------------------------
1 | module Data.Route exposing (..)
2 |
3 | import Page.NewRoom.Models
4 | import Page.Game.Models
5 | import Page.Tutorial.Models
6 |
7 |
8 | type Route problem guess
9 | = Home
10 | | NewRoom Page.NewRoom.Models.Model
11 | | Tutorial (Page.Tutorial.Models.Model problem guess)
12 | | Game (Page.Game.Models.Model problem guess)
13 | | NotOnBaseRoute
14 | | NotFound
15 |
--------------------------------------------------------------------------------
/src/Data/Guess.elm:
--------------------------------------------------------------------------------
1 | module Data.Guess exposing (..)
2 |
3 | import Time
4 | import Json.Decode as JD
5 |
6 |
7 | type alias Guess guess =
8 | { value : guess
9 | , madeAt : Time.Time
10 | }
11 |
12 |
13 | decoder : JD.Decoder guess -> JD.Decoder (Guess guess)
14 | decoder guessDecoder =
15 | JD.map2 Guess
16 | (JD.field "value" guessDecoder)
17 | (JD.field "madeAt" JD.float)
18 |
--------------------------------------------------------------------------------
/src/Page/Tutorial/Models.elm:
--------------------------------------------------------------------------------
1 | module Page.Tutorial.Models exposing (..)
2 |
3 | import Data.RoundTime as RoundTime
4 |
5 |
6 | type alias Model problem guess =
7 | { problem : Maybe problem
8 | , guess : Maybe guess
9 | , time : RoundTime.RoundTime
10 | }
11 |
12 |
13 | init : Model problem guess
14 | init =
15 | { problem = Nothing
16 | , guess = Nothing
17 | , time = RoundTime.init
18 | }
19 |
--------------------------------------------------------------------------------
/tests/Tests.elm:
--------------------------------------------------------------------------------
1 | module Tests exposing (..)
2 |
3 | import Test exposing (..)
4 | import Tests.Utils
5 | import Tests.Data.Room
6 | import Tests.Data.Player
7 | import Tests.Data.OutgoingMessage
8 |
9 |
10 | all : Test
11 | all =
12 | describe "elm-gameroom"
13 | [ Tests.Utils.tests
14 | , Tests.Data.Room.tests
15 | , Tests.Data.Player.tests
16 | , Tests.Data.OutgoingMessage.tests
17 | ]
18 |
--------------------------------------------------------------------------------
/src/Views/Header.elm:
--------------------------------------------------------------------------------
1 | module Views.Header exposing (view)
2 |
3 | import Html exposing (Html, header, text)
4 | import Messages exposing (Msg)
5 | import Views.Link as Link
6 | import Views.Header.Styles exposing (CssClasses(..), localClass)
7 |
8 |
9 | view : String -> Html (Msg problem guess)
10 | view icon =
11 | header [ localClass [ Root ] ]
12 | [ Link.view "/" [ localClass [ HomeLink ] ] [ text icon ]
13 | ]
14 |
--------------------------------------------------------------------------------
/src/Page/NewRoom/Models.elm:
--------------------------------------------------------------------------------
1 | module Page.NewRoom.Models exposing (..)
2 |
3 |
4 | type Status
5 | = Editing
6 | | Pending
7 | | Error
8 |
9 |
10 | type alias Model =
11 | { roomId : String
12 | , playerIds : List String
13 | , status : Status
14 | , entriesUrlized : Bool
15 | , isUrlizedNotificationDismissed : Bool
16 | }
17 |
18 |
19 | init : Model
20 | init =
21 | Model "" [ "", "" ] Editing False False
22 |
--------------------------------------------------------------------------------
/site/README.md:
--------------------------------------------------------------------------------
1 | This folder defines the content of the [demo site](https://elm-gameroom.firebaseapp.io), requiring all kinds of files from the rest of the repository, e.g. example games.
2 |
3 | To deploy, make sure you have `uglifyjs` installed: `npm i uglify-js -g`. Then run `./bin/site.sh build` from the repository's root, add firebase credentials manually to the distribution folder (follow instructions from the previous shell script), and run `./bin/site.sh deploy` to deploy.
4 |
--------------------------------------------------------------------------------
/src/Utils.elm:
--------------------------------------------------------------------------------
1 | module Utils exposing (trimspaces, urlize, template)
2 |
3 | import Regex exposing (..)
4 |
5 |
6 | trimspaces : String -> String
7 | trimspaces =
8 | replace All (regex " ") (\_ -> "-")
9 |
10 |
11 | urlize : String -> String
12 | urlize =
13 | trimspaces << String.toLower
14 |
15 |
16 | template : String -> String -> String
17 | template templ value =
18 | replace
19 | All
20 | ("${}" |> escape |> regex)
21 | (\_ -> value)
22 | templ
23 |
--------------------------------------------------------------------------------
/src/Views/Link.elm:
--------------------------------------------------------------------------------
1 | module Views.Link exposing (view)
2 |
3 | import Html exposing (Html, a)
4 | import Html.Events exposing (onClick)
5 | import Messages exposing (Msg(Navigate))
6 |
7 |
8 | view :
9 | String
10 | -> List (Html.Attribute (Msg problem guess))
11 | -> List (Html (Msg problem guess))
12 | -> Html (Msg problem guess)
13 | view url attrs children =
14 | a
15 | (attrs
16 | ++ [ onClick (Navigate url)
17 | ]
18 | )
19 | children
20 |
--------------------------------------------------------------------------------
/src/Views/Attribution.elm:
--------------------------------------------------------------------------------
1 | module Views.Attribution exposing (view)
2 |
3 | import Html exposing (Html, div, p, a, span, text)
4 | import Html.Attributes exposing (href)
5 | import Views.Attribution.Styles exposing (CssClasses(..), localClass)
6 | import Views.Logo
7 |
8 |
9 | view : Html msg
10 | view =
11 | a [ localClass [ Root ], href "https://elm-gameroom.firebaseapp.com" ]
12 | [ span [] [ text "Powered by " ]
13 | , Views.Logo.view
14 | , span [] [ text " elm-gameroom" ]
15 | ]
16 |
--------------------------------------------------------------------------------
/src/Views/NoMultiplayer.elm:
--------------------------------------------------------------------------------
1 | module Views.NoMultiplayer exposing (view)
2 |
3 | import Html exposing (Html, div, h1, p, text)
4 | import Views.NoMultiplayer.Styles exposing (CssClasses(..), localClass)
5 |
6 |
7 | view : Html msg
8 | view =
9 | div [ localClass [ Root ] ]
10 | [ h1 [] [ text "Multiplayer not yet working" ]
11 | , p [] [ text "You need to do some extra setup work for multiplayer to work on this game. Have a look at the elm-gameroom docs for instructions on how to proceed." ]
12 | ]
13 |
--------------------------------------------------------------------------------
/src/Page/NotFound/Views.elm:
--------------------------------------------------------------------------------
1 | module Page.NotFound.Views exposing (view)
2 |
3 | import Html exposing (Html, div, h2, p, a, text)
4 | import Messages exposing (Msg(..))
5 | import Views.Link
6 | import Page.NotFound.Views.Styles exposing (CssClasses(..), localClass)
7 |
8 |
9 | view : Html (Msg problem guess)
10 | view =
11 | div [ localClass [ Root ] ]
12 | [ h2 [] [ text "Not found.." ]
13 | , p [] [ text "We all get lost sometimes.." ]
14 | , Views.Link.view "/" [] [ text "Go to home page" ]
15 | ]
16 |
--------------------------------------------------------------------------------
/examples/lettero/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | elm-gameroom
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
17 |
18 |
19 |
--------------------------------------------------------------------------------
/examples/the-capitalist/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | elm-gameroom
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
17 |
18 |
19 |
--------------------------------------------------------------------------------
/src/Views/Timer.elm:
--------------------------------------------------------------------------------
1 | module Views.Timer exposing (view)
2 |
3 | import Html exposing (Html, div, text)
4 | import Html.Attributes exposing (style)
5 | import Views.Timer.Styles exposing (CssClasses(..), localClass)
6 |
7 |
8 | view : Float -> Html msg
9 | view ratio =
10 | let
11 | widthPercentage =
12 | ((max (ratio) 0) * 100 |> toString) ++ "%"
13 | in
14 | div
15 | [ localClass [ Root ]
16 | , style
17 | [ ( "transform", "translateX(-" ++ widthPercentage ++ ")" )
18 | ]
19 | ]
20 | [ text " " ]
21 |
--------------------------------------------------------------------------------
/examples/fast-and-moebius/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | Fast and Moebius
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
17 |
18 |
19 |
--------------------------------------------------------------------------------
/examples/spacecraterball/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | Spacecraterball
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
17 |
18 |
19 |
--------------------------------------------------------------------------------
/src/Messages.elm:
--------------------------------------------------------------------------------
1 | module Messages exposing (..)
2 |
3 | import Window
4 | import Data.Route exposing (Route)
5 | import Data.IncomingMessage as InMsg
6 | import Page.Tutorial.Messages
7 | import Page.Game.Messages
8 | import Page.NewRoom.Messages
9 |
10 |
11 | type Msg problem guess
12 | = ChangeRoute (Route problem guess)
13 | | IncomingMessage (InMsg.IncomingMessage problem guess)
14 | | GameMsg (Page.Game.Messages.Msg problem guess)
15 | | NewRoomMsg Page.NewRoom.Messages.Msg
16 | | TutorialMsg (Page.Tutorial.Messages.Msg problem guess)
17 | | Navigate String
18 | | Resize Window.Size
19 | | NoOp
20 |
--------------------------------------------------------------------------------
/src/Data/Round.elm:
--------------------------------------------------------------------------------
1 | module Data.Round exposing (..)
2 |
3 | import Json.Decode as JD
4 | import Json.Encode as JE
5 |
6 |
7 | type alias Round problem =
8 | { no : Int
9 | , problem : problem
10 | }
11 |
12 |
13 | encoder : (problem -> JE.Value) -> (Round problem -> JE.Value)
14 | encoder problemEncoder round =
15 | JE.object
16 | [ ( "no", JE.int round.no )
17 | , ( "problem", problemEncoder round.problem )
18 | ]
19 |
20 |
21 | decoder : JD.Decoder problem -> JD.Decoder (Round problem)
22 | decoder problemDecoder =
23 | JD.map2 Round
24 | (JD.field "no" JD.int)
25 | (JD.field "problem" problemDecoder)
26 |
--------------------------------------------------------------------------------
/src/Views/NoMultiplayer/Styles.elm:
--------------------------------------------------------------------------------
1 | module Views.NoMultiplayer.Styles exposing (..)
2 |
3 | import Html
4 | import Html.CssHelpers
5 | import Css exposing (..)
6 | import Css.Namespace exposing (namespace)
7 |
8 |
9 | cssNamespace : String
10 | cssNamespace =
11 | "nomultiplayer"
12 |
13 |
14 | type CssClasses
15 | = Root
16 |
17 |
18 | localClass : List class -> Html.Attribute msg
19 | localClass =
20 | Html.CssHelpers.withNamespace cssNamespace |> .class
21 |
22 |
23 | styles : List Css.Snippet
24 | styles =
25 | [ class Root
26 | [ maxWidth (px 600)
27 | , padding (px 20)
28 | , textAlign center
29 | ]
30 | ]
31 | |> namespace cssNamespace
32 |
--------------------------------------------------------------------------------
/examples/counterclockwooze/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | elm-gameroom
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
18 |
19 |
20 |
--------------------------------------------------------------------------------
/tests/Tests/Utils.elm:
--------------------------------------------------------------------------------
1 | module Tests.Utils exposing (..)
2 |
3 | import Test exposing (..)
4 | import Expect
5 | import Utils
6 |
7 |
8 | tests : Test
9 | tests =
10 | describe "Utils"
11 | [ describe "template"
12 | [ test "replaces value if ${} is present" <|
13 | \() ->
14 | Utils.template "Hi, my name is ${}." "Peter"
15 | |> Expect.equal "Hi, my name is Peter."
16 | , test "returns original template if ${} is not present" <|
17 | \() ->
18 | Utils.template "Hi, my name is ${." "Peter"
19 | |> Expect.equal "Hi, my name is ${."
20 | ]
21 | ]
22 |
--------------------------------------------------------------------------------
/site/src/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | elm-gameroom
6 |
7 |
8 |
9 |
10 |
11 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
--------------------------------------------------------------------------------
/src/Views/Scoreboard.elm:
--------------------------------------------------------------------------------
1 | module Views.Scoreboard exposing (view)
2 |
3 | import Html exposing (Html, div, text, span)
4 | import Views.Scoreboard.Styles exposing (CssClasses(..), localClass)
5 |
6 |
7 | view : List ( String, Int ) -> Html msg
8 | view scores =
9 | div
10 | [ localClass [ Root ]
11 | ]
12 | [ scores
13 | |> List.map
14 | (\( player, score ) ->
15 | span [ localClass [ List ] ]
16 | [ span [ localClass [ Player ] ] [ (text player) ]
17 | , span [ localClass [ Score ] ] [ (text (toString score)) ]
18 | ]
19 | )
20 | |> (\list -> div [] list)
21 | ]
22 |
--------------------------------------------------------------------------------
/src/Views/Footer/Styles.elm:
--------------------------------------------------------------------------------
1 | module Views.Footer.Styles exposing (..)
2 |
3 | import Html
4 | import Html.CssHelpers
5 | import Css exposing (..)
6 | import Css.Namespace exposing (namespace)
7 | import Styles.Mixins as Mixins
8 |
9 |
10 | cssNamespace : String
11 | cssNamespace =
12 | "footer"
13 |
14 |
15 | type CssClasses
16 | = Root
17 |
18 |
19 | localClass : List class -> Html.Attribute msg
20 | localClass =
21 | Html.CssHelpers.withNamespace cssNamespace |> .class
22 |
23 |
24 | styles : List Css.Snippet
25 | styles =
26 | [ class Root <|
27 | [ position fixed
28 | , right (px 0)
29 | , bottom (px 0)
30 | , width (pct 100)
31 | ]
32 | ++ Mixins.standardBoxShadow
33 | ]
34 | |> namespace cssNamespace
35 |
--------------------------------------------------------------------------------
/src/Page/NotFound/Views/Styles.elm:
--------------------------------------------------------------------------------
1 | module Page.NotFound.Views.Styles exposing (..)
2 |
3 | import Html
4 | import Html.CssHelpers
5 | import Css exposing (..)
6 | import Css.Elements exposing (a)
7 | import Css.Namespace exposing (namespace)
8 | import Styles.Mixins as Mixins
9 |
10 |
11 | cssNamespace : String
12 | cssNamespace =
13 | "notfound"
14 |
15 |
16 | type CssClasses
17 | = Root
18 |
19 |
20 | localClass : List class -> Html.Attribute msg
21 | localClass =
22 | Html.CssHelpers.withNamespace cssNamespace |> .class
23 |
24 |
25 | styles : List Snippet
26 | styles =
27 | [ class Root
28 | ([ descendants
29 | [ a Mixins.button
30 | ]
31 | ]
32 | ++ Mixins.centered
33 | )
34 | ]
35 | |> namespace cssNamespace
36 |
--------------------------------------------------------------------------------
/src/Views/Timer/Styles.elm:
--------------------------------------------------------------------------------
1 | module Views.Timer.Styles exposing (..)
2 |
3 | import Html
4 | import Html.CssHelpers
5 | import Css exposing (..)
6 | import Css.Namespace exposing (namespace)
7 | import Styles.Constants exposing (..)
8 |
9 |
10 | cssNamespace : String
11 | cssNamespace =
12 | "timer"
13 |
14 |
15 | type CssClasses
16 | = Root
17 |
18 |
19 | localClass : List class -> Html.Attribute msg
20 | localClass =
21 | Html.CssHelpers.withNamespace cssNamespace |> .class
22 |
23 |
24 | styles : List Snippet
25 | styles =
26 | [ class Root
27 | [ position absolute
28 | , top (px 0)
29 | , width (pct 100)
30 | , height (px 2)
31 | , left (px 0)
32 | , backgroundColor (hex lightBlue)
33 | ]
34 | ]
35 | |> namespace cssNamespace
36 |
--------------------------------------------------------------------------------
/bin/run-webrtc-test-server.js:
--------------------------------------------------------------------------------
1 | // This is a test server run for testing the webrtc datastore implementation
2 | const fs = require('fs')
3 | const path = require('path')
4 | const http = require('http')
5 | const port = 3000
6 |
7 | const html = `
8 |
9 |
10 |
11 | `
12 |
13 | const server = http.createServer((req, res) => {
14 | if (req.url === '/') {
15 | res.writeHead(200, {
16 | 'Cache-Control': 'no-cache'
17 | })
18 | res.end(html)
19 | } else if (req.url === '/webrtc.js') {
20 | res.writeHead(200, {
21 | 'Cache-Control': 'no-cache'
22 | })
23 | fs.createReadStream(path.join(__dirname, '../src/js/db/webrtc.js')).pipe(res)
24 | }
25 | })
26 |
27 | server.listen(port, () => {
28 | console.log(`Listening on ${port}.`)
29 | })
30 |
--------------------------------------------------------------------------------
/src/Page/Home/Views.elm:
--------------------------------------------------------------------------------
1 | module Page.Home.Views exposing (view)
2 |
3 | import Html exposing (Html, div, text, h1, p)
4 | import Messages exposing (Msg(..))
5 | import Data.Spec as Spec
6 | import Views.Link as Link
7 | import Page.Home.Views.Styles exposing (CssClasses(..), localClass)
8 |
9 |
10 | view : Spec.DetailedSpec problem guess -> Html (Msg problem guess)
11 | view spec =
12 | div [ localClass [ Root ] ]
13 | [ div [ localClass [ Logo ] ] [ text spec.unicodeIcon ]
14 | , h1 [] [ text spec.name ]
15 | , p [ localClass [ Subheading ] ] [ text spec.subheading ]
16 | , div [ localClass [ Nav ] ]
17 | [ Link.view "/tutorial" [ localClass [ Link ] ] [ text "Try" ]
18 | , Link.view "/new" [ localClass [ Link ] ] [ text "Play" ]
19 | ]
20 | ]
21 |
--------------------------------------------------------------------------------
/src/Styles/Constants.elm:
--------------------------------------------------------------------------------
1 | module Styles.Constants exposing (..)
2 |
3 | -- Dimensions
4 |
5 |
6 | standardBorderRadius : Float
7 | standardBorderRadius =
8 | 6
9 |
10 |
11 |
12 | -- Colors (developed under https://coolors.co/e52938-fcfefb-3fafb5-2d739e-173156)
13 |
14 |
15 | black : String
16 | black =
17 | "0F2037"
18 |
19 |
20 | grey : String
21 | grey =
22 | "CFD0CE"
23 |
24 |
25 | white : String
26 | white =
27 | "FCFEFB"
28 |
29 |
30 | lightGrey : String
31 | lightGrey =
32 | "EBECEA"
33 |
34 |
35 | darkGrey : String
36 | darkGrey =
37 | "3d3d3c"
38 |
39 |
40 | blue : String
41 | blue =
42 | "2D739E"
43 |
44 |
45 | lightBlue : String
46 | lightBlue =
47 | "407FA6"
48 |
49 |
50 | faintBlue : String
51 | faintBlue =
52 | "9FBFD2"
53 |
54 |
55 | cyan : String
56 | cyan =
57 | "27ABB2"
58 |
59 |
60 | red : String
61 | red =
62 | "E52938"
63 |
--------------------------------------------------------------------------------
/elm-package.json:
--------------------------------------------------------------------------------
1 | {
2 | "version": "3.0.0",
3 | "summary": "Framework for custom multiplayer games",
4 | "repository": "https://github.com/peterszerzo/elm-gameroom.git",
5 | "license": "BSD3",
6 | "source-directories": [
7 | "./src/"
8 | ],
9 | "exposed-modules": [
10 | "Gameroom",
11 | "Gameroom.Context"
12 | ],
13 | "dependencies": {
14 | "elm-lang/animation-frame": "1.0.1 <= v < 2.0.0",
15 | "elm-lang/core": "5.0.0 <= v < 6.0.0",
16 | "elm-lang/html": "2.0.0 <= v < 3.0.0",
17 | "elm-lang/navigation": "2.1.0 <= v < 3.0.0",
18 | "elm-lang/svg": "2.0.0 <= v < 3.0.0",
19 | "elm-lang/window": "1.0.1 <= v < 2.0.0",
20 | "evancz/url-parser": "2.0.1 <= v < 3.0.0",
21 | "rtfeldman/elm-css": "9.1.0 <= v < 10.0.0",
22 | "rtfeldman/elm-css-helpers": "2.1.0 <= v < 3.0.0"
23 | },
24 | "elm-version": "0.18.0 <= v < 0.19.0"
25 | }
26 |
--------------------------------------------------------------------------------
/src/Views/Notification.elm:
--------------------------------------------------------------------------------
1 | module Views.Notification exposing (view)
2 |
3 | import Html exposing (Html, div, p, text)
4 | import Html.Events exposing (onClick)
5 | import Views.Notification.Styles exposing (CssClasses(..), localClass, localClassList)
6 |
7 |
8 | view : Maybe String -> Maybe msg -> Html msg
9 | view body handleClick =
10 | div
11 | [ localClassList
12 | [ ( Root, True )
13 | , ( RootActive, body /= Nothing )
14 | , ( RootWithCloseButton, handleClick /= Nothing )
15 | ]
16 | ]
17 | <|
18 | [ p []
19 | [ text (body |> Maybe.withDefault "")
20 | ]
21 | ]
22 | ++ (case handleClick of
23 | Just handleClick ->
24 | [ div [ localClass [ Close ], onClick handleClick ] [ text "×" ] ]
25 |
26 | Nothing ->
27 | []
28 | )
29 |
--------------------------------------------------------------------------------
/src/Views/Scoreboard/Styles.elm:
--------------------------------------------------------------------------------
1 | module Views.Scoreboard.Styles exposing (..)
2 |
3 | import Html
4 | import Html.CssHelpers
5 | import Css exposing (..)
6 | import Css.Namespace exposing (namespace)
7 | import Styles.Constants exposing (..)
8 |
9 |
10 | cssNamespace : String
11 | cssNamespace =
12 | "scoreboard"
13 |
14 |
15 | type CssClasses
16 | = Root
17 | | List
18 | | Player
19 | | Score
20 |
21 |
22 | localClass : List class -> Html.Attribute msg
23 | localClass =
24 | Html.CssHelpers.withNamespace cssNamespace |> .class
25 |
26 |
27 | styles : List Snippet
28 | styles =
29 | [ class Root
30 | [ width (pct 100)
31 | , padding (px 5)
32 | , backgroundColor (hex lightBlue)
33 | , color (hex white)
34 | , textAlign center
35 | ]
36 | , class List [ margin2 (px 0) (px 20) ]
37 | , class Player [ marginRight (px 8) ]
38 | ]
39 | |> namespace cssNamespace
40 |
--------------------------------------------------------------------------------
/site/elm-package.json:
--------------------------------------------------------------------------------
1 | {
2 | "version": "1.0.0",
3 | "summary": "elm-gameroom demo site",
4 | "repository": "https://github.com/user/project.git",
5 | "license": "BSD3",
6 | "source-directories": [
7 | "./src/",
8 | "../src/"
9 | ],
10 | "exposed-modules": [],
11 | "dependencies": {
12 | "elm-community/linear-algebra": "3.1.0 <= v < 4.0.0",
13 | "elm-community/webgl": "2.0.3 <= v < 3.0.0",
14 | "elm-lang/animation-frame": "1.0.1 <= v < 2.0.0",
15 | "elm-lang/core": "5.0.0 <= v < 6.0.0",
16 | "elm-lang/html": "2.0.0 <= v < 3.0.0",
17 | "elm-lang/navigation": "2.1.0 <= v < 3.0.0",
18 | "elm-lang/svg": "2.0.0 <= v < 3.0.0",
19 | "elm-lang/window": "1.0.1 <= v < 2.0.0",
20 | "evancz/url-parser": "2.0.1 <= v < 3.0.0",
21 | "rtfeldman/elm-css": "9.1.0 <= v < 10.0.0",
22 | "rtfeldman/elm-css-helpers": "2.1.0 <= v < 3.0.0"
23 | },
24 | "elm-version": "0.18.0 <= v < 0.19.0"
25 | }
26 |
--------------------------------------------------------------------------------
/examples/lettero/elm-package.json:
--------------------------------------------------------------------------------
1 | {
2 | "version": "1.0.0",
3 | "summary": "Lettero",
4 | "repository": "https://github.com/peterszerzo/elm-gameroom.git",
5 | "license": "BSD3",
6 | "source-directories": [
7 | ".",
8 | "../../src/"
9 | ],
10 | "exposed-modules": [],
11 | "dependencies": {
12 | "elm-community/linear-algebra": "3.1.0 <= v < 4.0.0",
13 | "elm-community/webgl": "2.0.3 <= v < 3.0.0",
14 | "elm-lang/animation-frame": "1.0.1 <= v < 2.0.0",
15 | "elm-lang/core": "5.0.0 <= v < 6.0.0",
16 | "elm-lang/html": "2.0.0 <= v < 3.0.0",
17 | "elm-lang/navigation": "2.1.0 <= v < 3.0.0",
18 | "elm-lang/svg": "2.0.0 <= v < 3.0.0",
19 | "elm-lang/window": "1.0.1 <= v < 2.0.0",
20 | "evancz/url-parser": "2.0.1 <= v < 3.0.0",
21 | "rtfeldman/elm-css": "9.1.0 <= v < 10.0.0",
22 | "rtfeldman/elm-css-helpers": "2.1.0 <= v < 3.0.0"
23 | },
24 | "elm-version": "0.18.0 <= v < 0.19.0"
25 | }
26 |
--------------------------------------------------------------------------------
/examples/counterclockwooze/elm-package.json:
--------------------------------------------------------------------------------
1 | {
2 | "version": "1.0.0",
3 | "summary": "Counterclockwooze",
4 | "repository": "https://github.com/peterszerzo/elm-gameroom.git",
5 | "license": "BSD3",
6 | "source-directories": [
7 | ".",
8 | "../../src/"
9 | ],
10 | "exposed-modules": [],
11 | "dependencies": {
12 | "elm-community/linear-algebra": "3.1.0 <= v < 4.0.0",
13 | "elm-community/webgl": "2.0.3 <= v < 3.0.0",
14 | "elm-lang/animation-frame": "1.0.1 <= v < 2.0.0",
15 | "elm-lang/core": "5.0.0 <= v < 6.0.0",
16 | "elm-lang/html": "2.0.0 <= v < 3.0.0",
17 | "elm-lang/navigation": "2.1.0 <= v < 3.0.0",
18 | "elm-lang/svg": "2.0.0 <= v < 3.0.0",
19 | "elm-lang/window": "1.0.1 <= v < 2.0.0",
20 | "evancz/url-parser": "2.0.1 <= v < 3.0.0",
21 | "rtfeldman/elm-css": "9.1.0 <= v < 10.0.0",
22 | "rtfeldman/elm-css-helpers": "2.1.0 <= v < 3.0.0"
23 | },
24 | "elm-version": "0.18.0 <= v < 0.19.0"
25 | }
26 |
--------------------------------------------------------------------------------
/examples/fast-and-moebius/elm-package.json:
--------------------------------------------------------------------------------
1 | {
2 | "version": "1.0.0",
3 | "summary": "Fast and Moebius",
4 | "repository": "https://github.com/peterszerzo/elm-gameroom.git",
5 | "license": "BSD3",
6 | "source-directories": [
7 | ".",
8 | "../../src/"
9 | ],
10 | "exposed-modules": [],
11 | "dependencies": {
12 | "elm-community/linear-algebra": "3.1.0 <= v < 4.0.0",
13 | "elm-community/webgl": "2.0.3 <= v < 3.0.0",
14 | "elm-lang/animation-frame": "1.0.1 <= v < 2.0.0",
15 | "elm-lang/core": "5.0.0 <= v < 6.0.0",
16 | "elm-lang/html": "2.0.0 <= v < 3.0.0",
17 | "elm-lang/navigation": "2.1.0 <= v < 3.0.0",
18 | "elm-lang/svg": "2.0.0 <= v < 3.0.0",
19 | "elm-lang/window": "1.0.1 <= v < 2.0.0",
20 | "evancz/url-parser": "2.0.1 <= v < 3.0.0",
21 | "rtfeldman/elm-css": "9.1.0 <= v < 10.0.0",
22 | "rtfeldman/elm-css-helpers": "2.1.0 <= v < 3.0.0"
23 | },
24 | "elm-version": "0.18.0 <= v < 0.19.0"
25 | }
26 |
--------------------------------------------------------------------------------
/examples/spacecraterball/elm-package.json:
--------------------------------------------------------------------------------
1 | {
2 | "version": "1.0.0",
3 | "summary": "Spacecraterball",
4 | "repository": "https://github.com/peterszerzo/elm-gameroom.git",
5 | "license": "BSD3",
6 | "source-directories": [
7 | ".",
8 | "../../src/"
9 | ],
10 | "exposed-modules": [],
11 | "dependencies": {
12 | "elm-community/linear-algebra": "3.1.0 <= v < 4.0.0",
13 | "elm-community/webgl": "2.0.3 <= v < 3.0.0",
14 | "elm-lang/animation-frame": "1.0.1 <= v < 2.0.0",
15 | "elm-lang/core": "5.0.0 <= v < 6.0.0",
16 | "elm-lang/html": "2.0.0 <= v < 3.0.0",
17 | "elm-lang/navigation": "2.1.0 <= v < 3.0.0",
18 | "elm-lang/svg": "2.0.0 <= v < 3.0.0",
19 | "elm-lang/window": "1.0.1 <= v < 2.0.0",
20 | "evancz/url-parser": "2.0.1 <= v < 3.0.0",
21 | "rtfeldman/elm-css": "9.1.0 <= v < 10.0.0",
22 | "rtfeldman/elm-css-helpers": "2.1.0 <= v < 3.0.0"
23 | },
24 | "elm-version": "0.18.0 <= v < 0.19.0"
25 | }
26 |
--------------------------------------------------------------------------------
/examples/the-capitalist/elm-package.json:
--------------------------------------------------------------------------------
1 | {
2 | "version": "1.0.0",
3 | "summary": "Counterclockwooze",
4 | "repository": "https://github.com/peterszerzo/elm-gameroom.git",
5 | "license": "BSD3",
6 | "source-directories": [
7 | ".",
8 | "../../src/"
9 | ],
10 | "exposed-modules": [],
11 | "dependencies": {
12 | "elm-community/linear-algebra": "3.1.0 <= v < 4.0.0",
13 | "elm-community/webgl": "2.0.3 <= v < 3.0.0",
14 | "elm-lang/animation-frame": "1.0.1 <= v < 2.0.0",
15 | "elm-lang/core": "5.0.0 <= v < 6.0.0",
16 | "elm-lang/html": "2.0.0 <= v < 3.0.0",
17 | "elm-lang/navigation": "2.1.0 <= v < 3.0.0",
18 | "elm-lang/svg": "2.0.0 <= v < 3.0.0",
19 | "elm-lang/window": "1.0.1 <= v < 2.0.0",
20 | "evancz/url-parser": "2.0.1 <= v < 3.0.0",
21 | "rtfeldman/elm-css": "9.1.0 <= v < 10.0.0",
22 | "rtfeldman/elm-css-helpers": "2.1.0 <= v < 3.0.0"
23 | },
24 | "elm-version": "0.18.0 <= v < 0.19.0"
25 | }
26 |
--------------------------------------------------------------------------------
/src/Page/Home/Views/Styles.elm:
--------------------------------------------------------------------------------
1 | module Page.Home.Views.Styles exposing (..)
2 |
3 | import Html
4 | import Html.CssHelpers
5 | import Css exposing (..)
6 | import Css.Namespace exposing (namespace)
7 | import Styles.Mixins as Mixins
8 | import Styles.Constants exposing (..)
9 |
10 |
11 | cssNamespace : String
12 | cssNamespace =
13 | "home"
14 |
15 |
16 | type CssClasses
17 | = Root
18 | | Logo
19 | | Link
20 | | Subheading
21 | | Nav
22 |
23 |
24 | localClass : List class -> Html.Attribute msg
25 | localClass =
26 | Html.CssHelpers.withNamespace cssNamespace |> .class
27 |
28 |
29 | styles : List Snippet
30 | styles =
31 | [ class Root Mixins.centered
32 | , class Logo
33 | [ width (px 80)
34 | , height (px 80)
35 | , margin auto
36 | , border3 (px 1) solid (hex white)
37 | , fontSize (px 60)
38 | ]
39 | , class Link Mixins.button
40 | , class Subheading [ fontSize (Css.rem 1.25), margin2 (px 10) auto ]
41 | , class Nav [ marginTop (px 30) ]
42 | ]
43 | |> namespace cssNamespace
44 |
--------------------------------------------------------------------------------
/src/Views/Header/Styles.elm:
--------------------------------------------------------------------------------
1 | module Views.Header.Styles exposing (..)
2 |
3 | import Html
4 | import Html.CssHelpers
5 | import Css exposing (..)
6 | import Css.Namespace exposing (namespace)
7 |
8 |
9 | cssNamespace : String
10 | cssNamespace =
11 | "header"
12 |
13 |
14 | type CssClasses
15 | = Root
16 | | HomeLink
17 |
18 |
19 | localClass : List class -> Html.Attribute msg
20 | localClass =
21 | Html.CssHelpers.withNamespace cssNamespace |> .class
22 |
23 |
24 | styles : List Css.Snippet
25 | styles =
26 | [ class Root
27 | [ position fixed
28 | , display block
29 | , height (px 80)
30 | , width (px 80)
31 | , padding (px 20)
32 | , top (px 0)
33 | , left (px 0)
34 | , zIndex (int 10)
35 | ]
36 | , class HomeLink
37 | [ height (pct 100)
38 | , width (pct 100)
39 | , fontSize (px 40)
40 | , lineHeight (num 1.1)
41 | , textAlign left
42 | , display block
43 | , cursor pointer
44 | ]
45 | ]
46 | |> namespace cssNamespace
47 |
--------------------------------------------------------------------------------
/src/Page/Tutorial/Update.elm:
--------------------------------------------------------------------------------
1 | module Page.Tutorial.Update exposing (..)
2 |
3 | import Data.RoundTime as RoundTime
4 | import Page.Tutorial.Messages exposing (Msg(..))
5 | import Page.Tutorial.Models exposing (Model)
6 | import Data.Spec as Spec
7 |
8 |
9 | update :
10 | Spec.DetailedSpec problem guess
11 | -> Msg problem guess
12 | -> Model problem guess
13 | -- Returns the new model and a boolean value; if it's true, the higher-level update function should generate a new game round.
14 | -> ( Model problem guess, Bool )
15 | update spec msg model =
16 | case msg of
17 | RequestNewProblem ->
18 | ( model, True )
19 |
20 | ReceiveProblem problem ->
21 | ( { model
22 | | problem = Just problem
23 | , guess = Nothing
24 | , time = RoundTime.init
25 | }
26 | , False
27 | )
28 |
29 | Guess guess ->
30 | ( { model | guess = Just guess }, False )
31 |
32 | Tick time ->
33 | ( { model | time = RoundTime.update time model.time }, False )
34 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | The MIT License
2 |
3 | Copyright (c) 2017 Peter Szerzo
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in
13 | all copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21 | THE SOFTWARE.
22 |
--------------------------------------------------------------------------------
/src/Page/NewRoom/Views/Styles.elm:
--------------------------------------------------------------------------------
1 | module Page.NewRoom.Views.Styles exposing (..)
2 |
3 | import Html
4 | import Html.CssHelpers
5 | import Css exposing (..)
6 | import Css.Namespace exposing (namespace)
7 | import Styles.Mixins as Mixins
8 |
9 |
10 | cssNamespace : String
11 | cssNamespace =
12 | "newroom"
13 |
14 |
15 | type CssClasses
16 | = Root
17 | | Logo
18 | | Title
19 | | Button
20 | | ButtonDisabled
21 | | FormButton
22 |
23 |
24 | localClass : List class -> Html.Attribute msg
25 | localClass =
26 | Html.CssHelpers.withNamespace cssNamespace |> .class
27 |
28 |
29 | localClassList : List ( class, Bool ) -> Html.Attribute msg
30 | localClassList =
31 | Html.CssHelpers.withNamespace cssNamespace |> .classList
32 |
33 |
34 | styles : List Snippet
35 | styles =
36 | [ class Root <| Mixins.centered ++ [ textAlign left ]
37 | , class Button Mixins.button
38 | , class ButtonDisabled Mixins.buttonDisabled
39 | , class Title [ textAlign center ]
40 | , class FormButton
41 | [ width (pct 100)
42 | , margin3 (px 25) (px 0) (px 0)
43 | ]
44 | ]
45 | |> namespace cssNamespace
46 |
--------------------------------------------------------------------------------
/bin/site.sh:
--------------------------------------------------------------------------------
1 | #!/bin/sh
2 |
3 | ROOT=site
4 | DIST=dist
5 | SRC=src
6 | LIBSRC=../src
7 | EXAMPLES=(spacecraterball counterclockwooze lettero the-capitalist fast-and-moebius)
8 |
9 | if [ $1 = "build" ]; then
10 | cd $ROOT
11 | rm -rf $DIST
12 | mkdir $DIST
13 |
14 | cp $LIBSRC/js/talk-to-ports.js $DIST
15 | cp -r $LIBSRC/js/db $DIST
16 | cp $SRC/index.html $DIST
17 | cp $SRC/index.js $DIST
18 |
19 | elm-make src/Main.elm --output $DIST/home.js
20 | uglifyjs $DIST/home.js -o $DIST/home.js --compress --mangle
21 |
22 | for EXAMPLE in "${EXAMPLES[@]}"
23 | do
24 | elm-make ../examples/$EXAMPLE/Main.elm --output $DIST/$EXAMPLE.js
25 | uglifyjs $DIST/$EXAMPLE.js -o $DIST/$EXAMPLE.js --compress --mangle
26 | done
27 |
28 | echo ""
29 | echo "---"
30 | echo "🚧 Important 🚧"
31 | echo "Add firebase config manually to site/dist/index.js!"
32 | echo "---"
33 | elif [ $1 = "deploy" ]; then
34 | cd $ROOT
35 | firebase deploy
36 | elif [ $1 = "run" ]; then
37 | cd $ROOT
38 | elm-live $SRC/Main.elm --output $SRC/home.js --dir=$SRC --open --debug --pushstate
39 | else
40 | echo "Not a valid command. Use either build, deploy or run."
41 | fi
42 |
--------------------------------------------------------------------------------
/tests/elm-package.json:
--------------------------------------------------------------------------------
1 | {
2 | "version": "1.0.0",
3 | "summary": "Sample Elm Test",
4 | "repository": "https://github.com/user/project.git",
5 | "license": "BSD-3-Clause",
6 | "source-directories": [
7 | ".",
8 | "../src"
9 | ],
10 | "exposed-modules": [],
11 | "dependencies": {
12 | "elm-community/linear-algebra": "3.1.0 <= v < 4.0.0",
13 | "elm-community/webgl": "2.0.3 <= v < 3.0.0",
14 | "elm-lang/animation-frame": "1.0.1 <= v < 2.0.0",
15 | "elm-lang/core": "5.0.0 <= v < 6.0.0",
16 | "elm-lang/html": "2.0.0 <= v < 3.0.0",
17 | "elm-lang/navigation": "2.1.0 <= v < 3.0.0",
18 | "elm-lang/svg": "2.0.0 <= v < 3.0.0",
19 | "elm-lang/window": "1.0.1 <= v < 2.0.0",
20 | "evancz/url-parser": "2.0.1 <= v < 3.0.0",
21 | "rtfeldman/elm-css": "8.2.0 <= v < 9.0.0",
22 | "rtfeldman/elm-css-helpers": "2.1.0 <= v < 3.0.0",
23 | "elm-community/json-extra": "2.0.0 <= v < 3.0.0",
24 | "mgold/elm-random-pcg": "4.0.2 <= v < 5.0.0",
25 | "elm-community/elm-test": "3.0.0 <= v < 4.0.0",
26 | "rtfeldman/node-test-runner": "3.0.0 <= v < 4.0.0"
27 | },
28 | "elm-version": "0.18.0 <= v < 0.19.0"
29 | }
30 |
--------------------------------------------------------------------------------
/site/src/index.js:
--------------------------------------------------------------------------------
1 | ;(function () {
2 | // This is filled out manually in the dist folder, right before uploading.
3 | var firebaseConfig = {
4 | apiKey: '',
5 | authDomain: '',
6 | databaseURL: '',
7 | storageBucket: '',
8 | messagingSenderId: ''
9 | }
10 | var rootNode = document.getElementById('Root')
11 | var games = [
12 | 'lettero',
13 | 'counterclockwooze',
14 | 'spacecraterball',
15 | 'the-capitalist',
16 | 'fast-and-moebius'
17 | ]
18 | var path = window.location.pathname
19 | var game = games.filter(function (url) {
20 | return new RegExp('^' + url).test(path.slice(1))
21 | })[0]
22 | if (game) {
23 | var scriptTag = document.createElement('script')
24 | scriptTag.src = '/' + game + '.js?' + window.__ELM_GAMEROOM_HASH__
25 | scriptTag.onload = function () {
26 | var app = window.Elm.Main.embed(rootNode)
27 | window.talkToPorts(window.db(firebaseConfig, {rootRef: '/' + game}), app.ports)
28 | }
29 | document.body.appendChild(scriptTag)
30 | } else {
31 | var scriptTag = document.createElement('script')
32 | scriptTag.src = '/home.js?' + window.__ELM_GAMEROOM_HASH__
33 | scriptTag.onload = function () {
34 | var app = window.Elm.Main.embed(rootNode)
35 | }
36 | document.body.appendChild(scriptTag)
37 | }
38 | }())
39 |
--------------------------------------------------------------------------------
/tests/Tests/Data/Player.elm:
--------------------------------------------------------------------------------
1 | module Tests.Data.Player exposing (..)
2 |
3 | import Test exposing (..)
4 | import Expect
5 | import Json.Decode as JD
6 | import Data.Player as Player
7 |
8 |
9 | tests : Test
10 | tests =
11 | describe "Player"
12 | [ describe "decoder"
13 | [ test "decodes player with no current guess" <|
14 | \() ->
15 | "{\"id\": \"player\", \"roomId\": \"room\", \"isReady\": false, \"score\": 0, \"guess\": \"__elm-gameroom__null__\"}"
16 | |> JD.decodeString (Player.decoder JD.int)
17 | |> Result.map (\player -> player.guess == Nothing)
18 | |> Result.withDefault False
19 | |> Expect.equal True
20 | , test "decodes player with current guess" <|
21 | \() ->
22 | "{\"id\": \"player\", \"roomId\": \"room\", \"isReady\": false, \"score\": 0, \"guess\": {\"value\": 2, \"madeAt\": 3}}"
23 | |> JD.decodeString (Player.decoder JD.int)
24 | |> Result.map (\player -> player.guess |> Maybe.map .value |> Maybe.map ((==) 2) |> Maybe.withDefault False)
25 | |> Result.withDefault False
26 | |> Expect.equal True
27 | ]
28 | ]
29 |
--------------------------------------------------------------------------------
/src/Data/IncomingMessage.elm:
--------------------------------------------------------------------------------
1 | module Data.IncomingMessage exposing (..)
2 |
3 | import Json.Decode as JD
4 | import Data.Room as Room
5 |
6 |
7 | type IncomingMessage problem guess
8 | = RoomCreated (Room.Room problem guess)
9 | | RoomUpdated (Room.Room problem guess)
10 |
11 |
12 | decoder : JD.Decoder problem -> JD.Decoder guess -> JD.Decoder (IncomingMessage problem guess)
13 | decoder problemDecoder guessDecoder =
14 | JD.map2 (\type_ payload -> ( type_, payload ))
15 | (JD.field "type" JD.string)
16 | (JD.field "payload" JD.value)
17 | |> JD.andThen
18 | (\( type_, payload ) ->
19 | case type_ of
20 | "room:created" ->
21 | JD.decodeValue (Room.decoder problemDecoder guessDecoder) payload
22 | |> Result.map (\room -> JD.succeed (RoomCreated room))
23 | |> Result.withDefault (JD.fail "Failed to decode created room.")
24 |
25 | "room:updated" ->
26 | JD.decodeValue (Room.decoder problemDecoder guessDecoder) payload
27 | |> Result.map (\room -> JD.succeed (RoomUpdated room))
28 | |> Result.withDefault (JD.fail "Failed to decode updated room.")
29 |
30 | _ ->
31 | JD.fail "Could not decode incoming message."
32 | )
33 |
--------------------------------------------------------------------------------
/src/Data/RoundTime.elm:
--------------------------------------------------------------------------------
1 | module Data.RoundTime
2 | exposing
3 | ( RoundTime
4 | , init
5 | , update
6 | , justPassed
7 | , timeSinceNewRound
8 | )
9 |
10 | import Time
11 |
12 |
13 | type RoundTime
14 | = RoundTime
15 | { current : Maybe Time.Time
16 | , atStart : Maybe Time.Time
17 | }
18 |
19 |
20 | init : RoundTime
21 | init =
22 | RoundTime
23 | { current = Nothing
24 | , atStart = Nothing
25 | }
26 |
27 |
28 | timeSinceNewRound : RoundTime -> Time.Time
29 | timeSinceNewRound (RoundTime roundTime) =
30 | Maybe.map2
31 | (\current atStart -> current - atStart)
32 | roundTime.current
33 | roundTime.atStart
34 | |> Maybe.withDefault 0
35 |
36 |
37 | update : Time.Time -> RoundTime -> RoundTime
38 | update time (RoundTime roundTime) =
39 | RoundTime
40 | { roundTime
41 | | current = Just time
42 | , atStart = roundTime.atStart |> Maybe.withDefault time |> Just
43 | }
44 |
45 |
46 | justPassed : Time.Time -> RoundTime -> RoundTime -> Bool
47 | justPassed duration prev current =
48 | let
49 | prevSinceRoundStart =
50 | timeSinceNewRound prev
51 |
52 | currentSinceRoundStart =
53 | timeSinceNewRound current
54 | in
55 | prevSinceRoundStart <= duration && currentSinceRoundStart > duration
56 |
--------------------------------------------------------------------------------
/src/Copy.elm:
--------------------------------------------------------------------------------
1 | module Copy exposing (..)
2 |
3 | -- Copy
4 |
5 |
6 | correctGuess : String
7 | correctGuess =
8 | "Correct - let's see if you made it the fastest.."
9 |
10 |
11 | incorrectGuess : String
12 | incorrectGuess =
13 | "Not quite, not quite unfortunately.."
14 |
15 |
16 | evaluatedGuess : String
17 | evaluatedGuess =
18 | "This is scoring you a ${}. Let's see how the others are doing.."
19 |
20 |
21 | win : String
22 | win =
23 | "Nice job, you win!"
24 |
25 |
26 | lose : String
27 | lose =
28 | "This one goes to ${}. Go get them in the next round!"
29 |
30 |
31 | tie : String
32 | tie =
33 | "It's a tie, folks, it's a tie.."
34 |
35 |
36 | tutorialStartup : String
37 | tutorialStartup =
38 | "Hey, let's practice. Click the button to get a game problem you can solve."
39 |
40 |
41 | tutorialEvaluatedGuess : String
42 | tutorialEvaluatedGuess =
43 | "This one will score you a ${}. Can you do better?"
44 |
45 |
46 | newRoomPageTitle : String
47 | newRoomPageTitle =
48 | "Game on!"
49 |
50 |
51 | newRoomFormIntro : String
52 | newRoomFormIntro =
53 | "But first, some forms.. In order to play with your friends, use this form to create your very own room. You’ll then be able to share links unique to each player, control when you feel ready, and be on your way!"
54 |
55 |
56 | casualNamesWarning : String
57 | casualNamesWarning =
58 | "We took the liberty to make your names casual and URL-friendly for your convenience :)."
59 |
--------------------------------------------------------------------------------
/src/Page/Game/Views/Styles.elm:
--------------------------------------------------------------------------------
1 | module Page.Game.Views.Styles exposing (..)
2 |
3 | import Html
4 | import Html.CssHelpers
5 | import Css exposing (..)
6 | import Css.Elements exposing (ul, li)
7 | import Css.Namespace exposing (namespace)
8 | import Styles.Mixins as Mixins
9 |
10 |
11 | cssNamespace : String
12 | cssNamespace =
13 | "game"
14 |
15 |
16 | type CssClasses
17 | = Root
18 | | GamePlay
19 | | ReadyPrompt
20 | | Link
21 | | DisabledLink
22 | | LoaderContainer
23 |
24 |
25 | localClass : List class -> Html.Attribute msg
26 | localClass =
27 | Html.CssHelpers.withNamespace cssNamespace |> .class
28 |
29 |
30 | localClassList : List ( class, Bool ) -> Html.Attribute msg
31 | localClassList =
32 | Html.CssHelpers.withNamespace cssNamespace |> .classList
33 |
34 |
35 | styles : List Css.Snippet
36 | styles =
37 | [ class Root []
38 | , class GamePlay
39 | [ position absolute
40 | , top (px 0)
41 | , bottom (px 0)
42 | , left (px 0)
43 | , right (px 0)
44 | ]
45 | , class ReadyPrompt <|
46 | [ descendants
47 | [ ul
48 | [ listStyle none
49 | , margin (px 0)
50 | , padding (px 0)
51 | ]
52 | , li
53 | [ display inlineBlock
54 | ]
55 | ]
56 | ]
57 | ++ Mixins.centered
58 | , class Link Mixins.button
59 | , class DisabledLink Mixins.buttonDisabled
60 | , class LoaderContainer Mixins.centered
61 | ]
62 | |> namespace cssNamespace
63 |
--------------------------------------------------------------------------------
/src/Gameroom/Context.elm:
--------------------------------------------------------------------------------
1 | module Gameroom.Context exposing (Context)
2 |
3 | {-| When developing elm-gameroom games, the [Spec](/Gameroom-Spec) object requires a view function that controls how the problem should be represented. This function takes a context as a first parameter, which you can use to display additional clues in your game, such as feedback when a guess is made or when the correct guess may be revealed. It also gives you access the current round's timer so you can get all animated and even WebGL-y, if that strikes your fancy.
4 |
5 | # The Context type
6 | @docs Context
7 | -}
8 |
9 | import Window
10 | import Time
11 |
12 |
13 | {-| The context object, containing the following fields:
14 |
15 | * `windowSize`: the size of the window, as per `elm-lang/window`.
16 | * `roundTime`: time since the current round started.
17 | * `ownGuess`: the guess made by the current client, if any.
18 | * `opponentGuesses`: a `( String, guess )` tuple listing any guesses made by opponents.
19 | * `isRoundOver`: states whether the current game round has been decided. At this point, you can reveal the correct answer while the round is in cooldown.
20 | * `scores`: lists the scores of each player as a `List ( String, Int )`. Use if the [noPeripheralUi](/Gameroom#noPeripheralUi) setting is enabled, and you want to render a custom-styles scoreboard on your own.
21 | -}
22 | type alias Context guess =
23 | { windowSize : Window.Size
24 | , roundTime : Time.Time
25 | , ownGuess : Maybe guess
26 | , opponentGuesses : List ( String, guess )
27 | , isRoundOver : Bool
28 | , scores : List ( String, Int )
29 | }
30 |
--------------------------------------------------------------------------------
/src/Data/OutgoingMessage.elm:
--------------------------------------------------------------------------------
1 | module Data.OutgoingMessage exposing (..)
2 |
3 | import Json.Encode as JE
4 | import Data.Room as Room
5 | import Data.Player as Player
6 |
7 |
8 | type OutgoingMessage problem guess
9 | = CreateRoom (Room.Room problem guess)
10 | | UpdateRoom (Room.Room problem guess)
11 | | UpdatePlayer (Player.Player guess)
12 | | SubscribeToRoom String
13 | | UnsubscribeFromRoom String
14 |
15 |
16 | encoder :
17 | (problem -> JE.Value)
18 | -> (guess -> JE.Value)
19 | -> OutgoingMessage problem guess
20 | -> JE.Value
21 | encoder problemEncoder guessEncoder cmd =
22 | case cmd of
23 | CreateRoom room ->
24 | JE.object
25 | [ ( "type", JE.string "create:room" )
26 | , ( "payload", Room.encoder problemEncoder guessEncoder room )
27 | ]
28 |
29 | UpdateRoom room ->
30 | JE.object
31 | [ ( "type", JE.string "update:room" )
32 | , ( "payload", Room.encoder problemEncoder guessEncoder room )
33 | ]
34 |
35 | UpdatePlayer player ->
36 | JE.object
37 | [ ( "type", JE.string "update:player" )
38 | , ( "payload", Player.encoder guessEncoder player )
39 | ]
40 |
41 | SubscribeToRoom roomId ->
42 | JE.object
43 | [ ( "type", JE.string "subscribeto:room" )
44 | , ( "payload", JE.string roomId )
45 | ]
46 |
47 | UnsubscribeFromRoom roomId ->
48 | JE.object
49 | [ ( "type", JE.string "unsubscribefrom:room" )
50 | , ( "payload", JE.string roomId )
51 | ]
52 |
--------------------------------------------------------------------------------
/tests/Tests/Data/OutgoingMessage.elm:
--------------------------------------------------------------------------------
1 | module Tests.Data.OutgoingMessage exposing (..)
2 |
3 | import Test exposing (..)
4 | import Expect
5 | import Data.Room as Room
6 | import Json.Decode as JD
7 | import Json.Encode as JE
8 | import Data.OutgoingMessage exposing (OutgoingMessage(..), encoder)
9 |
10 |
11 | type alias Problem =
12 | String
13 |
14 |
15 | problemEncoder : Problem -> JE.Value
16 | problemEncoder =
17 | JE.string
18 |
19 |
20 | type alias Guess =
21 | String
22 |
23 |
24 | guessEncoder : Guess -> JE.Value
25 | guessEncoder =
26 | JE.string
27 |
28 |
29 | testRoom : Room.Room Problem Guess
30 | testRoom =
31 | Room.create "room" [ "player1", "player2" ]
32 |
33 |
34 | tests : Test
35 | tests =
36 | describe "OutgoingMessage"
37 | [ describe "encoder"
38 | [ test "adds correct type value to CreateRoom message" <|
39 | \() ->
40 | CreateRoom testRoom
41 | |> encoder problemEncoder guessEncoder
42 | |> JD.decodeValue (JD.at [ "type" ] JD.string)
43 | |> Result.map ((==) "create:room")
44 | |> Result.withDefault False
45 | |> Expect.equal True
46 | , test "adds correct type value to UpdateRoom message" <|
47 | \() ->
48 | UpdateRoom testRoom
49 | |> encoder problemEncoder guessEncoder
50 | |> JD.decodeValue (JD.at [ "type" ] JD.string)
51 | |> Result.map ((==) "update:room")
52 | |> Result.withDefault False
53 | |> Expect.equal True
54 | ]
55 | ]
56 |
--------------------------------------------------------------------------------
/src/js/db/firebase.js:
--------------------------------------------------------------------------------
1 | /**
2 | * Firebase data store
3 | * @returns {Object} db - Datastore instance, following elm-gameroom API.
4 | */
5 | ;(function (root, factory) {
6 | if (typeof module === 'object' && module.exports) {
7 | module.exports = factory(root)
8 | } else {
9 | root.db = factory(root)
10 | }
11 | }(this, function (window) {
12 | var db = function (firebaseConfig, options) {
13 | var firebase = window.firebase
14 |
15 | var rootRef = (options && options.rootRef) || ''
16 |
17 | var firebaseApp = firebase.initializeApp(firebaseConfig)
18 |
19 | var database = firebaseApp.database()
20 |
21 | return {
22 | getRoom: function (roomId) {
23 | return database.ref(rootRef + '/rooms/' + roomId)
24 | .once('value')
25 | .then(function (snapshot) {
26 | return snapshot.val()
27 | })
28 | },
29 |
30 | createRoom: function (room) {
31 | return database.ref(rootRef + '/rooms/' + room.id).set(room)
32 | },
33 |
34 | updateRoom: function (room) {
35 | return database.ref(rootRef + '/rooms/' + room.id).set(room)
36 | },
37 |
38 | updatePlayer: function (player) {
39 | return database.ref(rootRef + '/rooms/' + player.roomId + '/players/' + player.id).set(player)
40 | },
41 |
42 | subscribeToRoom: function (roomId, next) {
43 | return database.ref(rootRef + '/rooms/' + roomId).on('value', function (snapshot) {
44 | next(snapshot.val())
45 | })
46 | },
47 |
48 | unsubscribeFromRoom: function (roomId) {
49 | return database.ref(rootRef + '/rooms/' + roomId).off()
50 | }
51 | }
52 | }
53 |
54 | return db
55 | }))
56 |
--------------------------------------------------------------------------------
/src/Page/Tutorial/Views/Styles.elm:
--------------------------------------------------------------------------------
1 | module Page.Tutorial.Views.Styles exposing (..)
2 |
3 | import Html
4 | import Html.CssHelpers
5 | import Css exposing (..)
6 | import Css.Namespace exposing (namespace)
7 | import Styles.Constants exposing (..)
8 | import Styles.Mixins as Mixins
9 |
10 |
11 | cssNamespace : String
12 | cssNamespace =
13 | "tutorial"
14 |
15 |
16 | type CssClasses
17 | = Root
18 | | Button
19 |
20 |
21 | localClass : List class -> Html.Attribute msg
22 | localClass =
23 | Html.CssHelpers.withNamespace cssNamespace |> .class
24 |
25 |
26 | styles : List Snippet
27 | styles =
28 | [ class Root
29 | [ position absolute
30 | , left (px 0)
31 | , top (px 0)
32 | , bottom (px 0)
33 | , right (px 0)
34 | ]
35 | , class Button <|
36 | [ width (px 40)
37 | , height (px 40)
38 | , backgroundColor (hex blue)
39 | , color (hex white)
40 | , position fixed
41 | , bottom (px 20)
42 | , lineHeight (num 1)
43 | , left (px 20)
44 | , textAlign center
45 | , fontSize (px 20)
46 | , paddingTop (px 10)
47 | , borderRadius (pct 50)
48 | , after
49 | [ property "content" "' '"
50 | , position absolute
51 | , top (px 0)
52 | , left (px 0)
53 | , bottom (px 0)
54 | , right (px 0)
55 | , property "transition" "background-color 0.3s"
56 | ]
57 | , hover
58 | [ after
59 | [ property "background-color" "rgba(255, 255, 255, 0.1)"
60 | ]
61 | ]
62 | ]
63 | ++ Mixins.standardBoxShadow
64 | ]
65 | |> namespace cssNamespace
66 |
--------------------------------------------------------------------------------
/src/Subscriptions.elm:
--------------------------------------------------------------------------------
1 | module Subscriptions exposing (..)
2 |
3 | import Window
4 | import Time
5 | import Json.Decode as JD
6 | import Messages
7 | import Data.Route as Route
8 | import Models exposing (Model)
9 | import Page.Tutorial.Messages
10 | import Page.Game.Messages
11 | import Data.Spec as Spec
12 | import Data.IncomingMessage as InMsg
13 | import AnimationFrame
14 |
15 |
16 | subscriptions :
17 | Spec.DetailedSpec problem guess
18 | -> Model problem guess
19 | -> Sub (Messages.Msg problem guess)
20 | subscriptions spec model =
21 | Sub.batch
22 | [ (spec.ports
23 | |> Maybe.map .incoming
24 | |> Maybe.withDefault (always Sub.none)
25 | )
26 | (\val ->
27 | val
28 | |> JD.decodeValue (InMsg.decoder spec.problemDecoder spec.guessDecoder)
29 | |> Result.map Messages.IncomingMessage
30 | |> Result.withDefault Messages.NoOp
31 | )
32 | , case model.route of
33 | Route.Game _ ->
34 | Sub.batch
35 | [ -- This extra timer is necessary for when the game is tested in two different browser windows (animationframe doesn't fire when the tab is not active).
36 | Time.every (100 * Time.millisecond) (Messages.GameMsg << Page.Game.Messages.Tick)
37 | , AnimationFrame.times (Messages.GameMsg << Page.Game.Messages.Tick)
38 | , Window.resizes Messages.Resize
39 | ]
40 |
41 | Route.Tutorial _ ->
42 | Sub.batch
43 | [ AnimationFrame.times (Messages.TutorialMsg << Page.Tutorial.Messages.Tick)
44 | , Window.resizes Messages.Resize
45 | ]
46 |
47 | _ ->
48 | Sub.none
49 | ]
50 |
--------------------------------------------------------------------------------
/src/Router.elm:
--------------------------------------------------------------------------------
1 | module Router exposing (..)
2 |
3 | import Navigation
4 | import UrlParser exposing (..)
5 | import Data.Route exposing (Route(..))
6 | import Page.NewRoom.Models
7 | import Page.Game.Models
8 | import Page.Tutorial.Models
9 |
10 |
11 | startsWithBase : String -> String -> Bool
12 | startsWithBase basePath path_ =
13 | String.left (String.length basePath) path_ == basePath
14 |
15 |
16 | sWithBasePath : String -> String -> Parser a a
17 | sWithBasePath basePath slug =
18 | -- Redefines UrlParser's 's' function to take into account a base path.
19 | let
20 | baseSlug =
21 | String.dropLeft 1 basePath
22 | in
23 | if baseSlug == "" then
24 | s slug
25 | else
26 | (if slug == "" then
27 | s baseSlug
28 | else
29 | s baseSlug > s slug
30 | )
31 |
32 |
33 | matchers : String -> UrlParser.Parser (Route problem guess -> a) a
34 | matchers basePath =
35 | let
36 | s_ =
37 | sWithBasePath basePath
38 | in
39 | UrlParser.oneOf
40 | [ s_ "" |> map Home
41 | , s_ "tutorial" |> map (Tutorial Page.Tutorial.Models.init)
42 | , s_ "rooms"
43 | > string
44 | > string
45 | |> map
46 | (\roomId playerId ->
47 | Page.Game.Models.init roomId playerId
48 | |> Game
49 | )
50 | , s_ "new" |> map (NewRoom Page.NewRoom.Models.init)
51 | ]
52 |
53 |
54 | parse : String -> Navigation.Location -> Route problem guess
55 | parse basePath location =
56 | if startsWithBase basePath location.pathname then
57 | location
58 | |> UrlParser.parsePath (matchers basePath)
59 | |> Maybe.withDefault NotFound
60 | else
61 | NotOnBaseRoute
62 |
--------------------------------------------------------------------------------
/src/js/talk-to-ports.js:
--------------------------------------------------------------------------------
1 | /**
2 | * This function handles communication between the Elm ports and the datastore.
3 | * @param {object} db - datastore API
4 | * @param {object} ports - Elm ports
5 | * @return {object} ports
6 | */
7 | ;(function (root, factory) {
8 | if (typeof module === 'object' && module.exports) {
9 | module.exports = factory(root)
10 | } else {
11 | root.talkToPorts = factory(root)
12 | }
13 | }(this, function (window) {
14 | return function talkToPorts (db, ports) {
15 | ports.outgoing.subscribe(function (data) {
16 | var type = data.type
17 | var payload = data.payload
18 | switch (type) {
19 | // Subscribe to room, sending room:updated messages
20 | case 'subscribeto:room':
21 | return db.subscribeToRoom(payload, function (room) {
22 | ports.incoming.send({
23 | type: 'room:updated',
24 | payload: room
25 | })
26 | })
27 | // Unsubscribe from room, making sure room:updated messages are no longer sent
28 | case 'unsubscribefrom:room':
29 | return db.unsubscribeFromRoom(payload)
30 | // Create new game room in storage, sending back a room:created message.
31 | case 'create:room':
32 | return db.createRoom(payload).then(function () {
33 | ports.incoming.send({
34 | type: 'room:created',
35 | payload: payload
36 | })
37 | })
38 | // Update room. If subscribed, this should signal back to the roomUpdated port.
39 | // Hence, no feedback is necessary in this method.
40 | case 'update:room':
41 | return db.updateRoom(payload)
42 | case 'update:player':
43 | return db.updatePlayer(payload)
44 | default:
45 | return
46 | }
47 | })
48 |
49 | return ports
50 | }
51 | }))
52 |
--------------------------------------------------------------------------------
/src/Views/Attribution/Styles.elm:
--------------------------------------------------------------------------------
1 | module Views.Attribution.Styles exposing (..)
2 |
3 | import Html
4 | import Html.CssHelpers
5 | import Css exposing (..)
6 | import Css.Elements exposing (svg, p, a, span)
7 | import Css.Namespace exposing (namespace)
8 | import Styles.Constants exposing (..)
9 |
10 |
11 | cssNamespace : String
12 | cssNamespace =
13 | "attribution"
14 |
15 |
16 | type CssClasses
17 | = Root
18 |
19 |
20 | localClass : List class -> Html.Attribute msg
21 | localClass =
22 | Html.CssHelpers.withNamespace cssNamespace |> .class
23 |
24 |
25 | styles : List Css.Snippet
26 | styles =
27 | [ Css.class Root
28 | [ position fixed
29 | , bottom (px 10)
30 | , opacity (num 0.4)
31 | , width (px 240)
32 | , property "left" "calc(50% - 120px)"
33 | , color (hex black)
34 | , textDecoration none
35 | , fontSize (Css.rem 0.75)
36 | , property "transition" "all 0.3s"
37 | , hover
38 | [ opacity (num 1.0)
39 | ]
40 | , descendants
41 | [ svg
42 | [ width (px 24)
43 | , height (px 24)
44 | , display inlineBlock
45 | , verticalAlign middle
46 | , position relative
47 | , top (px -5)
48 | ]
49 | , everything
50 | [ display inlineBlock
51 | , verticalAlign middle
52 | ]
53 | , span
54 | [ firstOfType
55 | [ width (px 100)
56 | , textAlign right
57 | , marginRight (px 6)
58 | ]
59 | , lastOfType
60 | [ width (px 100)
61 | , textAlign left
62 | , marginLeft (px 6)
63 | ]
64 | ]
65 | ]
66 | ]
67 | ]
68 | |> namespace cssNamespace
69 |
--------------------------------------------------------------------------------
/src/Views/Notification/Styles.elm:
--------------------------------------------------------------------------------
1 | module Views.Notification.Styles exposing (..)
2 |
3 | import Html
4 | import Html.CssHelpers
5 | import Css exposing (..)
6 | import Css.Namespace exposing (namespace)
7 | import Styles.Constants exposing (..)
8 | import Styles.Mixins as Mixins
9 |
10 |
11 | cssNamespace : String
12 | cssNamespace =
13 | "notification"
14 |
15 |
16 | type CssClasses
17 | = Root
18 | | RootActive
19 | | RootWithCloseButton
20 | | Close
21 |
22 |
23 | localClass : List class -> Html.Attribute msg
24 | localClass =
25 | Html.CssHelpers.withNamespace cssNamespace |> .class
26 |
27 |
28 | localClassList : List ( class, Bool ) -> Html.Attribute msg
29 | localClassList =
30 | Html.CssHelpers.withNamespace cssNamespace |> .classList
31 |
32 |
33 | styles : List Snippet
34 | styles =
35 | [ class Root <|
36 | [ position fixed
37 | , top (px 20)
38 | , right (px 20)
39 | , textAlign left
40 | , opacity (num 0)
41 | , maxWidth (px 240)
42 | , property "pointer-events" "none"
43 | , backgroundColor (hex blue)
44 | , color (hex white)
45 | , borderRadius (px standardBorderRadius)
46 | , padding2 (px 10) (px 20)
47 | , descendants
48 | [ everything [ margin (px 0) ]
49 | ]
50 | ]
51 | ++ Mixins.standardBoxShadow
52 | , class RootActive
53 | [ opacity (num 1)
54 | , property "pointer-events" "all"
55 | , property "transition" "opacity 0.3s"
56 | ]
57 | , class RootWithCloseButton
58 | [ padding4 (px 10) (px 30) (px 10) (px 20)
59 | ]
60 | , class Close
61 | [ position absolute
62 | , top (px 0)
63 | , right (px 0)
64 | , padding (px 10)
65 | , color (hex white)
66 | , lineHeight (num 0.8)
67 | , fontSize (Css.rem 2)
68 | , cursor pointer
69 | ]
70 | ]
71 | |> namespace cssNamespace
72 |
--------------------------------------------------------------------------------
/src/js/README.md:
--------------------------------------------------------------------------------
1 | # elm-gameroom JavaScript utilities
2 |
3 | An Elm app running on `elm-gameroom` needs a thin piece of JavaScript that talks to a generic backend. This folder provides a generic datastore API implemented in generic backends, as well as the of glue code that talks to the Elm app's ports.
4 |
5 | These pieces of JavaScript are considered boilerplate, and `elm-gameroom` aims to keep them to a minimum.
6 |
7 | ## db
8 |
9 | The `db/*.js` files each export a function taking in some configuration, and returning an object with methods that interact with the back-end. This API is the following:
10 |
11 | ```js
12 | {
13 | getRoom: function (id) {
14 | // Retrieves the room object by a given id.
15 | },
16 |
17 | setRoom: function (room) {
18 | // Stores the room object coming from Elm in the datastore.
19 | },
20 |
21 | subscribeToRoom: function (roomId, onValue) {
22 | // Subscribes to a room, calling callback when it changes (including at the start)
23 | },
24 |
25 | unsubscribeFromRoom: function (roomId) {
26 | // Clears all subscribers from the given room for the given client
27 | },
28 |
29 | setPlayer: function (player) {
30 | // Sets a player, nested inside the room under the players field.
31 | // Note that the player object contains an id and roomId fields.
32 | // E.g. in Firebase, they can be found under the key:
33 | // `/rooms/${player.roomId}/players/${player.id}`
34 | }
35 | }
36 | ```
37 |
38 | ## talk-to-ports.js
39 |
40 | If your database implementation follows the `db` API, then you can use this piece of code to talk to your Elm ports, as follows:
41 |
42 | ```js
43 | import talkToPorts from '~/elm-stuff/packages/peterszerzo/elm-gameroom/1.0.0/src/js/talk-to-ports'
44 |
45 | // Import local storage db implementation
46 | import db from '~/elm-stuff/packages/peterszerzo/elm-gameroom/1.0.0/src/js/db/local-storage'
47 |
48 | // Start Elm app
49 | const app = Elm.Main.embed(document.getElementById('root'))
50 |
51 | // Talk to Elm's ports
52 | talkToPorts(db, app.ports)
53 | ```
54 |
--------------------------------------------------------------------------------
/src/js/db/local-storage.js:
--------------------------------------------------------------------------------
1 | /**
2 | * Simple local db, polling localStorage.
3 | * Only works if both clients are in the same browser window.
4 | * => only useful for testing games.
5 | */
6 | ;(function (root, factory) {
7 | if (typeof module === 'object' && module.exports) {
8 | module.exports = factory(root)
9 | } else {
10 | root.db = factory(root)
11 | }
12 | }(this, function (window) {
13 | var db = function () {
14 | var localStorage = window.localStorage
15 |
16 | var _subscribersByRoomId = {}
17 |
18 | return {
19 | getRoom: function (roomId) {
20 | return Promise.resolve(JSON.parse(localStorage.getItem('/rooms/' + roomId)))
21 | },
22 |
23 | createRoom: function (room) {
24 | localStorage.setItem('/rooms/' + room.id, JSON.stringify(room))
25 | return Promise.resolve(room)
26 | },
27 |
28 | updateRoom: function (room) {
29 | localStorage.setItem('/rooms/' + room.id, JSON.stringify(room))
30 | return Promise.resolve(room)
31 | },
32 |
33 | updatePlayer: function (player) {
34 | var room = JSON.parse(localStorage.getItem('/rooms/' + player.roomId))
35 | room.players[player.id] = player
36 | localStorage.setItem('/rooms/' + player.roomId, JSON.stringify(room))
37 | return Promise.resolve(player)
38 | },
39 |
40 | subscribeToRoom: function (roomId, onValue) {
41 | var previousValue
42 | _subscribersByRoomId[roomId] = {
43 | onValue: onValue,
44 | interval: window.setInterval(function () {
45 | var value = localStorage.getItem('/rooms/' + roomId)
46 | if (previousValue !== value) {
47 | onValue(JSON.parse(value))
48 | }
49 | previousValue = value
50 | }, 100)
51 | }
52 | return onValue
53 | },
54 |
55 | unsubscribeFromRoom: function (roomId) {
56 | window.clearInterval(_subscribersByRoomId[roomId].interval)
57 | _subscribersByRoomId[roomId] = null
58 | }
59 | }
60 | }
61 |
62 | return db
63 | }))
64 |
--------------------------------------------------------------------------------
/src/Page/Game/Helpers.elm:
--------------------------------------------------------------------------------
1 | module Page.Game.Helpers exposing (..)
2 |
3 | import Data.Spec as Spec
4 | import Page.Game.Models exposing (Model, getOwnGuess)
5 | import Data.Room as Room
6 | import Data.RoundTime as RoundTime
7 | import Copy
8 | import Utils
9 |
10 |
11 | getNotificationContent : Spec.DetailedSpec problem guess -> Model problem guess -> Maybe String
12 | getNotificationContent spec model =
13 | let
14 | roundTime =
15 | RoundTime.timeSinceNewRound model.time
16 | in
17 | case model.room of
18 | Just room ->
19 | if (roundTime < spec.roundDuration) then
20 | Maybe.map2
21 | (\guess round ->
22 | let
23 | eval =
24 | spec.evaluate round.problem guess.value
25 | in
26 | case spec.clearWinnerEvaluation of
27 | Just clearWinnerEval ->
28 | if eval == clearWinnerEval then
29 | Copy.correctGuess
30 | else
31 | Copy.incorrectGuess
32 |
33 | Nothing ->
34 | Utils.template Copy.evaluatedGuess (toString eval)
35 | )
36 | (getOwnGuess model)
37 | room.round
38 | else
39 | Room.getRoundWinner spec.evaluate spec.clearWinnerEvaluation room
40 | |> Maybe.map
41 | (\winnerId ->
42 | if winnerId == model.playerId then
43 | Copy.win
44 | else
45 | Utils.template Copy.lose winnerId
46 | )
47 | |> Maybe.withDefault Copy.tie
48 | |> Just
49 |
50 | Nothing ->
51 | Nothing
52 |
--------------------------------------------------------------------------------
/src/Page/Game/Models.elm:
--------------------------------------------------------------------------------
1 | module Page.Game.Models exposing (..)
2 |
3 | import Dict
4 | import Data.Guess exposing (Guess)
5 | import Data.Room as Room
6 | import Data.RoundTime as RoundTime
7 | import Data.Player as Player
8 |
9 |
10 | type alias Model problem guess =
11 | { roomId : Room.RoomId
12 | , playerId : Player.PlayerId
13 | , room : Maybe (Room.Room problem guess)
14 | , time : RoundTime.RoundTime
15 | }
16 |
17 |
18 |
19 | -- Helpers
20 |
21 |
22 | init : Room.RoomId -> Player.PlayerId -> Model problem guess
23 | init roomId playerId =
24 | { roomId = roomId
25 | , playerId = playerId
26 | , room = Nothing
27 | , time = RoundTime.init
28 | }
29 |
30 |
31 | isHost : Model problem guess -> Bool
32 | isHost game =
33 | game.room
34 | |> Maybe.map (\room -> room.host == game.playerId)
35 | |> Maybe.withDefault False
36 |
37 |
38 | setOwnGuess : guess -> Model problem guess -> Model problem guess
39 | setOwnGuess guess model =
40 | case model.room of
41 | Just room ->
42 | let
43 | newGuess =
44 | Just { value = guess, madeAt = RoundTime.timeSinceNewRound model.time }
45 |
46 | players =
47 | room.players
48 | |> Dict.map
49 | (\playerId player ->
50 | if playerId == model.playerId then
51 | { player | guess = newGuess }
52 | else
53 | player
54 | )
55 |
56 | newRoom =
57 | { room | players = players }
58 | in
59 | { model | room = Just newRoom }
60 |
61 | Nothing ->
62 | model
63 |
64 |
65 | getOwnPlayer : Model problem guess -> Maybe (Player.Player guess)
66 | getOwnPlayer model =
67 | model.room
68 | |> Maybe.map .players
69 | |> Maybe.andThen (Dict.get model.playerId)
70 |
71 |
72 | getOwnGuess : Model problem guess -> Maybe (Guess guess)
73 | getOwnGuess model =
74 | getOwnPlayer model
75 | |> Maybe.andThen .guess
76 |
--------------------------------------------------------------------------------
/src/Page/Tutorial/Views.elm:
--------------------------------------------------------------------------------
1 | module Page.Tutorial.Views exposing (view)
2 |
3 | import Window
4 | import Html exposing (Html, map, div, text, button, h1, h2, label, input, fieldset, span, ul, li, a, p)
5 | import Html.Events exposing (onClick)
6 | import Data.RoundTime as RoundTime
7 | import Data.Spec as Spec
8 | import Page.Tutorial.Models exposing (Model)
9 | import Page.Tutorial.Messages exposing (Msg(..))
10 | import Page.Tutorial.Views.Styles exposing (CssClasses(..), localClass)
11 | import Views.Notification
12 | import Utils
13 | import Copy
14 |
15 |
16 | view :
17 | Spec.DetailedSpec problem guess
18 | -> Window.Size
19 | -> Model problem guess
20 | -> Html (Msg problem guess)
21 | view spec windowSize model =
22 | let
23 | roundTime =
24 | RoundTime.timeSinceNewRound model.time
25 | in
26 | div
27 | [ localClass [ Root ]
28 | ]
29 | [ Views.Notification.view
30 | (model.problem
31 | |> Maybe.map
32 | (\problem ->
33 | case model.guess of
34 | Nothing ->
35 | spec.instructions
36 |
37 | Just guess ->
38 | Utils.template Copy.tutorialEvaluatedGuess (spec.evaluate problem guess |> toString)
39 | )
40 | |> Maybe.withDefault Copy.tutorialStartup
41 | |> Just
42 | )
43 | Nothing
44 | , div [ localClass [ Button ], onClick RequestNewProblem ] [ text "▶" ]
45 | , model.problem
46 | |> Maybe.map
47 | (spec.view
48 | { windowSize = windowSize
49 | , roundTime = roundTime
50 | , ownGuess = model.guess
51 | , opponentGuesses = []
52 | , isRoundOver = False
53 | , scores = []
54 | }
55 | )
56 | |> Maybe.map (map Guess)
57 | |> Maybe.withDefault (div [] [])
58 | ]
59 |
--------------------------------------------------------------------------------
/src/Styles/Shared.elm:
--------------------------------------------------------------------------------
1 | module Styles.Shared exposing (..)
2 |
3 | import Css exposing (..)
4 | import Css.Elements exposing (html, body, input, label, button, a, h1, h2, h3, p)
5 | import Styles.Constants exposing (..)
6 | import Styles.Mixins as Mixins
7 |
8 |
9 | type CssIds
10 | = Root
11 |
12 |
13 | styles : List Snippet
14 | styles =
15 | [ everything
16 | [ boxSizing borderBox
17 | , property "font-family" "Source Sans Pro, Verdana, Geneva, sans-serif"
18 | , property "-webkit-font-smoothing" "antialiased"
19 | , property "-moz-osx-font-smoothing" "grayscale"
20 | ]
21 | , html
22 | [ fontSize (pct 80)
23 | ]
24 | , mediaQuery ("screen and (min-width: 600px)")
25 | [ html
26 | [ fontSize (pct 100)
27 | ]
28 | ]
29 | , each [ html, body ]
30 | [ padding (px 0)
31 | , margin (px 0)
32 | , width (pct 100)
33 | , height (pct 100)
34 | ]
35 | , body
36 | [ position relative
37 | , color (hex black)
38 | ]
39 | , id Root
40 | [ width (pct 100)
41 | , height (pct 100)
42 | ]
43 | , h1 Mixins.heroType
44 | , h2 Mixins.subheroType
45 | , h3 Mixins.headingType
46 | , p Mixins.bodyType
47 | , input
48 | [ display block
49 | , width (pct 100)
50 | , padding2 (px 8) (px 8)
51 | , borderRadius (px 4)
52 | , outline none
53 | , boxShadow none
54 | , fontSize (Css.rem 1)
55 | , border3 (px 1) solid (hex grey)
56 | , marginTop (px 6)
57 | , property "transition" "border 0.3s"
58 | , focus
59 | [ borderColor (hex blue)
60 | ]
61 | ]
62 | , label
63 | [ display block
64 | , textAlign left
65 | , marginTop (px 30)
66 | , width (pct 100)
67 | , position relative
68 | , color (hex darkGrey)
69 | , children
70 | [ button
71 | [ position absolute
72 | , top (px 0)
73 | , right (px 0)
74 | , border (px 0)
75 | , fontSize (Css.rem 1)
76 | , backgroundColor transparent
77 | ]
78 | ]
79 | ]
80 | ]
81 |
--------------------------------------------------------------------------------
/site/src/Styles.elm:
--------------------------------------------------------------------------------
1 | module Styles exposing (..)
2 |
3 | import Html
4 | import Html.CssHelpers
5 | import Css exposing (..)
6 | import Css.Elements exposing (svg, h1, main_, footer, header)
7 | import Css.Namespace exposing (namespace)
8 | import Styles.Shared
9 | import Styles.Mixins
10 |
11 |
12 | cssNamespace : String
13 | cssNamespace =
14 | "elmgameroomhome"
15 |
16 |
17 | type CssClasses
18 | = Root
19 | | Link
20 | | SimpleLink
21 |
22 |
23 | css : Css.Stylesheet
24 | css =
25 | stylesheet <|
26 | ([ class Root
27 | [ padding2 (px 60) (px 20)
28 | , textAlign center
29 | , display block
30 | , descendants
31 | [ svg
32 | [ width (px 60)
33 | , height (px 60)
34 | , margin auto
35 | ]
36 | , h1
37 | [ property "font-weight" "300"
38 | ]
39 | ]
40 | ]
41 | , class Link Styles.Mixins.button
42 | , class SimpleLink
43 | [ textDecoration none
44 | , color (hex "2D739E")
45 | , borderBottom3 (px 1) solid currentColor
46 | , property "transition" "all 0.3s"
47 | , hover [ color (hex "3890c6") ]
48 | ]
49 | , header
50 | [ paddingBottom (px 30)
51 | ]
52 | , main_
53 | [ padding2 (px 60) (px 20)
54 | , maxWidth (px 640)
55 | , margin auto
56 | , borderTop3 (px 1) solid (hex "cccccc")
57 | , borderBottom3 (px 1) solid (hex "cccccc")
58 | ]
59 | , footer
60 | [ maxWidth (px 640)
61 | , padding2 (px 30) (px 40)
62 | , margin auto
63 | , descendants
64 | [ everything
65 | [ maxWidth (px 400)
66 | ]
67 | ]
68 | ]
69 | ]
70 | |> namespace cssNamespace
71 | )
72 | ++ Styles.Shared.styles
73 |
74 |
75 | cssText : String
76 | cssText =
77 | compile [ css ] |> .css
78 |
79 |
80 | localClass : List class -> Html.Attribute msg
81 | localClass =
82 | Html.CssHelpers.withNamespace cssNamespace |> .class
83 |
--------------------------------------------------------------------------------
/src/Views.elm:
--------------------------------------------------------------------------------
1 | module Views exposing (view)
2 |
3 | import Html exposing (Html, div, node, p, a, text)
4 | import Html.Attributes exposing (href)
5 | import Data.Route as Route
6 | import Data.Spec as Spec
7 | import Models exposing (Model)
8 | import Messages exposing (Msg(..))
9 | import Page.Game.Views
10 | import Page.Home.Views
11 | import Page.NewRoom.Views
12 | import Page.NotFound.Views
13 | import Page.Tutorial.Views
14 | import Views.Layout
15 | import Views.NoMultiplayer
16 |
17 |
18 | view : Spec.DetailedSpec problem guess -> Model problem guess -> Html (Msg problem guess)
19 | view spec model =
20 | let
21 | isHome =
22 | model.route == Route.Home
23 |
24 | noMultiplayer =
25 | spec.ports == Nothing
26 |
27 | content =
28 | case model.route of
29 | Route.Home ->
30 | Page.Home.Views.view spec
31 |
32 | Route.Game game ->
33 | if noMultiplayer then
34 | Views.NoMultiplayer.view
35 | else
36 | Page.Game.Views.view spec model.windowSize game
37 | |> Html.map GameMsg
38 |
39 | Route.NewRoom newRoom ->
40 | if noMultiplayer then
41 | Views.NoMultiplayer.view
42 | else
43 | Page.NewRoom.Views.view newRoom
44 | |> Html.map NewRoomMsg
45 |
46 | Route.NotFound ->
47 | Page.NotFound.Views.view
48 |
49 | Route.NotOnBaseRoute ->
50 | div []
51 | [ p [] [ text "Not on configured base path. Redirecting.." ]
52 | , p []
53 | [ text "If not redirected in a couple of seconds, "
54 | , a [ href spec.basePath ] [ text "click here" ]
55 | , text "."
56 | ]
57 | ]
58 |
59 | Route.Tutorial tutorial ->
60 | Page.Tutorial.Views.view spec model.windowSize tutorial
61 | |> Html.map TutorialMsg
62 | in
63 | Views.Layout.view spec.inlineStyle isHome spec.unicodeIcon [ content ]
64 |
--------------------------------------------------------------------------------
/site/src/Main.elm:
--------------------------------------------------------------------------------
1 | module Main exposing (..)
2 |
3 | import Time
4 | import AnimationFrame
5 | import Html exposing (Html, div, h1, h2, p, a, text, program, node, footer, main_, header)
6 | import Html.Attributes exposing (class, href)
7 | import Views.Logo
8 | import Styles exposing (cssText, CssClasses(..), localClass)
9 |
10 |
11 | main : Program Never Model Msg
12 | main =
13 | program
14 | { view = view
15 | , update = update
16 | , init = init
17 | , subscriptions = subscriptions
18 | }
19 |
20 |
21 | games : List String
22 | games =
23 | [ "lettero"
24 | , "counterclockwooze"
25 | , "spacecraterball"
26 | , "the-capitalist"
27 | , "fast-and-moebius"
28 | ]
29 |
30 |
31 | type alias Model =
32 | { time : Time.Time
33 | }
34 |
35 |
36 | type Msg
37 | = Tick Time.Time
38 |
39 |
40 | init : ( Model, Cmd Msg )
41 | init =
42 | ( Model 0, Cmd.none )
43 |
44 |
45 | subscriptions : Model -> Sub Msg
46 | subscriptions model =
47 | AnimationFrame.times Tick
48 |
49 |
50 | update : Msg -> Model -> ( Model, Cmd Msg )
51 | update msg model =
52 | case msg of
53 | Tick time ->
54 | ( { model | time = time }, Cmd.none )
55 |
56 |
57 | view : Model -> Html msg
58 | view model =
59 | div [ localClass [ Root ] ] <|
60 | [ node "style" [] [ cssText |> text ]
61 | , header []
62 | [ Views.Logo.animatedView 1 model.time
63 | , h1 [] [ text "elm-gameroom" ]
64 | , p [] [ text "framework for reliably custom multiplayer games" ]
65 | ]
66 | , main_ []
67 | [ p [] [ text "hey, feel like playing? here is a collection of multiplayer games written in a few hundred lines each in the comfortable, trusted hands of ElmLang:" ]
68 | , div [] <|
69 | List.map
70 | (\game ->
71 | a
72 | [ localClass [ Link ]
73 | , href ("/" ++ game)
74 | ]
75 | [ text game ]
76 | )
77 | games
78 | ]
79 | , footer []
80 | [ p []
81 | [ text "find the project on "
82 | , a
83 | [ localClass [ SimpleLink ]
84 | , href "http://package.elm-lang.org/packages/peterszerzo/elm-gameroom/latest"
85 | ]
86 | [ text "GitHub" ]
87 | , text ". feel free to open a new pull request if you have game ideas"
88 | ]
89 | ]
90 | ]
91 |
--------------------------------------------------------------------------------
/src/Data/Player.elm:
--------------------------------------------------------------------------------
1 | module Data.Player exposing (..)
2 |
3 | import Dict
4 | import Json.Decode as JD
5 | import Json.Encode as JE
6 | import Data.Guess as Guess
7 |
8 |
9 | type alias PlayerId =
10 | String
11 |
12 |
13 | type alias Player guess =
14 | { id : String
15 | , roomId : String
16 | , isReady : Bool
17 | , score : Int
18 | , guess :
19 | Maybe (Guess.Guess guess)
20 | }
21 |
22 |
23 | type alias Players guess =
24 | Dict.Dict String (Player guess)
25 |
26 |
27 | create : String -> String -> Player guess
28 | create id roomId =
29 | { id = id
30 | , roomId = roomId
31 | , isReady = False
32 | , score = 0
33 | , guess = Nothing
34 | }
35 |
36 |
37 | extractGuesses : List ( String, Player guess ) -> List ( String, guess )
38 | extractGuesses players =
39 | case players of
40 | [] ->
41 | []
42 |
43 | ( playerId, player ) :: tail ->
44 | (case player.guess of
45 | Just guess ->
46 | [ ( playerId, guess.value ) ]
47 |
48 | Nothing ->
49 | []
50 | )
51 | ++ (extractGuesses tail)
52 |
53 |
54 |
55 | -- Encoders
56 |
57 |
58 | encoder : (guess -> JE.Value) -> (Player guess -> JE.Value)
59 | encoder guessEncoder player =
60 | JE.object <|
61 | [ ( "id", JE.string player.id )
62 | , ( "roomId", JE.string player.roomId )
63 | , ( "isReady", JE.bool player.isReady )
64 | , ( "score", JE.int player.score )
65 | ]
66 | ++ (player.guess
67 | |> Maybe.map
68 | (\guess ->
69 | [ ( "guess"
70 | , JE.object
71 | [ ( "value", guessEncoder guess.value )
72 | , ( "madeAt", JE.float guess.madeAt )
73 | ]
74 | )
75 | ]
76 | )
77 | |> Maybe.withDefault []
78 | )
79 |
80 |
81 | collectionEncoder : (guess -> JE.Value) -> (Players guess -> JE.Value)
82 | collectionEncoder guessEncoder players =
83 | players
84 | |> Dict.toList
85 | |> List.map (\( key, player ) -> ( key, encoder guessEncoder player ))
86 | |> JE.object
87 |
88 |
89 |
90 | -- Decoders
91 |
92 |
93 | decoder : JD.Decoder guess -> JD.Decoder (Player guess)
94 | decoder guessDecoder =
95 | JD.map5 Player
96 | (JD.field "id" JD.string)
97 | (JD.field "roomId" JD.string)
98 | (JD.field "isReady" JD.bool)
99 | (JD.field "score" JD.int)
100 | (JD.maybe (JD.field "guess" (Guess.decoder guessDecoder)))
101 |
--------------------------------------------------------------------------------
/src/Views/Layout.elm:
--------------------------------------------------------------------------------
1 | module Views.Layout exposing (view, css)
2 |
3 | import Html exposing (Html, div, node, p, a, text)
4 | import Html.CssHelpers
5 | import Css exposing (Stylesheet, stylesheet, position, fixed, top, px, bottom, left, right, backgroundColor, hex)
6 | import Css.Namespace exposing (namespace)
7 | import Css.File exposing (compile)
8 | import Messages
9 | import Views.Attribution
10 | import Views.Attribution.Styles
11 | import Views.Footer.Styles
12 | import Views.Header
13 | import Views.Header.Styles
14 | import Views.Notification.Styles
15 | import Views.Scoreboard.Styles
16 | import Views.NoMultiplayer.Styles
17 | import Views.Timer.Styles
18 | import Page.Game.Views.Styles
19 | import Page.Home.Views.Styles
20 | import Page.NewRoom.Views.Styles
21 | import Page.NotFound.Views.Styles
22 | import Page.Tutorial.Views.Styles
23 | import Styles.Shared
24 | import Styles.Constants exposing (white)
25 |
26 |
27 | type CssClasses
28 | = Root
29 |
30 |
31 | cssNamespace : String
32 | cssNamespace =
33 | "layout"
34 |
35 |
36 | styles : List Css.Snippet
37 | styles =
38 | [ Css.class Root
39 | [ position fixed
40 | , top (px 0)
41 | , bottom (px 0)
42 | , left (px 0)
43 | , right (px 0)
44 | , Css.displayFlex
45 | , Css.alignItems Css.center
46 | , Css.justifyContent Css.center
47 | , backgroundColor (hex white)
48 | ]
49 | ]
50 | |> namespace cssNamespace
51 |
52 |
53 | css : Stylesheet
54 | css =
55 | stylesheet
56 | (Styles.Shared.styles
57 | ++ styles
58 | ++ Views.Attribution.Styles.styles
59 | ++ Views.Footer.Styles.styles
60 | ++ Views.Header.Styles.styles
61 | ++ Views.Notification.Styles.styles
62 | ++ Views.NoMultiplayer.Styles.styles
63 | ++ Views.Scoreboard.Styles.styles
64 | ++ Views.Timer.Styles.styles
65 | ++ Page.Game.Views.Styles.styles
66 | ++ Page.Home.Views.Styles.styles
67 | ++ Page.NewRoom.Views.Styles.styles
68 | ++ Page.NotFound.Views.Styles.styles
69 | ++ Page.Tutorial.Views.Styles.styles
70 | )
71 |
72 |
73 | class : List class -> Html.Attribute msg
74 | class =
75 | Html.CssHelpers.withNamespace cssNamespace |> .class
76 |
77 |
78 | view : Bool -> Bool -> String -> List (Html (Messages.Msg problem guess)) -> Html (Messages.Msg problem guess)
79 | view renderInlineStyle showAttribution icon children =
80 | div
81 | [ class [ Root ]
82 | ]
83 | <|
84 | (if renderInlineStyle then
85 | [ node "style" [] [ compile [ css ] |> .css |> text ] ]
86 | else
87 | []
88 | )
89 | ++ (if showAttribution then
90 | [ Views.Attribution.view ]
91 | else
92 | [ Views.Header.view icon
93 | ]
94 | )
95 | ++ children
96 |
--------------------------------------------------------------------------------
/src/Page/NewRoom/Update.elm:
--------------------------------------------------------------------------------
1 | module Page.NewRoom.Update exposing (..)
2 |
3 | import Page.NewRoom.Models exposing (Model, Status(..))
4 | import Utils exposing (urlize)
5 | import Page.NewRoom.Messages exposing (Msg(..))
6 |
7 |
8 | update : Msg -> Model -> ( Model, Bool, Maybe String )
9 | update msg model =
10 | case msg of
11 | ChangeRoomId newRoomId ->
12 | let
13 | urlizedNewRoomId =
14 | urlize newRoomId
15 |
16 | isChanging =
17 | urlizedNewRoomId
18 | /= newRoomId
19 | in
20 | ( { model
21 | | roomId = urlizedNewRoomId
22 | , entriesUrlized =
23 | if isChanging then
24 | True
25 | else
26 | model.entriesUrlized
27 | }
28 | , False
29 | , Nothing
30 | )
31 |
32 | ChangePlayerId index newPlayerId ->
33 | let
34 | urlizedNewPlayerId =
35 | urlize newPlayerId
36 |
37 | isChanging =
38 | urlizedNewPlayerId
39 | /= newPlayerId
40 | in
41 | ( { model
42 | | playerIds =
43 | List.indexedMap
44 | (\index_ oldValue ->
45 | if index_ == index then
46 | urlizedNewPlayerId
47 | else
48 | oldValue
49 | )
50 | model.playerIds
51 | , entriesUrlized =
52 | if isChanging then
53 | True
54 | else
55 | model.entriesUrlized
56 | }
57 | , False
58 | , Nothing
59 | )
60 |
61 | AddPlayer ->
62 | ( { model | playerIds = model.playerIds ++ [ "" ] }
63 | , False
64 | , Nothing
65 | )
66 |
67 | RemovePlayer index ->
68 | ( { model | playerIds = (List.take index model.playerIds) ++ (List.drop (index + 1) model.playerIds) }
69 | , False
70 | , Nothing
71 | )
72 |
73 | CreateRequest ->
74 | ( { model | status = Pending }
75 | , True
76 | , Nothing
77 | )
78 |
79 | DismissUrlizeNotification ->
80 | ( { model | isUrlizedNotificationDismissed = True }, False, Nothing )
81 |
82 | CreateResponse response ->
83 | ( model
84 | , False
85 | , Just
86 | ("/rooms/" ++ model.roomId ++ "/" ++ (model.playerIds |> List.head |> Maybe.withDefault ""))
87 | )
88 |
--------------------------------------------------------------------------------
/src/Styles/Mixins.elm:
--------------------------------------------------------------------------------
1 | module Styles.Mixins exposing (..)
2 |
3 | import Css exposing (..)
4 | import Styles.Constants exposing (..)
5 |
6 |
7 | -- Utilities
8 |
9 |
10 | centered : List Style
11 | centered =
12 | [ maxWidth (px 540)
13 | , maxHeight (pct 100)
14 | , overflowY auto
15 | , width (pct 100)
16 | , margin (px 0)
17 | , padding (px 20)
18 | , textAlign center
19 | ]
20 |
21 |
22 | standardBoxShadow : List Style
23 | standardBoxShadow =
24 | [ property "box-shadow" "0 0 18px rgba(0, 0, 0, 0.08), 0 0 6px rgba(0, 0, 0, 0.16)"
25 | ]
26 |
27 |
28 | intenseBoxShadow : List Style
29 | intenseBoxShadow =
30 | [ property "box-shadow" "0 0 30px rgba(0, 0, 0, 0.2), 0 0 10px rgba(0, 0, 0, 0.4)"
31 | ]
32 |
33 |
34 |
35 | -- Typography
36 |
37 |
38 | heroType : List Style
39 | heroType =
40 | [ fontSize (Css.rem 2.5)
41 | , property "font-weight" "300"
42 | , letterSpacing (Css.rem 0.05)
43 | , margin2 (px 10) auto
44 | ]
45 |
46 |
47 | subheroType : List Style
48 | subheroType =
49 | [ fontSize (Css.rem 2)
50 | , property "font-weight" "300"
51 | , letterSpacing (Css.rem 0.05)
52 | , margin3 (px 20) auto (px 20)
53 | ]
54 |
55 |
56 | headingType : List Style
57 | headingType =
58 | [ fontSize (Css.rem 1.5)
59 | , property "font-weight" "300"
60 | , letterSpacing (Css.rem 0.05)
61 | , margin3 (px 20) auto (px 40)
62 | ]
63 |
64 |
65 | bodyType : List Style
66 | bodyType =
67 | [ fontSize (Css.rem 1)
68 | , property "font-weight" "400"
69 | , letterSpacing (Css.rem 0.05)
70 | , lineHeight (num 1.5)
71 | , margin2 (px 10) auto
72 | ]
73 |
74 |
75 |
76 | -- Buttons
77 |
78 |
79 | button : List Style
80 | button =
81 | [ color (hex white)
82 | , cursor pointer
83 | , position relative
84 | , display inlineBlock
85 | , backgroundColor (hex blue)
86 | , fontSize (Css.rem 1)
87 | , letterSpacing (Css.rem 0.05)
88 | , margin (px 10)
89 | , padding2 (px 8) (px 16)
90 | , textDecoration none
91 | , borderRadius (px standardBorderRadius)
92 | , border (px 0)
93 | , outline none
94 | , border3 (px 1) solid (hex blue)
95 | , property "transition" "all 0.3s"
96 | , after
97 | [ property "content" "' '"
98 | , position absolute
99 | , top (px 0)
100 | , left (px 0)
101 | , bottom (px 0)
102 | , right (px 0)
103 | , property "transition" "background-color 0.3s"
104 | ]
105 | , hover
106 | [ after
107 | [ property "background-color" "rgba(255, 255, 255, 0.1)"
108 | ]
109 | ]
110 | , focus
111 | intenseBoxShadow
112 | ]
113 | ++ standardBoxShadow
114 |
115 |
116 | buttonDisabled : List Style
117 | buttonDisabled =
118 | [ borderColor (hex darkGrey)
119 | , color (hex darkGrey)
120 | , backgroundColor transparent
121 | , property "opacity" "0.6"
122 | , cursor initial
123 | , boxShadow none
124 | ]
125 |
--------------------------------------------------------------------------------
/src/Views/Logo.elm:
--------------------------------------------------------------------------------
1 | module Views.Logo exposing (view, animatedView)
2 |
3 | import Html exposing (Html)
4 | import Time
5 | import Svg exposing (svg, polygon)
6 | import Svg.Attributes exposing (points, viewBox, width, height, stroke, strokeWidth, fill, transform)
7 | import Styles.Constants exposing (..)
8 |
9 |
10 | type alias Polygon =
11 | { coords : List ( Float, Float )
12 | , color : String
13 | , transform : ( Float, Float )
14 | , transformPhase : Float
15 | }
16 |
17 |
18 | polygons : List Polygon
19 | polygons =
20 | [ { coords =
21 | [ ( 30.0, -30.0 )
22 | , ( 30.0, 3.33 )
23 | , ( 13.33, -13.33 )
24 | ]
25 | , color = cyan
26 | , transform = ( 0, 0 )
27 | , transformPhase = 0
28 | }
29 | , { coords =
30 | [ ( 30.0, -30.0 )
31 | , ( 13.33, -13.33 )
32 | , ( -3.33, -30.0 )
33 | ]
34 | , color = blue
35 | , transform = ( 0, 0 )
36 | , transformPhase = 0
37 | }
38 | , { coords =
39 | [ ( 16.67, 1.71 )
40 | , ( 16.67, 15.05 )
41 | , ( 30.0, 15.05 )
42 | ]
43 | , color = black
44 | , transform = ( 0, 1 )
45 | , transformPhase = 4 * pi / 3
46 | }
47 | , { coords =
48 | [ ( 3.33, -17.55 )
49 | , ( 16.67, -4.22 )
50 | , ( 3.33, 9.12 )
51 | ]
52 | , color = red
53 | , transform = ( 0, 3 )
54 | , transformPhase = pi
55 | }
56 | , { coords =
57 | [ ( -3.33, 6.0 )
58 | , ( -16.67, -7.33 )
59 | , ( -30.0, 6.0 )
60 | ]
61 | , color = blue
62 | , transform = ( 0, 1.5 )
63 | , transformPhase = pi / 3
64 | }
65 | , { coords =
66 | [ ( -16.67, -14.16 )
67 | , ( -3.33, -27.49 )
68 | , ( -3.33, -1.17 )
69 | ]
70 | , color = cyan
71 | , transform = ( 0, 1.6 )
72 | , transformPhase = 2 * pi / 3
73 | }
74 | , { coords =
75 | [ ( -30.0, -3.33 )
76 | , ( -30.0, -30.0 )
77 | , ( -3.33, -30.0 )
78 | ]
79 | , color = black
80 | , transform = ( 0, 0 )
81 | , transformPhase = 0
82 | }
83 | ]
84 |
85 |
86 | viewPolygon : Float -> Time.Time -> Polygon -> Html msg
87 | viewPolygon amplitude time { coords, color, transform, transformPhase } =
88 | let
89 | sinTime =
90 | sin (time / 1000 + transformPhase)
91 |
92 | factor =
93 | amplitude * sinTime
94 |
95 | dx =
96 | factor * (Tuple.first transform)
97 |
98 | dy =
99 | factor * (Tuple.second transform)
100 | in
101 | polygon
102 | [ points
103 | (coords
104 | |> List.map
105 | (\( x, y ) ->
106 | (toString (x + dx)) ++ "," ++ (toString (-(y + dy)))
107 | )
108 | |> String.join " "
109 | )
110 | , stroke "#FFF"
111 | , strokeWidth "1.5"
112 | , fill ("#" ++ color)
113 | ]
114 | []
115 |
116 |
117 | view : Html msg
118 | view =
119 | animatedView 0 0
120 |
121 |
122 | animatedView : Float -> Time.Time -> Html msg
123 | animatedView amplitude time =
124 | svg [ viewBox "-30 -30 60 60" ]
125 | (List.map (viewPolygon amplitude time) polygons)
126 |
--------------------------------------------------------------------------------
/src/Page/NewRoom/Views.elm:
--------------------------------------------------------------------------------
1 | module Page.NewRoom.Views exposing (view)
2 |
3 | import Html exposing (Html, div, text, button, form, h1, h2, label, input, fieldset, span, ul, li, a, p)
4 | import Html.Attributes exposing (class, style, type_, value, id, for, href, placeholder, disabled)
5 | import Html.Events exposing (onClick, onInput, onSubmit)
6 | import Page.NewRoom.Models exposing (Model, Status(..))
7 | import Page.NewRoom.Messages exposing (Msg(..))
8 | import Page.NewRoom.Views.Styles exposing (CssClasses(..), localClass, localClassList)
9 | import Views.Loader
10 | import Views.Notification
11 | import Copy
12 |
13 |
14 | viewForm : Model -> List (Html Msg)
15 | viewForm model =
16 | let
17 | canSubmit =
18 | (String.length model.roomId > 0)
19 | && (model.playerIds |> List.map (\playerId -> String.length playerId > 0) |> List.all identity)
20 | in
21 | [ h2 [ localClass [ Title ] ] [ text Copy.newRoomPageTitle ]
22 | , p [] [ text Copy.newRoomFormIntro ]
23 | , form
24 | [ onSubmit CreateRequest
25 | ]
26 | [ label [ for "roomid" ]
27 | [ text "Room Id"
28 | , input
29 | [ id "roomid"
30 | , type_ "text"
31 | , onInput ChangeRoomId
32 | , placeholder "e.g. theroom"
33 | , value model.roomId
34 | ]
35 | []
36 | ]
37 | , div []
38 | (List.indexedMap
39 | (\index playerId ->
40 | let
41 | fieldId =
42 | "playerid-" ++ (toString index)
43 | in
44 | label [ for fieldId ]
45 | ([ text ("Player " ++ (toString (index + 1)))
46 | , input
47 | [ id fieldId
48 | , type_ "text"
49 | , onInput (ChangePlayerId index)
50 | , placeholder
51 | (if rem index 2 == 0 then
52 | "e.g. alfred"
53 | else
54 | "e.g. samantha"
55 | )
56 | , value (List.drop index model.playerIds |> List.head |> Maybe.withDefault "")
57 | ]
58 | []
59 | ]
60 | ++ (if List.length model.playerIds > 2 then
61 | [ button
62 | [ onClick (RemovePlayer index)
63 | ]
64 | [ text "✕" ]
65 | ]
66 | else
67 | []
68 | )
69 | )
70 | )
71 | model.playerIds
72 | )
73 | , button
74 | [ localClass [ Button, FormButton ]
75 | , onClick AddPlayer
76 | ]
77 | [ text "Add player" ]
78 | , button
79 | [ localClassList [ ( Button, True ), ( FormButton, True ), ( ButtonDisabled, not canSubmit ) ]
80 | , type_ "submit"
81 | , disabled (not canSubmit)
82 | ]
83 | [ text "Create room" ]
84 | ]
85 | , Views.Notification.view
86 | (if not model.entriesUrlized || model.isUrlizedNotificationDismissed then
87 | Nothing
88 | else
89 | Just Copy.casualNamesWarning
90 | )
91 | (Just DismissUrlizeNotification)
92 | ]
93 |
94 |
95 | view : Model -> Html Msg
96 | view model =
97 | div [ localClass [ Root ] ]
98 | (case model.status of
99 | Editing ->
100 | viewForm model
101 |
102 | Pending ->
103 | [ Views.Loader.view ]
104 |
105 | Error ->
106 | [ h2 [] [ text "There was an error creating your room :/" ]
107 | , p [] [ text "Please reload the page and try again." ]
108 | , a [ href "new", localClass [ Button ] ] [ text "Reload" ]
109 | ]
110 | )
111 |
--------------------------------------------------------------------------------
/src/Data/Spec.elm:
--------------------------------------------------------------------------------
1 | module Data.Spec exposing (..)
2 |
3 | {-| With the Spec object, you can define your game declaratively, specifying only what is unique to it.
4 |
5 | # The full spec
6 | @docs Spec
7 |
8 | # The view
9 | @docs View, Ticks, Copy
10 |
11 | # Game logic
12 | @docs ProblemGenerator
13 |
14 | # Data management
15 | @docs ProblemEncoder, ProblemDecoder, GuessEncoder, GuessDecoder
16 | -}
17 |
18 | import Html
19 | import Time
20 | import Random
21 | import Messages exposing (Msg)
22 | import Data.Ports as Ports
23 | import Json.Decode as Decode
24 | import Json.Encode as Encode
25 | import Gameroom.Context exposing (Context)
26 |
27 |
28 | type Setting problem guess
29 | = BasePath String
30 | | Name String
31 | | Subheading String
32 | | Instructions String
33 | | UnicodeIcon String
34 | | RoundDuration Time.Time
35 | | CooldownDuration Time.Time
36 | | ClearWinner Float
37 | | SetPorts (Ports.Ports (Msg problem guess))
38 | | NoInlineStyle
39 | | NoPeripheralUi
40 |
41 |
42 | {-| Define the basic mechanics of a multiplayer game, all generalized over a type variable representing a `problem`, and one representing a `guess`. Each field in the record is documented separately in this module.
43 | -}
44 | type alias Spec problem guess =
45 | { view : Context guess -> problem -> Html.Html guess
46 | , evaluate : problem -> guess -> Float
47 | , problemGenerator : Random.Generator problem
48 | , problemEncoder : problem -> Encode.Value
49 | , problemDecoder : Decode.Decoder problem
50 | , guessEncoder : guess -> Encode.Value
51 | , guessDecoder : Decode.Decoder guess
52 | }
53 |
54 |
55 | {-| Augments the spec with data from options.
56 | -}
57 | type alias DetailedSpec problem guess =
58 | { basePath : String
59 | , unicodeIcon : String
60 | , name : String
61 | , subheading : String
62 | , instructions : String
63 | , roundDuration : Time.Time
64 | , cooldownDuration : Time.Time
65 | , clearWinnerEvaluation : Maybe Float
66 | , ports : Maybe (Ports.Ports (Msg problem guess))
67 | , inlineStyle : Bool
68 | , peripheralUi : Bool
69 | , view : Context guess -> problem -> Html.Html guess
70 | , evaluate : problem -> guess -> Float
71 | , problemGenerator : Random.Generator problem
72 | , problemEncoder : problem -> Encode.Value
73 | , problemDecoder : Decode.Decoder problem
74 | , guessEncoder : guess -> Encode.Value
75 | , guessDecoder : Decode.Decoder guess
76 | }
77 |
78 |
79 | buildDetailedSpec : List (Setting problem guess) -> Spec problem guess -> DetailedSpec problem guess
80 | buildDetailedSpec options spec =
81 | List.foldl
82 | (\option spec ->
83 | case option of
84 | BasePath basePath ->
85 | let
86 | baseSlug =
87 | basePath
88 | |> (\path_ ->
89 | -- Remove leading slash
90 | if String.left 1 path_ == "/" then
91 | String.dropLeft 1 path_
92 | else
93 | path_
94 | )
95 | |> (\path_ ->
96 | -- Remove trailing slash
97 | if String.right 1 path_ == "/" then
98 | String.dropRight 1 path_
99 | else
100 | path_
101 | )
102 | in
103 | { spec | basePath = "/" ++ baseSlug }
104 |
105 | Name name ->
106 | { spec | name = name }
107 |
108 | Subheading subheading ->
109 | { spec | subheading = subheading }
110 |
111 | Instructions instructions ->
112 | { spec | instructions = instructions }
113 |
114 | UnicodeIcon icon ->
115 | { spec | unicodeIcon = icon }
116 |
117 | RoundDuration duration ->
118 | { spec | roundDuration = duration }
119 |
120 | CooldownDuration duration ->
121 | { spec | cooldownDuration = duration }
122 |
123 | ClearWinner maxEvaluation ->
124 | { spec | clearWinnerEvaluation = Just maxEvaluation }
125 |
126 | SetPorts p ->
127 | { spec | ports = Just p }
128 |
129 | NoInlineStyle ->
130 | { spec | inlineStyle = False }
131 |
132 | NoPeripheralUi ->
133 | { spec | peripheralUi = False }
134 | )
135 | { basePath = "/"
136 | , unicodeIcon = "\x1F3D3"
137 | , name = "Game"
138 | , subheading = "A great game to play with your friends"
139 | , instructions = "Win the game!"
140 | , roundDuration = 4 * Time.second
141 | , cooldownDuration = 2 * Time.second
142 | , clearWinnerEvaluation = Nothing
143 | , ports = Nothing
144 | , view = spec.view
145 | , inlineStyle = True
146 | , peripheralUi = True
147 | , evaluate = spec.evaluate
148 | , problemGenerator = spec.problemGenerator
149 | , problemEncoder = spec.problemEncoder
150 | , problemDecoder = spec.problemDecoder
151 | , guessEncoder = spec.guessEncoder
152 | , guessDecoder = spec.guessDecoder
153 | }
154 | options
155 |
156 |
157 | sendToPort : DetailedSpec problem guess -> (Encode.Value -> Cmd (Msg problem guess))
158 | sendToPort spec =
159 | spec.ports
160 | |> Maybe.map .outgoing
161 | |> Maybe.withDefault (always Cmd.none)
162 |
--------------------------------------------------------------------------------
/src/Update.elm:
--------------------------------------------------------------------------------
1 | module Update exposing (..)
2 |
3 | import Navigation
4 | import Random
5 | import Messages exposing (..)
6 | import Models exposing (Model)
7 | import Data.Route as Route
8 | import Data.Room as Room
9 | import Data.Spec as Spec
10 | import Data.IncomingMessage as InMsg
11 | import Data.OutgoingMessage as OutgoingMessage
12 | import Page.Game.Messages
13 | import Page.Game.Update
14 | import Page.NewRoom.Messages
15 | import Page.NewRoom.Update
16 | import Page.Tutorial.Messages
17 | import Page.Tutorial.Update
18 |
19 |
20 | cmdOnRouteChange :
21 | Spec.DetailedSpec problem guess
22 | -> Route.Route problem guess
23 | -> Maybe (Route.Route problem guess)
24 | -> Cmd (Msg problem guess)
25 | cmdOnRouteChange spec route prevRoute =
26 | let
27 | sendToPort =
28 | Spec.sendToPort spec
29 | in
30 | case ( route, prevRoute ) of
31 | ( Route.Game game, _ ) ->
32 | OutgoingMessage.SubscribeToRoom game.roomId
33 | |> OutgoingMessage.encoder spec.problemEncoder spec.guessEncoder
34 | |> sendToPort
35 |
36 | ( _, Just (Route.Game prevGame) ) ->
37 | -- Unsubscribe from a previous room
38 | OutgoingMessage.UnsubscribeFromRoom prevGame.roomId
39 | |> OutgoingMessage.encoder spec.problemEncoder spec.guessEncoder
40 | |> sendToPort
41 |
42 | ( Route.Tutorial _, _ ) ->
43 | Random.generate (Messages.TutorialMsg << Page.Tutorial.Messages.ReceiveProblem) spec.problemGenerator
44 |
45 | ( _, _ ) ->
46 | Cmd.none
47 |
48 |
49 | navigationNewUrl : String -> String -> Cmd (Msg problem guess)
50 | navigationNewUrl basePath newUrl =
51 | basePath
52 | ++ newUrl
53 | |> Navigation.newUrl
54 |
55 |
56 | update :
57 | Spec.DetailedSpec problem guess
58 | -> Msg problem guess
59 | -> Model problem guess
60 | -> ( Model problem guess, Cmd (Msg problem guess) )
61 | update spec msg model =
62 | let
63 | sendToPort =
64 | Spec.sendToPort spec
65 | in
66 | case ( model.route, msg ) of
67 | ( _, Navigate newUrl ) ->
68 | ( model
69 | , navigationNewUrl spec.basePath newUrl
70 | )
71 |
72 | ( oldRoute, ChangeRoute route ) ->
73 | ( { model | route = route }
74 | , Cmd.batch
75 | [ cmdOnRouteChange spec route (Just oldRoute)
76 | , if route == Route.NotOnBaseRoute then
77 | (Navigation.newUrl spec.basePath)
78 | else
79 | Cmd.none
80 | ]
81 | )
82 |
83 | ( _, Resize newWindowSize ) ->
84 | ( { model | windowSize = newWindowSize }, Cmd.none )
85 |
86 | ( Route.Game game, GameMsg gameMsg ) ->
87 | let
88 | ( newGame, commandValues, generateNewRound ) =
89 | Page.Game.Update.update spec gameMsg game
90 | in
91 | ( { model | route = Route.Game newGame }
92 | , Cmd.batch <|
93 | (List.map sendToPort commandValues)
94 | ++ (if generateNewRound then
95 | [ (Random.generate (\pb -> Messages.GameMsg (Page.Game.Messages.ReceiveNewProblem pb)) spec.problemGenerator) ]
96 | else
97 | []
98 | )
99 | )
100 |
101 | ( Route.Game game, IncomingMessage (InMsg.RoomUpdated room) ) ->
102 | let
103 | ( newGame, commandValues, generateNewRound ) =
104 | Page.Game.Update.update spec (Page.Game.Messages.ReceiveUpdate room) game
105 | in
106 | ( { model | route = Route.Game newGame }
107 | , Cmd.batch <|
108 | (List.map sendToPort commandValues)
109 | ++ (if generateNewRound then
110 | [ (Random.generate (\pb -> Messages.GameMsg (Page.Game.Messages.ReceiveNewProblem pb)) spec.problemGenerator) ]
111 | else
112 | []
113 | )
114 | )
115 |
116 | ( Route.NewRoom newRoom, NewRoomMsg newRoomMsg ) ->
117 | let
118 | ( newNewRoom, sendSaveCommand, newUrl ) =
119 | (Page.NewRoom.Update.update newRoomMsg newRoom)
120 | in
121 | ( { model | route = Route.NewRoom newNewRoom }
122 | , if sendSaveCommand then
123 | Room.create newRoom.roomId newRoom.playerIds
124 | |> OutgoingMessage.CreateRoom
125 | |> OutgoingMessage.encoder spec.problemEncoder spec.guessEncoder
126 | |> sendToPort
127 | else
128 | Cmd.none
129 | )
130 |
131 | ( Route.NewRoom newRoom, IncomingMessage (InMsg.RoomCreated room) ) ->
132 | let
133 | ( newModel, _, newUrl ) =
134 | Page.NewRoom.Update.update (Page.NewRoom.Messages.CreateResponse "") newRoom
135 | in
136 | ( { model
137 | | route =
138 | Route.NewRoom newRoom
139 | }
140 | , newUrl
141 | |> Maybe.map (navigationNewUrl spec.basePath)
142 | |> Maybe.withDefault Cmd.none
143 | )
144 |
145 | ( Route.Tutorial tutorial, TutorialMsg msg ) ->
146 | let
147 | ( newTutorial, generateNewRound ) =
148 | Page.Tutorial.Update.update spec msg tutorial
149 | in
150 | ( { model | route = Route.Tutorial newTutorial }
151 | , if generateNewRound then
152 | Random.generate (Messages.TutorialMsg << Page.Tutorial.Messages.ReceiveProblem) spec.problemGenerator
153 | else
154 | Cmd.none
155 | )
156 |
157 | ( _, _ ) ->
158 | ( model, Cmd.none )
159 |
--------------------------------------------------------------------------------
/tests/Tests/Data/Room.elm:
--------------------------------------------------------------------------------
1 | module Tests.Data.Room exposing (..)
2 |
3 | import Test exposing (..)
4 | import Dict
5 | import Expect
6 | import Data.Room as Room
7 |
8 |
9 | tests : Test
10 | tests =
11 | describe "Room"
12 | [ describe "allPlayersReady"
13 | [ test "returns True on empty players" <|
14 | \() ->
15 | Room.allPlayersReady
16 | { id = "123"
17 | , host = "456"
18 | , round = Nothing
19 | , players = Dict.empty
20 | }
21 | |> Expect.equal True
22 | ]
23 | , describe "updatePreservingLocalGuesses"
24 | [ test "preserves if round did not change" <|
25 | \() ->
26 | (Room.updatePreservingLocalGuesses
27 | { id = "123"
28 | , host = "456"
29 | , round = Just { no = 1, problem = "1234" }
30 | , players =
31 | Dict.fromList
32 | [ ( "player1"
33 | , { id = "player1"
34 | , roomId = "123"
35 | , isReady = True
36 | , score = 0
37 | , guess = Nothing
38 | }
39 | )
40 | , ( "player2"
41 | , { id = "player2"
42 | , roomId = "123"
43 | , isReady = True
44 | , score = 0
45 | , guess =
46 | Nothing
47 | }
48 | )
49 | ]
50 | }
51 | { id = "123"
52 | , host = "456"
53 | , round = Just { no = 1, problem = "1234" }
54 | , players =
55 | Dict.fromList
56 | [ ( "player1"
57 | , { id = "player1"
58 | , roomId = "123"
59 | , isReady = True
60 | , score = 0
61 | , guess = Nothing
62 | }
63 | )
64 | , ( "player2"
65 | , { id = "player2"
66 | , roomId = "123"
67 | , isReady = True
68 | , score = 0
69 | , guess =
70 | Just
71 | { value = 0
72 | , madeAt = 100
73 | }
74 | }
75 | )
76 | ]
77 | }
78 | |> .players
79 | |> Dict.get "player2"
80 | |> Maybe.andThen .guess
81 | |> Maybe.map .value
82 | |> Expect.equal (Just 0)
83 | )
84 | , test "does not preserve if round changed" <|
85 | \() ->
86 | (Room.updatePreservingLocalGuesses
87 | { id = "123"
88 | , host = "456"
89 | , round = Just { no = 2, problem = "1324" }
90 | , players =
91 | Dict.fromList
92 | [ ( "player1"
93 | , { id = "player1"
94 | , roomId = "123"
95 | , isReady = True
96 | , score = 0
97 | , guess = Nothing
98 | }
99 | )
100 | , ( "player2"
101 | , { id = "player2"
102 | , roomId = "123"
103 | , isReady = True
104 | , score = 0
105 | , guess =
106 | Nothing
107 | }
108 | )
109 | ]
110 | }
111 | { id = "123"
112 | , host = "456"
113 | , round = Just { no = 1, problem = "1234" }
114 | , players =
115 | Dict.fromList
116 | [ ( "player1"
117 | , { id = "player1"
118 | , roomId = "123"
119 | , isReady = True
120 | , score = 0
121 | , guess = Nothing
122 | }
123 | )
124 | , ( "player2"
125 | , { id = "player2"
126 | , roomId = "123"
127 | , isReady = True
128 | , score = 0
129 | , guess =
130 | Just
131 | { value = 0
132 | , madeAt = 100
133 | }
134 | }
135 | )
136 | ]
137 | }
138 | |> .players
139 | |> Dict.get "player2"
140 | |> Maybe.andThen .guess
141 | |> Maybe.map .value
142 | |> Expect.equal Nothing
143 | )
144 | ]
145 | ]
146 |
--------------------------------------------------------------------------------
/examples/lettero/Main.elm:
--------------------------------------------------------------------------------
1 | port module Main exposing (..)
2 |
3 | import Random
4 | import Json.Encode as JE
5 | import Json.Decode as JD
6 | import Html exposing (Html, div, text, span)
7 | import Html.Attributes exposing (class, style)
8 | import Html.Events exposing (onClick)
9 | import Gameroom exposing (..)
10 |
11 |
12 | -- Types
13 |
14 |
15 | type alias Problem =
16 | { word : String
17 | , startAngle : Float
18 | }
19 |
20 |
21 | type alias Guess =
22 | Int
23 |
24 |
25 |
26 | -- Spec
27 |
28 |
29 | spec : Spec Problem Guess
30 | spec =
31 | { view =
32 | (\context problem ->
33 | div
34 | [ style
35 | [ ( "position", "absolute" )
36 | , ( "width", "75vmin" )
37 | , ( "height", "75vmin" )
38 | , ( "top", "50%" )
39 | , ( "left", "50%" )
40 | , ( "transform", "scale(1.0, 1.0) translate3d(-50%, -50%, 0) rotate(" ++ (context.roundTime / 80 |> toString) ++ "deg)" )
41 | ]
42 | ]
43 | (problem.word
44 | |> String.toList
45 | |> List.indexedMap
46 | (\index character ->
47 | let
48 | angle =
49 | (index |> toFloat)
50 | / (problem.word
51 | |> String.length
52 | |> toFloat
53 | )
54 | * 2
55 | * pi
56 | + problem.startAngle
57 |
58 | isGuessedBySelf =
59 | context.ownGuess == (Just index)
60 |
61 | isMarkedCorrect =
62 | (index == 0) && (isGuessedBySelf || context.isRoundOver)
63 |
64 | isGuessedByOthers =
65 | context.opponentGuesses
66 | |> List.filter (\( playerId, guess ) -> guess == index)
67 | |> List.head
68 | |> Maybe.map (\( playerId, guess ) -> guess == index)
69 | |> Maybe.withDefault False
70 | in
71 | span
72 | [ style
73 | ([ ( "position", "absolute" )
74 | , ( "display", "block" )
75 | , ( "cursor", "pointer" )
76 | , ( "font-size", "calc(3vh + 3vw)" )
77 | , ( "width", "calc(4.5vh + 4.5vw)" )
78 | , ( "height", "calc(4.5vh + 4.5vw)" )
79 | , ( "padding-top", "calc(0.6vh + 0.6vw)" )
80 | , ( "line-height", "1" )
81 | , ( "border-radius", "50%" )
82 | , ( "text-align", "center" )
83 | , ( "border", "2px solid transparent" )
84 | , ( "top", ((1 - sin angle) * 50 |> toString) ++ "%" )
85 | , ( "left", ((1 - cos angle) * 50 |> toString) ++ "%" )
86 | , ( "transform", "translate3d(-50%, -50%, 0) rotate(" ++ ((angle * 180 / pi - 90) |> toString) ++ "deg)" )
87 | , ( "text-transform", "uppercase" )
88 | ]
89 | ++ (if isMarkedCorrect then
90 | [ ( "border", "2px solid black" )
91 | , ( "background-color", "black" )
92 | , ( "color", "white" )
93 | ]
94 | else if (isGuessedBySelf || (isGuessedByOthers && context.isRoundOver)) then
95 | [ ( "border", "2px solid black" )
96 | ]
97 | else
98 | []
99 | )
100 | )
101 | , onClick index
102 | ]
103 | [ text (String.fromChar character) ]
104 | )
105 | )
106 | )
107 | , evaluate =
108 | (\problem guess ->
109 | if (guess == 0) then
110 | 100
111 | else
112 | 0
113 | )
114 | , problemGenerator =
115 | Random.map2 Problem
116 | (generatorFromList "perrywinkle" <|
117 | [ "gingerberry", "apples", "vineyard", "is", "tablespoon", "cutlery", "laborer" ]
118 | ++ [ "grenade", "coaster", "mahogany", "burrito", "cilantro", "kettle" ]
119 | ++ [ "revenue", "stool", "ginger", "electricity", "purple", "backpack" ]
120 | ++ [ "phone", "bill", "family", "cucumber", "terrific", "towel", "tower" ]
121 | ++ [ "lightbulb", "leaf", "loaf", "parrot", "rack", "rope", "poor", "strap" ]
122 | ++ [ "faucet", "lipstick", "grapefruit", "pickle", "woodpecker" ]
123 | )
124 | (Random.float 0 (2 * pi))
125 | , guessEncoder = JE.int
126 | , guessDecoder = JD.int
127 | , problemEncoder =
128 | (\problem ->
129 | JE.object
130 | [ ( "word", JE.string problem.word )
131 | , ( "startAngle", JE.float problem.startAngle )
132 | ]
133 | )
134 | , problemDecoder =
135 | JD.map2 Problem
136 | (JD.field "word" JD.string)
137 | (JD.field "startAngle" JD.float)
138 | }
139 |
140 |
141 |
142 | -- Ports
143 |
144 |
145 | port outgoing : JE.Value -> Cmd msg
146 |
147 |
148 | port incoming : (JE.Value -> msg) -> Sub msg
149 |
150 |
151 |
152 | -- Main
153 |
154 |
155 | main : Program Never (Model Problem Guess) (Msg Problem Guess)
156 | main =
157 | gameWith
158 | [ basePath "/lettero"
159 | , unicodeIcon "✏️"
160 | , name "Lettero"
161 | , subheading "A mildly frustrating wordgame!"
162 | , instructions "There is a word in there somewhere - tap its first letter!"
163 | , clearWinner 100
164 | , responsiblePorts { incoming = incoming, outgoing = outgoing }
165 | ]
166 | spec
167 |
--------------------------------------------------------------------------------
/examples/counterclockwooze/Main.elm:
--------------------------------------------------------------------------------
1 | port module Main exposing (..)
2 |
3 | import Random
4 | import Json.Encode as JE
5 | import Json.Decode as JD
6 | import Html exposing (Html, div, text, span)
7 | import Html.Attributes exposing (class, style, attribute)
8 | import Html.Events exposing (onClick)
9 | import Svg exposing (polygon, svg, g, circle)
10 | import Svg.Attributes exposing (width, height, viewBox, points, transform, r, cx, cy, fill, stroke, strokeWidth)
11 | import Gameroom exposing (..)
12 |
13 |
14 | -- Types
15 |
16 |
17 | type alias Point =
18 | { x : Float
19 | , y : Float
20 | }
21 |
22 |
23 | type alias Problem =
24 | List Point
25 |
26 |
27 | type alias Guess =
28 | Int
29 |
30 |
31 |
32 | -- Spec
33 |
34 |
35 | grey : String
36 | grey =
37 | "#222226"
38 |
39 |
40 | spec : Spec Problem Guess
41 | spec =
42 | { view =
43 | (\context problem ->
44 | div
45 | [ class "spinning-shapes-container"
46 | , style
47 | [ ( "position", "absolute" )
48 | , ( "width", "80vmin" )
49 | , ( "height", "80vmin" )
50 | , ( "top", "50%" )
51 | , ( "left", "50%" )
52 | , ( "transform", "scale(1.0, 1.0) translate3d(-50%, -50%, 0)" )
53 | ]
54 | ]
55 | [ svg [ viewBox "0 0 1000 1000" ]
56 | (List.indexedMap
57 | (\index { x, y } ->
58 | let
59 | isCorrect =
60 | (index == 0)
61 |
62 | isGuessedBySelf =
63 | context.ownGuess == (Just index)
64 |
65 | isMarkedCorrect =
66 | isCorrect && (isGuessedBySelf || context.isRoundOver)
67 |
68 | isGuessedByOthers =
69 | context.opponentGuesses
70 | |> List.filter (\( playerId, guess ) -> guess == index)
71 | |> List.head
72 | |> Maybe.map (\( playerId, guess ) -> guess == index)
73 | |> Maybe.withDefault False
74 |
75 | translateString =
76 | ("translate(" ++ (toString (x * 800 + 100)) ++ "," ++ (toString (y * 800 + 100)) ++ ")")
77 | in
78 | g [ transform translateString ]
79 | [ circle
80 | [ r "80"
81 | , cx "0"
82 | , cy "0"
83 | , fill
84 | (if (isCorrect && (context.isRoundOver || isGuessedBySelf)) then
85 | grey
86 | else
87 | "none"
88 | )
89 | , stroke
90 | (if (isGuessedBySelf || (isGuessedByOthers && context.isRoundOver)) then
91 | grey
92 | else
93 | "rgba(255, 255, 255, 0)"
94 | )
95 | , strokeWidth "2"
96 | ]
97 | []
98 | , polygon
99 | [ points "-50,-28.8 50,-28.8 0,57.7"
100 | , fill
101 | (if (isCorrect && (context.isRoundOver || isGuessedBySelf)) then
102 | "white"
103 | else
104 | grey
105 | )
106 | , attribute "transform"
107 | ("rotate("
108 | ++ ((context.roundTime / 16)
109 | |> (*) 0.5
110 | |> (*)
111 | (if index == 0 then
112 | 1
113 | else
114 | -1
115 | )
116 | |> toString
117 | )
118 | ++ ")"
119 | )
120 | , onClick index
121 | ]
122 | []
123 | ]
124 | )
125 | problem
126 | )
127 | ]
128 | )
129 | , evaluate =
130 | (\problem guess ->
131 | if guess == 0 then
132 | 100
133 | else
134 | 0
135 | )
136 | , problemGenerator =
137 | Random.list 10 (Random.map2 Point (Random.float 0 1) (Random.float 0 1))
138 | , guessEncoder = JE.int
139 | , guessDecoder = JD.int
140 | , problemEncoder =
141 | List.map
142 | (\triangle ->
143 | JE.object
144 | [ ( "x", JE.float triangle.x )
145 | , ( "y", JE.float triangle.y )
146 | ]
147 | )
148 | >> JE.list
149 | , problemDecoder =
150 | JD.list
151 | (JD.map2 Point
152 | (JD.field "x" JD.float)
153 | (JD.field "y" JD.float)
154 | )
155 | }
156 |
157 |
158 |
159 | -- Config
160 |
161 |
162 | port outgoing : JE.Value -> Cmd msg
163 |
164 |
165 | port incoming : (JE.Value -> msg) -> Sub msg
166 |
167 |
168 |
169 | -- Program
170 |
171 |
172 | main : Program Never (Model Problem Guess) (Msg Problem Guess)
173 | main =
174 | gameWith
175 | [ basePath "/counterclockwooze"
176 | , unicodeIcon "🕒"
177 | , name "Counterclockwooze"
178 | , subheading "A dizzying geometric game for the family"
179 | , instructions "One of the shapes spins the other way - care to find it?"
180 | , clearWinner 100
181 | , responsiblePorts { outgoing = outgoing, incoming = incoming }
182 | ]
183 | spec
184 |
--------------------------------------------------------------------------------
/examples/the-capitalist/Main.elm:
--------------------------------------------------------------------------------
1 | port module Main exposing (..)
2 |
3 | import Random
4 | import Html exposing (Html, div, text, span, h1, ul, li)
5 | import Html.Attributes exposing (class, style, attribute)
6 | import Html.Events exposing (onClick)
7 | import Json.Encode as Encode
8 | import Json.Decode as Decode
9 | import Gameroom exposing (..)
10 | import Gameroom.Context exposing (Context)
11 |
12 |
13 | -- Main
14 |
15 |
16 | main : Program Never (Model Problem Guess) (Msg Problem Guess)
17 | main =
18 | gameWith
19 | [ basePath "/the-capitalist"
20 | , unicodeIcon "💰"
21 | , name "The Capitalist"
22 | , subheading "Oh, not that kind, though, more like a person who knows the capital of a lot of countries.."
23 | , instructions "Find the capital of the country!"
24 | , clearWinner 100
25 | , responsiblePorts { outgoing = outgoing, incoming = incoming }
26 | ]
27 | spec
28 |
29 |
30 |
31 | -- Types, encoders and decoders
32 |
33 |
34 | type alias Problem =
35 | { question : String
36 | , answers : List String
37 | , correct : Int
38 | }
39 |
40 |
41 | problemEncoder : Problem -> Encode.Value
42 | problemEncoder problem =
43 | Encode.object
44 | [ ( "question", Encode.string problem.question )
45 | , ( "answers"
46 | , List.map Encode.string problem.answers
47 | |> Encode.list
48 | )
49 | , ( "correct", Encode.int problem.correct )
50 | ]
51 |
52 |
53 | problemDecoder : Decode.Decoder Problem
54 | problemDecoder =
55 | Decode.map3 Problem
56 | (Decode.field "question" Decode.string)
57 | (Decode.field "answers" (Decode.list Decode.string))
58 | (Decode.field "correct" Decode.int)
59 |
60 |
61 | type alias Guess =
62 | Int
63 |
64 |
65 | guessEncoder : Guess -> Encode.Value
66 | guessEncoder =
67 | Encode.int
68 |
69 |
70 | guessDecoder : Decode.Decoder Guess
71 | guessDecoder =
72 | Decode.int
73 |
74 |
75 |
76 | -- Game spec
77 |
78 |
79 | spec : Spec Problem Guess
80 | spec =
81 | { view = view
82 | , evaluate = evaluate
83 | , problemGenerator = problemGenerator
84 | , guessEncoder = guessEncoder
85 | , guessDecoder = guessDecoder
86 | , problemEncoder = problemEncoder
87 | , problemDecoder = problemDecoder
88 | }
89 |
90 |
91 | view : Context Guess -> Problem -> Html Guess
92 | view context problem =
93 | -- not used, only here to show a more concise version of the game view
94 | div []
95 | [ div []
96 | [ h1 [] [ text problem.question ]
97 | , ul []
98 | (List.indexedMap
99 | (\index answer ->
100 | -- events map to raw guesses (in this case, an Int)
101 | li [ onClick index ] [ text answer ]
102 | )
103 | problem.answers
104 | )
105 | ]
106 | ]
107 |
108 |
109 | styledView : Context Guess -> Problem -> Html Guess
110 | styledView context problem =
111 | div [ style containerStyle ]
112 | [ div [ style contentStyle ]
113 | [ h1 [] [ text problem.question ]
114 | , ul
115 | [ style listStyle
116 | ]
117 | (List.indexedMap
118 | (\index answer ->
119 | let
120 | isGuessedBySelf =
121 | context.ownGuess == (Just index)
122 |
123 | isMarkedCorrect =
124 | (index == problem.correct) && (isGuessedBySelf || context.isRoundOver)
125 | in
126 | li
127 | [ onClick index
128 | , style <|
129 | listItemBaseStyle isGuessedBySelf isMarkedCorrect
130 | ]
131 | [ text answer ]
132 | )
133 | problem.answers
134 | )
135 | ]
136 | ]
137 |
138 |
139 | evaluate : Problem -> Guess -> Float
140 | evaluate problem guess =
141 | -- a correct guess maps to a higher evaluation
142 | if (guess == problem.correct) then
143 | 100
144 | else
145 | 0
146 |
147 |
148 | problemGenerator : Random.Generator Problem
149 | problemGenerator =
150 | generatorFromList
151 | { question = "🇱🇻 Latvia"
152 | , answers = [ "Tallin", "Riga", "Vilnius", "Moscow" ]
153 | , correct = 1
154 | }
155 | -- a list of problems identical to the record above
156 | problems
157 |
158 |
159 |
160 | -- Ports
161 |
162 |
163 | port outgoing : Encode.Value -> Cmd msg
164 |
165 |
166 | port incoming : (Encode.Value -> msg) -> Sub msg
167 |
168 |
169 |
170 | -- Problem DB
171 |
172 |
173 | problems : List Problem
174 | problems =
175 | [ { question = "🇭🇺 Hungary"
176 | , answers = [ "Budapest", "Pécs", "Mosonmagyaróvár", "Garmisch-Partenkirchen" ]
177 | , correct = 0
178 | }
179 | , { question = "🇧🇳 Brunei"
180 | , answers = [ "Munich", "Copenhagen", "Jakarta", "Bandar Seri Begawan" ]
181 | , correct = 3
182 | }
183 | , { question = "🇸🇮 Slovenia"
184 | , answers = [ "Munich", "Ljubljana", "Jakarta", "Bandar Seri Begawan" ]
185 | , correct = 1
186 | }
187 | , { question = "🇫🇷 France"
188 | , answers = [ "Bordeaux", "Paris", "Paris, NY" ]
189 | , correct = 1
190 | }
191 | , { question = "🇩🇪 Germany"
192 | , answers = [ "Bordeaux", "Berlin, NH", "Berlin", "Stuttgart" ]
193 | , correct = 2
194 | }
195 | , { question = "🇩🇰 Denmark"
196 | , answers = [ "Ansterdam", "Aarhus", "Copenhagen", "Christiania" ]
197 | , correct = 2
198 | }
199 | ]
200 |
201 |
202 |
203 | -- Styles
204 |
205 |
206 | containerStyle : List ( String, String )
207 | containerStyle =
208 | [ ( "width", "100%" )
209 | , ( "height", "100%" )
210 | , ( "display", "flex" )
211 | , ( "align-items", "center" )
212 | , ( "justify-content", "center" )
213 | ]
214 |
215 |
216 | contentStyle : List ( String, String )
217 | contentStyle =
218 | [ ( "margin", "0" )
219 | , ( "width", "auto" )
220 | , ( "max-width", "440px" )
221 | , ( "text-align", "center" )
222 | ]
223 |
224 |
225 | listStyle : List ( String, String )
226 | listStyle =
227 | [ ( "list-style", "none" )
228 | , ( "padding-left", "0" )
229 | ]
230 |
231 |
232 | listItemBaseStyle : Bool -> Bool -> List ( String, String )
233 | listItemBaseStyle isGuessedBySelf isMarkedCorrect =
234 | [ ( "margin", "12px" )
235 | , ( "display", "inline-block" )
236 | , ( "border-width", "1px" )
237 | , ( "border-style", "solid" )
238 | , ( "cursor", "pointer" )
239 | , ( "padding", "8px 16px" )
240 | , ( "border-radius", "6px" )
241 | ]
242 | ++ [ ( "border-color"
243 | , if isGuessedBySelf then
244 | "#333333"
245 | else
246 | "transparent"
247 | )
248 | , ( "background-color"
249 | , if isMarkedCorrect then
250 | "#333333"
251 | else
252 | "transparent"
253 | )
254 | , ( "color"
255 | , if isMarkedCorrect then
256 | "#FFFFFF"
257 | else
258 | "#333333"
259 | )
260 | ]
261 |
--------------------------------------------------------------------------------
/src/Data/Room.elm:
--------------------------------------------------------------------------------
1 | module Data.Room exposing (..)
2 |
3 | import Dict
4 | import Json.Decode as JD
5 | import Json.Encode as JE
6 | import Data.Player as Player
7 | import Data.Round as Round
8 |
9 |
10 | -- Type definitions
11 |
12 |
13 | type alias RoomId =
14 | String
15 |
16 |
17 | type alias Room problem guess =
18 | { id : String
19 | , host : Player.PlayerId
20 | , round : Maybe (Round.Round problem)
21 | , players : Dict.Dict String (Player.Player guess)
22 | }
23 |
24 |
25 |
26 | -- Helpers
27 |
28 |
29 | create : RoomId -> List Player.PlayerId -> Room problem guess
30 | create roomId playerIds =
31 | { id = roomId
32 | , host = playerIds |> List.head |> Maybe.withDefault ""
33 | , round = Nothing
34 | , players = Dict.fromList (List.map (\playerId -> ( playerId, Player.create playerId roomId )) playerIds)
35 | }
36 |
37 |
38 | updatePreservingLocalGuesses : Room problem guess -> Room problem guess -> Room problem guess
39 | updatePreservingLocalGuesses newRoom oldRoom =
40 | -- Since the application state only listens to full room updates,
41 | -- it can occur that one player's guess is nulled by another player's update.
42 | -- This method protects agains that.
43 | let
44 | didRoundChange =
45 | Maybe.map2
46 | (\newRound round -> newRound.no /= round.no)
47 | newRoom.round
48 | oldRoom.round
49 | |> Maybe.withDefault True
50 |
51 | newPlayers =
52 | newRoom.players
53 | |> Dict.toList
54 | |> List.map
55 | (\( playerId, player ) ->
56 | ( playerId
57 | , { player
58 | | guess =
59 | -- If the round didn't change, and the new guess is Nothing
60 | -- keep the old guess, which may be something.
61 | if
62 | (not didRoundChange
63 | && (player.guess == Nothing)
64 | )
65 | then
66 | oldRoom.players
67 | |> Dict.get playerId
68 | |> Maybe.map .guess
69 | |> Maybe.withDefault player.guess
70 | else
71 | player.guess
72 | }
73 | )
74 | )
75 | |> Dict.fromList
76 | in
77 | { newRoom | players = newPlayers }
78 |
79 |
80 | allPlayersReady : Room problem guess -> Bool
81 | allPlayersReady room =
82 | room.players
83 | |> Dict.toList
84 | |> List.map Tuple.second
85 | |> List.map .isReady
86 | |> List.all identity
87 |
88 |
89 | allPlayersGuessed : Room problem guess -> Bool
90 | allPlayersGuessed room =
91 | room.players
92 | |> Dict.toList
93 | |> List.map Tuple.second
94 | |> List.map .guess
95 | |> List.all ((/=) Nothing)
96 |
97 |
98 | bigNumber : Float
99 | bigNumber =
100 | 100000
101 |
102 |
103 | getRoundWinner : (problem -> guess -> Float) -> Maybe Float -> Room problem guess -> Maybe Player.PlayerId
104 | getRoundWinner evaluate clearWinnerEvaluation room =
105 | room.players
106 | |> Dict.toList
107 | |> List.filterMap
108 | (\( playerId, player ) ->
109 | player.guess
110 | |> Maybe.map2
111 | (\round guess ->
112 | ( playerId
113 | , evaluate round.problem guess.value
114 | , guess.madeAt
115 | )
116 | )
117 | room.round
118 | )
119 | |> List.sortWith
120 | (\( playerId1, eval1, madeAt1 ) ( playerId2, eval2, madeAt2 ) ->
121 | if eval1 > eval2 then
122 | LT
123 | else if eval1 < eval2 then
124 | GT
125 | else
126 | (if madeAt1 > madeAt2 then
127 | GT
128 | else
129 | LT
130 | )
131 | )
132 | |> List.head
133 | |> Maybe.map
134 | (\( playerId, eval, _ ) ->
135 | case clearWinnerEvaluation of
136 | Just clearWinnerEval ->
137 | if eval == clearWinnerEval then
138 | Just playerId
139 | else
140 | Nothing
141 |
142 | Nothing ->
143 | Just playerId
144 | )
145 | |> Maybe.withDefault Nothing
146 |
147 |
148 | updatePlayer :
149 | (Player.Player guess -> Player.Player guess)
150 | -> Player.PlayerId
151 | -> Room problem guess
152 | -> Room problem guess
153 | updatePlayer transform playerId room =
154 | case (Dict.get playerId room.players) of
155 | Just player ->
156 | { room | players = Dict.insert playerId (transform player) room.players }
157 |
158 | Nothing ->
159 | room
160 |
161 |
162 | setScores :
163 | Maybe Player.PlayerId
164 | -> Room problem guess
165 | -> Room problem guess
166 | setScores maybeWinnerId room =
167 | { room
168 | | players =
169 | case maybeWinnerId of
170 | Just winnerId ->
171 | room.players
172 | |> Dict.toList
173 | |> List.map
174 | (\( playerId, player ) ->
175 | if playerId == winnerId then
176 | ( playerId
177 | , { player
178 | | score = player.score + 1
179 | }
180 | )
181 | else
182 | ( playerId
183 | , player
184 | )
185 | )
186 | |> Dict.fromList
187 |
188 | Nothing ->
189 | room.players
190 | }
191 |
192 |
193 |
194 | -- Encoders
195 |
196 |
197 | encoder : (problem -> JE.Value) -> (guess -> JE.Value) -> Room problem guess -> JE.Value
198 | encoder problemEncoder guessEncoder room =
199 | JE.object <|
200 | [ ( "id", JE.string room.id )
201 | , ( "host", JE.string room.host )
202 | , ( "players", Player.collectionEncoder guessEncoder room.players )
203 | ]
204 | ++ (room.round
205 | |> Maybe.map
206 | (\round ->
207 | [ ( "round", Round.encoder problemEncoder round )
208 | ]
209 | )
210 | |> Maybe.withDefault []
211 | )
212 |
213 |
214 |
215 | -- Decoders
216 |
217 |
218 | decoder : JD.Decoder problem -> JD.Decoder guess -> JD.Decoder (Room problem guess)
219 | decoder problemDecoder guessDecoder =
220 | JD.map4 Room
221 | (JD.field "id" JD.string)
222 | (JD.field "host" JD.string)
223 | (JD.maybe (JD.field "round" (Round.decoder problemDecoder)))
224 | (JD.field "players" (JD.dict (Player.decoder guessDecoder)))
225 |
--------------------------------------------------------------------------------
/src/js/db/webrtc.js:
--------------------------------------------------------------------------------
1 | // Stores room state, peer clients and connections by room id
2 | ;(function (root, factory) {
3 | if (typeof module === 'object' && module.exports) {
4 | module.exports = factory()
5 | } else {
6 | root.db = factory()
7 | }
8 | }(this, function () {
9 | var peerOptions = {
10 | key: 'lwjd5qra8257b9'
11 | }
12 |
13 | var log = console.log.bind(console)
14 |
15 | function memoize (fn) {
16 | return function () {
17 | var args = Array.prototype.slice.call(arguments)
18 | var hash = args.reduce(function (acc, current) {
19 | return acc + ((current === Object(current)) ? JSON.stringify(current) : current)
20 | }, '')
21 | fn.memo = fn.memo || {}
22 | if (fn.memo[hash]) {
23 | return fn.memo[hash]
24 | }
25 | var returnValue = fn.apply(this, args)
26 | fn.memo[hash] = returnValue
27 | return returnValue
28 | }
29 | }
30 |
31 | var loadPeerJs = memoize(function loadPeerJs () {
32 | var url = 'http://cdn.peerjs.com/0.3/peer.js'
33 | return new Promise(function (resolve, reject) {
34 | var scriptTag = document.createElement('script')
35 | scriptTag.src = url
36 | scriptTag.onload = function () {
37 | resolve(window.Peer)
38 | }
39 | document.body.appendChild(scriptTag)
40 | })
41 | })
42 |
43 | var db = function () {
44 | var rooms = {}
45 |
46 | function updateSubscribers (roomId) {
47 | if (!isHost(roomId)) {
48 | return
49 | }
50 | rooms[roomId].subscribers.forEach(function (connection) {
51 | connection.send({
52 | type: 'room:updated',
53 | payload: rooms[roomId].state
54 | })
55 | })
56 | rooms[roomId].ownSubscribers.forEach(function (subscriber) {
57 | subscriber(rooms[roomId].state)
58 | })
59 | }
60 |
61 | var connectToHost = memoize(function (roomId) {
62 | return loadPeerJs().then(function (Peer) {
63 | return new Promise(function (resolve, reject) {
64 | var room = rooms[roomId]
65 | var peer
66 | if (room && room.peer) {
67 | peer = room.peer
68 | } else {
69 | peer = new Peer('elm-gameroom-' + roomId + '-' + new Date().getTime(), peerOptions)
70 | peer.on('error', log)
71 | }
72 | var connection
73 | if (room && room.connectionToHost) {
74 | connection = room.connectionToHost
75 | } else {
76 | connection = peer.connect('elm-gameroom-' + roomId)
77 | connection.on('error', log)
78 | }
79 | rooms[roomId] = {
80 | peer: peer,
81 | connectionToHost: connection,
82 | isHost: false
83 | }
84 | connection.on('open', function () {
85 | resolve(connection)
86 | })
87 | })
88 | })
89 | })
90 |
91 | function isHost (roomId) {
92 | return rooms[roomId] && rooms[roomId].isHost
93 | }
94 |
95 | return {
96 | getRoom: function (roomId) {
97 | if (isHost(roomId)) {
98 | return Promise.resolve(rooms[roomId].state)
99 | } else {
100 | return loadPeerJs().then(function (Peer) {
101 | return connectToHost(roomId).then(function (connection) {
102 | return new Promise(function (resolve, reject) {
103 | var onData = function (data) {
104 | resolve(data)
105 | }
106 | connection.on('data', onData)
107 | connection.send({
108 | type: 'get:room',
109 | payload: {
110 | roomId: roomId
111 | }
112 | })
113 | })
114 | })
115 | .catch(log)
116 | })
117 | }
118 | },
119 |
120 | createRoom: function (room) {
121 | return loadPeerJs().then(function (Peer) {
122 | var peer = new Peer('elm-gameroom-' + room.id, peerOptions)
123 | rooms[room.id] = {
124 | peer: peer,
125 | isHost: true,
126 | connectionToHost: null,
127 | state: room,
128 | subscribers: [],
129 | ownSubscribers: []
130 | }
131 | peer.on('connection', function (connection) {
132 | connection.on('data', function (msg) {
133 | switch (msg.type) {
134 | // Subscribe to room, sending room:updated messages
135 | case 'get:room':
136 | return connection.send(rooms[room.id].state)
137 | case 'subscribeto:room':
138 | connection.send({
139 | type: 'room:updated',
140 | payload: rooms[room.id].state
141 | })
142 | rooms[room.id].subscribers.push(connection)
143 | return
144 | case 'unsubscribefrom:room':
145 | // This need not be handled, as closed connections are removed automatically
146 | return
147 | case 'update:room':
148 | rooms[room.id].state = msg.payload
149 | updateSubscribers(room.id)
150 | return
151 | case 'update:player':
152 | rooms[room.id].state.players[msg.payload.id] = msg.payload
153 | updateSubscribers(room.id)
154 | return
155 | }
156 | })
157 | })
158 | })
159 | .catch(log)
160 | },
161 |
162 | subscribeToRoom: function (roomId, onValue) {
163 | if (isHost(roomId)) {
164 | rooms[roomId].ownSubscribers.push(onValue)
165 | onValue(rooms[roomId].state)
166 | } else {
167 | return connectToHost(roomId).then(function (connection) {
168 | connection.on('data', function (msg) {
169 | if (msg.type === 'room:updated') {
170 | onValue(msg.payload)
171 | }
172 | })
173 | connection.send({
174 | type: 'subscribeto:room',
175 | payload: roomId
176 | })
177 | })
178 | }
179 | },
180 |
181 | unsubscribeFromRoom: function (roomId) {
182 | if (isHost(roomId)) {
183 | rooms[roomId].ownSubscribers = []
184 | } else {
185 | // TODO: handle this case
186 | return
187 | }
188 | },
189 |
190 | updateRoom: function (room) {
191 | if (isHost(room.id)) {
192 | rooms[room.id].state = room
193 | updateSubscribers(room.id)
194 | return Promise.resolve(room)
195 | } else {
196 | return connectToHost(room.id).then(function (connection) {
197 | connection.send({
198 | type: 'update:room',
199 | payload: room
200 | })
201 | return connection
202 | })
203 | .then(function (connection) {
204 | return room
205 | })
206 | .catch(log)
207 | }
208 | },
209 |
210 | updatePlayer: function (player) {
211 | if (isHost(player.roomId)) {
212 | rooms[player.roomId].state.players[player.id] = player
213 | updateSubscribers(player.roomId)
214 | return Promise.resolve(player)
215 | } else {
216 | return connectToHost(player.roomId).then(function (connection) {
217 | connection.send({
218 | type: 'update:player',
219 | payload: player
220 | })
221 | return connection
222 | })
223 | .then(function (connection) {
224 | return player
225 | })
226 | .catch(log)
227 | }
228 | }
229 | }
230 | }
231 |
232 | return db
233 | }))
234 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # 🏓 elm-gameroom
2 |
3 | This is a framework for creating multiplayer guessing games by the boatloads, all within the comfort of Elm. Specify only what is unique to each game, write no logic on the back-end, and have it all wired up and ready to play.
4 |
5 | `elm-gameroom` takes care of calling game rounds, generating problems and reconciling scores, all the while talking to either a generic real-time database such as Firebase (adapter provided), or have clients sort things out amongst themselves via WebRTC (JavaScript glue code provided).
6 |
7 | For some context, see [this talk](https://www.youtube.com/watch?v=sBCz6atTRZk).
8 |
9 | ## Running the examples
10 |
11 | You can try some games made with the framework by playing in two tabs of the same browser - and this will be tremendously useful as you write your own games later on.
12 |
13 | To do that, install elm-live: `npm i -g elm-live`. Then run the following command: `bin/example.sh $NAME`, where `$NAME` is a folder name within `./examples`, either `counterclockwooze`, `lettero`, `spacecraterball` or `the-capitalist`.
14 |
15 | ## Making your own game
16 |
17 | To create a simple trivia game, all you need is yay'much (head to the [The Capitalist](/examples/the-capitalist/Main.elm) for a version that is a bit longer and broken down):
18 |
19 | ```elm
20 | module Main exposing (..)
21 |
22 | import Html exposing (..)
23 | import Html.Events exposing (onClick)
24 | import Json.Encode as Encode
25 | import Json.Decode as Decode
26 | import Gameroom exposing (..)
27 |
28 | -- The problem is record containing
29 | -- a question, possible answers
30 | -- and the index of the correct answer.
31 | type alias Problem =
32 | { question : String
33 | , answer : List String
34 | , correct : Int
35 | }
36 |
37 | -- The guess is an integer holding the index of the correct answer
38 | type alias Guess = Int
39 |
40 | -- The main program uses the `game` method, which takes a spec object.
41 | main =
42 | game
43 | { view =
44 | -- Takes a view context and a problem
45 | -- returns Html that emits raw guesses.
46 | (\context problem ->
47 | div
48 | []
49 | [ h1 [] [ text problem.question ]
50 | , ul []
51 | <| List.indexedMap
52 | (\index answer ->
53 | li
54 | --Cclicks on an answer
55 | -- emit raw guesses.
56 | [ onClick index
57 | ]
58 | [ text answer
59 | ]
60 | )
61 | problem.answers
62 | ]
63 | )
64 | , evaluate =
65 | -- Correct guess evaluates to 100, an incorrect one to 0.
66 | (\problem guess ->
67 | if guess == problem.correct then 100 else 0
68 | )
69 | , problemGenerator =
70 | generatorFromList
71 | -- Default problem
72 | { question = "Which one is not a taste?"
73 | , answers = [ "salty", "sour", "notebook" ],
74 | , correct = 2
75 | }
76 | [ -- A list of additional problems.
77 | -- `generatorFromList` will generate randomly
78 | -- from this list in each round.
79 | ]
80 | -- Some encoders and decoders,
81 | -- required to transfer data between machines
82 | -- in multiplayer mode.
83 | , guessEncoder = Encode.string
84 | , guessDecoder = Decode.string
85 | , problemEncoder =
86 | (\pb ->
87 | Encode.object
88 | [ ( "question", Encode.string pb.question )
89 | , ( "answers",
90 | List.map Encode.string pb.answers
91 | |> Encode.list
92 | )
93 | , ( "correct", Encode.int pb.correct )
94 | ]
95 | )
96 | , problemDecoder =
97 | Decode.map3 Problem
98 | (Decode.field "question" Decode.string)
99 | (Decode.field "answers" (Decode.list Decode.string))
100 | (Decode.field "correct" Decode.int)
101 | }
102 | ```
103 |
104 | And there you have it - the barebones of your game are defined. There are a couple of steps to take until this becomes playable in multiplayer, but this already renders the tutorial section, so you can get a feel for how your game would play.
105 |
106 | ### Multiplayer functionality
107 |
108 | In order to set up communication between machines, the data needs to go from the Elm app to the outside world. In order to keep the back-end generic, this is done through ports. Compared to the example above, the following modifications are needed:
109 |
110 | ```elm
111 | port outgoing : Encode.Value -> Cmd msg
112 |
113 | port incoming : (Encode.Value -> msg) -> Sub msg
114 |
115 | main =
116 | gameWith
117 | [ responsiblePorts { incoming = incoming, outgoing = outgoing } ]
118 | { -- spec object from before
119 | }
120 | ```
121 |
122 | Instead of `game`, we now use `gameWith`, which allows a list of settings to be passed to the game program constructor. The first and most important such setting is `responsiblePorts`, which expects a record of incoming and outgoing ports.
123 |
124 | We still need to wire up these ports (described right below), but once we do, the game is deployable and playable with any number of players. And it's just boilerplate, I promise :).
125 |
126 | ### The JavaScript
127 |
128 | Under `./src/js` in this repo, you'll find two types of JavaScript files: `db/*.js` and `talk-to-ports.js`. The `db` files are different implementations of the same promise-based API that the client can use to subscribe to a room, send updates to both players and the whole game room, etc. This you can implement yourself for your choice of datastore, or you can use the implementations we provide for `Firebase`, `WebRTC` (Android phones in luck) and `localStorage` (this one assumes that you're in the same browser, so you can test the games you're making in multiplayer). `talk-to-ports.js` wires things up between this database API and the ports we just defined, like so:
129 |
130 | ```html
131 |
132 |
137 |
138 |
139 |
143 | ```
144 |
145 | And there you have it, the game is fully functional in multiplayer!
146 |
147 | ### Customization
148 |
149 | Lots more customization options are available:
150 |
151 | ```elm
152 | main =
153 | gameWith
154 | [ name "MyCoolGame"
155 | , roundDuration (10 * Time.second)
156 | , cooldownDuration (4 * Time.second)
157 | , clearWinner 100
158 | , noPeripheralUi
159 | ]
160 | { -- spec object from before
161 | }
162 | ```
163 |
164 | This produces a game with a custom name, custom round duration, custom cooldown duration between rounds, a clear winner at evaluation 100 (meaning no player can win unless their guess evaluates to exactly 100), and disable the peripheral ui - the score board, timer and winner notifications - so you can build those yourself in whichever design you prefer.
165 |
166 | ## But how does it work?
167 |
168 | `elm-gameroom` aims to keep as much logic and responsibility on the client, so that new games can be set up easily. This involves a fair bit of reconciliation, juggling around race conditions etc., but I'll just start with the gist:
169 |
170 | When a client creates a game room, it becomes the room's host, meaning that it will run decisive game logic such as updating scores and generating game problems in its browser. It then pushes updates to wherever the game state lives, whether it's a Firebase datastore or its very own memory. The other clients subscribe to this datastore, and only send their individual guesses to it.
171 |
172 | Working this way allows most of the logic to live in Elm, and keep outside code as thin as possible.
173 |
174 | ## Long story short
175 |
176 | `elm-gameroom` aims to make it easy for you to make games, so you can make loads of them and focus on what's interesting. Please let me know what you make, and what I can do to make the process nicer for you :).
177 |
--------------------------------------------------------------------------------
/src/Page/Game/Update.elm:
--------------------------------------------------------------------------------
1 | module Page.Game.Update exposing (..)
2 |
3 | import Dict
4 | import Json.Encode as JE
5 | import Data.OutgoingMessage as OutgoingMessage
6 | import Page.Game.Messages exposing (Msg(..))
7 | import Page.Game.Models exposing (Model, setOwnGuess, getOwnPlayer)
8 | import Data.Room as Room
9 | import Data.Player as Player
10 | import Data.RoundTime as RoundTime
11 | import Data.Spec as Spec
12 |
13 |
14 | updateRoomCmd :
15 | Spec.DetailedSpec problem guess
16 | -> Model problem guess
17 | -> Maybe JE.Value
18 | updateRoomCmd spec model =
19 | model.room
20 | |> Maybe.map (OutgoingMessage.UpdateRoom >> (OutgoingMessage.encoder spec.problemEncoder spec.guessEncoder))
21 |
22 |
23 | updatePlayerCmd :
24 | Spec.DetailedSpec problem guess
25 | -> Maybe (Player.Player guess)
26 | -> Maybe JE.Value
27 | updatePlayerCmd spec player =
28 | player
29 | |> Maybe.map (OutgoingMessage.UpdatePlayer >> (OutgoingMessage.encoder spec.problemEncoder spec.guessEncoder))
30 |
31 |
32 | update :
33 | Spec.DetailedSpec problem guess
34 | -> Msg problem guess
35 | -> Model problem guess
36 | -> ( Model problem guess, List JE.Value, Bool )
37 | update spec msg model =
38 | case ( msg, model.room ) of
39 | ( ReceiveUpdate room, Just prevRoom ) ->
40 | let
41 | isHost =
42 | room.host == model.playerId
43 |
44 | isNewRound =
45 | Maybe.map2 (\newRound oldRound -> newRound.no /= oldRound.no) room.round prevRoom.round |> Maybe.withDefault True
46 |
47 | allPlayersReady =
48 | Room.allPlayersReady room
49 |
50 | prevAllPlayersReady =
51 | Room.allPlayersReady prevRoom
52 |
53 | resetTime =
54 | isNewRound || (allPlayersReady && (not prevAllPlayersReady))
55 |
56 | initiateNewRound =
57 | isHost && allPlayersReady && (room.round == Nothing)
58 | in
59 | ( { model
60 | | room = Just room
61 | , time =
62 | if resetTime then
63 | RoundTime.init
64 | else
65 | model.time
66 | }
67 | , []
68 | , initiateNewRound
69 | )
70 |
71 | ( ReceiveUpdate room, Nothing ) ->
72 | ( { model | room = Just room }, [], False )
73 |
74 | ( ReceiveNewProblem problem, Just room ) ->
75 | let
76 | newRound =
77 | room.round
78 | |> Maybe.map
79 | (\round ->
80 | { no = round.no + 1
81 | , problem = problem
82 | }
83 | )
84 | |> Maybe.withDefault
85 | { no = 0
86 | , problem = problem
87 | }
88 | |> Just
89 |
90 | newRoom =
91 | { room
92 | | round = newRound
93 | , players = Dict.map (\playerId player -> { player | guess = Nothing }) room.players
94 | }
95 |
96 | newModel =
97 | { model
98 | | room = Just newRoom
99 | , time = RoundTime.init
100 | }
101 | in
102 | ( newModel
103 | , [ updateRoomCmd spec newModel ] |> List.filterMap identity
104 | , False
105 | )
106 |
107 | ( ReceiveNewProblem problem, Nothing ) ->
108 | -- Impossible state
109 | ( model, [], False )
110 |
111 | ( Guess guess, Just room ) ->
112 | let
113 | isRoundOver =
114 | RoundTime.timeSinceNewRound model.time > spec.roundDuration
115 |
116 | newModel =
117 | setOwnGuess guess model
118 |
119 | newPlayer =
120 | getOwnPlayer newModel
121 | in
122 | if isRoundOver then
123 | ( model, [], False )
124 | else
125 | ( newModel, [ updatePlayerCmd spec newPlayer ] |> List.filterMap identity, False )
126 |
127 | ( Guess guess, Nothing ) ->
128 | -- Impossible state
129 | ( model, [], False )
130 |
131 | ( MarkReady, Just room ) ->
132 | let
133 | newRoom =
134 | room
135 | |> (Room.updatePlayer
136 | (\pl -> { pl | isReady = not pl.isReady })
137 | model.playerId
138 | )
139 |
140 | newModel =
141 | { model
142 | | room = Just newRoom
143 | , time = RoundTime.init
144 | }
145 | in
146 | ( newModel
147 | , [ updateRoomCmd spec newModel ] |> List.filterMap identity
148 | , False
149 | )
150 |
151 | ( MarkReady, Nothing ) ->
152 | -- Impossible state
153 | ( model, [], False )
154 |
155 | ( Tick time, Just room ) ->
156 | let
157 | potentialRoundWinner =
158 | Room.getRoundWinner spec.evaluate spec.clearWinnerEvaluation room
159 |
160 | allPlayersReady =
161 | Room.allPlayersReady room
162 |
163 | isHost =
164 | room.host == model.playerId
165 |
166 | newTime =
167 | RoundTime.update time model.time
168 |
169 | isRoundJustOver =
170 | RoundTime.justPassed
171 | spec.roundDuration
172 | model.time
173 | newTime
174 |
175 | isCooldownJustOver =
176 | RoundTime.justPassed
177 | (spec.roundDuration + spec.cooldownDuration)
178 | model.time
179 | newTime
180 |
181 | initiateNewRound =
182 | isHost
183 | && ((room.round == Nothing) || isCooldownJustOver)
184 |
185 | ( newRoom, isScoreSet ) =
186 | if (isHost && isRoundJustOver) then
187 | (case potentialRoundWinner of
188 | Just winnerId ->
189 | ( if room.host == model.playerId then
190 | Room.setScores (Just winnerId) room
191 | else
192 | room
193 | , True
194 | )
195 |
196 | Nothing ->
197 | ( if room.host == model.playerId then
198 | Room.setScores Nothing room
199 | else
200 | room
201 | , True
202 | )
203 | )
204 | else
205 | ( room, False )
206 |
207 | newModel =
208 | { model
209 | | room =
210 | Just newRoom
211 | , time =
212 | if allPlayersReady then
213 | RoundTime.update time model.time
214 | else
215 | model.time
216 | }
217 | in
218 | ( newModel
219 | , [ if isScoreSet then
220 | updateRoomCmd spec newModel
221 | else
222 | Nothing
223 | ]
224 | |> List.filterMap identity
225 | , initiateNewRound
226 | )
227 |
228 | ( Tick time, Nothing ) ->
229 | ( model
230 | , []
231 | , False
232 | )
233 |
--------------------------------------------------------------------------------
/src/Page/Game/Views.elm:
--------------------------------------------------------------------------------
1 | module Page.Game.Views exposing (view)
2 |
3 | import Dict
4 | import Window
5 | import Html exposing (Html, div, text, p, h2, ul, li, span, a)
6 | import Html.Attributes exposing (class, style, href)
7 | import Html.Events exposing (onClick)
8 | import Data.Spec as Spec
9 | import Data.Room as Room
10 | import Data.RoundTime as RoundTime
11 | import Page.Game.Messages exposing (Msg(..))
12 | import Page.Game.Models exposing (Model, isHost)
13 | import Page.Game.Helpers exposing (getNotificationContent)
14 | import Views.Footer as Footer
15 | import Views.Scoreboard as Scoreboard
16 | import Views.Timer as Timer
17 | import Views.Notification as Notification
18 | import Views.Loader as Loader
19 | import Views.NoMultiplayer as NoMultiplayer
20 | import Page.Game.Views.Styles exposing (CssClasses(..), localClass, localClassList)
21 |
22 |
23 | viewReadyPrompt :
24 | Spec.DetailedSpec problem guess
25 | -> Model problem guess
26 | -> Room.Room problem guess
27 | -> Html (Msg problem guess)
28 | viewReadyPrompt spec model room =
29 | div [ localClass [ ReadyPrompt ] ] <|
30 | [ h2 [] [ text ("Hello, gamer " ++ model.playerId ++ "!") ]
31 | , p [] [ text ("And welcome to room " ++ model.roomId ++ ".") ]
32 | ]
33 | ++ (if isHost model then
34 | [ p [] [ text "You are a host for this room. Here are the links to invite them to the game:" ]
35 | , ul []
36 | (model.room
37 | |> Maybe.map .players
38 | |> Maybe.map Dict.toList
39 | |> Maybe.map
40 | (List.filter (\( playerId, _ ) -> model.playerId /= playerId)
41 | >> List.map
42 | ((\( playerId, player ) ->
43 | a
44 | [ localClassList [ ( Link, True ), ( DisabledLink, playerId == model.playerId ) ]
45 | , href
46 | (spec.basePath
47 | ++ (if spec.basePath == "/" then
48 | "rooms/"
49 | else
50 | "/rooms/"
51 | )
52 | ++ model.roomId
53 | ++ "/"
54 | ++ playerId
55 | )
56 | ]
57 | [ text (playerId ++ "'s game link") ]
58 | )
59 | )
60 | )
61 | |> Maybe.withDefault []
62 | )
63 | ]
64 | else
65 | []
66 | )
67 | ++ [ p [] [ text "Do let us know when you're ready - the game starts immediately once all players marked themselves as such." ]
68 | , ul
69 | []
70 | (room.players
71 | |> Dict.toList
72 | |> List.map Tuple.second
73 | |> List.map
74 | (\pl ->
75 | li []
76 | [ span
77 | ([ localClassList
78 | [ ( Link, True )
79 | , ( DisabledLink, model.playerId /= pl.id )
80 | ]
81 | ]
82 | ++ (if model.playerId == pl.id then
83 | [ onClick MarkReady ]
84 | else
85 | []
86 | )
87 | )
88 | [ text
89 | (if pl.id == model.playerId then
90 | (if pl.isReady then
91 | "Mark me non-ready"
92 | else
93 | "I feel ready"
94 | )
95 | else
96 | (pl.id
97 | ++ (if pl.isReady then
98 | " is ready"
99 | else
100 | " is prepping"
101 | )
102 | )
103 | )
104 | ]
105 | ]
106 | )
107 | )
108 | ]
109 |
110 |
111 | viewRoom :
112 | Spec.DetailedSpec problem guess
113 | -> Window.Size
114 | -> Model problem guess
115 | -> Room.Room problem guess
116 | -> List (Html (Msg problem guess))
117 | viewRoom spec windowSize model room =
118 | [ if Room.allPlayersReady room then
119 | (case room.round of
120 | Just round ->
121 | div
122 | [ localClass [ GamePlay ] ]
123 | [ Html.map Guess
124 | (spec.view
125 | { windowSize = windowSize
126 | , roundTime = RoundTime.timeSinceNewRound model.time
127 | , ownGuess = room.players |> Dict.get model.playerId |> Maybe.andThen .guess |> Maybe.map .value
128 | , opponentGuesses =
129 | room.players
130 | |> Dict.toList
131 | |> List.filterMap
132 | (\( playerId, player ) ->
133 | if playerId /= model.playerId then
134 | player.guess
135 | |> Maybe.map .value
136 | |> Maybe.map (\val -> ( playerId, val ))
137 | else
138 | Nothing
139 | )
140 | , isRoundOver = RoundTime.timeSinceNewRound model.time > spec.roundDuration
141 | , scores =
142 | room.players
143 | |> Dict.toList
144 | |> List.map
145 | (\( id, pl ) ->
146 | ( id, pl.score )
147 | )
148 | }
149 | round.problem
150 | )
151 | ]
152 |
153 | Nothing ->
154 | div [] [ text "Awaiting game" ]
155 | )
156 | else
157 | viewReadyPrompt spec model room
158 | ]
159 | ++ (if spec.peripheralUi then
160 | [ Notification.view (getNotificationContent spec model) Nothing ]
161 | else
162 | []
163 | )
164 | ++ (if (Room.allPlayersReady room) && spec.peripheralUi then
165 | [ Timer.view ((RoundTime.timeSinceNewRound model.time) / spec.roundDuration) ]
166 | else
167 | []
168 | )
169 | ++ (if spec.peripheralUi then
170 | [ Footer.view
171 | [ room.players
172 | |> Dict.toList
173 | |> List.map
174 | (\( playerId, player ) ->
175 | ( player.id, player.score )
176 | )
177 | |> Scoreboard.view
178 | ]
179 | ]
180 | else
181 | []
182 | )
183 |
184 |
185 | view :
186 | Spec.DetailedSpec problem guess
187 | -> Window.Size
188 | -> Model problem guess
189 | -> Html (Msg problem guess)
190 | view spec windowSize model =
191 | div [ localClass [ Root ] ]
192 | (case model.room of
193 | Just room ->
194 | viewRoom spec windowSize model room
195 |
196 | Nothing ->
197 | [ div [ localClass [ LoaderContainer ] ] [ Loader.view ] ]
198 | )
199 |
--------------------------------------------------------------------------------
/src/Gameroom.elm:
--------------------------------------------------------------------------------
1 | module Gameroom
2 | exposing
3 | ( Spec
4 | , Ports
5 | , Msg
6 | , Model
7 | , basePath
8 | , name
9 | , subheading
10 | , instructions
11 | , roundDuration
12 | , cooldownDuration
13 | , clearWinner
14 | , responsiblePorts
15 | , generatorFromList
16 | , noInlineStyle
17 | , noPeripheralUi
18 | , css
19 | , unicodeIcon
20 | , game
21 | , gameWith
22 | )
23 |
24 | {-| This is a framework for creating [multiplayer Guessing games by the boatloads](https://www.youtube.com/watch?v=sBCz6atTRZk), all within the comfort of Elm. Specify only what is unique to a game, write no logic on the back-end, and have it all wired up and ready to play.
25 |
26 | `elm-gameroom` takes care of calling game rounds, generating problems and reconciling scores, as well as talking to either a generic real-time database such as Firebase (JS adapter provided), with have clients sort things out amongst themselves via WebRTC (JavaScript glue code provided).
27 |
28 | # The main game program
29 | @docs game, gameWith
30 |
31 | # Game spec
32 | @docs Spec
33 |
34 | Use these `Msg` and `Model` types to annotate your program when using the [game](/Gameroom#game) or [gameWith](/Gameroom#gameWith) methods.
35 | @docs Model, Msg
36 |
37 | # Ports
38 | @docs Ports
39 |
40 | # Settings
41 | @docs basePath, name, subheading, instructions, unicodeIcon, clearWinner, roundDuration, cooldownDuration, noInlineStyle, noPeripheralUi, responsiblePorts
42 |
43 | # Miscellaneous
44 | @docs css
45 |
46 | # Utils
47 | @docs generatorFromList
48 | -}
49 |
50 | import Window
51 | import Task
52 | import Time
53 | import Navigation
54 | import Random
55 | import Css
56 | import Models
57 | import Data.Ports as Ports
58 | import Subscriptions exposing (subscriptions)
59 | import Messages
60 | import Update exposing (update, cmdOnRouteChange)
61 | import Router
62 | import Data.Route as Route
63 | import Data.Ports as Ports
64 | import Data.Spec as Spec exposing (Setting(..), buildDetailedSpec)
65 | import Views exposing (view)
66 | import Views.Layout
67 |
68 |
69 | {-| Define the unique bits and pieces to your game, all generalized over a type variable representing a `problem`, and one representing a `guess`. It's going to look a little heavy, but it'll make sense very quickly, I promise. Here it goes:
70 |
71 | type alias Spec problem guess =
72 | { view : Context guess -> problem -> Html guess
73 | , evaluate : problem -> guess -> Float
74 | , problemGenerator : Random.Generator problem
75 | , problemEncoder : problem -> Encode.Value
76 | , problemDecoder : Decode.Decoder problem
77 | , guessEncoder : guess -> Encode.Value
78 | , guessDecoder : Decode.Decoder guess
79 | }
80 |
81 | * view: The core of the user interface corresponding to the current game round, excluding all navigation, notifications and the score boards. Emits guesses. The first argument is a view context containing peripheral information such as window size, round time, already recorded guesses etc., and it's [documented on its own](/Gameroom-Context). The second, main argument is the current game problem.
82 | * evaluate: given a problem and a guess, returns a numerical evaluation of the guess. The player with the highest evaluation wins a given round. Note that this is affected by the [clearWinner](/Gameroom#clearWinner) setting, which specifies that only by attaining a certain highest evaluation can a player win.
83 | * problemGenerator: a random generator churning out new problems. If your problems are a simple list, there is a [convenient helper](/Gameroom#generatorFromList).
84 | -}
85 | type alias Spec problem guess =
86 | Spec.Spec problem guess
87 |
88 |
89 | {-| Msg type alias for the game program.
90 | -}
91 | type alias Msg problem guess =
92 | Messages.Msg problem guess
93 |
94 |
95 | {-| Model type alias for the game program.
96 | -}
97 | type alias Model problem guess =
98 | Models.Model problem guess
99 |
100 |
101 | {-| The Ports record contains incoming and outgoing ports necessary for a guessing game, like so:
102 |
103 | port outgoing : Json.Encode.Value -> Cmd msg
104 |
105 | port incoming : (Json.Encode.Value -> msg) -> Sub msg
106 |
107 | ports = { incoming = incoming, outgoing = outgoing }
108 | -}
109 | type alias Ports msg =
110 | Ports.Ports msg
111 |
112 |
113 | {-| If your game doesn't start at the root route, you need to tell the package so the routing is done correctly, e.g. `basePath "/game1"`. This is useful if you want to host multiple games on the same domain, and have them share data stores. This is how the demo site is set up :).
114 |
115 | You can omit the leading slash or have an extra trailing slash. However, base paths with inner slashes such as `/games/game1` are currently not supported.
116 | -}
117 | basePath : String -> Setting problem guess
118 | basePath url =
119 | BasePath url
120 |
121 |
122 | {-| Set the duration of the game round (how long players have to make their guesses).
123 | -}
124 | roundDuration : Time.Time -> Setting problem guess
125 | roundDuration duration =
126 | RoundDuration duration
127 |
128 |
129 | {-| Set the duration of the cooldown phase after a game round is over.
130 | -}
131 | cooldownDuration : Time.Time -> Setting problem guess
132 | cooldownDuration duration =
133 | CooldownDuration duration
134 |
135 |
136 | {-| The name of your game, e.g. `name "YouWillSurelyLose"`.
137 | -}
138 | name : String -> Setting problem guess
139 | name name_ =
140 | Name name_
141 |
142 |
143 | {-| A subheading to go under the name on the home page.
144 | -}
145 | subheading : String -> Setting problem guess
146 | subheading subheading_ =
147 | Subheading subheading_
148 |
149 |
150 | {-| Instructions displayed in the tutorial section.
151 | -}
152 | instructions : String -> Setting problem guess
153 | instructions instructions_ =
154 | Instructions instructions_
155 |
156 |
157 | {-| A unicode icon for your game.
158 | -}
159 | unicodeIcon : String -> Setting problem guess
160 | unicodeIcon icon_ =
161 | UnicodeIcon icon_
162 |
163 |
164 | {-| In the most general case, players compete in getting as close as possible to a given goal. However, sometimes you might want to simplify the game and designate winners only if they attained a specific evaluation value specified by `Spec.evaluate`.
165 |
166 | If you use the clearWinner setting, make sure `evaluate` does not depend on the timestamp.
167 | -}
168 | clearWinner : Float -> Setting problem guess
169 | clearWinner maxEvaluation =
170 | ClearWinner maxEvaluation
171 |
172 |
173 | {-| By default, the game interface renders an inline