Browse Source

Initial port to cyclejs

Thomas Dy 8 years ago
parent
commit
519094bda1

+ 1 - 0
.gitignore

@@ -4,3 +4,4 @@ project/target
 target
 tmp
 public/components
+node_modules

+ 12 - 0
app/Global.scala

@@ -0,0 +1,12 @@
+import play.api._
+
+import support.Webpack
+
+object Global extends GlobalSettings {
+  override def onStart(app: play.api.Application){
+    Webpack.startHotReloadServer()
+  }
+  override def onStop(app: play.api.Application){
+    Webpack.stopHotReloadServer()
+  }
+}

+ 32 - 0
app/support/Webpack.scala

@@ -0,0 +1,32 @@
+package support
+
+import scala.sys.process._
+
+import play.api.templates.Html
+
+object Webpack {
+  val BIN = "./node_modules/.bin/webpack-dev-server"
+  val PORT = "9001"
+  val OPTS = Seq(
+    "--inline",
+    "--output-public-path", s"http://localhost:$PORT/assets/",
+    "--port", PORT,
+    "--hot"
+  )
+
+  val hotReloadScriptUrl = s"http://localhost:$PORT/assets/bundle.js"
+  val hotReloadScript = Html(s"<script type='text/javascript' src='$hotReloadScriptUrl'></script>")
+
+  var process: Option[Process] = None
+
+  def startHotReloadServer() {
+    if(process.isEmpty) {
+      val logger = ProcessLogger(println, println)
+      process = Some(Process(BIN, OPTS).run(logger))
+    }
+  }
+
+  def stopHotReloadServer() {
+    process.map(_.destroy())
+  }
+}

+ 4 - 30
app/views/index.scala.html

@@ -1,37 +1,15 @@
 @()
 <!DOCTYPE html>
 
-<html ng-app="taboo">
+<html>
 <head>
   <title>Game n' Chat</title>
   <meta name="viewport" content="width=device-width, initial-scale=1.0">
-  <link rel="stylesheet" media="screen" href="@routes.Assets.at("components/bootstrap/dist/css/bootstrap.min.css")">
+  <link rel="stylesheet" media="screen" href="https://maxcdn.bootstrapcdn.com/bootstrap/3.0.3/css/bootstrap.min.css">
   <link rel="stylesheet" media="screen" href="@routes.Assets.at("stylesheets/main.css")">
   <link rel="shortcut icon" type="image/png" href="@routes.Assets.at("images/favicon.png")">
 </head>
-<body ng-controller="ViewCtrl">
-
-<nav class="navbar navbar-inverse navbar-static-top" role="navigation">
-<div class="container">
-  <div class="navbar-header">
-    <span class="brand navbar-brand">Game n' Chat</span>
-  </div>
-  <ul class="nav navbar-nav collapse navbar-collapse ng-cloak">
-    <li ng-repeat="li in nav" class="{{li.partial == view ? 'active' : ''}}">
-    <a href ng-click="setView(li.partial)">{{li.name}}</a>
-    </li>
-  </ul>
-  <div class="navbar-right collapse navbar-collapse ng-cloak" ng-controller="LoginCtrl">
-    <p class="navbar-text" ng-show="!service.isConnected() && view != 'chatRoom'">
-      <a href class="navbar-link" ng-click="setView('chatRoom')">Play!</a>
-    </p>
-    <p class="navbar-text" ng-show="service.isConnected()">
-      Logged in as {{service.username}} —
-      <a href class="navbar-link" ng-click="service.disconnect()">Disconnect</a>
-    </p>
-  </div>
-</div>
-</nav>
+<body>
 
 <div class="container">
   <div class="content" ng-include="partial(view)"> </div>
@@ -43,11 +21,7 @@
 </div>
 
 <script src="@routes.Application.javascriptRoutes" type="text/javascript"></script>
-<script src="@routes.Assets.at("components/jquery/jquery.min.js")" type="text/javascript"></script>
-<script src="@routes.Assets.at("components/angular/angular.min.js")" type="text/javascript"></script>
-<script src="@routes.Assets.at("javascripts/chatServices.js")" type="text/javascript"></script>
-<script src="@routes.Assets.at("javascripts/tabooServices.js")" type="text/javascript"></script>
-<script src="@routes.Assets.at("javascripts/main.js")" type="text/javascript"></script>
+@support.Webpack.hotReloadScript
 
 </body>
 </html>

+ 114 - 0
assets/index.js

