]> git.cworth.org Git - lmno.games/blob - tictactoe/tictactoe.jsx
Display all players, not just a single component
[lmno.games] / tictactoe / tictactoe.jsx
1 const Team = {
2   X: 0,
3   O: 1,
4   properties: {
5     0: {name: "X"},
6     1: {name: "O"}
7   }
8 };
9
10 function undisplay(element) {
11   element.style.display="none";
12 }
13
14 function add_message(severity, message) {
15   message = `<div class="message ${severity}" onclick="undisplay(this)">
16 <span class="hide-button" onclick="undisplay(this.parentElement)">&times;</span>
17 ${message}
18 </div>`;
19   const message_area = document.getElementById('message-area');
20   message_area.insertAdjacentHTML('beforeend', message);
21 }
22
23 /*********************************************************
24  * Handling server-sent event stream                     *
25  *********************************************************/
26
27 const events = new EventSource("events");
28
29 events.onerror = function(event) {
30   if (event.target.readyState === EventSource.CLOSED) {
31       add_message("danger", "Connection to server lost.");
32   }
33 };
34
35 events.addEventListener("game-info", event => {
36   const info = JSON.parse(event.data);
37
38   window.game.set_game_info(info);
39 });
40
41 events.addEventListener("player-info", event => {
42   const info = JSON.parse(event.data);
43
44   window.game.set_player_info(info);
45 });
46
47 events.addEventListener("player-enter", event => {
48   const info = JSON.parse(event.data);
49
50   window.game.set_opponent_info(info);
51 });
52
53 events.addEventListener("player-update", event => {
54   const info = JSON.parse(event.data);
55
56   if (info.id === window.game.state.player_info.id)
57     window.game.set_player_info(info);
58   else
59     window.game.set_opponent_info(info);
60 });
61
62 events.addEventListener("move", event => {
63   const move = JSON.parse(event.data);
64
65   window.game.receive_move(move);
66 });
67
68 events.addEventListener("game-state", event => {
69   const state = JSON.parse(event.data);
70
71   window.game.reset_board();
72
73   for (let square of state.moves) {
74     window.game.receive_move(square);
75   }
76 });
77
78 /*********************************************************
79  * Game and supporting classes                           *
80  *********************************************************/
81
82 function GameInfo(props) {
83   if (! props.id)
84     return null;
85
86   return (
87     <div className="game-info">
88       <h2>{props.id}</h2>
89       Invite a friend to play by sending this URL: {props.url}
90     </div>
91   );
92 }
93
94 function PlayerInfo(props) {
95   if (! props.player.id)
96     return null;
97
98   return (
99     <div className="player-info">
100       <h2>Players</h2>
101       {props.player.name}
102       {props.player.team ? ` (${props.player.team})` : ""}
103       {props.opponents.map(opponent => (
104         <span key={opponent.id}>
105           {", "}
106           {opponent.name}
107           {opponent.team ? ` (${opponent.team})` : ""}
108         </span>
109       ))}
110     </div>
111   );
112 }
113
114 function Square(props) {
115   let className = "square";
116
117   if (props.value) {
118     className += " occupied";
119   } else if (props.active) {
120     className += " open";
121   }
122
123   const onClick = props.active ? props.onClick : null;
124
125   return (
126     <div className={className}
127          onClick={onClick}>
128       {props.value}
129     </div>
130   );
131 }
132
133 class Board extends React.Component {
134   render_square(i) {
135     const value = this.props.squares[i];
136     return (
137       <Square
138         value={value}
139         active={this.props.active && ! value}
140         onClick={() => this.props.onClick(i)}
141       />
142     );
143   }
144
145   render() {
146     return (
147       <div>
148         <div className="board-row">
149           {this.render_square(0)}
150           {this.render_square(1)}
151           {this.render_square(2)}
152         </div>
153         <div className="board-row">
154           {this.render_square(3)}
155           {this.render_square(4)}
156           {this.render_square(5)}
157         </div>
158         <div className="board-row">
159           {this.render_square(6)}
160           {this.render_square(7)}
161           {this.render_square(8)}
162         </div>
163       </div>
164     );
165   }
166 }
167
168 function fetch_method_json(method, api = '', data = {}) {
169   const response = fetch(api, {
170     method: method,
171     headers: {
172       'Content-Type': 'application/json'
173     },
174     body: JSON.stringify(data)
175   });
176   return response;
177 }
178
179 function fetch_post_json(api = '', data = {}) {
180   return fetch_method_json('POST', api, data);
181 }
182
183 async function fetch_put_json(api = '', data = {}) {
184   return fetch_method_json('PUT', api, data);
185 }
186
187 class Game extends React.Component {
188   constructor(props) {
189     super(props);
190     this.state = {
191       game_info: {},
192       player_info: {},
193       opponent_info: [],
194       history: [
195         {
196           squares: Array(9).fill(null)
197         }
198       ],
199       step_number: 0,
200       next_to_play: Team.X
201     };
202   }
203
204   set_game_info(info) {
205     this.setState({
206       game_info: info
207     });
208   }
209
210   set_player_info(info) {
211     this.setState({
212       player_info: info
213     });
214   }
215
216   set_opponent_info(info) {
217     const new_opponents = [...this.state.opponent_info];
218     const idx = new_opponents.findIndex(o => o.id === info.id);
219     if (idx >= 0) {
220       new_opponents[idx] = info;
221     } else {
222       new_opponents.push(info);
223     }
224     this.setState({
225       opponent_info: new_opponents
226     });
227   }
228
229   reset_board() {
230     this.setState({
231       history: [
232         {
233           squares: Array(9).fill(null)
234         }
235       ],
236       step_number: 0,
237       next_to_play: Team.X
238     });
239   }
240
241   receive_move(i) {
242     const history = this.state.history.slice(0, this.state.step_number + 1);
243     const current = history[history.length - 1];
244     const squares = current.squares.slice();
245     if (calculate_winner(squares) || squares[i]) {
246       return;
247     }
248     squares[i] = Team.properties[this.state.next_to_play].name;
249     let next_to_play;
250     if (this.state.next_to_play === Team.X)
251       next_to_play = Team.O;
252     else
253       next_to_play = Team.X;
254     this.setState({
255       history: history.concat([
256         {
257           squares: squares
258         }
259       ]),
260       step_number: history.length,
261       next_to_play: next_to_play
262     });
263   }
264
265   async handle_click(i, first_move) {
266     let move = { move: i };
267     if (first_move) {
268       move.assert_first_move = true;
269     }
270     const response = await fetch_post_json("move", move);
271     if (response.status == 200) {
272       const result = await response.json();
273       if (! result.legal)
274         add_message("danger", result.message);
275     } else {
276       add_message("danger", `Error occurred sending move`);
277     }
278   }
279
280   join_team(team) {
281     fetch_put_json("player", {team: team});
282   }
283
284   render() {
285     const state = this.state;
286     const history = state.history;
287     const current = history[state.step_number];
288     const winner = calculate_winner(current.squares);
289     const first_move = state.step_number === 0;
290     const my_team = state.player_info.team;
291     var board_active;
292
293     let status;
294     if (winner)
295     {
296       status = winner + " wins!";
297       if (state.player_info.team != "")
298       {
299         if (my_team === winner)
300           status += " Congratulations!";
301         else
302           status += " Better luck next time.";
303       }
304       board_active = false;
305     }
306     else if (first_move)
307     {
308       status = "Either player can make the first move.";
309       board_active = true;
310     }
311     else if (my_team === "")
312     {
313       status = "You're just watching the game.";
314       board_active = false;
315     }
316     else if (my_team === Team.properties[state.next_to_play].name)
317     {
318       status = "Your turn. Make a move.";
319       board_active = true;
320     }
321     else
322     {
323       status = "Waiting for your opponent to move.";
324       board_active = false;
325     }
326
327     return [
328       <GameInfo
329         key="game-info"
330         id={state.game_info.id}
331         url={state.game_info.url}
332       />,
333       <PlayerInfo
334         key="player-info"
335         player={state.player_info}
336         opponents={state.opponent_info}
337       />,
338       <div key="game" className="game">
339         <button className="inline"
340                 onClick={() => this.join_team('X')}>Join Team X</button>
341         {" "}
342         <button className="inline"
343                 onClick={() => this.join_team('O')}>Join Team O</button>
344         <div>{status}</div>
345         <div className="game-board">
346           <Board
347             active={board_active}
348             squares={current.squares}
349             onClick={i => this.handle_click(i, first_move)}
350           />
351         </div>
352       </div>
353     ];
354   }
355 }
356
357 ReactDOM.render(<Game
358                   ref={(me) => window.game = me}
359                 />, document.getElementById("tictactoe"));
360
361 function calculate_winner(squares) {
362   const lines = [
363     [0, 1, 2],
364     [3, 4, 5],
365     [6, 7, 8],
366     [0, 3, 6],
367     [1, 4, 7],
368     [2, 5, 8],
369     [0, 4, 8],
370     [2, 4, 6]
371   ];
372   for (let i = 0; i < lines.length; i++) {
373     const [a, b, c] = lines[i];
374     if (squares[a] && squares[a] === squares[b] && squares[a] === squares[c]) {
375       return squares[a];
376     }
377   }
378   return null;
379 }