]> git.cworth.org Git - lmno.games/commitdiff
Merge remote-tracking branch 'origin/master' into flempires/requests-generalized
authorKevin Worth <kworth082@gmail.com>
Mon, 6 Jul 2020 12:54:03 +0000 (08:54 -0400)
committerKevin Worth <kworth082@gmail.com>
Mon, 6 Jul 2020 12:54:03 +0000 (08:54 -0400)
20 files changed:
.babelrc
.gitignore
Makefile
README
empathy/.gitignore [new file with mode: 0644]
empathy/Makefile [new file with mode: 0644]
empathy/empathy.css [new file with mode: 0644]
empathy/empathy.jsx [new file with mode: 0644]
empathy/index.html [new file with mode: 0644]
empires/game.css
empires/game.js
index.html
scribe/.gitignore [new file with mode: 0644]
scribe/Makefile [new file with mode: 0644]
scribe/index.html [new file with mode: 0644]
scribe/scribe.css [new file with mode: 0644]
scribe/scribe.jsx [new file with mode: 0644]
style.css
tictactoe/tictactoe.css
tictactoe/tictactoe.jsx

index b182744c4ffad8c8cfa3edf7b07f5fea86769352..48b7809b36d01a97e71148583c523b50145e7ead 100644 (file)
--- a/.babelrc
+++ b/.babelrc
@@ -2,11 +2,15 @@
   "presets": ["react"],
   "env": {
     "production": {
+      "plugins": [
+        "transform-object-rest-spread"
+      ]
     },
     "development": {
       "plugins": [
         "transform-react-jsx-self",
-        "transform-react-jsx-source"
+        "transform-react-jsx-source",
+        "transform-object-rest-spread"
       ]
     }
   }
