├── firestore.indexes.json ├── public ├── favicon.ico ├── img │ ├── cow1.jpg │ ├── cow2.jpg │ ├── cow3.jpg │ └── bg-masthead.jpg ├── favicon-16x16.png ├── favicon-32x32.png ├── apple-touch-icon.png ├── android-chrome-192x192.png ├── android-chrome-512x512.png ├── site.webmanifest ├── js │ └── main.js └── css │ └── main.css ├── Procfile ├── bootstrap └── custom.scss ├── deps.edn ├── firebase.json ├── shadow-cljs.edn ├── task ├── LICENSE ├── src └── cows │ ├── core.cljs │ ├── lib.clj │ ├── mutations.cljs │ ├── db.cljs │ ├── lib.cljs │ ├── core.clj │ ├── util.cljs │ ├── fn.cljs │ └── components.cljs ├── package.json ├── firestore.rules ├── .gitignore └── README.md /firestore.indexes.json: -------------------------------------------------------------------------------- 1 | { 2 | "indexes": [], 3 | "fieldOverrides": [] 4 | } 5 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jacobobryant/mystery-cows/HEAD/public/favicon.ico -------------------------------------------------------------------------------- /public/img/cow1.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jacobobryant/mystery-cows/HEAD/public/img/cow1.jpg -------------------------------------------------------------------------------- /public/img/cow2.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jacobobryant/mystery-cows/HEAD/public/img/cow2.jpg -------------------------------------------------------------------------------- /public/img/cow3.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jacobobryant/mystery-cows/HEAD/public/img/cow3.jpg -------------------------------------------------------------------------------- /public/favicon-16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jacobobryant/mystery-cows/HEAD/public/favicon-16x16.png -------------------------------------------------------------------------------- /public/favicon-32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jacobobryant/mystery-cows/HEAD/public/favicon-32x32.png -------------------------------------------------------------------------------- /public/img/bg-masthead.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jacobobryant/mystery-cows/HEAD/public/img/bg-masthead.jpg -------------------------------------------------------------------------------- /public/apple-touch-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jacobobryant/mystery-cows/HEAD/public/apple-touch-icon.png -------------------------------------------------------------------------------- /public/android-chrome-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jacobobryant/mystery-cows/HEAD/public/android-chrome-192x192.png -------------------------------------------------------------------------------- /public/android-chrome-512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jacobobryant/mystery-cows/HEAD/public/android-chrome-512x512.png -------------------------------------------------------------------------------- /Procfile: -------------------------------------------------------------------------------- 1 | sass: ./task sass -w 2 | shadow: npx shadow-cljs server 3 | html: npx onchange -v 'src/**/*.clj' -- clj -m cows.core 4 | -------------------------------------------------------------------------------- /bootstrap/custom.scss: -------------------------------------------------------------------------------- 1 | $theme-colors: ( 2 | "primary": #82663e, 3 | "secondary": #746f72 4 | ); 5 | 6 | @import "./bootstrap-4.4.1/scss/bootstrap.scss"; 7 | -------------------------------------------------------------------------------- /public/site.webmanifest: -------------------------------------------------------------------------------- 1 | {"name":"","short_name":"","icons":[{"src":"/android-chrome-192x192.png","sizes":"192x192","type":"image/png"},{"src":"/android-chrome-512x512.png","sizes":"512x512","type":"image/png"}],"theme_color":"#ffffff","background_color":"#ffffff","display":"standalone"} -------------------------------------------------------------------------------- /deps.edn: -------------------------------------------------------------------------------- 1 | {:aliases {:cljs {:extra-deps {org.clojure/clojurescript {:mvn/version "1.10.520"} 2 | thheller/shadow-cljs {:mvn/version "2.8.40"} 3 | binaryage/devtools {:mvn/version "0.9.10"} 4 | cljsjs/firebase {:mvn/version "5.7.3-1"} 5 | trident/firestore {:mvn/version "0.2.3"}}}} 6 | :deps 7 | {rum {:mvn/version "0.11.4"}}} 8 | -------------------------------------------------------------------------------- /firebase.json: -------------------------------------------------------------------------------- 1 | { 2 | "hosting": { 3 | "public": "public", 4 | "ignore": [ 5 | "firebase.json", 6 | "**/.*", 7 | "**/node_modules/**" 8 | ] 9 | }, 10 | "firestore": { 11 | "rules": "firestore.rules", 12 | "indexes": "firestore.indexes.json" 13 | }, 14 | "emulators": { 15 | "functions": { 16 | "port": 5001 17 | }, 18 | "firestore": { 19 | "port": 8080 20 | }, 21 | "hosting": { 22 | "port": 5000 23 | } 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /public/js/main.js: -------------------------------------------------------------------------------- 1 | var ui = new firebaseui.auth.AuthUI(firebase.auth()); 2 | var uiConfig = { 3 | signInSuccessUrl: '/app/', 4 | signInOptions: [ 5 | { 6 | provider: firebase.auth.EmailAuthProvider.PROVIDER_ID, 7 | signInMethod: firebase.auth.EmailAuthProvider.EMAIL_LINK_SIGN_IN_METHOD, 8 | requireDisplayName: false 9 | }, 10 | firebase.auth.GoogleAuthProvider.PROVIDER_ID, 11 | ], 12 | tosUrl: '/tos.txt', 13 | privacyPolicyUrl: '/privacy.txt', 14 | credentialHelper: firebaseui.auth.CredentialHelper.NONE 15 | }; 16 | 17 | ui.start('#firebaseui-auth-container', uiConfig); 18 | -------------------------------------------------------------------------------- /shadow-cljs.edn: -------------------------------------------------------------------------------- 1 | {:deps {:aliases [:cljs]} 2 | :nrepl {:port 7890} 3 | :builds {:main {:target :browser 4 | :output-dir "public/cljs" 5 | :asset-path "/cljs" 6 | :compiler-options {:infer-externs :auto} 7 | :modules {:main {:init-fn cows.core/init 8 | :entries [cows.core]}} 9 | :devtools {:after-load cows.core/mount}} 10 | :fn {:target :node-library 11 | :compiler-options {:infer-externs :auto} 12 | :output-to "functions/index.js" 13 | :exports-var cows.fn/exports}}} 14 | -------------------------------------------------------------------------------- /task: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -e 3 | 4 | sass () { 5 | npx node-sass "$@" bootstrap/custom.scss public/css/bootstrap.css 6 | } 7 | 8 | setup () { 9 | npm install 10 | wget -O public/css/firebase-ui-auth-4.4.0.css https://www.gstatic.com/firebasejs/ui/4.4.0/firebase-ui-auth.css 11 | wget -O public/js/firebase-ui-auth-4.4.0.js https://www.gstatic.com/firebasejs/ui/4.4.0/firebase-ui-auth.js 12 | if ! [ -d bootstrap/bootstrap-4.4.1 ]; then 13 | cd bootstrap 14 | wget https://github.com/twbs/bootstrap/archive/v4.4.1.zip 15 | unzip v4.4.1.zip 16 | rm v4.4.1.zip 17 | fi 18 | } 19 | 20 | dev () { 21 | sass 22 | clj -m cows.core 23 | overmind s 24 | } 25 | 26 | deploy () { 27 | sass 28 | clj -m cows.core 29 | rm -rf $(find public/ -name cljs-runtime) 30 | npx shadow-cljs release main fn 31 | cp package.json functions/package.json 32 | npx firebase deploy 33 | } 34 | 35 | "$@" 36 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2020 Jacob O'Bryant 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 4 | 5 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 6 | 7 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 8 | -------------------------------------------------------------------------------- /src/cows/core.cljs: -------------------------------------------------------------------------------- 1 | (ns cows.core 2 | (:require-macros 3 | [cljs.core.async.macros :refer [go]]) 4 | (:require 5 | [cljs.core.async :refer [> (partition 2 forms) 23 | (reduce 24 | (fn [[defs sources] [sym form]] 25 | (let [deps (->> form 26 | flatten-form 27 | (map sources) 28 | (filter some?) 29 | distinct 30 | vec) 31 | k (keyword (name nspace) (name sym))] 32 | [(conj defs `(defonce ~sym (rum.core/derived-atom ~deps ~k 33 | (fn ~deps 34 | ~form)))) 35 | (conj sources sym)])) 36 | [[] (set sources)]) 37 | first)) 38 | 39 | (defmacro defderivations [& args] 40 | `(do ~@(apply derivations args))) 41 | -------------------------------------------------------------------------------- /public/css/main.css: -------------------------------------------------------------------------------- 1 | /*! 2 | * Start Bootstrap - Landing Page v5.0.7 (https://startbootstrap.com/template-overviews/landing-page) 3 | * Copyright 2013-2019 Start Bootstrap 4 | * Licensed under MIT (https://github.com/BlackrockDigital/startbootstrap-landing-page/blob/master/LICENSE) 5 | */ 6 | 7 | header.masthead { 8 | position: relative; 9 | background-color: #343a40; 10 | background: url("../img/bg-masthead.jpg") no-repeat center center; 11 | background-size: cover; 12 | padding-top: 8rem; 13 | padding-bottom: 8rem; 14 | } 15 | 16 | header.masthead .overlay { 17 | position: absolute; 18 | background-color: #212529; 19 | height: 100%; 20 | width: 100%; 21 | top: 0; 22 | left: 0; 23 | opacity: 0.3; 24 | } 25 | 26 | .masthead-h1 { 27 | font-size: 2rem; 28 | } 29 | 30 | @media (min-width: 768px) { 31 | header.masthead { 32 | padding-top: 12rem; 33 | padding-bottom: 12rem; 34 | } 35 | .masthead-h1 { 36 | font-size: 3rem; 37 | } 38 | } 39 | 40 | .available-room:hover { 41 | cursor: pointer; 42 | background-color: #bbb; 43 | } 44 | 45 | .available-square:hover { 46 | cursor: pointer; 47 | background-color: rgb(173, 91, 3) !important; 48 | } 49 | 50 | .sidebar { 51 | height: 279.5px; 52 | } 53 | 54 | @media (min-width: 992px) { 55 | .sidebar { 56 | height: 575px; 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /firestore.rules: -------------------------------------------------------------------------------- 1 | rules_version = '2'; 2 | service cloud.firestore { 3 | match /databases/{database}/documents { 4 | match /games/{game} { 5 | allow create: if request.auth.uid != null && request.resource.data.state == "lobby" && request.resource.data.players == [request.auth.uid]; 6 | allow read: if request.auth.uid != null && 7 | (!exists(/databases/$(database)/documents/games/$(game)) || 8 | resource.data.state == "lobby" || 9 | request.auth.uid in resource.data.players); 10 | match /cards/{uid} { 11 | allow read: if request.auth.uid == uid; 12 | } 13 | match /messages/{message} { 14 | allow read: if !exists(/databases/$(database)/documents/games/$(game)) || 15 | request.auth.uid in get(/databases/$(database)/documents/games/$(game)).data.players; 16 | allow create: if request.resource.data.user == request.auth.uid && 17 | request.auth.uid in get(/databases/$(database)/documents/games/$(game)).data.players; 18 | } 19 | match /events/{eventId} { 20 | allow read: if !exists(/databases/$(database)/documents/games/$(game)) || 21 | request.auth.uid in get(/databases/$(database)/documents/games/$(game)).data.players; 22 | } 23 | match /responses/{eventId} { 24 | allow read: if request.auth.uid == resource.data.suggester || 25 | request.auth.uid == resource.data.responder; 26 | } 27 | match /accusations/{eventId} { 28 | allow read: if request.auth.uid == resource.data.player; 29 | } 30 | } 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .cpcache 2 | .firebaserc 3 | .nrepl-port 4 | .shadow-cljs/ 5 | bootstrap/bootstrap-4.4.1 6 | credentials.json 7 | functions/index.js 8 | functions/package.json 9 | public/**/*.html 10 | public/cljs/ 11 | public/css/bootstrap.css 12 | public/css/firebase-ui-auth-4.4.0.css 13 | public/js/firebase-ui-auth-4.4.0.js 14 | 15 | # Logs 16 | logs 17 | *.log 18 | npm-debug.log* 19 | yarn-debug.log* 20 | yarn-error.log* 21 | firebase-debug.log* 22 | 23 | # Firebase cache 24 | .firebase/ 25 | 26 | # Firebase config 27 | 28 | # Uncomment this if you'd like others to create their own Firebase project. 29 | # For a team working on the same Firebase project(s), it is recommended to leave 30 | # it commented so all members can deploy to the same project(s) in .firebaserc. 31 | # .firebaserc 32 | 33 | # Runtime data 34 | pids 35 | *.pid 36 | *.seed 37 | *.pid.lock 38 | 39 | # Directory for instrumented libs generated by jscoverage/JSCover 40 | lib-cov 41 | 42 | # Coverage directory used by tools like istanbul 43 | coverage 44 | 45 | # nyc test coverage 46 | .nyc_output 47 | 48 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 49 | .grunt 50 | 51 | # Bower dependency directory (https://bower.io/) 52 | bower_components 53 | 54 | # node-waf configuration 55 | .lock-wscript 56 | 57 | # Compiled binary addons (http://nodejs.org/api/addons.html) 58 | build/Release 59 | 60 | # Dependency directories 61 | node_modules/ 62 | 63 | # Optional npm cache directory 64 | .npm 65 | 66 | # Optional eslint cache 67 | .eslintcache 68 | 69 | # Optional REPL history 70 | .node_repl_history 71 | 72 | # Output of 'npm pack' 73 | *.tgz 74 | 75 | # Yarn Integrity file 76 | .yarn-integrity 77 | 78 | # dotenv environment variables file 79 | .env 80 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Mystery Cows 2 | 3 | Note: I no longer use Firebase (I switched to [Biff](https://biff.findka.com), so this repo is not necessarily up-to-date. 4 | 5 | This is an example program (a cow-themed version of the board game Clue) meant 6 | to demonstrate how to make a web app with ClojureScript and Firebase. It's 7 | deployed at [cows.jacobobryant.com](https://cows.jacobobryant.com). 8 | 9 | The following requires no knowledge of Clojure or Firebase. Once you have it 10 | running, you can start poking around the code to see how it works. While 11 | running `./task dev`, any Clojure(Script) files you change will be recompiled 12 | automatically. 13 | 14 | I've also recorded [some 15 | commentary](https://www.youtube.com/watch?v=c6CylfdcsTo) on the architecture of 16 | this project. The most interesting part is at 17 | [25:42](https://youtu.be/c6CylfdcsTo?t=1542), where I explain how you can 18 | specify declaratively which Firestore subscriptions the app needs based on its 19 | current state. 20 | 21 | ## Setup 22 | 23 | 1. At [firebase.google.com](https://firebase.google.com), create a new project. 24 | 2. In the "Authentication" section, click "Set up sign-in method." First enable 25 | "Email/Password," and then also enable "Email link (passwordless sign-in)." 26 | After that, enable sign-in with Google. 27 | 3. In the side bar, click on "Cloud Firestore" and then "Create database." 28 | Accept the defaults. 29 | 4. Install dependencies: [NPM](https://www.npmjs.com/get-npm), 30 | [Clojure](https://clojure.org/guides/getting_started) and 31 | [Overmind](https://github.com/DarthSim/overmind). 32 | 5. Clone this repository. Inside the project directory, run `./task setup`. 33 | 6. Run `firebase login` and then `firebase init`. Select Firestore, Hosting and 34 | Functions, and select your existing project. Accept the defaults for 35 | everything elses. 36 | 37 | ## Development 38 | 39 | 1. Run `./task dev`. 40 | 2. After Shadow CLJS loads, go to [localhost:9630](http://localhost:9630). 41 | Hover over "Builds," then check the "main" and "fn" boxes. 42 | 3. In a new terminal, run `firebase emulators:start`. 43 | 44 | The app will be available at [localhost:5000](http://localhost:5000). 45 | 46 | ## Deploy 47 | 48 | Run `./task deploy`. The app will be available at 49 | `https://your-project-id.web.app`. 50 | 51 | ## License 52 | 53 | Distributed under the [EPL v2.0](LICENSE) 54 | 55 | Copyright © 2020 [Jacob O'Bryant](https://jacobobryant.com). 56 | -------------------------------------------------------------------------------- /src/cows/mutations.cljs: -------------------------------------------------------------------------------- 1 | (ns cows.mutations 2 | (:require-macros 3 | [cljs.core.async.macros :refer [go]]) 4 | (:require 5 | [cljs.core.async :refer [> [:events :responses :accusations] 20 | ; For some reason, (map data) makes this fail. Bug in defderivations? 21 | (map #(% data)) 22 | (apply merge) 23 | vals 24 | (sort-by :timestamp)) 25 | 26 | current-game (->> games 27 | vals 28 | (filter (fn [{:keys [players]}] 29 | (some #{uid} players))) 30 | first) 31 | 32 | players (:players current-game) 33 | current-player (:current-player current-game) 34 | responder (:responder current-game) 35 | suggestion (:suggestion current-game) 36 | roll-result (:roll-result current-game) 37 | face-up-cards (:face-up-cards current-game) 38 | winner (:winner current-game) 39 | state (keyword (:state current-game)) 40 | losers (set (:losers current-game)) 41 | game-id (-> current-game :ident second) 42 | 43 | cards (get-in data [:cards [:games game-id uid] :cards]) 44 | messages (when game-id 45 | (->> all-messages 46 | vals 47 | (filter (fn [{[_ [_ message-game-id]] :ident}] 48 | (= game-id message-game-id))) 49 | (sort-by :timestamp))) 50 | names (zipmap players util/names) 51 | colors (zipmap players util/colors) 52 | your-turn (= uid current-player) 53 | responding (and (= :respond state) (= responder uid)) 54 | positions (u/map-keys name (:positions current-game)) 55 | available-locations (when (and (= state :after-roll) 56 | (every? some? [(positions current-player) roll-result])) 57 | (->> (util/available-locations (positions current-player) roll-result) 58 | (map #(cond-> % (string? %) util/rooms-map)) 59 | (into #{}))) 60 | 61 | subscriptions (if game-id 62 | #{[:games game-id] 63 | [:cards [:games game-id uid]] 64 | [:messages [:games game-id]] 65 | [:events [:games game-id]] 66 | {:ident [:responses [:games game-id]] :where [[:responder '== uid]]} 67 | {:ident [:responses [:games game-id]] :where [[:suggester '== uid]]} 68 | {:ident [:accusations [:games game-id]] :where [[:player '== uid]]}} 69 | #{{:ident [:games] :where [[:state '== "lobby"]]} 70 | {:ident [:games] :where [[:players 'array-contains uid]]}})) 71 | 72 | (def env (capture-env 'cows.db)) 73 | -------------------------------------------------------------------------------- /src/cows/lib.cljs: -------------------------------------------------------------------------------- 1 | (ns cows.lib 2 | (:require-macros 3 | [cows.lib] 4 | [cljs.core.async.macros :refer [go go-loop]]) 5 | (:require 6 | [cljs.core.async :as async :refer [take! chan put!]] 7 | [clojure.edn :as edn] 8 | [rum.core] 9 | [clojure.set :as set] 10 | [cljs.core.async :refer [close!]] 11 | [trident.util :as u])) 12 | 13 | (defn maintain-subscriptions 14 | "Watch for changes in a set of subscriptions (stored in sub-atom), subscribing 15 | and unsubscribing accordingly. sub-fn should take an element of @sub-atom and 16 | return a channel that delivers the subscription channel after the first subscription result 17 | has been received. This is necessary because otherwise, old subscriptions would 18 | be closed too early, causing problems for the calculation of sub-atom." 19 | [sub-atom sub-fn] 20 | (let [sub->chan (atom {}) 21 | c (chan) 22 | watch (fn [_ _ old-subs new-subs] 23 | (put! c [old-subs new-subs]))] 24 | (go-loop [] 25 | (let [[old-subs new-subs] (chan merge (zipmap new-subs new-channels)) 31 | (doseq [channel (map @sub->chan old-subs)] 32 | (close! channel)) 33 | (swap! sub->chan #(apply dissoc % old-subs))) 34 | (recur)) 35 | (add-watch sub-atom ::maintain-subscriptions watch) 36 | (watch nil nil #{} @sub-atom))) 37 | 38 | (defn merge-subscription-results! 39 | "Continually merge results from subscription into sub-data-atom. Returns a channel 40 | that delivers sub-channel after the first result has been merged." 41 | [{:keys [sub-data-atom merge-result sub-key sub-channel]}] 42 | (go 43 | (let [merge! #(swap! sub-data-atom update sub-key merge-result %)] 44 | (merge! ( ns-segment 63 | (not-empty (namespace k)) (str "." (namespace k))) 64 | (name k))) 65 | 66 | (defn prepend-keys [ns-segment m] 67 | (u/map-keys #(prepend-ns ns-segment %) m)) 68 | 69 | (defn firebase-fns [ks] 70 | (u/map-to (fn [k] 71 | (let [f (.. js/firebase 72 | functions 73 | (httpsCallable (name k)))] 74 | (fn [data] 75 | (-> data 76 | pr-str 77 | f 78 | u/js context 91 | (js->clj :keywordize-keys true) 92 | (assoc :event event)) 93 | env (-> env 94 | (merge (prepend-keys "auth" (:auth env))) 95 | (dissoc :auth)) 96 | result (handler env data)] 97 | (if (chan? result) 98 | (js/Promise. 99 | (fn [success] 100 | (take! result (comp success pr-str)))) 101 | (pr-str result))))) 102 | -------------------------------------------------------------------------------- /src/cows/core.clj: -------------------------------------------------------------------------------- 1 | (ns cows.core 2 | (:require 3 | [clojure.java.io :as io] 4 | [rum.core :as rum :refer [defc]])) 5 | 6 | (def h-style {:font-family "'Lato', 'Helvetica Neue', Helvetica, Arial, sans-serif" 7 | :font-weight 700}) 8 | 9 | (def default-head 10 | (list 11 | [:title "Mystery Cows"] 12 | [:meta {:name "author" :content "Jacob O'Bryant"}] 13 | [:meta {:name "description" 14 | :content (str "A board game written with Clojure. Part of " 15 | "The Solo Hacker's Guide To Clojure.")}] 16 | [:meta {:charset "utf-8"}] 17 | [:meta {:name "viewport" :content "width=device-width, initial-scale=1"}] 18 | [:link {:rel "stylesheet" :href "/css/bootstrap.css"}] 19 | [:link {:rel "stylesheet" :href "/css/main.css"}] 20 | [:link {:rel "apple-touch-icon" :sizes "180x180" :href "/apple-touch-icon.png"}] 21 | [:link {:rel "icon" :type "image/png" :sizes "32x32" :href "/favicon-32x32.png"}] 22 | [:link {:rel "icon" :type "image/png" :sizes "16x16" :href "/favicon-16x16.png"}] 23 | [:link {:rel "manifest" :href "/site.webmanifest"}])) 24 | 25 | (def navbar 26 | [:nav.navbar.navbar-dark.bg-primary.static-top 27 | [:.navbar-brand "Mystery Cows"]]) 28 | 29 | (def header 30 | [:header.masthead.text-white.text-center 31 | [:.overlay] 32 | [:.container 33 | [:.row 34 | [:.col-xl-9.mx-auto 35 | [:h1.masthead-h1.mb-3 {:style h-style} 36 | "There's been a murder at the dairy. Put your deductive " 37 | "skills to the test and find out hoof dunnit."]]] 38 | [:.d-flex.justify-content-center 39 | [:a.btn.btn-primary.btn-block.btn-lg 40 | {:style {:z-index 1 41 | :max-width "10rem"} 42 | :href "/login/"} 43 | "Sign in"]]]]) 44 | 45 | (defc testimonial-item [{:keys [img-src title text]}] 46 | [:.col-lg-4 47 | [:.mx-auto.mb-5.mb-lg-0 {:style {:max-width "18rem"}} 48 | [:img.img-fluid.rounded-circle.mb-3 49 | {:style {:max-width "12rem" 50 | :box-shadow "0px 5px 5px 0px #adb5bd"} 51 | :alt "" 52 | :src img-src}] 53 | [:h5 {:style h-style} title] 54 | [:p.font-weight-light.mb-0 text]]]) 55 | 56 | (def testimonials 57 | [:section.text-center.bg-light 58 | {:style {:padding-top "7rem" 59 | :padding-bottom "7rem"}} 60 | [:.container 61 | [:h2.mb-5 {:style h-style} 62 | "What bovines are saying..."] 63 | [:.row 64 | (for [[img-src title text] [["img/cow1.jpg" 65 | "Margaret E." 66 | "\"This is fantastic! Thanks so much guys!\""] 67 | ["img/cow2.jpg" 68 | "Fred S." 69 | "\"Moo\""] 70 | ["img/cow3.jpg" 71 | "Sarah W." 72 | "\"I knew it was Larry all along...\""]]] 73 | (testimonial-item {:img-src img-src 74 | :title title 75 | :text text}))]]]) 76 | 77 | (defn firebase-js [modules] 78 | (list 79 | [:script {:src "/__/firebase/7.11.0/firebase-app.js"}] 80 | (for [m modules] 81 | [:script {:src (str "/__/firebase/7.11.0/firebase-" (name m) ".js")}]) 82 | [:script {:src "/__/firebase/init.js"}])) 83 | 84 | (defc base-page [{:keys [firebase-modules head scripts]} & contents] 85 | [:html {:lang "en-US" 86 | :style {:min-height "100%"}} 87 | (into 88 | [:head 89 | default-head] 90 | head) 91 | (into 92 | [:body {:style {:font-family "'Helvetica Neue', Helvetica, Arial, sans-serif"}} 93 | navbar 94 | contents 95 | (when (not-empty firebase-modules) 96 | (firebase-js firebase-modules))] 97 | scripts)]) 98 | 99 | (def ensure-logged-in 100 | [:script 101 | {:dangerouslySetInnerHTML 102 | {:__html "firebase.auth().onAuthStateChanged(u => { if (!u) window.location.href = '/'; });"}}]) 103 | 104 | (def ensure-logged-out 105 | [:script 106 | {:dangerouslySetInnerHTML 107 | {:__html "firebase.auth().onAuthStateChanged(u => { if (u) window.location.href = '/app/'; });"}}]) 108 | 109 | (defc landing-page [] 110 | (base-page {:firebase-modules [:auth] 111 | :scripts [ensure-logged-out]} 112 | header 113 | testimonials)) 114 | 115 | (defc login [] 116 | (base-page {:head [[:link {:type "text/css" 117 | :rel "stylesheet" 118 | :href "/css/firebase-ui-auth-4.4.0.css"}]] 119 | :firebase-modules [:auth] 120 | :scripts [[:script {:src "/js/firebase-ui-auth-4.4.0.js"}] 121 | [:script {:src "/js/main.js"}] 122 | ensure-logged-out]} 123 | [:#firebaseui-auth-container 124 | {:style {:margin-top "7rem"}}])) 125 | 126 | (defc app [] 127 | (base-page {:firebase-modules [:auth :firestore :functions] 128 | :scripts [ensure-logged-in 129 | [:script {:src "/cljs/main.js"}]]} 130 | [:#app 131 | [:.d-flex.flex-column.align-items-center.mt-4 132 | [:.spinner-border.text-primary 133 | {:role "status"} 134 | [:span.sr-only 135 | "Loading..."]]]])) 136 | 137 | (def pages 138 | {"/" landing-page 139 | "/login/" login 140 | "/app/" app}) 141 | 142 | (defn -main [] 143 | (doseq [[path component] pages 144 | :let [full-path (str "public" path "index.html")]] 145 | (io/make-parents full-path) 146 | (spit full-path (rum/render-static-markup (component))))) 147 | -------------------------------------------------------------------------------- /src/cows/util.cljs: -------------------------------------------------------------------------------- 1 | (ns cows.util 2 | (:require 3 | [clojure.spec.alpha :as s] 4 | [clojure.set :as set] 5 | [clojure.string :as str])) 6 | 7 | (def adjectives ["miserable" "vomitous" "artless" "bawdy" "beslubbering" "bootless" "churlish" "clouted" "craven" "currish" "dankish" "dissembling" "droning" "errant" "fawning" "fobbing" "froward" "frothy" "gleeking" "goatish" "gorbellied" "impertinent" "infectious" "jarring" "loggerheaded" "lumpish" "mammering" "mangled" "mewling" "paunchy" "pribbling" "puking" "puny" "qualling" "rank" "reeky" "roguish" "ruttish" "saucy" "spleeny" "spongy" "surly" "tottering" "unmuzzled" "vain" "venomed" "villainous" "warped" "wayward" "weedy" "yeasty"]) 8 | 9 | (def nouns ["mass" "pig" "apple-john" "baggage" "barnacle" "bladder" "boar-pig" "bugbear" "bum-bailey" "canker-blossom" "clack-dish" "clotpole" "coxcomb" "codpiece" "death-token" "dewberry" "flap-dragon" "flirt-gill" "foot-licker" "fustilarian" "giglet" "gudgeon" "haggard" "harpy" "hedge-pig" "horn-beast" "hugger-mugger" "joithead" "lout" "maggot-pie" "malt-worm" "mammet" "measle" "minnow" "miscreant" "moldwarp" "mumble-news" "nut-hook" "pigeon-egg" "pignut" "puttock" "pumpion" "ratsbane" "scut" "skainsmate" "strumpet" "varlot" "vassal" "whey-face" "wagtail"]) 10 | 11 | (defn rand-word [v seed] 12 | (v (mod (hash seed) (count v)))) 13 | 14 | (defn username [uid] 15 | (str (rand-word adjectives uid) "-" (rand-word nouns uid))) 16 | 17 | (def names ["Miss Scarlet" 18 | "Colonel Mustard" 19 | "Mrs. Peacock" 20 | "Mr. Green" 21 | "Professor Plum" 22 | "Mrs. White"]) 23 | 24 | (def colors ["red" 25 | "#ffdb58" 26 | "blue" 27 | "darkgreen" 28 | "purple" 29 | "white"]) 30 | 31 | (def weapons ["Knife" 32 | "Lead pipe" 33 | "Candlestick" 34 | "Rope" 35 | "Revolver" 36 | "Wrench"]) 37 | 38 | (def rooms ["Study" 39 | "Hall" 40 | "Lounge" 41 | "Dining Room" 42 | "Kitchen" 43 | "Ballroom" 44 | "Conservatory" 45 | "Billiard Room" 46 | "Library"]) 47 | 48 | (defn starting-cards [players] 49 | (let [cards (->> [names weapons rooms] 50 | (mapcat (comp rest shuffle)) 51 | shuffle) 52 | hand-size (quot (count cards) (count players)) 53 | hands (partition-all hand-size cards) 54 | face-up-cards (nth hands (count players) nil)] 55 | [(zipmap players hands) face-up-cards])) 56 | 57 | (defn starting-positions [players] 58 | (zipmap players (shuffle [[0 16] [7 23] [24 14] [24 9] [18 0] [5 0]]))) 59 | 60 | (def raw-board 61 | [" - r" 62 | " A A A - - - -" 63 | " A A A - - - - C C C" 64 | " - - B B B - - C C C" 65 | " - - - - - 0 - 1 B B B - -" 66 | "p - - - - - - - - - -" 67 | " - - - - - 2 - - - - -" 68 | " I I I - - - - 1 1 - - - - - - - - - - y" 69 | " I I I 8 - - - - 3 - - - - -" 70 | " - - - -" 71 | " - - - - -" 72 | " 7 - 8 - - - - - - - D D D" 73 | " - - - - 3 D D D" 74 | " H H H - - - - -" 75 | " H H H - - - - -" 76 | " 7 - - - - - - - - - - - -" 77 | " - - - 5 - - - - 5 - - - - - - - -" 78 | " - - - - - - - - - - 4 - - - -" 79 | "b - - - - - - - - -" 80 | " 6 - 5 F F F 5 -" 81 | " - - F F F - - E E E" 82 | " G G G - - - - E E E" 83 | " G G G - - - -" 84 | " - - - - - -" 85 | " g w"]) 86 | 87 | (def board (remove #(empty? (str/trim %)) raw-board)) 88 | (def raw-board-width (count (reduce #(max-key count %1 %2) board))) 89 | (def board-width (quot (inc raw-board-width) 2)) 90 | 91 | (defn map-inverse [m] 92 | (reduce 93 | (fn [inverse [k v]] 94 | (update inverse v 95 | #(if (nil? %) 96 | #{k} 97 | (conj % k)))) 98 | {} 99 | m)) 100 | 101 | (defn dissoc-by [m f] 102 | (into {} (remove (comp f second) m))) 103 | 104 | (defn lookup [i j] 105 | (get (nth board i) j)) 106 | 107 | (def starting-board 108 | (let [board-map (into {} (for [i (range (count board)) 109 | j (range 0 raw-board-width 2)] 110 | [[i (quot j 2)] (lookup i j)]))] 111 | (dissoc-by board-map #(contains? #{\space nil} %)))) 112 | 113 | (def board-inverse (dissoc (map-inverse starting-board) \-)) 114 | 115 | (def room-coordinates 116 | (-> board-inverse 117 | (select-keys [\A \B \C \D \E \F \G \H \I]) 118 | (set/rename-keys {\A \0 119 | \B \1 120 | \C \2 121 | \D \3 122 | \E \4 123 | \F \5 124 | \G \6 125 | \H \7 126 | \I \8}))) 127 | 128 | (def room-char->name (zipmap (sort (keys room-coordinates)) rooms)) 129 | 130 | (defn coordinates-with [values] 131 | (mapcat (comp vec board-inverse) values)) 132 | 133 | (defn replace-values [board values replacement] 134 | (reduce 135 | #(assoc %1 %2 replacement) 136 | board 137 | (coordinates-with values))) 138 | 139 | (def player-chars #{\r \y \b \g \p \w}) 140 | (def room-print-chars #{\A \B \C \D \E \F \G \H \I}) 141 | 142 | (def empty-board 143 | (as-> starting-board x 144 | (replace-values x player-chars \-) 145 | (reduce dissoc x (coordinates-with room-print-chars)))) 146 | 147 | (def room-tiles 148 | {:study [0 0 7 4] 149 | :hall [0 9 6 7] 150 | :lounge [0 17 7 6] 151 | :dining-room [9 16 8 7] 152 | :kitchen [18 18 6 7] 153 | :ballroom [17 8 8 6] 154 | :conservatory [19 0 6 6] 155 | :billiard-room [12 0 6 5] 156 | :library [6 0 7 5]}) 157 | 158 | (def card-names 159 | {:scarlet "Miss Scarlet" 160 | :mustard "Colonel Mustard" 161 | :peacock "Mrs. Peacock" 162 | :green "Mr. Green" 163 | :plum "Mr. Plum" 164 | :white "Mrs. White" 165 | :knife "Knife" 166 | :lead-pipe "Lead pipe" 167 | :candlestick "Candlestick" 168 | :rope "Rope" 169 | :revolver "Revolver" 170 | :wrench "Wrench" 171 | :study "Study" 172 | :hall "Hall" 173 | :lounge "Lounge" 174 | :dining-room "Dining room" 175 | :kitchen "Kitchen" 176 | :ballroom "Ballroom" 177 | :conservatory "Conservatory" 178 | :billiard-room "Billiard room" 179 | :library "Library"}) 180 | 181 | (def door-directions 182 | {[4 6] :horizontal 183 | [4 9] :vertical 184 | [7 11] :horizontal 185 | [7 12] :horizontal 186 | [6 17] :horizontal 187 | [9 17] :horizontal 188 | [12 16] :vertical 189 | [18 19] :horizontal 190 | [19 16] :vertical 191 | [17 14] :horizontal 192 | [17 9] :horizontal 193 | [19 8] :vertical 194 | [19 5] :vertical 195 | [15 6] :vertical 196 | [12 1] :horizontal 197 | [11 3] :horizontal 198 | [8 7] :vertical}) 199 | 200 | (s/def ::coordinate (s/tuple int? int?)) 201 | 202 | (defn conj-some [coll x] 203 | (cond-> coll 204 | x (conj x))) 205 | 206 | ; Like in Nacho Libre 207 | (def secret-tunnels {\0 \4, \4 \0, \2 \6, \6 \2}) 208 | 209 | (def all-coordinates (set (keys starting-board))) 210 | 211 | (def room-chars #{\0 \1 \2 \3 \4 \5 \6 \7 \8}) 212 | 213 | (defn adjacent-locations 214 | [source] 215 | (let [[x y] source, 216 | coordinates 217 | (set/intersection all-coordinates 218 | #{[(inc x) y] [(dec x) y] [x (inc y)] [x (dec y)]}), 219 | room (room-chars (starting-board source))] 220 | (conj-some coordinates room))) 221 | 222 | ; BFS 223 | (defn available-locations 224 | [source roll] 225 | (let [[roll-0 visited-0] 226 | (if (s/valid? ::coordinate source) 227 | [roll #{source}] 228 | [(dec roll) (conj-some (board-inverse source) 229 | (secret-tunnels source))])] 230 | (loop [roll roll-0 231 | visited visited-0 232 | current-batch visited-0] 233 | (if (= roll 0) 234 | visited 235 | (let [next-batch (as-> current-batch x 236 | (filter #(s/valid? ::coordinate %) x) 237 | (map adjacent-locations x) 238 | (apply set/union x) 239 | (set/difference x visited))] 240 | (recur (dec roll) 241 | (set/union visited next-batch) 242 | next-batch)))))) 243 | 244 | (def rooms-vec [:study :hall :lounge :dining-room :kitchen 245 | :ballroom :conservatory :billiard-room :library]) 246 | 247 | (def rooms-map 248 | (into {} (map vector (map #(char (+ 48 %)) (range 10)) rooms-vec))) 249 | 250 | (def rooms-map-invert (set/map-invert rooms-map)) 251 | 252 | (defn valid-move? [{:keys [positions roll dest player]}] 253 | (let [taken (filter #(s/valid? ::coordinate %) (vals positions)) 254 | available (available-locations (positions player) roll) 255 | available (apply disj available taken)] 256 | (contains? available dest))) 257 | 258 | (defn next-player [players losers current-player] 259 | (let [players (vec (remove (set losers) players))] 260 | (-> players 261 | (.indexOf current-player) 262 | inc 263 | (mod (count players)) 264 | players))) 265 | 266 | (defn next-players [players current-player] 267 | (concat (rest (drop-while (complement #{current-player}) players)) 268 | (take-while (complement #{current-player}) players))) 269 | 270 | (defn in-between [players p1 p2] 271 | (take-while #(not= % p2) (next-players players p1))) 272 | 273 | (defn split-by [f coll] 274 | (reduce 275 | #(update %1 (if (f %2) 0 1) conj %2) 276 | [nil nil] 277 | coll)) 278 | 279 | (defn positions->coordinates [positions] 280 | (reduce (fn [player->coordinates [player position]] 281 | (assoc player->coordinates 282 | player (cond-> position 283 | (not (s/valid? ::coordinate position)) 284 | (->> 285 | room-coordinates 286 | (remove (set (vals player->coordinates))) 287 | first)))) 288 | {} 289 | positions)) 290 | -------------------------------------------------------------------------------- /src/cows/fn.cljs: -------------------------------------------------------------------------------- 1 | (ns cows.fn 2 | (:require-macros 3 | [cljs.core.async.macros :refer [go]]) 4 | (:require 5 | ["firebase-functions" :as functions] 6 | ["firebase-admin" :as admin :refer [firestore]] 7 | [cljs.core.async :as async :refer [name (positions (keyword current-player))) 112 | suggestion [person weapon room] 113 | responder (->> (util/next-players players current-player) 114 | (map #(pull (firestore) [:cards [:games game-id %]])) 115 | (async/map vector) 116 | > players 145 | (map #(pull (firestore) [:cards [:games game-id %]])) 146 | (async/map vector) 147 | > players 159 | (remove (conj (set losers) uid)) 160 | first) 161 | :default nil) 162 | game-data (u/assoc-some 163 | {:state (if correct 164 | "game-over" 165 | "start-turn")} 166 | :current-player (when-not correct 167 | (util/next-player players losers uid)) 168 | :losers (when-not correct 169 | (.. firestore 170 | -FieldValue 171 | (arrayUnion uid))) 172 | :winner winner 173 | :solution (when winner solution))] 174 | (let [event-id (str (random-uuid)) 175 | event {:event "accuse" 176 | :timestamp (u/now) 177 | :player uid 178 | :correct correct 179 | :cards [person weapon room]} 180 | events (cond-> 181 | {[:events [:games game-id event-id]] event} 182 | (not correct) (assoc [:accusations [:games game-id event-id]] 183 | (assoc event :solution solution)))] 184 | (> [:messages :cards :events :responses] 216 | (map #(query (firestore) [% [:games game-id]])) 217 | (async/map vector) 218 | > handle 245 | lib/wrap-fn 246 | (.onCall functions/https))}) 247 | -------------------------------------------------------------------------------- /src/cows/components.cljs: -------------------------------------------------------------------------------- 1 | (ns cows.components 2 | (:require 3 | [clojure.string :as str] 4 | [clojure.spec.alpha :as s] 5 | [cows.util :as util] 6 | [trident.util :as u] 7 | [rum.core :as rum :refer [defc defcs static reactive react local]])) 8 | 9 | (defc text-button < static 10 | [props & contents] 11 | (into 12 | [:span.text-primary (assoc-in props [:style :cursor] "pointer")] 13 | contents)) 14 | 15 | (defc user-info < reactive 16 | [{:db/keys [uid email] 17 | :misc/keys [auth]}] 18 | [:div 19 | [:.d-flex.flex-md-row.flex-column 20 | [:div "Username: " (util/username (react uid))] 21 | [:.flex-grow-1] 22 | [:div 23 | (react email) " (" 24 | (text-button {:on-click #(.signOut ^js auth)} "sign out") ")"]] 25 | [:hr.mt-1]]) 26 | 27 | (defc game-card < static {:key-fn (comp second :ident)} 28 | [{:m/keys [join-game] :as env} {:keys [players] [_ game-id] :ident}] 29 | [:.p-3.border.border-dark.rounded.mr-3.mb-3 30 | [:div "Game ID: " (subs game-id 0 4)] 31 | [:ul.pl-4 32 | (for [p players] 33 | [:li {:key p} (util/username p)])] 34 | [:button.btn.btn-primary.btn-sm.btn-block 35 | {:disabled (>= (count players) 6) 36 | :on-click #(join-game env game-id)} 37 | "Join"]]) 38 | 39 | (defc game-list < reactive 40 | [{:keys [m/create-game db/games] :as env}] 41 | [:div 42 | [:button.btn.btn-primary {:on-click #(create-game env)} 43 | "Create game"] 44 | [:.mt-4] 45 | [:.d-flex.flex-wrap 46 | (->> (react games) 47 | vals 48 | (map (partial game-card env)))]]) 49 | 50 | (defc message < static {:key-fn #(.getTime (:timestamp %))} 51 | [{:keys [user timestamp text]}] 52 | [:div.mb-2 53 | [:strong (util/username user)] 54 | [:.text-muted.small (.toLocaleString timestamp)] 55 | (map #(if (= "\n" %) [:br] %) text)]) 56 | 57 | (def square-size 23) 58 | 59 | ; Of course, the hardest part about making a chat component is rendering it. >:-( 60 | (defcs chat < reactive 61 | {:did-update (fn [{component :rum/react-component :as s}] 62 | (let [node (js/ReactDOM.findDOMNode component) 63 | box (.querySelector node ".scroll-box")] 64 | (set! (.-scrollTop box) (.-scrollHeight box))) 65 | s)} 66 | (local "" ::text) 67 | 68 | [{::keys [text]} {:keys [db/messages m/send-message] :as env}] 69 | [:.border.rounded.border-primary.d-flex.flex-column.flex-grow-1.sidebar 70 | {:style {:overflow "hidden"}} 71 | [:.flex-grow-1] 72 | [:.pl-3.scroll-box.border-bottom {:style {:overflow-y "auto"}} 73 | (map message (react messages))] 74 | [:textarea.form-control.border-0.flex-shrink-0 75 | {:rows 1 76 | :placeholder "Enter message" 77 | :on-key-down #(when (and (= 13 (.-keyCode %)) 78 | (not (.-shiftKey %))) 79 | (send-message env @text) 80 | (reset! text "") 81 | (.preventDefault %)) 82 | :value @text 83 | :on-change #(reset! text (.. % -target -value))}]]) 84 | 85 | (defc event < reactive {:key-fn (fn [_ {[_ [_ _ event-id]] :ident}] 86 | event-id)} 87 | [{:db/keys [uid players]} 88 | {:keys [event timestamp player roll destination cards 89 | correct responder solution suggester card]}] 90 | (let [pronoun #(if (= % (react uid)) 91 | "You" 92 | (util/username %))] 93 | [:.mb-2.border-top.pt-2 94 | (case event 95 | "suggest" [:div 96 | (str (pronoun player) " made a suggestion: " (str/join ", " cards)) [:br] 97 | (if responder 98 | (for [p (util/in-between (react players) player responder)] 99 | [:span {:key p} 100 | (pronoun p) " couldn't respond." [:br]]) 101 | "No one could respond.")] 102 | "respond" (str (pronoun responder) " showed " (str/lower-case (pronoun suggester)) 103 | " a card" 104 | (if card 105 | (str ": " card) 106 | ".")) 107 | "accuse" [:div (pronoun player) " made an accusation: " (str/join ", " cards) [:br] 108 | (if correct "Correct!" "Wrong!") 109 | (when solution 110 | (str " Solution: " (str/join ", " solution)))])])) 111 | 112 | (defc events < reactive 113 | {:did-update (fn [{component :rum/react-component :as s}] 114 | (let [node (js/ReactDOM.findDOMNode component) 115 | box (.querySelector node ".scroll-box")] 116 | (set! (.-scrollTop box) (.-scrollHeight box))) 117 | s)} 118 | 119 | [{:keys [db/events] :as env}] 120 | [:.border.rounded.border-primary.d-flex.flex-column.sidebar 121 | {:style {:overflow "hidden"}} 122 | [:.flex-grow-1] 123 | [:.p-2.scroll-box.border-bottom {:style {:overflow-y "auto"}} 124 | (map #(event env %) (react events))]]) 125 | 126 | (defc game-lobby < reactive 127 | [{:keys [db/current-game m/leave-game m/start-game] :as env}] 128 | (let [{:keys [players state] 129 | [_ game-id] :ident} (react current-game)] 130 | [:div 131 | [:div "Game ID: " (subs game-id 0 4)] 132 | [:ul.pl-4 133 | (for [p players] 134 | [:li {:key p} (util/username p)])] 135 | [:div 136 | [:button.btn.btn-primary.mr-2 137 | {:disabled (< (count players) 3) 138 | :on-click #(start-game env)} 139 | "Start game"] 140 | [:button.btn.btn-secondary {:on-click #(leave-game env)} "Leave game"]]])) 141 | 142 | (defc select-card < reactive 143 | [model cards] 144 | [:select.form-control.mr-2 145 | {:value (react model) 146 | :on-change #(reset! model (.. % -target -value))} 147 | (for [x cards] 148 | [:option {:key x :value x} x])]) 149 | 150 | (defcs choose-cards < reactive (local nil ::person) (local nil ::weapon) (local nil ::room) 151 | [{::keys [person weapon room]} {:keys [include-room on-choose text on-cancel]}] 152 | (let [btn [:button.btn.btn-primary.flex-shrink-0 153 | {:on-click #(->> [(or @person (first util/names)) 154 | (or @weapon (first util/weapons)) 155 | (or @room (first util/rooms))] 156 | (remove nil?) 157 | on-choose)} 158 | text] 159 | selects [:.d-flex 160 | (select-card person util/names) 161 | (select-card weapon util/weapons) 162 | (when include-room 163 | (select-card room util/rooms)) 164 | (when-not on-cancel 165 | btn)]] 166 | (if on-cancel 167 | [:div 168 | selects 169 | [:.d-flex.mt-2 170 | btn 171 | [:button.btn.btn-secondary.ml-2 {:on-click on-cancel} "Cancel"]]] 172 | selects))) 173 | 174 | (defcs show-card < reactive (local nil ::choice) 175 | [{::keys [choice]} {:keys [db/suggestion db/cards m/respond] :as env}] 176 | (let [suggestion (filter (set (react cards)) (react suggestion)) 177 | choice-value (or @choice (first suggestion))] 178 | [:.d-flex 179 | (for [card suggestion 180 | :let [selected (= choice-value card)]] 181 | [:.form-check.form-check-inline.mr-3 182 | {:key card 183 | :style {:cursor "pointer"} 184 | :on-click #(reset! choice card)} 185 | [:input.form-check-input 186 | {:checked selected 187 | :style {:cursor "pointer"} 188 | :type "radio"}] 189 | [:label.form-check-label 190 | {:style {:cursor "pointer"}} 191 | card]]) 192 | [:button.btn.btn-primary.ml-2 193 | {:on-click #(respond env choice-value)} 194 | "Show card"]])) 195 | 196 | (defcs accuse < reactive (local false ::accusing) 197 | [{::keys [accusing]} {:keys [m/accuse m/end-turn] :as env}] 198 | (if @accusing 199 | (choose-cards {:text "Make accusation" 200 | :include-room true 201 | :on-cancel #(reset! accusing false) 202 | :on-choose #(accuse env %)}) 203 | [:.d-flex 204 | [:button.btn.btn-primary.mr-2 {:on-click #(end-turn env)} "End turn"] 205 | [:button.btn.btn-secondary {:on-click #(reset! accusing true)} "Make accusation"]])) 206 | 207 | (defc turn-controls < reactive 208 | [{:db/keys [your-turn state uid winner responder 209 | responding current-player roll-result] 210 | :m/keys [roll suggest] :as env}] 211 | (let [your-turn (react your-turn) 212 | username (util/username (react current-player))] 213 | (case (react state) 214 | :start-turn (if your-turn 215 | [:button.btn.btn-primary {:on-click #(roll env)} "Roll dice"] 216 | [:p "Waiting for " username " to roll the dice."]) 217 | 218 | :after-roll (if your-turn 219 | [:p "You rolled a " (react roll-result) ". Choose a destination."] 220 | [:p username " rolled " (react roll-result) "."]) 221 | 222 | :suggest (if your-turn 223 | (choose-cards {:text "Make suggestion" 224 | :on-choose #(suggest env %)}) 225 | [:p "Waiting for " username " to make a suggestion."]) 226 | 227 | :respond (if (react responding) 228 | (show-card env) 229 | [:p "Waiting for " (util/username (react responder)) " to show " 230 | (if your-turn "you" username) " a card."]) 231 | 232 | :accuse (if your-turn 233 | (accuse env) 234 | [:p "Waiting for " username " to end their turn or make an accusation."]) 235 | 236 | :game-over [:p "Game over. " 237 | (if (= (react uid) (react winner)) 238 | "You won!" 239 | (str (util/username (react winner)) " won."))]))) 240 | 241 | (defn board-element [row col width height z] 242 | {:position "absolute" 243 | :top (int (* square-size row)) 244 | :left (int (* square-size col)) 245 | :width (int (* square-size width)) 246 | :height (int (* square-size height)) 247 | :z-index z}) 248 | 249 | (defc board < reactive 250 | [{:db/keys [available-locations positions colors your-turn state] 251 | :m/keys [move] :as env}] 252 | (let [available-locations (react available-locations) 253 | moving (and (react your-turn) (= (react state) :after-roll))] 254 | [:.flex-shrink-0 {:style {:position "relative" 255 | :width (* square-size util/board-width) 256 | :height (* square-size 25)}} 257 | 258 | ; Rooms 259 | (for [[room [row col width height]] util/room-tiles 260 | :let [available (contains? available-locations room) 261 | can-move (and available moving)]] 262 | [:div {:key (name room) 263 | :class (when can-move "available-room") 264 | :style (merge (board-element row col width height 1) 265 | {:text-align "center"}) 266 | :on-click (when can-move 267 | #(move env (util/rooms-map-invert room)))} 268 | [:div {:style (cond-> {} 269 | (= :conservatory room) (assoc :margin-left "-20px") 270 | available (assoc :font-weight "bold"))} 271 | (util/card-names room)]]) 272 | 273 | (for [[row col :as loc] (keys util/empty-board) 274 | :let [available (contains? available-locations loc) 275 | can-move (and available moving) 276 | [bottom-border right-border] 277 | (for [i [0 1] 278 | :let [next-loc (update loc i inc) 279 | next-available (contains? available-locations next-loc)]] 280 | (str "1px solid " (if (or available next-available) "black" "#bc9971"))) 281 | [top-border left-border] 282 | (for [i [0 1] 283 | :let [prev-loc (update loc i dec)]] 284 | (when (and available (not (contains? util/empty-board prev-loc))) 285 | "1px solid black"))]] 286 | [:div {:key (pr-str loc) 287 | :class (when can-move "available-square") 288 | :style (merge (board-element row col 1 1 2) 289 | {:border-top top-border 290 | :border-left left-border 291 | :border-right right-border 292 | :border-bottom bottom-border 293 | :background-color "#eac8a3"}) 294 | :on-click (when can-move #(move env loc))}]) 295 | 296 | ; Doors 297 | (for [[[row col] orientation] util/door-directions] 298 | (let [horizontal (= orientation :horizontal) 299 | [width height] (cond-> [0.1 1] horizontal reverse) 300 | style (merge (board-element row col width height 3) 301 | {:background-color "red"}) 302 | style (update style (if horizontal :top :left) dec)] 303 | [:div {:key (pr-str [row col]) 304 | :style style}])) 305 | 306 | ; Players 307 | (for [[player [row col]] (util/positions->coordinates (react positions))] 308 | (let [style (merge (board-element row col 0.7 0.7 4) 309 | {:margin (int (* square-size 0.15)) 310 | :background-color ((react colors) player)})] 311 | [:div {:key player 312 | :style style}]))])) 313 | 314 | (defc static-info < reactive 315 | [{:db/keys [events uid losers players names current-player cards face-up-cards]}] 316 | [:div 317 | [:div "Your cards: " (str/join ", " (react cards))] 318 | (when (not-empty (react face-up-cards)) 319 | [:div.mt-2 "Face-up cards: " (str/join ", " (react face-up-cards))]) 320 | [:hr] 321 | [:div "Players: "] 322 | [:ul.mb-2 323 | (for [p (react players)] 324 | [:li {:key p 325 | :class [(when (= (react current-player) p) "font-weight-bold") 326 | (when (= (react uid) p) "font-italic")]} 327 | (cond->> 328 | (str (util/username p) " (" ((react names) p) ")") 329 | ((react losers) p) (vector :del))])]]) 330 | 331 | (defc game < reactive 332 | [{:keys [db/state m/quit] :as env}] 333 | (if (= :lobby (react state)) 334 | [:div 335 | (game-lobby env) 336 | [:.mt-3] 337 | (chat env)] 338 | [:.d-flex 339 | [:div 340 | (board env) 341 | [:.mb-3] 342 | [:.d-flex.justify-content-center 343 | (turn-controls env)]] 344 | [:.mr-4] 345 | [:.flex-grow-1 346 | [:.d-flex.flex-column.flex-lg-row 347 | {:style {:margin-bottom "16px" 348 | :height (* 25 square-size) 349 | :max-height (* 25 square-size)}} 350 | [:div {:style {:flex-basis 0 351 | :flex-grow 1 352 | :flex-shrink 0}} 353 | (events env)] 354 | [:div {:style {:width "16px" 355 | :height "16px"}}] 356 | [:div {:style {:flex-basis 0 357 | :flex-grow 1 358 | :flex-shrink 0}} 359 | (chat env)]] 360 | [:.border.border-primary.rounded.p-2 361 | (static-info env) 362 | [:.d-flex.justify-content-end 363 | [:button.btn.btn-secondary {:on-click #(quit env)} "Quit"]]]]])) 364 | 365 | (defc main < reactive 366 | [{:keys [db/game-id] :as env}] 367 | [:div 368 | [:.container-fluid.mt-2 369 | (user-info env) 370 | [:.mt-2] 371 | (if (react game-id) 372 | (game env) 373 | (game-list env)) 374 | [:.mt-3]]]) 375 | --------------------------------------------------------------------------------