@@ -0,0 +1,114 @@
+var Cycle = require('cyclejs');
+var Rx = Cycle.Rx;
+var h = Cycle.h;
+var about = require('./templates/about');
+
+var nav = [
+  {partial: 'about', name: 'About'},
+  {partial: 'chatRoom', name: 'Chat'},
+  {partial: 'contribute', name: 'Contribute'}
+];
+
+function intent(drivers) {
+  var DOM = drivers.DOM;
+  return {
+    disconnect$: DOM.get('#disconnect', 'click'),
+    username$: DOM.get('#username', 'input').map(function(ev) {
+      return ev.target.value;
+    }),
+    login$: DOM.get('#login-form', 'submit').map(function(ev) {
+      ev.preventDefault();
+      return true;
+    })
+  };
+}
+
+function model(actions) {
+  return {
+    username$: actions.login$
+      .withLatestFrom(actions.username$, function(submit, username) {
+        return username;
+      })
+      .merge(actions.disconnect$.map(function() { return null; }))
+      .startWith(null)
+    ,
+    route$: Rx.Observable.fromEvent(window, 'hashchange')
+      .map(function(hashEvent) { return hashEvent.target.location.hash.replace('#', '') })
+      .startWith(window.location.hash.replace('#', '') || 'about')
+  }
+}
+
+function renderLogin(username) {
+  var content;
+  if(!username) {
+    content = [ h('a.navbar-link', {href: '#chatRoom'}, ['Play!']) ];
+  }
+  if(username) {
+    content = [
+      "Logged in as "+username+" — ",
+      h('a.navbar-link#disconnect', ['Disconnect'])
+    ];
+  }
+  return h('div', {className: 'navbar-right collapse navbar-collapse'}, [
+    h('p.navbar-text', content)
+  ]);
+}
+
+function renderNav(route, username) {
+  return (
+    h('nav', {className: 'navbar navbar-inverse navbar-static-top', role: 'navigation'}, [
+      h('div.container', [
+        h('div.navbar-header', [
+          h('span.brand.navbar-brand', ["Game 'n Chat"])
+        ]),
+        h('ul', {className: 'nav navbar-nav collapse navbar-collapse'}, nav.map(function(item) {
+          var className = '';
+          if(item.partial == route) {
+            className = 'active';
+          }
+          return h('li', {className: className}, [
+            h('a', {href: '#'+item.partial}, [ item.name ])
+          ]);
+        })),
+        renderLogin(username)
+      ]),
+    ])
+  )
+}
+
+function renderFooter() {
+  return h('footer', [
+    h('p', [
+      h('a', {href: 'http://twitter.com/pleasantprog', target: '_blank'}, ['@pleasantprog'])
+    ])
+  ]);
+}
+
+function renderContainer(route) {
+  var content = require('./templates/'+route)();
+  return h('div.container', [
+    h('div.content', [content]),
+    renderFooter()
+  ]);
+}
+
+function view(model) {
+  return Rx.Observable.combineLatest(
+    model.route$,
+    model.username$,
+    function(route, username) {
+      return h('div', [
+        renderNav(route, username),
+        renderContainer(route)
+      ]);
+    }
+  );
+}
+
+function main(drivers) {
+  return { DOM: view(model(intent(drivers))) };
+}
+
+Cycle.run(main, {
+  DOM: Cycle.makeDOMDriver('body')
+});

+ 90 - 0
assets/templates/about.js