index 10a74dafaa2001429dcbb9990706787302cf7df5..ee4a0ef783f8c405baf41dd3857e84094bc49f1d 100644 (file)
@@ -1,3 +1,6 @@
+TODO
+.nogit
+.gitattributes
 deps/*.js
 .deploy-source
 react.js
index 265297388947d7976315e044a4cb8df13f68915f..4132e9652cea38de0140aa220721beea3ad5a035 100644 (file)
--- a/Makefile
+++ b/Makefile
@@ -41,7 +41,6 @@ react-dom.js: deps/react-dom.production.min.js
 export BABEL_ENV=production
 %.js: %.jsx
        $(call quiet,BABEL) $^ --out-file $@
-       @chmod a-w $@
 else
 react.js: deps/react.development.js
        $(call quiet,CP) $^ $@
@@ -52,7 +51,6 @@ react-dom.js: deps/react-dom.development.js
 export BABEL_ENV=development
 %.js: %.jsx
        $(call quiet,BABEL) $^ --out-file $@
-       @chmod a-w $@
 endif
 
 # The user has not set any verbosity, default to quiet mode and inform the
@@ -77,9 +75,13 @@ deps: $(REACT_DOWNLOADS)
        $(call quiet,SHA512) deps/*.sha512
 
 DOWNLOAD=wget $(WGET_VERBOSE_FLAGS) -nc -P deps
-deps/%.js:
-       $(call quiet,DOWNLOAD) https://unpkg.com/react@16/umd/$@
-       $(call quiet,SHA512) $(patsubst %,%.sha512,$@)
+deps/react.%.js:
+       $(call quiet,DOWNLOAD) https://unpkg.com/react@16/umd/$(@:deps/%=%)
+       $(call quiet,SHA512) $(@:%=%.sha512)
+
+deps/react-dom.%.js:
+       $(call quiet,DOWNLOAD) https://unpkg.com/react-dom@16/umd/$(@:deps/%=%)
+       $(call quiet,SHA512) $(@:%=%.sha512)
 
 deploy:
        rm -rf .deploy-source
diff --git a/README b/README
index 1afb559523710c3909064c81fed7ae140cc92007..08815537541cfed6e2987f3ecef5aef5d71c6665 100644 (file)
--- a/README
+++ b/README
@@ -1,4 +1,4 @@
-Static resources for the htttps://lmno.games/ website
+Static resources for the https://lmno.games/ website
 
 This repository contains the static HTML, JavaScript, and CSS files
 used by the lmno.games website, including support for downloading and
@@ -16,7 +16,10 @@ code:
 Dependencies
 ------------
 Compiling the source requires the babel (version 6) CLI to be
-available as "babeljs" along with the "react" preset. This can be
-achieved on Debian with:
+available as "babeljs" along with the "react" preset and support for
+"object spread" syntax. This can be achieved on Debian with:
 
-       sudo apt install node-babel-cli node-babel-preset-react
+       sudo apt install \
+                 node-babel-cli \
+                 node-babel-preset-react \
+                 node-babel-plugin-transform-object-rest-spread
diff --git a/empathy/.gitignore b/empathy/.gitignore
new file mode 100644 (file)
index 0000000..053da87
--- /dev/null
@@ -0,0 +1 @@
+empathy.js
diff --git a/empathy/Makefile b/empathy/Makefile
new file mode 100644 (file)
index 0000000..9f07401
--- /dev/null
@@ -0,0 +1,12 @@
+# Defer all targets up to the upper-level
+#
+# This requires two recipes. The first to cover the case of no
+# explicit target specifed (so when invoked as "make" we call "make"
+# at the upper-level) and then a .DEFAULT recipe to pass any explicit
+# target up as well, (so that an invocation of "make foo" results in a
+# call to "make foo" above.
+all:
+       $(MAKE) -C ..
+
+.DEFAULT:
+       $(MAKE) -C .. $@
diff --git a/empathy/empathy.css b/empathy/empathy.css
new file mode 100644 (file)
index 0000000..a32f2de
--- /dev/null
@@ -0,0 +1,142 @@
+.vote-button {
+    width: 100%;
+    background-color: var(--accent-color);
+    color: var(--text-fg-on-accent);
+    text-align: left;
+    border-radius: 25px;
+    font-size: 200%;
+    padding: 1em;
+    margin-bottom: 0.25em;
+    position: relative;
+}
+
+.vote-choices {
+    display: flex;
+    flex-wrap: wrap;
+}
+
+.vote-choice {
+    font-size: 40%;
+    background-color: var(--accent-color-bright);
+    color: var(--text-fg-on-accent-bright);
+    border-radius: 4px;
+    padding-left: 4px;
+    padding-right: 4px;
+    margin-right: 0.5em;
+}
+
+@media (hover:hover) {
+    button:hover .vote-choice {
+        background-color: var(--accent-color);
+        color: var(--text-fg-on-accent);
+    }
+}
+
+.ambiguity-group {
+    width: 100%;
+    display: flex;
+    flex-wrap: wrap;
+    justify-content: center;
+    align-items: center;
+    background-color: var(--accent-color);
+    margin-bottom: 0.25em;
+    padding: 0;
+    border-radius: 10px;
+    position: relative;
+}
+
+.ambiguity-button {
+    border-radius: 10px;
+    margin: 0;
+    overflow-wrap: break-word;
+    overflow-y: hidden;
+}
+
+.ambiguity-button.selected {
+    background-color: var(--accent-color-bright);
+    color: var(--text-fg-on-accent-bright);
+}
+
+@keyframes bounce {
+    0% {
+        transform: translateY(0);
+        animation-timing-function: cubic-bezier(0.333, 0.667, 0.667, 1);
+    }
+    20% {
+        transform: translateY(-5px);
+        animation-timing-function: cubic-bezier(0.333, 0, 0,667, 0.333);
+    }
+    40% {
+        transform: translateY(0);
+    }
+    100% {
+        transform: translateY(0);
+    }
+}
+
+.typing span {
+    font-size: 150%;
+    line-height: 0;
+
+    display: inline-block;
+    animation-name: bounce;
+    animation-duration: 2s;
+    animation-iteration-count: infinite;
+    animation-timing-function: linear;
+}
+
+.typing span:nth-child(2) {
+    animation-delay: .2s;
+}
+
+.typing span:nth-child(3) {
+    animation-delay: .4s;
+}
+
+.typing.active span {
+    opacity: 1.0;
+}
+
+.typing.idle span {
+    opacity: 0.0;
+    transition-property: opacity;
+    transition-duration: 2s;
+    transition-delay: 1s;
+}
+
+.achievement {
+    border-radius: 4px;
+    background-color: var(--accent-color-bright);
+    color: var(--text-fg-on-accent-bright);
+    font-size: 72%;
+    font-weight: bold;
+    padding: 0.25em;
+    text-transform: uppercase;
+}
+
+.star-button {
+    color: white;
+    opacity: 0.5;
+    font-size: 125%;
+    font-weight: bold;
+    cursor: pointer;
+    position: absolute;
+    right: 0.5em;
+    top: 0;
+}
+
+@media (hover:hover) {
+    .star-button:hover {
+        color: var(--accent-color-bright);
+        opacity: 1.0;
+    }
+}
+
+.star-button.selected {
+    color: var(--accent-color-bright);
+    opacity: 1.0;
+}
+
+.player-idle {
+    opacity: 0.5;
+}
diff --git a/empathy/empathy.jsx b/empathy/empathy.jsx
new file mode 100644 (file)
index 0000000..90bf0fd
--- /dev/null
@@ -0,0 +1,1401 @@
+const MAX_PROMPT_ITEMS = 20;
+
+function undisplay(element) {
+  element.style.display="none";
+}
+
+function add_message(severity, message) {
+  message = `<div class="message ${severity}" onclick="undisplay(this)">
+<span class="hide-button" onclick="undisplay(this.parentElement)">&times;</span>
+${message}
+</div>`;
+  const message_area = document.getElementById('message-area');
+  message_area.insertAdjacentHTML('beforeend', message);
+}
+
+/*********************************************************
+ * Handling server-sent event stream                     *
+ *********************************************************/
+
+const events = new EventSource("events");
+
+events.onerror = function(event) {
+  if (event.target.readyState === EventSource.CLOSED) {
+    setTimeout(() => {
+      add_message("danger", "Connection to server lost.");
+    }, 1000);
+  }
+};
+
+events.addEventListener("game-info", event => {
+  const info = JSON.parse(event.data);
+
+  window.game.set_game_info(info);
+});
+
+events.addEventListener("player-info", event => {
+  const info = JSON.parse(event.data);
+
+  window.game.set_player_info(info);
+});
+
+events.addEventListener("player-enter", event => {
+  const info = JSON.parse(event.data);
+
+  window.game.set_other_player_info(info);
+});
+
+events.addEventListener("player-exit", event => {
+  const info = JSON.parse(event.data);
+
+  window.game.disable_player(info);
+});
+
+events.addEventListener("player-update", event => {
+  const info = JSON.parse(event.data);
+
+  if (info.id === window.game.state.player_info.id)
+    window.game.set_player_info(info);
+  else
+    window.game.set_other_player_info(info);
+});
+
+events.addEventListener("game-state", event => {
+  const state = JSON.parse(event.data);
+
+  window.game.reset_game_state();
+
+  window.game.set_prompts(state.prompts);
+
+  window.game.set_active_prompt(state.active_prompt);
+
+  window.game.set_players_answered(state.players_answered);
+
+  window.game.set_players_answering(state.players_answering);
+
+  window.game.set_answering_idle(state.answering_idle);
+
+  window.game.set_end_answers(state.end_answers);
+
+  window.game.set_ambiguities(state.ambiguities);
+
+  window.game.set_players_judged(state.players_judged);
+
+  window.game.set_players_judging(state.players_judging);
+
+  window.game.set_judging_idle(state.judging_idle);
+
+  window.game.set_end_judging(state.end_judging);
+
+  window.game.set_scores(state.scores);
+
+  window.game.set_new_game_votes(state.new_game_votes);
+
+  window.game.state_ready();
+});
+
+events.addEventListener("prompt", event => {
+  const prompt = JSON.parse(event.data);
+
+  window.game.add_or_update_prompt(prompt);
+});
+
+events.addEventListener("start", event => {
+  const prompt = JSON.parse(event.data);
+
+  window.game.set_active_prompt(prompt);
+});
+
+events.addEventListener("player-answered", event => {
+  const player = JSON.parse(event.data);
+
+  window.game.set_player_answered(player);
+});
+
+events.addEventListener("player-answering", event => {
+  const player = JSON.parse(event.data);
+
+  window.game.set_player_answering(player);
+});
+
+events.addEventListener("answering-idle", event => {
+  const value = JSON.parse(event.data);
+
+  window.game.set_answering_idle(value);
+});
+
+events.addEventListener("vote-end-answers", event => {
+  const player = JSON.parse(event.data);
+
+  window.game.set_player_vote_end_answers(player);
+});
+
+events.addEventListener("unvote-end-answers", event => {
+  const player = JSON.parse(event.data);
+
+  window.game.set_player_unvote_end_answers(player);
+});
+
+events.addEventListener("ambiguities", event => {
+  const ambiguities = JSON.parse(event.data);
+
+  window.game.set_ambiguities(ambiguities);
+});
+
+events.addEventListener("player-judged", event => {
+  const player = JSON.parse(event.data);
+
+  window.game.set_player_judged(player);
+});
+
+events.addEventListener("player-judging", event => {
+  const player = JSON.parse(event.data);
+
+  window.game.set_player_judging(player);
+});
+
+events.addEventListener("judging-idle", event => {
+  const value = JSON.parse(event.data);
+
+  window.game.set_judging_idle(value);
+});
+
+events.addEventListener("vote-end-judging", event => {
+  const player = JSON.parse(event.data);
+
+  window.game.set_player_vote_end_judging(player);
+});
+
+events.addEventListener("unvote-end-judging", event => {
+  const player = JSON.parse(event.data);
+
+  window.game.set_player_unvote_end_judging(player);
+});
+
+events.addEventListener("scores", event => {
+  const scores = JSON.parse(event.data);
+
+  window.game.set_scores(scores);
+});
+
+events.addEventListener("vote-new-game", event => {
+  const player = JSON.parse(event.data);
+
+  window.game.set_player_vote_new_game(player);
+});
+
+events.addEventListener("unvote-new-game", event => {
+  const player = JSON.parse(event.data);
+
+  window.game.set_player_unvote_new_game(player);
+});
+
+/*********************************************************
+ * Game and supporting classes                           *
+ *********************************************************/
+
+function copy_to_clipboard(id)
+{
+  const tmp = document.createElement("input");
+  tmp.setAttribute("value", document.getElementById(id).innerHTML);
+  document.body.appendChild(tmp);
+  tmp.select();
+  document.execCommand("copy");
+  document.body.removeChild(tmp);
+}
+
+const GameInfo = React.memo(props => {
+  if (! props.id)
+    return null;
+
+  return (
+    <div className="game-info">
+      <span className="game-id">{props.id}</span>
+      {" "}
+      Share this link to invite friends:{" "}
+      <span id="game-share-url">{props.url}</span>
+      {" "}
+      <button
+        className="inline"
+        onClick={() => copy_to_clipboard('game-share-url')}
+      >Copy Link</button>
+    </div>
+  );
+});
+
+const PlayerInfo = React.memo(props => {
+  if (! props.player.id)
+    return null;
+
+  const all_players = [{...props.player, active:true}, ...props.other_players];
+
+  const sorted_players = all_players.sort((a,b) => {
+    return b.score - a.score;
+  });
+
+  /* Return a new array with the separator interspersed between
+   * each element of the array passed in as the argument.
+   */
+  function intersperse(arr, sep) {
+    return arr.reduce((acc, val) => [...acc, sep, val], []).slice(1);
+  }
+
+  let names_and_scores = sorted_players.map(player => {
+    if (player.score) {
+      return (
+        <span
+          key={player.name}
+          className={player.active ? "player-active" : "player-idle"}
+        >
+        {player.name} ({player.score})
+        </span>
+      );
+    } else {
+      if (player.active)
+        return player.name;
+      else
+        return null;
+    }
+  }).filter(component => component != null);
+
+  names_and_scores = intersperse(names_and_scores, ", ");
+
+  return (
+    <div className="player-info">
+      <span className="players-header">Players: </span>
+      {names_and_scores}
+    </div>
+  );
+});
+
+function fetch_method_json(method, api = '', data = {}) {
+  const response = fetch(api, {
+    method: method,
+    headers: {
+      'Content-Type': 'application/json'
+    },
+    body: JSON.stringify(data)
+  });
+  return response;
+}
+
+function fetch_post_json(api = '', data = {}) {
+  return fetch_method_json('POST', api, data);
+}
+
+async function fetch_put_json(api = '', data = {}) {
+  return fetch_method_json('PUT', api, data);
+}
+
+class CategoryRequest extends React.PureComponent {
+  constructor(props) {
+    super(props);
+    this.category = React.createRef();
+
+    this.handle_change = this.handle_change.bind(this);
+    this.handle_submit = this.handle_submit.bind(this);
+  }
+
+  handle_change(event) {
+    const category_input = this.category.current;
+    const category = category_input.value;
+
+    const match = category.match(/[0-9]+/);
+    if (match) {
+      const num_items = parseInt(match[0], 10);
+      if (num_items > 0 && num_items <= MAX_PROMPT_ITEMS)
+        category_input.setCustomValidity("");
+    }
+  }
+
+  async handle_submit(event) {
+    const form = event.currentTarget;
+    const category_input = this.category.current;
+    const category = category_input.value;
+
+    /* Prevent the default page-changing form-submission behavior. */
+    event.preventDefault();
+
+    const match = category.match(/[0-9]+/);
+    if (match === null) {
+      category_input.setCustomValidity("Category must include a number");
+      form.reportValidity();
+      return;
+    }
+
+    const num_items = parseInt(match[0], 10);
+
+    if (num_items > MAX_PROMPT_ITEMS) {
+      category_input.setCustomValidity(`Maximum number of items is ${MAX_PROMPT_ITEMS}`);
+      form.reportValidity();
+      return;
+    }
+
+    if (num_items < 1) {
+      category_input.setCustomValidity("Category must require at least one item.");
+      form.reportValidity();
+      return;
+    }
+
+    const response = await fetch_post_json("prompts", {
+      items: num_items,
+      prompt: category
+    });
+
+    if (response.status === 200) {
+      const result = await response.json();
+      if (! result.valid) {
+        add_message("danger", result.message);
+        return;
+      }
+    } else {
+      add_message("danger", "An error occurred submitting your category");
+    }
+
+    form.reset();
+  }
+
+  render() {
+    return (
+      <div className="category-request">
+        <h2>Submit a Category</h2>
+        <p>
+          Suggest a category to play. Don't forget to include the
+          number of items for each person to submit.
+        </p>
+
+        <form onSubmit={this.handle_submit} >
+          <div className="form-field large">
+            <input
+              type="text"
+              id="category"
+              placeholder="6 things at the beach"
+              required
+              autoComplete="off"
+              onChange={this.handle_change}
+              ref={this.category}
+            />
+          </div>
+
+          <div className="form-field large">
+            <button type="submit">
+              Send
+            </button>
+          </div>
+
+        </form>
+      </div>
+    );
+  }
+}
+
+const PromptOption = React.memo(props => {
+
+  const prompt = props.prompt;
+
+  if (prompt.votes_against.find(v => v === props.player.name))
+    return false;
+
+  return (
+    <button
+      className="vote-button"
+      key={prompt.id}
+      onClick={() => fetch_post_json(`vote/${prompt.id}`) }
+    >
+      <span
+        className="hide-button"
+        onClick={(event) => {
+          event.stopPropagation();
+          fetch_post_json(`vote_against/${prompt.id}`);
+        }}
+      >
+        &times;
+      </span>
+      {prompt.prompt}
+      <div className="vote-choices">
+        {prompt.votes.map(v => {
+          return (
+            <div
+              key={v}
+              className="vote-choice"
+            >
+              {v}
+            </div>
+          );
+        })}
+      </div>
+    </button>
+  );
+});
+
+const PromptOptions = React.memo(props => {
+
+  if (props.prompts.length === 0)
+    return null;
+
+  return (
+    <div className="prompt-options">
+      <h2>Vote on Categories</h2>
+      <p>
+        Select any categories below that you'd like to play.
+        You can choose as many as you'd like.
+      </p>
+      {props.prompts.map(
+        prompt => <PromptOption
+                    key={prompt.id}
+                    prompt={prompt}
+                    player={props.player}
+                  />
+      )}
+    </div>
+  );
+});
+
+const LetsPlay = React.memo(props => {
+
+  const quorum = Math.max(0, props.num_players - props.prompts.length);
+  const max_votes = props.prompts.reduce(
+    (max_so_far, v) => Math.max(max_so_far, v.votes.length), 0);
+
+  if (max_votes < quorum) {
+    let text = `Before we play, we should collect a bit
+                more information about what category would
+                be interesting for this group. So, either
+                type a new category option above, or else`;
+    if (props.prompts.length) {
+      if (props.prompts.length > 1)
+        text += " vote on some of the categories below.";
+      else
+        text += " vote on the category below.";
+    } else {
+      text += " wait for others to submit, and then vote on them below.";
+    }
+
+    return (
+      <div className="before-we-play">
+        <p>
+          {text}
+        </p>
+      </div>
+    );
+  }
+
+  const candidates = props.prompts.filter(p => p.votes.length >= max_votes);
+  const index = Math.floor(Math.random() * candidates.length);
+  const winner = candidates[index];
+
+  return (
+    <div className="lets-play">
+      <h2>Let's Play</h2>
+      <p>
+        That should be enough voting. If you're not waiting for any
+        other players to join, then let's start.
+      </p>
+      <button
+        className="lets-play"
+        onClick={() => fetch_post_json(`start/${winner.id}`) }
+      >
+        Start Game
+      </button>
+    </div>
+  );
+});
+
+class Ambiguities extends React.PureComponent {
+
+  constructor(props) {
+    super(props);
+
+    function canonize(word) {
+      return word.replace(/((a|an|the) )?(.*?)s?$/i, '$3');
+    }
+
+    const word_sets = [];
+
+    for (let word of props.words) {
+      const word_canon = canonize(word);
+      let found_match = false;
+      for (let set of word_sets) {
+        const set_canon = canonize(set.values().next().value);
+        if (word_canon === set_canon) {
+          set.add(word);
+          found_match = true;;
+          break;
+        }
+      }
+      if (! found_match) {
+        const set = new Set();
+        set.add(word);
+        word_sets.push(set);
+      }
+    }
+
+    this.state = {
+      word_sets: word_sets,
+      selected: null,
+      starred: null
+    };
+
+    this.submitted = false;
+    this.judging_sent_recently = false;
+  }
+
+  async handle_submit() {
+
+    /* Don't submit a second time. */
+    if (this.submitted)
+      return;
+
+    const response = await fetch_post_json(
+      `judged/${this.props.prompt.id}`,{
+        word_groups: this.state.word_sets.map(
+          set => ({
+            words: Array.from(set),
+            kudos: this.state.starred === set ? true : false
+          }))
+      }
+    );
+
+    if (response.status === 200) {
+      const result = await response.json();
+      if (! result.valid) {
+        add_message("danger", result.message);
+        return;
+      }
+    } else {
+      add_message("danger", "An error occurred submitting the results of your judging");
+      return;
+    }
+
+    this.submitted = true;
+  }
+
+  handle_click(word) {
+
+    /* Let the server know we are doing some judging, (but rate limit
+     * this so we don't send a "judging" notification more frquently
+     * than necessary.
+     */
+    if (! this.judging_sent_recently) {
+      fetch_post_json(`judging/${this.props.prompt.id}`);
+      this.judging_sent_recently = true;
+      setTimeout(() => { this.judging_sent_recently = false; }, 1000);
+    }
+
+    if (this.state.selected == word) {
+      /* Second click on same word removes the word from the group. */
+      const idx = this.state.word_sets.findIndex(s => s.has(word));
+      const set = this.state.word_sets[idx];
+      if (set.size === 1) {
+        /* When the word is already alone, there's nothing to do but
+         * to un-select it. */
+        this.setState({
+          selected: null
+        });
+        return;
+      }
+
+      const new_set = new Set([...set].filter(w => w !== word));
+      this.setState({
+        selected: null,
+        word_sets: [...this.state.word_sets.slice(0, idx),
+                    new_set,
+                    new Set().add(word),
+                    ...this.state.word_sets.slice(idx+1)]
+      });
+    } else if (this.state.selected) {
+      /* Click of a second word groups the two together. */
+      const idx1 = this.state.word_sets.findIndex(s => s.has(this.state.selected));
+      const idx2 = this.state.word_sets.findIndex(s => s.has(word));
+      const set1 = this.state.word_sets[idx1];
+      const set2 = this.state.word_sets[idx2];
+      const new_set = new Set([...set2, ...set1]);
+      if (idx1 < idx2) {
+        this.setState({
+          selected: null,
+          word_sets: [...this.state.word_sets.slice(0, idx1),
+                      ...this.state.word_sets.slice(idx1 + 1, idx2),
+                      new_set,
+                      ...this.state.word_sets.slice(idx2 + 1)]
+        });
+      } else {
+        this.setState({
+          selected: null,
+          word_sets: [...this.state.word_sets.slice(0, idx2),
+                      new_set,
+                      ...this.state.word_sets.slice(idx2 + 1, idx1),
+                      ...this.state.word_sets.slice(idx1 + 1)]
+        });
+      }
+    } else {
+      /* First click of a word selects it. */
+      this.setState({
+        selected: word
+      });
+    }
+  }
+
+  render() {
+    let move_on_button = null;
+
+    if (this.props.idle) {
+      move_on_button = (
+        <button
+          className="vote-button"
+          onClick={() => fetch_post_json(`end-judging/${this.props.prompt.id}`) }
+        >
+          Move On Without Their Input
+          <div className="vote-choices">
+            {[...this.props.votes].map(v => {
+              return (
+                <div
+                  key={v}
+                  className="vote-choice"
+                >
+                  {v}
+                  </div>
+              );
+            })}
+          </div>
+        </button>
+      );
+    }
+
+    let still_waiting = null;
+    const judging_players = Object.keys(this.props.players_judging);
+    if (judging_players.length) {
+      still_waiting = (
+        <div>
+          <p>
+            Still waiting for the following player
+            {judging_players.length > 1 ? 's' : '' }
+            :
+          </p>
+          <ul>
+            {judging_players.map(player => {
+              return (
+                <li
+                  key={player}
+                >
+                  {player}{' '}
+                  <span className=
+                  {this.props.players_judging[player].active ?
+                   "typing active"
+                   :
+                   "typing idle"}>
+                    <span>{'.'}</span><span>{'.'}</span><span>{'.'}</span>
+                  </span>
+                </li>
+              );
+            })}
+          </ul>
+        </div>
+      );
+    }
+
+    if (this.props.players_judged.has(this.props.player.name)) {
+      return (
+        <div className="please-wait">
+          <h2>Submission received</h2>
+          <p>
+            The following players have completed judging:{' '}
+            {[...this.props.players_judged].join(', ')}
+          </p>
+          {still_waiting}
+          {move_on_button}
+
+        </div>
+      );
+    }
+
+    const btn_class = "ambiguity-button";
+    const btn_selected_class = btn_class + " selected";
+
+    return (
+      <div className="ambiguities">
+        <h2>Judging Answers</h2>
+        <p>
+          Click/tap on each pair of answers that should be scored as equivalent,
+          (or click a word twice to split it out from a group). Remember,
+          what goes around comes around, so it's best to be generous when
+          judging.
+        </p>
+        <p>
+          Also, for an especially fun or witty answer, you can give kudos
+          by clicking the star on the right. You may only do this for one
+          word/group.
+        </p>
+        <h2>{this.props.prompt.prompt}</h2>
+        {this.state.word_sets.map(set => {
+          return (
+            <div
+              className="ambiguity-group"
+              key={Array.from(set)[0]}
+            >
+              {Array.from(set).map(word => {
+                return (
+                  <button
+                    className={this.state.selected === word ?
+                               btn_selected_class : btn_class }
+                    key={word}
+                    onClick={() => this.handle_click(word)}
+                  >
+                    {word}
+                  </button>
+                );
+              })}
+              <span
+                className={this.state.starred === set ?
+                           "star-button selected" : "star-button"
+                          }
+                onClick={(event) => {
+                  event.stopPropagation();
+                  if (this.state.starred === set) {
+                    this.setState({
+                      starred: null
+                    });
+                  } else {
+                    this.setState({
+                      starred: set
+                    });
+                  }
+                }}
+              >
+              {this.state.starred === set ?
+               '★' : '☆'
+              }
+              </span>
+            </div>
+          );
+        })}
+        <p>
+          Click here when done judging:<br/>
+          <button
+            onClick={() => this.handle_submit()}
+          >
+            Send
+          </button>
+        </p>
+      </div>
+    );
+  }
+}
+
+class ActivePrompt extends React.PureComponent {
+
+  constructor(props) {
+    super(props);
+    const items = props.prompt.items;
+
+    this.submitted = false;
+
+    this.answers = [...Array(items)].map(() => React.createRef());
+    this.answering_sent_recently = false;
+
+    this.handle_submit = this.handle_submit.bind(this);
+    this.handle_change = this.handle_change.bind(this);
+  }
+
+  handle_change(event) {
+    /* We don't care (or even look) at what the player is typing at
+     * this point. We simply want to be informed that the player _is_
+     * typing so that we can tell the server (which will tell other
+     * players) that there is activity here.
+     */
+
+    /* Rate limit so that we don't send an "answering" notification
+     * more frequently than necessary.
+     */
+    if (! this.answering_sent_recently) {
+      fetch_post_json(`answering/${this.props.prompt.id}`);
+      this.answering_sent_recently = true;
+      setTimeout(() => { this.answering_sent_recently = false; }, 1000);
+    }
+  }
+
+  async handle_submit(event) {
+    const form = event.currentTarget;
+
+    /* Prevent the default page-changing form-submission behavior. */
+    event.preventDefault();
+
+    /* And don't submit a second time. */
+    if (this.submitted)
+      return;
+
+    const response = await fetch_post_json(`answer/${this.props.prompt.id}`, {
+      answers: this.answers.map(r => r.current.value)
+    });
+    if (response.status === 200) {
+      const result = await response.json();
+      if (! result.valid) {
+        add_message("danger", result.message);
+        return;
+      }
+    } else {
+      add_message("danger", "An error occurred submitting your answers");
+      return;
+    }
+
+    /* Everything worked. Server is happy with our answers. */
+    form.reset();
+    this.submitted = true;
+  }
+
+  render() {
+
+    let still_waiting = null;
+    const answering_players = Object.keys(this.props.players_answering);;
+    if (answering_players.length) {
+      still_waiting = (
+        <div>
+          <p>
+            Still waiting for the following player
+            {answering_players.length > 1 ? 's' : ''}
+            :
+          </p>
+          <ul>
+            {answering_players.map(player => {
+              return (
+                <li
+                  key={player}
+                >
+                  {player}{' '}
+                  <span className=
+                  {this.props.players_answering[player].active ?
+                   "typing active"
+                   :
+                   "typing idle"}>
+                    <span>{'.'}</span><span>{'.'}</span><span>{'.'}</span>
+                  </span>
+                </li>
+              );
+            })}
+          </ul>
+        </div>
+      );
+    }
+
+    let move_on_button = null;
+    if (this.props.idle) {
+      move_on_button =(
+        <button
+          className="vote-button"
+          onClick={() => fetch_post_json(`end-answers/${this.props.prompt.id}`) }
+        >
+          {answering_players.length ?
+           "Move On Without Their Answers" :
+           "Move On Without Anyone Else"}
+          <div className="vote-choices">
+            {[...this.props.votes].map(v => {
+              return (
+                <div
+                  key={v}
+                  className="vote-choice"
+                >
+                  {v}
+                </div>
+              );
+            })}
+          </div>
+        </button>
+      );
+    }
+
+    if (this.props.players_answered.has(this.props.player.name)) {
+      return (
+        <div className="please-wait">
+          <h2>Submission received</h2>
+          <p>
+            The following players have submitted their answers:{' '}
+            {[...this.props.players_answered].join(', ')}
+          </p>
+          {still_waiting}
+          {move_on_button}
+
+        </div>
+      );
+    }
+
+    return (
+      <div className="active-prompt">
+        <h2>The Game of Empathy</h2>
+        <p>
+          Remember, you're trying to match your answers with
+          what the other players submit.
+          Give {this.props.prompt.items} answer
+          {this.props.prompt.items > 1 ? 's' : ''} for the following prompt:
+        </p>
+        <h2>{this.props.prompt.prompt}</h2>
+        <form onSubmit={this.handle_submit}>
+          {[...Array(this.props.prompt.items)].map((whocares,i) => {
+            return (
+              <div
+                key={i}
+                className="form-field large">
+                <input
+                  type="text"
+                  name={`answer_${i}`}
+                  required
+                  autoComplete="off"
+                  onChange={this.handle_change}
+                  ref={this.answers[i]}
+                />
+              </div>
+            );
+          })}
+
+          <div
+            key="submit-button"
+            className="form-field large">
+            <button type="submit">
+              Send
+            </button>
+          </div>
+
+        </form>
+      </div>
+    );
+  }
+}
+
+class Game extends React.PureComponent {
+  constructor(props) {
+    super(props);
+    this.state = {
+      game_info: {},
+      player_info: {},
+      other_players: [],
+      prompts: [],
+      active_prompt: null,
+      players_answered: new Set(),
+      players_answering: {},
+      answering_idle: false,
+      end_answers_votes: new Set(),
+      ambiguities: null,
+      players_judged: new Set(),
+      players_judging: {},
+      judging_idle: false,
+      end_judging_votes: new Set(),
+      scores: null,
+      new_game_votes: new Set(),
+      ready: false
+    };
+  }
+
+  set_game_info(info) {
+    this.setState({
+      game_info: info
+    });
+  }
+
+  set_player_info(info) {
+    this.setState({
+      player_info: info
+    });
+  }
+
+  set_other_player_info(info) {
+    const other_players_copy = [...this.state.other_players];
+    const idx = other_players_copy.findIndex(o => o.id === info.id);
+    if (idx >= 0) {
+      other_players_copy[idx] = info;
+    } else {
+      other_players_copy.push(info);
+    }
+    this.setState({
+      other_players: other_players_copy
+    });
+  }
+
+  disable_player(info) {
+    const idx = this.state.other_players.findIndex(o => o.id === info.id);
+    if (idx < 0)
+      return;
+
+    const other_players_copy = [...this.state.other_players];
+    other_players_copy[idx].active = false;
+
+    this.setState({
+      other_players: other_players_copy
+    });
+  }
+
+  reset_game_state() {
+    this.setState({
+      prompts: [],
+      active_prompt: null,
+      players_answered: new Set(),
+      players_answering: {},
+      answering_idle: false,
+      end_answers_votes: new Set(),
+      ambiguities: null,
+      players_judged: new Set(),
+      players_judging: {},
+      judging_idle: false,
+      end_judging_votes: new Set(),
+      scores: null,
+      new_game_votes: new Set(),
+      ready: false
+    });
+  }
+
+  set_prompts(prompts) {
+    this.setState({
+      prompts: prompts
+    });
+  }
+
+  add_or_update_prompt(prompt) {
+    const prompts_copy = [...this.state.prompts];
+    const idx = prompts_copy.findIndex(p => p.id === prompt.id);
+    if (idx >= 0) {
+      prompts_copy[idx] = prompt;
+    } else {
+      prompts_copy.push(prompt);
+    }
+    this.setState({
+      prompts: prompts_copy
+    });
+  }
+
+  set_active_prompt(prompt) {
+    this.setState({
+      active_prompt: prompt
+    });
+  }
+
+  set_players_answered(players) {
+    this.setState({
+      players_answered: new Set(players)
+    });
+  }
+
+  set_player_answered(player) {
+    const new_players_answering = {...this.state.players_answering};
+    delete new_players_answering[player];
+
+    this.setState({
+      players_answered: new Set([...this.state.players_answered, player]),
+      players_answering: new_players_answering
+    });
+  }
+
+  set_players_answering(players) {
+    const players_answering = {};
+    for (let player of players) {
+      players_answering[player] = {active: false};
+    }
+    this.setState({
+      players_answering: players_answering
+    });
+  }
+
+  set_player_answering(player) {
+    /* Set the player as actively answering now. */
+    this.setState({
+      players_answering: {
+        ...this.state.players_answering,
+        [player]: {active: true}
+      }
+    });
+    /* And arrange to have them marked idle very shortly.
+     *
+     * Note: This timeout is intentionally very, very short. We only
+     * need it long enough that the browser has latched onto the state
+     * change to "active" above. We actually use a CSS transition
+     * delay to control the user-perceptible length of time after
+     * which an active player appears inactive.
+     */
+    setTimeout(() => {
+      this.setState({
+        players_answering: {
+          ...this.state.players_answering,
+          [player]: {active: false}
+        }
+      });
+    }, 100);
+  }
+
+  set_answering_idle(value) {
+    this.setState({
+      answering_idle: value
+    });
+  }
+
+  set_end_answers(players) {
+    this.setState({
+      end_answers_votes: new Set(players)
+    });
+  }
+
+  set_player_vote_end_answers(player) {
+    this.setState({
+      end_answers_votes: new Set([...this.state.end_answers_votes, player])
+    });
+  }
+
+  set_player_unvote_end_answers(player) {
+    this.setState({
+      end_answers_votes: new Set([...this.state.end_answers_votes].filter(p => p !== player))
+    });
+  }
+
+  set_ambiguities(ambiguities) {
+    this.setState({
+      ambiguities: ambiguities
+    });
+  }
+
+  set_players_judged(players) {
+    this.setState({
+      players_judged: new Set(players)
+    });
+  }
+
+  set_player_judged(player) {
+    const new_players_judging = {...this.state.players_judging};
+    delete new_players_judging[player];
+
+    this.setState({
+      players_judged: new Set([...this.state.players_judged, player]),
+      players_judging: new_players_judging
+    });
+  }
+
+  set_players_judging(players) {
+    const players_judging = {};
+    for (let player of players) {
+      players_judging[player] = {active: false};
+    }
+    this.setState({
+      players_judging: players_judging
+    });
+  }
+
+  set_player_judging(player) {
+    /* Set the player as actively judging now. */
+    this.setState({
+      players_judging: {
+        ...this.state.players_judging,
+        [player]: {active: true}
+      }
+    });
+    /* And arrange to have them marked idle very shortly.
+     *
+     * Note: This timeout is intentionally very, very short. We only
+     * need it long enough that the browser has latched onto the state
+     * change to "active" above. We actually use a CSS transition
+     * delay to control the user-perceptible length of time after
+     * which an active player appears inactive.
+     */
+    setTimeout(() => {
+      this.setState({
+        players_judging: {
+          ...this.state.players_judging,
+          [player]: {active: false}
+        }
+      });
+    }, 100);
+
+  }
+
+  set_judging_idle(value) {
+    this.setState({
+      judging_idle: value
+    });
+  }
+
+  set_end_judging(players) {
+    this.setState({
+      end_judging_votes: new Set(players)
+    });
+  }
+
+  set_player_vote_end_judging(player) {
+    this.setState({
+      end_judging_votes: new Set([...this.state.end_judging_votes, player])
+    });
+  }
+
+  set_player_unvote_end_judging(player) {
+    this.setState({
+      end_judging_votes: new Set([...this.state.end_judging_votes].filter(p => p !== player))
+    });
+  }
+
+  set_scores(scores) {
+    this.setState({
+      scores: scores
+    });
+  }
+
+  set_new_game_votes(players) {
+    this.setState({
+      new_game_votes: new Set(players)
+    });
+  }
+
+  set_player_vote_new_game(player) {
+    this.setState({
+      new_game_votes: new Set([...this.state.new_game_votes, player])
+    });
+  }
+
+  set_player_unvote_new_game(player) {
+    this.setState({
+      new_game_votes: new Set([...this.state.new_game_votes].filter(p => p !== player))
+    });
+  }
+
+  state_ready() {
+    this.setState({
+      ready: true
+    });
+  }
+
+  render() {
+    const state = this.state;
+
+    if (state.scores) {
+
+      const players_total = state.players_answered.size;
+
+      let perfect_score = 0;
+      for (let i = 0;
+           i < state.active_prompt.items &&
+           i < state.scores.words.length;
+           i++)
+      {
+        perfect_score += state.scores.words[i].players.length;
+      }
+
+      return (
+        <div className="scores">
+          <h2>{state.active_prompt.prompt}</h2>
+          <h2>Scores</h2>
+          <ul>
+            {state.scores.scores.map(score => {
+              let perfect = null;
+              if (score.score === perfect_score) {
+                perfect = <span className="achievement">Perfect!</span>;
+              }
+              let quirkster = null;
+              if (score.score === state.active_prompt.items) {
+                quirkster = <span className="achievement">Quirkster!</span>;
+              }
+              let kudos_slam = null;
+              if (score.kudos > 0 && score.kudos >= players_total - 1) {
+                kudos_slam = <span className="achievement">Kudos Slam!</span>;
+              }
+              return (
+                <li key={score.players[0]}>
+                  {score.players.join("/")}: {score.score}
+                  {score.kudos ? `, ${'★'.repeat(score.kudos)}` : ""}
+                  {' '}{perfect} {quirkster} {kudos_slam}
+                </li>
+              );
+            })}
+          </ul>
+          <h2>Words submitted</h2>
+          <ul>
+            {state.scores.words.map(word => {
+              let great_minds = null;
+              if (word.kudos.length && word.players.length > 1) {
+                great_minds = <span className="achievement">Great Minds!</span>;
+              }
+              let kudos_slam = null;
+              if (word.kudos.length > 0 && word.kudos.length >= players_total - 1) {
+                kudos_slam = <span className="achievement">Kudos Slam!</span>;
+              }
+              return (
+                <li key={word.word}>
+                  {word.word} ({word.players.length}
+                  {word.kudos.length ? `, ${'★'.repeat(word.kudos.length)}` : ""}
+                  ): {word.players.join(', ')}
+                  {' '}{great_minds}{kudos_slam}
+                </li>
+              );
+            })}
+          </ul>
+          <button
+            className="vote-button"
+            onClick={() => fetch_post_json(`new-game/${state.active_prompt.id}`) }
+          >
+            New Game
+            <div className="vote-choices">
+              {[...state.new_game_votes].map(v => {
+                return (
+                  <div
+                    key={v}
+                    className="vote-choice"
+                  >
+                    {v}
+                  </div>
+                );
+              })}
+            </div>
+          </button>
+        </div>
+      );
+    }
+
+    if (state.ambiguities){
+      return <Ambiguities
+               prompt={state.active_prompt}
+               words={state.ambiguities}
+               player={state.player_info}
+               players_judged={state.players_judged}
+               players_judging={state.players_judging}
+               idle={state.judging_idle}
+               votes={state.end_judging_votes}
+             />;
+    }
+
+    if (state.active_prompt) {
+      return <ActivePrompt
+               prompt={state.active_prompt}
+               player={state.player_info}
+               players_answered={state.players_answered}
+               players_answering={state.players_answering}
+               idle={state.answering_idle}
+               votes={state.end_answers_votes}
+             />;
+    }
+
+    if (! state.ready)
+      return null;
+
+    return [
+      <GameInfo
+        key="game-info"
+        id={state.game_info.id}
+        url={state.game_info.url}
+      />,
+      <PlayerInfo
+        key="player-info"
+        game={this}
+        player={state.player_info}
+        other_players={state.other_players}
+      />,
+      <p key="spacer"></p>,
+      <CategoryRequest
+        key="category-request"
+      />,
+      <LetsPlay
+        key="lets-play"
+        num_players={1+state.other_players.filter(p => p.active).length}
+        prompts={state.prompts}
+      />,
+      <PromptOptions
+        key="prompts"
+        prompts={state.prompts}
+        player={state.player_info}
+      />
+    ];
+  }
+}
+
+ReactDOM.render(<Game
+                  ref={(me) => window.game = me}
+                />, document.getElementById("empathy"));
diff --git a/empathy/index.html b/empathy/index.html
new file mode 100644 (file)
index 0000000..c25d75e
--- /dev/null
@@ -0,0 +1,36 @@
+<!DOCTYPE html>
+<html>
+  <head>
+    <meta charset="utf-8"/>
+    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
+
+    <title>The Game of Empathy</title>
+
+    <link rel="stylesheet" href="/reset.css" type="text/css" />
+    <link rel="stylesheet" href="/style.css" type="text/css" />
+  </head>
+  <body>
+
+    <script src="/lmno.js"></script>
+
+    <div id="page">
+
+      <h1>The Game of Empathy</h1>
+
+      <p>
+        You don't need to be right, you just need to agree with your
+        friends.
+      </p>
+
+      <div id="message-area">
+      </div>
+
+      <form onsubmit="lmno_new('empathy'); return false;">
+        <button type="submit">
+          Host a new game
+        </button>
+      </form>
+
+    </div>
+  </body>
+</html>
index 57cd206e989ce20c41288a1d6bb4d1c99b284e6a..bc3340bf2b75d9e0aa99bf6186b50f8c907492ce 100644 (file)
@@ -1,28 +1,28 @@
 /* By default, hide things that are not to be shown
- * until a particular game state is reached. */
-.show-state-join {
+ * until a particular game phase is reached. */
+.show-phase-join {
     display:none;
 }
 
-.show-state-reveal {
+.show-phase-reveal {
     display:none;
 }
 
-.show-state-capture {
+.show-phase-capture {
     display:none;
 }
 
 /* And by default, show things that will be hidden
- * when a particular game state is reached. */
-.hide-state-join {
+ * when a particular game phase is reached. */
+.hide-phase-join {
     display:block;
 }
 
-.hide-state-reveal {
+.hide-phase-reveal {
     display:block;
 }
 
-.hide-state-capture {
+.hide-phase-capture {
     display:block;
 }
 
index 8043d32dc281799adf2734c3ce1306bd50168617..05b801e352d0e6c0c51e84cfc157e43162024f50 100644 (file)
@@ -75,8 +75,6 @@ function register(form) {
 function toggle_host_tools() {
   const host_tools = document.getElementById("host-tools");
 
-  console.log("Toggling, host_tools.style.display is '" + host_tools.style.display + "'");
-
   if (host_tools.style.display === "block")
     host_tools.style.display = "none";
   else
@@ -195,28 +193,28 @@ function spectator_on_load() {
   state.spectator_id = JSON.parse(this.response);
 }
 
-events.addEventListener("game-state", function(event) {
+events.addEventListener("game-phase", function(event) {
   const data = JSON.parse(event.data);
-  const old_state = data.old_state;
-  const new_state = data.new_state;
+  const old_phase = data.old_phase;
+  const new_phase = data.new_phase;
 
-  const hide_selector = ".show-state-" +old_state+ ",.hide-state-" +new_state;
-  const show_selector = ".hide-state-" +old_state+ ",.show-state-" +new_state;
+  const hide_selector = ".show-phase-" +old_phase+ ",.hide-phase-" +new_phase;
+  const show_selector = ".hide-phase-" +old_phase+ ",.show-phase-" +new_phase;
 
-  /* Hide all elements based on the state transition. */
+  /* Hide all elements based on the phase transition. */
   var elts = document.querySelectorAll(hide_selector);
   for (const elt of elts) {
     elt.style.display = "none";
   }
 
-  /* And show all elements based on the same state transition. */
+  /* And show all elements based on the same phase transition. */
   elts = document.querySelectorAll(show_selector);
   for (const elt of elts) {
     elt.style.display = "block";
   }
 
-  /* Whenever the game enters the "join" state, add ourselves as a spectator. */
-  if (new_state === "join") {
+  /* Whenever the game enters the "join" phase, add ourselves as a spectator. */
+  if (new_phase === "join") {
     const request = new XMLHttpRequest();
 
     request.addEventListener("load", spectator_on_load);
index 261234ae26e26d8aefe509674613a11790bb799a..ec4fe0363b1618a97f47504e21b453d2df49bfa9 100644 (file)
@@ -58,6 +58,9 @@
           <li>
             <a href="empires">Empires</a>
           </li>
+          <li>
+            <a href="empathy">Empathy</a>
+          </li>
         </ul>
       </p>
 
diff --git a/scribe/.gitignore b/scribe/.gitignore
new file mode 100644 (file)
index 0000000..953891d
--- /dev/null
@@ -0,0 +1 @@
+scribe.js
diff --git a/scribe/Makefile b/scribe/Makefile
new file mode 100644 (file)
index 0000000..9f07401
--- /dev/null
@@ -0,0 +1,12 @@
+# Defer all targets up to the upper-level
+#
+# This requires two recipes. The first to cover the case of no
+# explicit target specifed (so when invoked as "make" we call "make"
+# at the upper-level) and then a .DEFAULT recipe to pass any explicit
+# target up as well, (so that an invocation of "make foo" results in a
+# call to "make foo" above.
+all:
+       $(MAKE) -C ..
+
+.DEFAULT:
+       $(MAKE) -C .. $@
diff --git a/scribe/index.html b/scribe/index.html
new file mode 100644 (file)
index 0000000..7638db9
--- /dev/null
@@ -0,0 +1,38 @@
+<!DOCTYPE html>
+<html>
+  <head>
+    <meta charset="utf-8"/>
+    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
+
+    <title>Scribe</title>
+
+    <link rel="stylesheet" href="/reset.css" type="text/css" />
+    <link rel="stylesheet" href="/style.css" type="text/css" />
+
+    <script src="/lmno.js"></script>
+  </head>
+  <body>
+
+    <div id="page">
+
+      <h1>Scribe</h1>
+
+      <p>
+        A game
+        by <a href="http://www.marksteeregames.com/Scribe_rules.html">Mark
+        Steere</a>, implemented by permission.
+      </p>
+
+      <div id="message-area">
+      </div>
+
+      <form onsubmit="lmno_new('scribe'); return false;">
+        <button type="submit">
+          Host a new game
+        </button>
+      </form>
+
+    </div>
+
+  </body>
+</html>
diff --git a/scribe/scribe.css b/scribe/scribe.css
new file mode 100644 (file)
index 0000000..aba532e
--- /dev/null
@@ -0,0 +1,103 @@
+/* We want our board to be the largest square that can
+ * fit. Unfortunately, it requires a bit of CSS magic to make that
+ * happen. We can set a width easily enough, but what we can't easily
+ * do is to set the height to be exactly the same as the width.
+ *
+ * So here's the magic to get that to happen. On the board container
+ * we set the height to 0 and the bottom padding to 100% (which just
+ * happens to be defined as relative to the _width_). So, now we have
+ * a square element. Hurrah!
+ *
+ * The problem is that this element has a nominal height of 0, so if
+ * any child sas "height: 100%" that will result in a 0-height child
+ * and won't be what we want.
+ *
+ * So the last piece of the magic is to use absolute placement of the
+ * board (which requires position:relative on its parent) and set all
+ * of its edges (top, left, bottom, right) to the extents of the
+ * container.
+ *
+ * Ta-da! Now our board element is square and does not have any
+ * dimensions of 0 so child elements can compute their sizes
+ * naturally.
+ */
+.board-container {
+    position: relative;
+    width: 100%;
+    height: 0;
+    padding-bottom: 100%;
+}
+
+.board {
+    position: absolute;
+    top: 0;
+    left: 0;
+    bottom: 0;
+    right: 0;
+
+    display: grid;
+    grid-template-columns: 1fr 1fr 1fr;
+    grid-template-rows: 1fr 1fr 1fr;
+    grid-gap: 1em;
+}
+
+.mini-grid {
+    width: 100%;
+    height: 100%;
+
+    display: grid;
+    grid-template-columns: 1fr 1fr 1fr;
+    grid-template-rows: 1fr 1fr 1fr;
+
+    border-radius: 6px;
+    border: 3px solid #999;
+}
+
+.mini-grid.active {
+    border: 3px solid var(--accent-color-bright);
+}
+
+.square {
+    display: flex;
+    justify-content: center;
+    align-items: center;
+    font-size: calc(min(8vw, .08 * var(--page-max-width)));
+    line-height: 0;
+    font-weight: bold;
+    border-bottom: 1px solid #999;
+    border-right: 1px solid #999;
+}
+
+.square.open {
+    cursor: pointer;
+}
+
+.square.occupied {
+    cursor: default;
+}
+
+.square.open:hover {
+    background-color: var(--accent-color-bright);
+}
+
+.square.last-move {
+    color: var(--accent-color-bright);
+}
+
+.glyphs {
+    padding-top: 0.5em;
+    display: grid;
+    grid-template-columns: 1fr 1fr 1fr 1fr 1fr;
+    grid-column-gap: 0.5em;
+    grid-row-gap: 0.25em;
+}
+
+.glyph-and-name {
+    display: flex;
+    flex-direction: column;
+    align-items: center;
+}
+
+.glyph {
+    width: 8vw;
+}
diff --git a/scribe/scribe.jsx b/scribe/scribe.jsx
new file mode 100644 (file)
index 0000000..ae05690
--- /dev/null
@@ -0,0 +1,667 @@
+function team_symbol(team) {
+  if (team === "+")
+    return "+";
+  else
+    return "o";
+}
+
+function undisplay(element) {
+  element.style.display="none";
+}
+
+function add_message(severity, message) {
+  message = `<div class="message ${severity}" onclick="undisplay(this)">
+<span class="hide-button" onclick="undisplay(this.parentElement)">&times;</span>
+${message}
+</div>`;
+  const message_area = document.getElementById('message-area');
+  message_area.insertAdjacentHTML('beforeend', message);
+}
+
+/*********************************************************
+ * Handling server-sent event stream                     *
+ *********************************************************/
+
+const events = new EventSource("events");
+
+events.onerror = function(event) {
+  if (event.target.readyState === EventSource.CLOSED) {
+    setTimeout(() => {
+      add_message("danger", "Connection to server lost.");
+    }, 1000);
+  }
+};
+
+events.addEventListener("game-info", event => {
+  const info = JSON.parse(event.data);
+
+  window.game.set_game_info(info);
+});
+
+events.addEventListener("player-info", event => {
+  const info = JSON.parse(event.data);
+
+  window.game.set_player_info(info);
+});
+
+events.addEventListener("player-enter", event => {
+  const info = JSON.parse(event.data);
+
+  window.game.set_other_player_info(info);
+});
+
+events.addEventListener("player-update", event => {
+  const info = JSON.parse(event.data);
+
+  if (info.id === window.game.state.player_info.id)
+    window.game.set_player_info(info);
+  else
+    window.game.set_other_player_info(info);
+});
+
+events.addEventListener("move", event => {
+  const move = JSON.parse(event.data);
+
+  window.game.receive_move(move);
+});
+
+events.addEventListener("game-state", event => {
+  const state = JSON.parse(event.data);
+
+  window.game.reset_board();
+
+  for (let square of state.moves) {
+    window.game.receive_move(square);
+  }
+});
+
+/*********************************************************
+ * Game and supporting classes                           *
+ *********************************************************/
+
+const scribe_glyphs = [
+  {
+    name: "Single",
+    squares: [1,0,0,
+              0,0,0,
+              0,0,0]
+  },
+  {
+    name: "Double",
+    squares: [1,1,0,
+              0,0,0,
+              0,0,0]
+  },
+  {
+    name: "Line",
+    squares: [1,1,1,
+              0,0,0,
+              0,0,0]
+  },
+  {
+    name: "Pipe",
+    squares: [0,0,1,
+              1,1,1,
+              0,0,0]
+  },
+  {
+    name: "Squat-T",
+    squares: [1,1,1,
+              0,1,0,
+              0,0,0]
+  },
+  {
+    name: "4-block",
+    squares: [1,1,0,
+              1,1,0,
+              0,0,0]
+  },
+  {
+    name: "T",
+    squares: [1,1,1,
+              0,1,0,
+              0,1,0]
+  },
+  {
+    name: "Cross",
+    squares: [0,1,0,
+              1,1,1,
+              0,1,0]
+  },
+  {
+    name: "6-block",
+    squares: [1,1,1,
+              1,1,1,
+              0,0,0]
+  },
+  {
+    name: "Bomber",
+    squares: [1,1,1,
+              0,1,1,
+              0,0,1]
+  },
+  {
+    name: "Chair",
+    squares: [0,0,1,
+              1,1,1,
+              1,0,1]
+  },
+  {
+    name: "J",
+    squares: [0,0,1,
+              1,0,1,
+              1,1,1]
+  },
+  {
+    name: "Earring",
+    squares: [0,1,1,
+              1,0,1,
+              1,1,1]
+  },
+  {
+    name: "House",
+    squares: [0,1,0,
+              1,1,1,
+              1,1,1]
+  },
+  {
+    name: "H",
+    squares: [1,0,1,
+              1,1,1,
+              1,0,1]
+  },
+  {
+    name: "U",
+    squares: [1,0,1,
+              1,0,1,
+              1,1,1]
+  },
+  {
+    name: "Ottoman",
+    squares: [1,1,1,
+              1,1,1,
+              1,0,1]
+  },
+  {
+    name: "O",
+    squares: [1,1,1,
+              1,0,1,
+              1,1,1]
+  },
+  {
+    name: "9-block",
+    squares: [1,1,1,
+              1,1,1,
+              1,1,1]
+  }
+];
+
+function copy_to_clipboard(id)
+{
+  const tmp = document.createElement("input");
+  tmp.setAttribute("value", document.getElementById(id).innerHTML);
+  document.body.appendChild(tmp);
+  tmp.select();
+  document.execCommand("copy");
+  document.body.removeChild(tmp);
+}
+
+function GameInfo(props) {
+  if (! props.id)
+    return null;
+
+  return (
+    <div className="game-info">
+      <span className="game-id">{props.id}</span>
+      {" "}
+      Share this link to invite a friend:{" "}
+      <span id="game-share-url">{props.url}</span>
+      {" "}
+      <button
+        className="inline"
+        onClick={() => copy_to_clipboard('game-share-url')}
+      >Copy Link</button>
+    </div>
+  );
+}
+
+function TeamButton(props) {
+  return <button className="inline"
+                 onClick={() => props.game.join_team(props.team)}>
+           {props.label}
+         </button>;
+}
+
+function TeamChoices(props) {
+  let other_team;
+  if (props.player.team === "+")
+    other_team = "o";
+  else
+    other_team = "+";
+
+  if (props.player.team === "") {
+    if (props.first_move) {
+      return null;
+    } else {
+      return [
+        <TeamButton key="+" game={props.game} team="+" label="Join ðŸž¥" />,
+        " ",
+        <TeamButton key="o" game={props.game} team="o" label="Join ðŸž‡" />
+      ];
+    }
+  } else {
+    return <TeamButton game={props.game} team={other_team} label="Switch" />;
+  }
+}
+
+function PlayerInfo(props) {
+  if (! props.player.id)
+    return null;
+
+  const choices = <TeamChoices
+                    game={props.game}
+                    first_move={props.first_move}
+                    player={props.player}
+                  />;
+
+  return (
+    <div className="player-info">
+      <span className="players-header">Players: </span>
+      {props.player.name}
+      {props.player.team ? ` (${props.player.team})` : ""}
+      {props.first_move ? "" : " "}
+      {choices}
+      {props.other_players.map(other => (
+        <span key={other.id}>
+          {", "}
+          {other.name}
+          {other.team ? ` (${other.team})` : ""}
+        </span>
+      ))}
+    </div>
+  );
+}
+
+function Glyph(props) {
+
+  const glyph_dots = [];
+
+  let last_square = 0;
+  for (let i = 0; i < 9; i++) {
+    if (props.squares[i])
+      last_square = i;
+  }
+
+  const height = Math.floor(20 * (Math.floor(last_square / 3) + 1));
+
+  const viewbox=`0 0 60 ${height}`;
+
+  for (let row = 0; row < 3; row++) {
+    for (let col = 0; col < 3; col++) {
+      if (props.squares[3 * row + col]) {
+        let cy = 10 + 20 * row;
+        let cx = 10 + 20 * col;
+        glyph_dots.push(
+          <circle
+            key={3 * row + col}
+            cx={cx}
+            cy={cy}
+            r="8"
+          />
+        );
+      }
+    }
+  }
+
+  return (<div className="glyph-and-name">
+            {props.name}
+            <div className="glyph">
+              <svg viewBox={viewbox}>
+                <g fill="#287789">
+                  {glyph_dots}
+                </g>
+              </svg>
+            </div>
+          </div>
+         );
+}
+
+function Square(props) {
+  let className = "square";
+
+  if (props.value) {
+    className += " occupied";
+  } else if (props.active) {
+    className += " open";
+  }
+
+  if (props.last_move) {
+    className += " last-move";
+  }
+
+  const onClick = props.active ? props.onClick : null;
+
+  return (
+    <div className={className}
+         onClick={onClick}>
+      {props.value}
+    </div>
+  );
+}
+
+function MiniGrid(props) {
+  function grid_square(j) {
+    const value = props.squares[j];
+    const last_move = props.last_moves.includes(j);
+
+    /* Even if the grid is active, the square is only active if
+     * unoccupied. */
+    const square_active = (props.active && (value === null));
+
+    return (
+      <Square
+        value={value}
+        active={square_active}
+        last_move={last_move}
+        onClick={() => props.onClick(j)}
+      />
+    );
+  }
+
+  /* Even if my parent thinks I'm active because of the last move, I
+   * might not _really_ be active if I'm full. */
+  let occupied = 0;
+  props.squares.forEach(element => {
+    if (element)
+      occupied++;
+  });
+
+  let class_name = "mini-grid";
+  if (props.active && occupied < 9)
+    class_name += " active";
+
+  return (
+    <div className={class_name}>
+      {grid_square(0)}
+      {grid_square(1)}
+      {grid_square(2)}
+      {grid_square(3)}
+      {grid_square(4)}
+      {grid_square(5)}
+      {grid_square(6)}
+      {grid_square(7)}
+      {grid_square(8)}
+    </div>
+  );
+}
+
+class Board extends React.Component {
+  mini_grid(i) {
+    /* This mini grid is active only if both:
+     *
+     * 1. It is our turn (this.props.active === true)
+     *
+     * 2. One of the following conditions is met:
+     *
+     *    a. This is this players first turn (last_two_moves[0] === null)
+     *    b. This mini grid corresponds to this players last turn
+     *    c. The mini grid that corresponds to the players last turn is full
+     */
+    let grid_active = false;
+    if (this.props.active) {
+      grid_active = true;
+      if (this.props.last_two_moves.length > 1) {
+        /* First index (0) gives us our last move, (that is, of the
+         * last two moves, it's the first one, so two moves ago).
+         *
+         * Second index (1) gives us the second number from that move,
+         * (that is, the index within the mini-grid that we last
+         * played).
+         */
+        const target = this.props.last_two_moves[0][1];
+        let occupied = 0;
+        this.props.squares[target].forEach(element => {
+          if (element)
+            occupied++;
+        });
+        /* If the target mini-grid isn't full then this grid is
+         * only active if it is that target. */
+        if (occupied < 9)
+          grid_active = (i === target);
+      }
+    }
+
+    /* We want to highlight each of the last two moves (both "+" and
+     * "o"). So we filter the last two moves that have a first index
+     * that matches this mini_grid and pass down their second index
+     * be highlighted.
+     */
+    const last_moves = this.props.last_two_moves.filter(move => move[0] === i)
+          .map(move => move[1]);
+
+    const squares = this.props.squares[i];
+    return (
+      <MiniGrid
+        squares={squares}
+        active={grid_active}
+        last_moves={last_moves}
+        onClick={(j) => this.props.onClick(i,j)}
+      />
+    );
+  }
+
+  render() {
+    return (
+      <div className="board-container">
+        <div className="board">
+          {this.mini_grid(0)}
+          {this.mini_grid(1)}
+          {this.mini_grid(2)}
+          {this.mini_grid(3)}
+          {this.mini_grid(4)}
+          {this.mini_grid(5)}
+          {this.mini_grid(6)}
+          {this.mini_grid(7)}
+          {this.mini_grid(8)}
+        </div>
+      </div>
+    );
+  }
+}
+
+function fetch_method_json(method, api = '', data = {}) {
+  const response = fetch(api, {
+    method: method,
+    headers: {
+      'Content-Type': 'application/json'
+    },
+    body: JSON.stringify(data)
+  });
+  return response;
+}
+
+function fetch_post_json(api = '', data = {}) {
+  return fetch_method_json('POST', api, data);
+}
+
+async function fetch_put_json(api = '', data = {}) {
+  return fetch_method_json('PUT', api, data);
+}
+
+class Game extends React.Component {
+  constructor(props) {
+    super(props);
+    this.state = {
+      game_info: {},
+      player_info: {},
+      other_players: [],
+      squares: [...Array(9)].map(() => Array(9).fill(null)),
+      moves: [],
+      next_to_play: "+",
+    };
+  }
+
+  set_game_info(info) {
+    this.setState({
+      game_info: info
+    });
+  }
+
+  set_player_info(info) {
+    this.setState({
+      player_info: info
+    });
+  }
+
+  set_other_player_info(info) {
+    const other_players_copy = [...this.state.other_players];
+    const idx = other_players_copy.findIndex(o => o.id === info.id);
+    if (idx >= 0) {
+      other_players_copy[idx] = info;
+    } else {
+      other_players_copy.push(info);
+    }
+    this.setState({
+      other_players: other_players_copy
+    });
+  }
+
+  reset_board() {
+    this.setState({
+      next_to_play: "+"
+    });
+  }
+
+  receive_move(move) {
+    if (this.state.moves.length === 81) {
+      return;
+    }
+    const symbol = team_symbol(this.state.next_to_play);
+    const new_squares = this.state.squares.map(arr => arr.slice());
+    new_squares[move[0]][move[1]] = symbol;
+    const new_moves = [...this.state.moves, move];
+    let next_to_play;
+    if (this.state.next_to_play === "+")
+      next_to_play = "o";
+    else
+      next_to_play = "+";
+    this.setState({
+      squares: new_squares,
+      moves: new_moves,
+      next_to_play: next_to_play
+    });
+  }
+
+  async handle_click(i, j, first_move) {
+    let move = {
+      move: [i, j]
+    };
+    if (first_move) {
+      move.assert_first_move = true;
+    }
+    const response = await fetch_post_json("move", move);
+    if (response.status == 200) {
+      const result = await response.json();
+      if (! result.legal)
+        add_message("danger", result.message);
+    } else {
+      add_message("danger", `Error occurred sending move`);
+    }
+  }
+
+  join_team(team) {
+    fetch_put_json("player", {team: team});
+  }
+
+  render() {
+    const state = this.state;
+    const first_move = state.moves.length === 0;
+    const my_team = state.player_info.team;
+    var board_active;
+
+    let status;
+    if (this.state.moves.length === 81)
+    {
+      status = "Game over";
+      board_active = false;
+    }
+    else if (first_move)
+    {
+      if (state.other_players.length == 0) {
+        status = "You can move or wait for another player to join.";
+      } else {
+        let qualifier;
+        if (state.other_players.length == 1) {
+          qualifier = "Either";
+        } else {
+          qualifier = "Any";
+        }
+        status = `${qualifier} player can make the first move.`;
+      }
+      board_active = true;
+    }
+    else if (my_team === "")
+    {
+      status = "You're just watching the game.";
+      board_active = false;
+    }
+    else if (my_team === state.next_to_play)
+    {
+      status = "Your turn. Make a move.";
+      board_active = true;
+    }
+    else
+    {
+      status = "Waiting for another player to ";
+      if (state.other_players.length == 0) {
+        status += "join.";
+      } else {
+        status += "move.";
+      }
+      board_active = false;
+    }
+
+    return [
+      <GameInfo
+        key="game-info"
+        id={state.game_info.id}
+        url={state.game_info.url}
+      />,
+      <PlayerInfo
+        key="player-info"
+        game={this}
+        first_move={first_move}
+        player={state.player_info}
+        other_players={state.other_players}
+      />,
+      <div key="game" className="game">
+        <div>{status}</div>
+        <div className="game-board">
+          <Board
+            active={board_active}
+            squares={state.squares}
+            last_two_moves={state.moves.slice(-2)}
+            onClick={(i,j) => this.handle_click(i, j, first_move)}
+          />
+        </div>
+      </div>,
+      <div key="glyphs" className="glyphs">
+        {
+          scribe_glyphs.map(glyph => {
+            return (
+              <Glyph
+                key={glyph.name}
+                name={glyph.name}
+                squares={glyph.squares}
+              />
+            );
+          })
+        }
+      </div>
+    ];
+  }
+}
+
+ReactDOM.render(<Game
+                  ref={(me) => window.game = me}
+                />, document.getElementById("scribe"));
index dfaa8e4f9fc32c58cc32a73904702cbb26057e70..bc611e5a16587f895e37be0864c5f6cc2b483454 100644 (file)
--- a/style.css
+++ b/style.css
@@ -1,3 +1,8 @@
+/* Because this is the only sizing that is sane, make it global. */
+* {
+    box-sizing: border-box;
+}
+
 /*\
 |*|
 |*| Properties for the page: colors, etc.
     /* A little color to avoid a fully monochromatic theme. */
     --accent-color: #287789;
     --accent-color-bright: #44c7ef;
+    --text-fg-on-accent: white;
+    --text-fg-on-accent-bright: #333738;
 
     /* Some colors intended to convey semnatics. */
     --warning-color: #ffa92a;
     --danger-color: #f56257;
     --danger-color-dark: #bc2822;
+
+    /* Page layout */
+    --page-max-width: 720px;
+    --page-max-pad: 50px;
+    --page-max-width-padded: calc(var(--page-max-width) + 2 * var(--page-max-pad));
 }
 
 /*\
@@ -39,6 +51,7 @@ body {
 h1,h2 {
     color: var(--text-fg-color);
     font-weight: bold;
+    margin-top: 0.5em;
 }
 
 h1 {
@@ -49,6 +62,11 @@ h2 {
     font-size: 110%;
 }
 
+/* Don't underline links in headers */
+h1 a {
+    text-decoration: none;
+}
+
 p,dl,dd,form {
     margin-bottom: 1em;
 }
@@ -65,13 +83,19 @@ a:link    {
 a:visited {
     color: var(--accent-color);
 }
-a:hover   {
-    color: var(--accent-color-bright);
+@media (hover:hover) {
+    a:hover   {
+        color: var(--accent-color-bright);
+    }
 }
 a:active  {
     color: var(--accent-color-bright);
 }
 
+strong {
+    font-weight: bold;
+}
+
 /*\
 |*|
 |*| Overall page layout
@@ -104,37 +128,36 @@ body {
 /* We never let the page content get larger than a large fixed width.
  *
  * And when the screen is wide enough, we can afford some "wasted"
- * space on either side of the page content. This starts at 0 for a
- * 620px wide page up to 50px on either side for a 820px wide page.
+ * space on either side of the page content, (up to --page-max-pad).
  *
- * Note: This 820px width for the page includes the padding so the
- * actual content is only ever as wide as 720px.
+ * Note: This --page-max-width-padded for the page includes the
+ * padding so the actual content is only ever as wide as
+ * --page-max-width.
  *
  * Wider than that and we start to see the background on either side
  * of the page content.
  */
 #page {
-    box-sizing: border-box;
-    max-width: 820px;
+    max-width: var(--page-max-width-padded);
     margin-left: auto;
     margin-right: auto;
     padding-top: 0;
-    padding-bottom: 0;
+    padding-bottom: 2em;
     padding-left: 1em;
     padding-right: 1em;
 }
 
-@media screen and (min-width: 720px) and (max-width: 820px) {
+@media screen and (min-width: var(--page-max-width)) and (max-width: var(--page-max-width-padded)) {
     #page {
-        padding-left: calc(1em + (100% - 720px)/2);
-        padding-right: calc(1em + (100% - 720px)/2);
+        padding-left: calc(1em + (100% - var(--page-max-width))/2);
+        padding-right: calc(1em + (100% - var(--page-pax-width))/2);
     }
 }
 
-@media screen and (min-width: 820px) {
+@media screen and (min-width: var(--page-pax-width-padded)) {
     #page {
-        padding-left: calc(1em + 50px);
-        padding-right: calc(1em + 50px);
+        padding-left: calc(1em + var(--page-max-pad));
+        padding-right: calc(1em + var(--page-max-pad));
     }
 }
 
@@ -197,6 +220,11 @@ form {
     grid-column-gap: 1em;
 }
 
+.form-field {
+    margin-top: 0.25em;
+    margin-bottom: 0.25em;
+}
+
 .form-field.small.left,.form-field.medium.left {
     grid-column-start: 1;
 }
@@ -247,18 +275,27 @@ button {
     border-radius: 4px;
     background-color: var(--accent-color);
     border: none;
-    color: white;
+    color: var(--text-fg-on-accent);
     text-align: center;
     font-size: 125%;
     margin-top: .25em;
     padding-top: 0.25em;
     padding-bottom: 0.25em;
     width: 200px;
+    cursor: pointer;
 }
 
-button:hover {
-    transform: translateY(-1px);
-    background-color: var(--accent-color-bright);
+button.inline {
+    font-size: 72%;
+    font-weight: bold;
+    width: auto;
+}
+
+@media (hover:hover) {
+    button:hover {
+        transform: translateY(-1px);
+        background-color: var(--accent-color-bright);
+    }
 }
 
 :focus {
@@ -301,6 +338,7 @@ button:hover {
 
 .hide-button {
     color: white;
+    opacity: 0.5;
     font-size: 125%;
     font-weight: bold;
     cursor: pointer;
@@ -309,6 +347,19 @@ button:hover {
     top: 0;
 }
 
-.hide-button:hover {
-    color: var(--danger-color-dark);
+@media (hover:hover) {
+    .hide-button:hover {
+        opacity: 1.0;
+    }
+}
+
+/*\
+|*|
+|*| Game-specific markup
+|*|
+\*/
+
+.game-id, .players-header {
+    font-size: 110%;
+    font-weight: bold;
 }
index 10ad37052f266b90fcdd9a0c5509f5737a657404..a9879334d710a2e96926f83140f88e8492b8063e 100644 (file)
@@ -17,31 +17,34 @@ ol, ul {
   color: black;
   border: 1px solid #999;
   float: left;
-  font-size: 24px;
+  font-size: 64px;
   font-weight: bold;
-  line-height: 34px;
-  height: 34px;
+  line-height: 90px;
+  width: 90px;
+  height: 90px;
   margin-right: -1px;
   margin-top: -1px;
   padding: 0;
   text-align: center;
-  width: 34px;
+  border-radius: 4px;
 }
 
-.square:focus {
-  outline: none;
+.square.open {
+    cursor: pointer;
 }
 
-.kbd-navigation .square:focus {
-  background: #ddd;
+.square.occupied {
+    cursor: default;
+}
+
+.square.open:hover {
+    background-color: var(--accent-color-bright);
 }
 
-.game {
-  display: flex;
-  flex-direction: row;
+.square:focus {
+  outline: none;
 }
 
-.game-info {
-  margin-left: 20px;
-  margin-bottom: 20px;
+.kbd-navigation .square:focus {
+  background: #ddd;
 }
index eff7eff32f6df572437130d0ec2c0e8ecf8c2a7b..dbfc5ef93e9ba41d21f026a14653e179828fbd4a 100644 (file)
+const Team = {
+  X: 0,
+  O: 1,
+  properties: {
+    0: {name: "X"},
+    1: {name: "O"}
+  }
+};
+
+function undisplay(element) {
+  element.style.display="none";
+}
+
+function add_message(severity, message) {
+  message = `<div class="message ${severity}" onclick="undisplay(this)">
+<span class="hide-button" onclick="undisplay(this.parentElement)">&times;</span>
+${message}
+</div>`;
+  const message_area = document.getElementById('message-area');
+  message_area.insertAdjacentHTML('beforeend', message);
+}
+
+/*********************************************************
+ * Handling server-sent event stream                     *
+ *********************************************************/
+
+const events = new EventSource("events");
+
+events.onerror = function(event) {
+  if (event.target.readyState === EventSource.CLOSED) {
+      add_message("danger", "Connection to server lost.");
+  }
+};
+
+events.addEventListener("game-info", event => {
+  const info = JSON.parse(event.data);
+
+  window.game.set_game_info(info);
+});
+
+events.addEventListener("player-info", event => {
+  const info = JSON.parse(event.data);
+
+  window.game.set_player_info(info);
+});
+
+events.addEventListener("player-enter", event => {
+  const info = JSON.parse(event.data);
+
+  window.game.set_other_player_info(info);
+});
+
+events.addEventListener("player-update", event => {
+  const info = JSON.parse(event.data);
+
+  if (info.id === window.game.state.player_info.id)
+    window.game.set_player_info(info);
+  else
+    window.game.set_other_player_info(info);
+});
+
+events.addEventListener("move", event => {
+  const move = JSON.parse(event.data);
+
+  window.game.receive_move(move);
+});
+
+events.addEventListener("game-state", event => {
+  const state = JSON.parse(event.data);
+
+  window.game.reset_board();
+
+  for (let square of state.moves) {
+    window.game.receive_move(square);
+  }
+});
+
+/*********************************************************
+ * Game and supporting classes                           *
+ *********************************************************/
+
+function GameInfo(props) {
+  if (! props.id)
+    return null;
+
+  return (
+    <div className="game-info">
+      <h2>{props.id}</h2>
+      Invite a friend to play by sending this URL: {props.url}
+    </div>
+  );
+}
+
+function TeamButton(props) {
+  return <button className="inline"
+                 onClick={() => props.game.join_team(props.team)}>
+           {props.label}
+         </button>;
+}
+
+function TeamChoices(props) {
+  let other_team;
+  if (props.player.team === "X")
+    other_team = "O";
+  else
+    other_team = "X";
+
+  if (props.player.team === "") {
+    if (props.first_move) {
+      return null;
+    } else {
+      return [
+        <TeamButton key="X" game={props.game} team="X" label="Join X" />,
+        " ",
+        <TeamButton key="O" game={props.game} team="O" label="Join O" />
+      ];
+    }
+  } else {
+    return <TeamButton game={props.game} team={other_team} label="Switch" />;
+  }
+}
+
+function PlayerInfo(props) {
+  if (! props.player.id)
+    return null;
+
+  const choices = <TeamChoices
+                    game={props.game}
+                    first_move={props.first_move}
+                    player={props.player}
+                  />;
+
+  return (
+    <div className="player-info">
+      <h2>Players</h2>
+      {props.player.name}
+      {props.player.team ? ` (${props.player.team})` : ""}
+      {props.first_move ? "" : " "}
+      {choices}
+      {props.other_players.map(other => (
+        <span key={other.id}>
+          {", "}
+          {other.name}
+          {other.team ? ` (${other.team})` : ""}
+        </span>
+      ))}
+    </div>
+  );
+}
+
 function Square(props) {
+  let className = "square";
+
+  if (props.value) {
+    className += " occupied";
+  } else if (props.active) {
+    className += " open";
+  }
+
+  const onClick = props.active ? props.onClick : null;
+
   return (
-    <button className="square" onClick={props.onClick}>
+    <div className={className}
+         onClick={onClick}>
       {props.value}
-    </button>
+    </div>
   );
 }
 
 class Board extends React.Component {
-  renderSquare(i) {
+  render_square(i) {
+    const value = this.props.squares[i];
     return (
       <Square
-        value={this.props.squares[i]}
+        value={value}
+        active={this.props.active && ! value}
         onClick={() => this.props.onClick(i)}
       />
     );
@@ -20,110 +183,231 @@ class Board extends React.Component {
     return (
       <div>
         <div className="board-row">
-          {this.renderSquare(0)}
-          {this.renderSquare(1)}
-          {this.renderSquare(2)}
+          {this.render_square(0)}
+          {this.render_square(1)}
+          {this.render_square(2)}
         </div>
         <div className="board-row">
-          {this.renderSquare(3)}
-          {this.renderSquare(4)}
-          {this.renderSquare(5)}
+          {this.render_square(3)}
+          {this.render_square(4)}
+          {this.render_square(5)}
         </div>
         <div className="board-row">
-          {this.renderSquare(6)}
-          {this.renderSquare(7)}
-          {this.renderSquare(8)}
+          {this.render_square(6)}
+          {this.render_square(7)}
+          {this.render_square(8)}
         </div>
       </div>
     );
   }
 }
 
+function fetch_method_json(method, api = '', data = {}) {
+  const response = fetch(api, {
+    method: method,
+    headers: {
+      'Content-Type': 'application/json'
+    },
+    body: JSON.stringify(data)
+  });
+  return response;
+}
+
+function fetch_post_json(api = '', data = {}) {
+  return fetch_method_json('POST', api, data);
+}
+
+async function fetch_put_json(api = '', data = {}) {
+  return fetch_method_json('PUT', api, data);
+}
+
 class Game extends React.Component {
   constructor(props) {
     super(props);
     this.state = {
+      game_info: {},
+      player_info: {},
+      other_players: [],
       history: [
         {
           squares: Array(9).fill(null)
         }
       ],
-      stepNumber: 0,
-      xIsNext: true
+      step_number: 0,
+      next_to_play: Team.X
     };
   }
 
-  handleClick(i) {
-    const history = this.state.history.slice(0, this.state.stepNumber + 1);
+  set_game_info(info) {
+    this.setState({
+      game_info: info
+    });
+  }
+
+  set_player_info(info) {
+    this.setState({
+      player_info: info
+    });
+  }
+
+  set_other_player_info(info) {
+    const other_players_copy = [...this.state.other_players];
+    const idx = other_players_copy.findIndex(o => o.id === info.id);
+    if (idx >= 0) {
+      other_players_copy[idx] = info;
+    } else {
+      other_players_copy.push(info);
+    }
+    this.setState({
+      other_players: other_players_copy
+    });
+  }
+
+  reset_board() {
+    this.setState({
+      history: [
+        {
+          squares: Array(9).fill(null)
+        }
+      ],
+      step_number: 0,
+      next_to_play: Team.X
+    });
+  }
+
+  receive_move(i) {
+    const history = this.state.history.slice(0, this.state.step_number + 1);
     const current = history[history.length - 1];
     const squares = current.squares.slice();
-    if (calculateWinner(squares) || squares[i]) {
+    if (calculate_winner(squares) || squares[i]) {
       return;
     }
-    squares[i] = this.state.xIsNext ? "X" : "O";
+    squares[i] = Team.properties[this.state.next_to_play].name;
+    let next_to_play;
+    if (this.state.next_to_play === Team.X)
+      next_to_play = Team.O;
+    else
+      next_to_play = Team.X;
     this.setState({
       history: history.concat([
         {
           squares: squares
         }
       ]),
-      stepNumber: history.length,
-      xIsNext: !this.state.xIsNext
+      step_number: history.length,
+      next_to_play: next_to_play
     });
   }
 
-  jumpTo(step) {
-    this.setState({
-      stepNumber: step,
-      xIsNext: (step % 2) === 0
-    });
+  async handle_click(i, first_move) {
+    let move = { move: i };
+    if (first_move) {
+      move.assert_first_move = true;
+    }
+    const response = await fetch_post_json("move", move);
+    if (response.status == 200) {
+      const result = await response.json();
+      if (! result.legal)
+        add_message("danger", result.message);
+    } else {
+      add_message("danger", `Error occurred sending move`);
+    }
+  }
+
+  join_team(team) {
+    fetch_put_json("player", {team: team});
   }
 
   render() {
-    const history = this.state.history;
-    const current = history[this.state.stepNumber];
-    const winner = calculateWinner(current.squares);
-
-    const moves = history.map((step, move) => {
-      const desc = move ?
-        'Go to move #' + move :
-        'Go to game start';
-      return (
-        <li key={move}>
-          <button onClick={() => this.jumpTo(move)}>{desc}</button>
-        </li>
-      );
-    });
+    const state = this.state;
+    const history = state.history;
+    const current = history[state.step_number];
+    const winner = calculate_winner(current.squares);
+    const first_move = state.step_number === 0;
+    const my_team = state.player_info.team;
+    var board_active;
 
     let status;
-    if (winner) {
-      status = "Winner: " + winner;
-    } else {
-      status = "Next player: " + (this.state.xIsNext ? "X" : "O");
+    if (winner)
+    {
+      status = winner + " wins!";
+      if (state.player_info.team != "")
+      {
+        if (my_team === winner)
+          status += " Congratulations!";
+        else
+          status += " Better luck next time.";
+      }
+      board_active = false;
+    }
+    else if (first_move)
+    {
+      if (state.other_players.length == 0) {
+        status = "You can move or wait for another player to join.";
+      } else {
+        let qualifier;
+        if (state.other_players.length == 1) {
+          qualifier = "Either";
+        } else {
+          qualifier = "Any";
+        }
+        status = `${qualifier} player can make the first move.`;
+      }
+      board_active = true;
+    }
+    else if (my_team === "")
+    {
+      status = "You're just watching the game.";
+      board_active = false;
+    }
+    else if (my_team === Team.properties[state.next_to_play].name)
+    {
+      status = "Your turn. Make a move.";
+      board_active = true;
+    }
+    else
+    {
+      status = "Waiting for another player to ";
+      if (state.other_players.length == 0) {
+        status += "join.";
+      } else {
+        status += "move.";
+      }
+      board_active = false;
     }
 
-    return (
-      <div className="game">
+    return [
+      <GameInfo
+        key="game-info"
+        id={state.game_info.id}
+        url={state.game_info.url}
+      />,
+      <PlayerInfo
+        key="player-info"
+        game={this}
+        first_move={first_move}
+        player={state.player_info}
+        other_players={state.other_players}
+      />,
+      <div key="game" className="game">
+        <div>{status}</div>
         <div className="game-board">
           <Board
+            active={board_active}
             squares={current.squares}
-            onClick={i => this.handleClick(i)}
+            onClick={i => this.handle_click(i, first_move)}
           />
         </div>
-        <div className="game-info">
-          <div>{status}</div>
-          <ol>{moves}</ol>
-        </div>
       </div>
-    );
+    ];
   }
 }
 
-// ========================================
-
-ReactDOM.render(<Game />, document.getElementById("tictactoe"));
+ReactDOM.render(<Game
+                  ref={(me) => window.game = me}
+                />, document.getElementById("tictactoe"));
 
-function calculateWinner(squares) {
+function calculate_winner(squares) {
   const lines = [
     [0, 1, 2],
     [3, 4, 5],