]> git.cworth.org Git - lmno.games/blobdiff - tictactoe/tictactoe.jsx
Allow either player to make the first move.
[lmno.games] / tictactoe / tictactoe.jsx
index eff7eff32f6df572437130d0ec2c0e8ecf8c2a7b..46cee2a7f42e9f4bc3a7809e7d93b3855eb383ed 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-update", event => {
+  const info = JSON.parse(event.data);
+
+  if (info.id === window.game.state.player_info.id)
+    window.game.set_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 PlayerInfo(props) {
+  if (! props.id)
+    return null;
+
+  return (
+    <div className="player-info">
+      <h2>Player</h2>
+      {props.name}, ID: {props.id},
+      {props.team ? ` on team ${props.team}` : " not on a team"}
+    </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 +131,206 @@ 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: {},
       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
+    });
+  }
+
+  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)
+    {
+      status = "Either 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 your opponent to 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"
+        id={state.player_info.id}
+        name={state.player_info.name}
+        team={state.player_info.team}
+      />,
+      <div key="game" className="game">
+        <button className="inline"
+                onClick={() => this.join_team('X')}>Join Team X</button>
+        &nbsp;
+        <button className="inline"
+                onClick={() => this.join_team('O')}>Join Team O</button>
+        <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],