Initial implementation of Scribe
authorCarl Worth <cworth@cworth.org>
Sat, 6 Jun 2020 15:55:23 +0000 (08:55 -0700)
committerCarl Worth <cworth@cworth.org>
Sat, 6 Jun 2020 15:55:23 +0000 (08:55 -0700)
This is not at all sophisticated yet.

Some of the things that are missing:

  * Proper layout of the board (need spacing to separate mini grids
    from each other).

  * Move restrictions: Don't allow a player to move in a super grid
    that doesn't correspond to their last move's mini-grid placement
    (unless the corresponding super-grid is full).

  * Presentation to the user of the scored glyph shapes

  * Scoring of completed mini grids

  * Scoring of the super grids for the final game

And on that last point, there needs to be an option to play either the
"majority" or "super-glyph" variation for the final scoring.

scribe/.gitignore [new file with mode: 0644]
scribe/Makefile [new file with mode: 0644]
scribe/game.html [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]

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/game.html b/scribe/game.html
new file mode 100644 (file)
index 0000000..b9136b7
--- /dev/null
@@ -0,0 +1,35 @@
+<!DOCTYPE html>
+<html>
+  <head>
+    <meta charset="utf-8"/>
+    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
+
+    <title>Tic-tac-toe</title>
+
+    <link rel="stylesheet" href="/reset.css" type="text/css" />
+    <link rel="stylesheet" href="/style.css" type="text/css" />
+    <link rel="stylesheet" href="tictactoe.css" type="text/css" />
+
+    <script src="/react.js"></script>
+    <script src="/react-dom.js"></script>
+    <script type="module" src="tictactoe.js"></script>
+  </head>
+  <body>
+
+    <div id="page">
+
+      <h1>Tic Tac Toe</h1>
+
+      <p>
+        Just the classic game.
+      </p>
+
+      <div id="message-area">
+      </div>
+
+      <div id="tictactoe"></div>
+
+    </div>
+
+  </body>
+</html>
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..4eaea8f
--- /dev/null
@@ -0,0 +1,50 @@
+ol, ul {
+  padding-left: 30px;
+}
+
+.board-row:after {
+  clear: both;
+  content: "";
+  display: table;
+}
+
+.status {
+  margin-bottom: 10px;
+}
+
+.square {
+  background: #fff;
+  color: black;
+  border: 1px solid #999;
+  float: left;
+  font-size: 20px;
+  font-weight: bold;
+  line-height: 25px;
+  width: 25px;
+  height: 25px;
+  margin-right: -1px;
+  margin-top: -1px;
+  padding: 0;
+  text-align: center;
+  border-radius: 4px;
+}
+
+.square.open {
+    cursor: pointer;
+}
+
+.square.occupied {
+    cursor: default;
+}
+
+.square.open:hover {
+    background-color: var(--accent-color-bright);
+}
+
+.square:focus {
+  outline: none;
+}
+
+.kbd-navigation .square:focus {
+  background: #ddd;
+}
diff --git a/scribe/scribe.jsx b/scribe/scribe.jsx
new file mode 100644 (file)
index 0000000..e7654b4
--- /dev/null
@@ -0,0 +1,494 @@
+function team_symbol(team) {
+  if (team === "+")
+    return "🞥";
+  else
+    return "🞇";
+}
+
+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 === "+")
+    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">
+      <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 (
+    <div className={className}
+         onClick={onClick}>
+      {props.value}
+    </div>
+  );
+}
+
+class Board extends React.Component {
+  render_square(i,j) {
+    const value = this.props.squares[i][j];
+    return (
+      <Square
+        value={value}
+        active={this.props.active && ! value}
+        onClick={() => this.props.onClick(i,j)}
+      />
+    );
+  }
+
+  render() {
+    return (
+      <div>
+        <div className="board-row">
+          {this.render_square(0,0)}
+          {this.render_square(0,1)}
+          {this.render_square(0,2)}
+          {" "}
+          {this.render_square(1,0)}
+          {this.render_square(1,1)}
+          {this.render_square(1,2)}
+          {" "}
+          {this.render_square(2,0)}
+          {this.render_square(2,1)}
+          {this.render_square(2,2)}
+        </div>
+        <div className="board-row">
+          {this.render_square(0,3)}
+          {this.render_square(0,4)}
+          {this.render_square(0,5)}
+          {" "}
+          {this.render_square(1,3)}
+          {this.render_square(1,4)}
+          {this.render_square(1,5)}
+          {" "}
+          {this.render_square(2,3)}
+          {this.render_square(2,4)}
+          {this.render_square(2,5)}
+        </div>
+        <div className="board-row">
+          {this.render_square(0,6)}
+          {this.render_square(0,7)}
+          {this.render_square(0,8)}
+          {" "}
+          {this.render_square(1,6)}
+          {this.render_square(1,7)}
+          {this.render_square(1,8)}
+          {" "}
+          {this.render_square(2,6)}
+          {this.render_square(2,7)}
+          {this.render_square(2,8)}
+        </div>
+
+        <div className="board-row">
+        </div>
+
+        <div className="board-row">
+          {this.render_square(3,0)}
+          {this.render_square(3,1)}
+          {this.render_square(3,2)}
+          {" "}
+          {this.render_square(4,0)}
+          {this.render_square(4,1)}
+          {this.render_square(4,2)}
+          {" "}
+          {this.render_square(5,0)}
+          {this.render_square(5,1)}
+          {this.render_square(5,2)}
+        </div>
+        <div className="board-row">
+          {this.render_square(3,3)}
+          {this.render_square(3,4)}
+          {this.render_square(3,5)}
+          {" "}
+          {this.render_square(4,3)}
+          {this.render_square(4,4)}
+          {this.render_square(4,5)}
+          {" "}
+          {this.render_square(5,3)}
+          {this.render_square(5,4)}
+          {this.render_square(5,5)}
+        </div>
+        <div className="board-row">
+          {this.render_square(3,6)}
+          {this.render_square(3,7)}
+          {this.render_square(3,8)}
+          {" "}
+          {this.render_square(4,6)}
+          {this.render_square(4,7)}
+          {this.render_square(4,8)}
+          {" "}
+          {this.render_square(5,6)}
+          {this.render_square(5,7)}
+          {this.render_square(5,8)}
+        </div>
+
+        <div className="board-row">
+        </div>
+
+        <div className="board-row">
+          {this.render_square(6,0)}
+          {this.render_square(6,1)}
+          {this.render_square(6,2)}
+          {" "}
+          {this.render_square(7,0)}
+          {this.render_square(7,1)}
+          {this.render_square(7,2)}
+          {" "}
+          {this.render_square(8,0)}
+          {this.render_square(8,1)}
+          {this.render_square(8,2)}
+        </div>
+        <div className="board-row">
+          {this.render_square(6,3)}
+          {this.render_square(6,4)}
+          {this.render_square(6,5)}
+          {" "}
+          {this.render_square(7,3)}
+          {this.render_square(7,4)}
+          {this.render_square(7,5)}
+          {" "}
+          {this.render_square(8,3)}
+          {this.render_square(8,4)}
+          {this.render_square(8,5)}
+        </div>
+        <div className="board-row">
+          {this.render_square(6,6)}
+          {this.render_square(6,7)}
+          {this.render_square(6,8)}
+          {" "}
+          {this.render_square(7,6)}
+          {this.render_square(7,7)}
+          {this.render_square(7,8)}
+          {" "}
+          {this.render_square(8,6)}
+          {this.render_square(8,7)}
+          {this.render_square(8,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).fill(null).map(() => Array(9).fill(null)),
+      moves: 0,
+      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 === 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;
+    let next_to_play;
+    if (this.state.next_to_play === "+")
+      next_to_play = "o";
+    else
+      next_to_play = "+";
+    this.setState({
+      squares: new_squares,
+      moves: this.state.moves + 1,
+      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 === 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}
+            onClick={(i,j) => this.handle_click(i, j, first_move)}
+          />
+        </div>
+      </div>
+    ];
+  }
+}
+
+ReactDOM.render(<Game
+                  ref={(me) => window.game = me}
+                />, document.getElementById("scribe"));