@@ -0,0 +1,90 @@
+var h = require('cyclejs').h;
+
+module.exports = function() {
+
+return (
+h("div", [
+  h("div.jumbotron", [
+    h("h1", [ "Game n' Chat" ]),
+    h("p", [
+      "Play games in a chatroom like it's the 2000s! Bringing IRC gaming to ",
+      "Web 2.0!"
+    ])
+  ]),
+  h("div.row", [
+    h("div.col-md-8", [
+      h("h2", [ "Taboo" ]),
+      h("p", [
+        "Taboo is a party game for around 4 to 10 people split into 2 teams. Each ",
+        "round, a player from a team becomes the ", h("em", [ "giver" ]), " and the rest of the ",
+        "team become ", h("em", [ "guessers" ]), ". The opposing team act as ", h("em", [ "monitors" ]), "."
+      ]),
+      h("p", [
+        "The ", h("em", [ "giver" ]), " is given a card containing a word and 5 taboo words. The ",
+        "giver must somehow tell the ", h("em", [ "guessers" ]), " about the word without mentioning ",
+        "the word itself or any of the 5 taboo words. The ", h("em", [ "monitors" ]), " act as ",
+        "judges to see if any of the words said are not allowed."
+      ]),
+      h("p", [
+        "If a guesser gets the word right, the team earns a point. If the giver says ",
+        "any taboo word, the team loses a point. The giver may also choose to pass ",
+        "and the team also loses a point. The giver is then given another card and ",
+        "this continues until the round time runs out."
+      ]),
+      h("h3", [ "Additional notes" ]),
+      h("p", [
+        "When there are enough players, the giver is announced and he can start the ",
+        "game by pressing the Start button. Normally, taboo rounds are 1 minute long, ",
+        "but we extend it to 2 minutes because of the time it takes to type things."
+      ]),
+      h("p", [
+        "The system can rudimentarily act as a monitor itself. If the giver types out ",
+        "any of the taboo words verbatim, the system immediately calls taboo on those. ",
+        "It can also check if the word was guessed correctly assuming it was spelled ",
+        "correctly. For all other cases, we will rely on the monitors and the giver ",
+        "to act in good faith."
+      ]),
+      h("p", [
+        "To facilitate faster playing, there are some command you can just type in:"
+      ]),
+      h("ul", [
+        h("li", [ h("code", [ "/s" ]), " - Start the round" ]),
+        h("li", [ h("code", [ "/p" ]), " - Pass" ]),
+        h("li", [ h("code", [ "/c" ]), " - Correct (someone got the word)" ]),
+        h("li", [ h("code", [ "/t" ]), " - Taboo" ])
+      ])
+    ]),
+    h("div#main.col-md-4.sidebar", [
+      h("h2", [ "What is this?" ]),
+      h("p", [
+        "Hi! This is just a side project I made where you can play Taboo online. ",
+        "I liked playing it with my friends during our Christmas party and I wanted ",
+        "to play a bit more."
+      ]),
+      h("p", [
+        "Feature-wise, it's a bit sparse. There's no score tracking beyond a single ",
+        "round, but it should at least have the core game mechanics ok. UI/UX could ",
+        "also use a lot of work. I'm also leaving the prospect of adding more games ",
+        "open. Like maybe Pinoy Henyo or whatever."
+      ]),
+      h("p", [
+        "Also, while the website is responsive now, you still can't play on mobile ",
+        "because I don't know how to layout the game such that it works."
+      ]),
+      h("p", [
+        "There aren't that many words yet, and I'd greatly appreciate contributing ",
+        "some for the game. There's also an API for accessing the word list in case ",
+        "you want to build your own Taboo-like thing. ", h("code", [ "GET /cards" ]), " should ",
+        "give you the entire card list, while ", h("code", [ "GET /cards/random" ]), " will ",
+        "give you a random card each time."
+      ]),
+      h("p", [
+        "Obligatory note, I do not own the rights to the Taboo board game. The card ",
+        "data was made by me and any contributors. I did not use any of the Taboo cards ",
+        "as a source for them."
+      ])
+    ])
+  ])
+])
+);
+}

+ 152 - 0
assets/templates/chatRoom.js

@@ -0,0 +1,152 @@
+var h = require('cyclejs').h;
+
+function renderLogin() {
+  var disconnected = true;
+  var error = null;
+  var hasRoom = false;
+  return (
+    h("div.row", [
+      error && h("div.alert.alert-danger", [
+        h("strong", [ "Oops!" ]), error + "."
+      ]),
+      h("div#login", [
+        disconnected && h("form.form-inline#login-form", [
+          h("input#username.form-control", {
+              "name": "username",
+              "type": "text",
+              "ng-model": "username",
+              "placeholder": "Username"
+          }),
+          h("br"),
+          h("br"),
+          h("button.btn", {
+              "type": "submit",
+              "ng-click": "service.connect(username); username=''"
+          }, [ hasRoom ? 'Join Room' : 'Create New Room' ])
+        ])
+      ])
+    ])
+  )
+}
+
+function renderChat(messages, me) {
+  return (
+    h("div#main.col-md-9", [
+      h("div#messages", [
+        h("table", messages.map(function(message) {
+          var classes = '.message';
+          classes += '.'+message.kind;
+          if(message.user == me) {
+            classes += '.me';
+          }
+          return h("tr"+classes, [
+            h("td.user", [ message.user ]),
+            h("td", [ message.message ])
+          ]);
+        })),
+        h("input#talk.form-control", {
+            "type": "text",
+            "ng-model": "text",
+            "ng-keypress": "onType($event)"
+        })
+      ])
+    ])
+  )
+}
+
+function renderSidebar() {
+  return (
+    h("div.col-md-3", {
+        "ng-controller": "GameCtrl"
+    }, [
+      h("button.btn.btn-primary", {
+          "ng-show": "game.pendingRound",
+          "ng-click": "game.startRound()"
+      }, [ "Start" ]),
+      h("div", {
+          "ng-if": "game.roundRunning()"
+      }, [
+        h("h2", [ h("ng-pluralize", {
+            "count": "game.timer.count",
+            "when": "{'0': 'Time\\'s up!', 'one': '1 second', 'other': '{} seconds'}"
+        }) ]),
+        h("h2", [ h("ng-pluralize", {
+            "count": "game.points",
+            "when": "{'one': '1 point', 'other': '{} points'}"
+        }) ]),
+        h("hr")
+      ]),
+      h("div", {
+          "ng-if": "game.roundRunning()"
+      }, [
+        h("h3", {
+            "ng-show": "game.isPlayer()"
+        }, [ "you are the giver" ]),
+        h("h3", {
+            "ng-show": "game.isMonitor()"
+        }, [ "you are a monitor" ]),
+        h("h3", {
+            "ng-show": "game.isGuesser()"
+        }, [ "you are a guesser" ]),
+        h("button.btn.btn-warning", {
+            "ng-show": "game.isPlayer()",
+            "ng-click": "game.pass()"
+        }, [ "Pass" ]),
+        h("button.btn.btn-danger", {
+            "ng-show": "game.isMonitor()",
+            "ng-click": "game.taboo()"
+        }, [ "Uh-uh!" ]),
+        h("button.btn.btn-success", {
+            "ng-show": "game.isMonitor() || game.isPlayer()",
+            "ng-click": "game.correct()"
+        }, [ "Correct" ])
+      ]),
+      h("div", {
+          "ng-show": "game.card"
+      }, [
+        h("h2", [ "Card" ]),
+        h("h3", [ "{{game.card.word}}" ]),
+        h("ul.taboo", [
+          h("li", {
+              "ng-repeat": "word in game.card.taboo"
+          }, [ "{{word}}" ])
+        ])
+      ]),
+      h("h2", [ "Team A" ]),
+      h("ul.members", [
+        h("li", {
+            "ng-repeat": "member in game.teamA.members"
+        }, [ "{{member}}" ])
+      ]),
+      h("h2", [ "Team B" ]),
+      h("ul.members", [
+        h("li", {
+            "ng-repeat": "member in game.teamB.members"
+        }, [ "{{member}}" ])
+      ])
+    ])
+  )
+}
+
+function renderMain() {
+  return h("div.row", [
+    renderChat(),
+    renderSideBar()
+  ])
+}
+
+module.exports = function() {
+  var isConnected = false;
+  return (
+    h("div", [
+      h("div.page-header", [
+        h("h1", isConnected ?
+          [ "Welcome ", h("small", [ "You are playing as {{service.username}}" ]) ]
+          :
+          [ "Welcome ", h("small", [ "login to play" ]) ]
+         )
+      ]),
+      isConnected ? renderMain() : renderLogin()
+    ])
+  );
+}

+ 96 - 0
assets/templates/contribute.js

@@ -0,0 +1,96 @@
+var h = require('cyclejs').h;
+
+module.exports = function() {
+  var submitting = false;
+  var exists = false;
+  var thanks = false;
+  return (
+h("div", [
+  h("div.page-header", [
+    h("h1", [ "Contribute" ])
+  ]),
+  h("div.row", [
+    h("div#main.col-md-8", [
+      h("h3", [ "Add a card" ]),
+      h("p", [
+        "Help make the game! Contribute words and make the game better. Also, please ",
+        "original work only. Don't just blindly copy a card from any of the Taboo games."
+      ]),
+      h("form#cardForm", {
+          "role": "form",
+          "ng-submit": "submit()"
+      }, [
+        h("div.form-group", [
+          h("label", {
+              "for": "inputWord"
+          }, [ "Word" ]),
+          h("input#inputWord.form-control", {
+              "type": "text",
+              "ng-model": "card.word",
+              "placeholder": "Word",
+              "ng-change": "check()",
+              "required": ""
+          }),
+          exists ? h("span", [ "We already have this word" ]) : null
+        ]),
+        h("div.form-group", [
+          h("label", [ "Taboo Words" ]),
+          h("input.form-control", {
+              "type": "text",
+              "ng-model": "card.taboos[0]",
+              "placeholder": "Taboo Word",
+              "required": ""
+          }),
+          h("input.form-control", {
+              "type": "text",
+              "ng-model": "card.taboos[1]",
+              "placeholder": "Taboo Word",
+              "required": ""
+          }),
+          h("input.form-control", {
+              "type": "text",
+              "ng-model": "card.taboos[2]",
+              "placeholder": "Taboo Word",
+              "required": ""
+          }),
+          h("input.form-control", {
+              "type": "text",
+              "ng-model": "card.taboos[3]",
+              "placeholder": "Taboo Word",
+              "required": ""
+          }),
+          h("input.form-control", {
+              "type": "text",
+              "ng-model": "card.taboos[4]",
+              "placeholder": "Taboo Word",
+              "required": ""
+          })
+        ]),
+        h("input.btn.btn-primary", {
+            "disabled": submitting,
+            "type": "submit",
+            "value": submitting ? 'Submitting...' : 'Submit'
+        }),
+        thanks ? h("span", [ "Thank you!" ]) : null
+      ])
+    ]),
+    h("div.col-md-4.sidebar", [
+      h("h3", [ "Help code" ]),
+      h("p", [
+        "Want to help code instead? Fork the ", h("a", {
+            "href": "https://github.com/thatsmydoing/gamenchat"
+        }, [ "repo" ]), " or submit ",
+        "an ", h("a", {
+            "href": "https://github.com/thatsmydoing/gamenchat/issues"
+        }, [ "issue" ]), "."
+      ]),
+      h("h3", [ "Help design" ]),
+      h("p", [
+        "Yes, it's bootstrap. Not even custom colors. If you like it, maybe you can make it look nicer."
+      ])
+    ])
+  ])
+])
+  )
+}
+

+ 4 - 0
build.sbt

@@ -8,4 +8,8 @@ libraryDependencies ++= Seq(jdbc, anorm)
 
 libraryDependencies += "postgresql" % "postgresql" % "9.1-901.jdbc4"
 
+libraryDependencies += "org.webjars" % "bootstrap" % "2.3.2"
+
+libraryDependencies += "org.webjars" %% "webjars-play" % "2.2.2-1"
+
 playScalaSettings

+ 1 - 0
conf/routes

@@ -15,3 +15,4 @@ GET     /cards/exists                    controllers.Cards.exists(word: String)
 
 # Map static resources from the /public folder to the /assets URL path
 GET     /assets/*file                    controllers.Assets.at(path="/public", file)
+GET     /webjars/*file                   controllers.WebJarAssets.at(file)

+ 18 - 0
package.json

@@ -0,0 +1,18 @@
+{
+  "name": "gamenchat",
+  "version": "0.0.1",
+  "description": "Game 'n Chat",
+  "scripts": {
+    "webpack": "webpack",
+    "webpack-production": "webpack --config webpack-production.config.js"
+  },
+  "author": "Thomas Dy <thatsmydoing@gmail.com>",
+  "license": "MIT",
+  "dependencies": {
+    "cyclejs": "^0.24.1",
+    "node-libs-browser": "^0.5.2",
+    "webpack": "^1.10.1",
+    "webpack-dev-server": "^1.10.1"
+  },
+  "devDependencies": {}
+}

+ 26 - 0
webpack-base.config.js

@@ -0,0 +1,26 @@
+var webpack = require('webpack');
+
+module.exports = function(production) {
+    var plugins = [];
+    if(production) {
+        plugins.push(
+            new webpack.optimize.UglifyJsPlugin(),
+            new webpack.DefinePlugin({
+                "process.env": {
+                    NODE_ENV: JSON.stringify("production")
+                }
+            })
+        );
+    }
+    return {
+        entry: './assets/index',
+        output: {
+            path: 'public',
+            publicPath: '/assets/',
+            filename: 'bundle.js'
+        },
+        devtool: 'eval',
+        plugins: plugins
+    }
+};
+

+ 30 - 0
webpack-production.config.js

@@ -0,0 +1,30 @@
+var webpack = require('webpack');
+
+module.exports = [
+    require('./webpack-base.config.js')(true),
+    {
+        entry: './assets/server',
+        output: {
+            path: 'public',
+            publicPath: '/assets/',
+            filename: 'server.js'
+        },
+        resolve: {
+            extensions: ['', '.js', '.jsx']
+        },
+        plugins: [
+            new webpack.IgnorePlugin(/reqwest/),
+            new webpack.optimize.UglifyJsPlugin(),
+            new webpack.DefinePlugin({
+                "process.env": {
+                    NODE_ENV: JSON.stringify("production")
+                }
+            })
+        ],
+        module: {
+            loaders: [
+                { test: /\.jsx$/, loader: 'jsx' }
+            ]
+        }
+    }
+]

+ 1 - 0
webpack.config.js

@@ -0,0 +1 @@
+module.exports = require('./webpack-base.config.js')(false);