]> git.cworth.org Git - lmno.games/blob - tictactoe/tictactoe.jsx
dbfc5ef93e9ba41d21f026a14653e179828fbd4a
[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_other_player_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_other_player_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 TeamButton(props) {
95   return <button className="inline"
96                  onClick={() => props.game.join_team(props.team)}>
97            {props.label}
98          </button>;
99 }
100
101 function TeamChoices(props) {
102   let other_team;
103   if (props.player.team === "X")
104     other_team = "O";
105   else
106     other_team = "X";
107
108   if (props.player.team === "") {
109     if (props.first_move) {
110       return null;
111     } else {
112       return [
113         <TeamButton key="X" game={props.game} team="X" label="Join X" />,
114         " ",
115         <TeamButton key="O" game={props.game} team="O" label="Join O" />
116       ];
117     }
118   } else {
119     return <TeamButton game={props.game} team={other_team} label="Switch" />;
120   }
121 }
122
123 function PlayerInfo(props) {
124   if (! props.player.id)
125     return null;
126
127   const choices = <TeamChoices
128                     game={props.game}
129                     first_move={props.first_move}
130                     player={props.player}
131                   />;
132
133   return (
134     <div className="player-info">
135       <h2>Players</h2>
136       {props.player.name}
137       {props.player.team ? ` (${props.player.team})` : ""}
138       {props.first_move ? "" : " "}
139       {choices}
140       {props.other_players.map(other => (
141         <span key={other.id}>
142           {", "}
143           {other.name}
144           {other.team ? ` (${other.team})` : ""}
145         </span>
146       ))}
147     </div>
148   );
149 }
150
151 function Square(props) {
152   let className = "square";
153
154   if (props.value) {
155     className += " occupied";
156   } else if (props.active) {
157     className += " open";
158   }
159
160   const onClick = props.active ? props.onClick : null;
161
162   return (
163     <div className={className}
164          onClick={onClick}>
165       {props.value}
166     </div>
167   );
168 }
169
170 class Board extends React.Component {
171   render_square(i) {
172     const value = this.props.squares[i];
173     return (
174       <Square
175         value={value}
176         active={this.props.active && ! value}
177         onClick={() => this.props.onClick(i)}
178       />
179     );
180   }
181
182   render() {
183     return (
184       <div>
185         <div className="board-row">
186           {this.render_square(0)}
187           {this.render_square(1)}
188           {this.render_square(2)}
189         </div>
190         <div className="board-row">
191           {this.render_square(3)}
192           {this.render_square(4)}
193           {this.render_square(5)}
194         </div>
195         <div className="board-row">
196           {this.render_square(6)}
197           {this.render_square(7)}
198           {this.render_square(8)}
199         </div>
200       </div>
201     );
202   }
203 }
204
205 function fetch_method_json(method, api = '', data = {}) {
206   const response = fetch(api, {
207     method: method,
208     headers: {
209       'Content-Type': 'application/json'
210     },
211     body: JSON.stringify(data)
212   });
213   return response;
214 }
215
216 function fetch_post_json(api = '', data = {}) {
217   return fetch_method_json('POST', api, data);
218 }
219
220 async function fetch_put_json(api = '', data = {}) {
221   return fetch_method_json('PUT', api, data);
222 }
223
224 class Game extends React.Component {
225   constructor(props) {
226     super(props);
227     this.state = {
228       game_info: {},
229       player_info: {},
230       other_players: [],
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   set_game_info(info) {
242     this.setState({
243       game_info: info
244     });
245   }
246
247   set_player_info(info) {
248     this.setState({
249       player_info: info
250     });
251   }
252
253   set_other_player_info(info) {
254     const other_players_copy = [...this.state.other_players];
255     const idx = other_players_copy.findIndex(o => o.id === info.id);
256     if (idx >= 0) {
257       other_players_copy[idx] = info;
258     } else {
259       other_players_copy.push(info);
260     }
261     this.setState({
262       other_players: other_players_copy
263     });
264   }
265
266   reset_board() {
267     this.setState({
268       history: [
269         {
270           squares: Array(9).fill(null)
271         }
272       ],
273       step_number: 0,
274       next_to_play: Team.X
275     });
276   }
277
278   receive_move(i) {
279     const history = this.state.history.slice(0, this.state.step_number + 1);
280     const current = history[history.length - 1];
281     const squares = current.squares.slice();
282     if (calculate_winner(squares) || squares[i]) {
283       return;
284     }
285     squares[i] = Team.properties[this.state.next_to_play].name;
286     let next_to_play;
287     if (this.state.next_to_play === Team.X)
288       next_to_play = Team.O;
289     else
290       next_to_play = Team.X;
291     this.setState({
292       history: history.concat([
293         {
294           squares: squares
295         }
296       ]),
297       step_number: history.length,
298       next_to_play: next_to_play
299     });
300   }
301
302   async handle_click(i, first_move) {
303     let move = { move: i };
304     if (first_move) {
305       move.assert_first_move = true;
306     }
307     const response = await fetch_post_json("move", move);
308     if (response.status == 200) {
309       const result = await response.json();
310       if (! result.legal)
311         add_message("danger", result.message);
312     } else {
313       add_message("danger", `Error occurred sending move`);
314     }
315   }
316
317   join_team(team) {
318     fetch_put_json("player", {team: team});
319   }
320
321   render() {
322     const state = this.state;
323     const history = state.history;
324     const current = history[state.step_number];
325     const winner = calculate_winner(current.squares);
326     const first_move = state.step_number === 0;
327     const my_team = state.player_info.team;
328     var board_active;
329
330     let status;
331     if (winner)
332     {
333       status = winner + " wins!";
334       if (state.player_info.team != "")
335       {
336         if (my_team === winner)
337           status += " Congratulations!";
338         else
339           status += " Better luck next time.";
340       }
341       board_active = false;
342     }
343     else if (first_move)
344     {
345       if (state.other_players.length == 0) {
346         status = "You can move or wait for another player to join.";
347       } else {
348         let qualifier;
349         if (state.other_players.length == 1) {
350           qualifier = "Either";
351         } else {
352           qualifier = "Any";
353         }
354         status = `${qualifier} player can make the first move.`;
355       }
356       board_active = true;
357     }
358     else if (my_team === "")
359     {
360       status = "You're just watching the game.";
361       board_active = false;
362     }
363     else if (my_team === Team.properties[state.next_to_play].name)
364     {
365       status = "Your turn. Make a move.";
366       board_active = true;
367     }
368     else
369     {
370       status = "Waiting for another player to ";
371       if (state.other_players.length == 0) {
372         status += "join.";
373       } else {
374         status += "move.";
375       }
376       board_active = false;
377     }
378
379     return [
380       <GameInfo
381         key="game-info"
382         id={state.game_info.id}
383         url={state.game_info.url}
384       />,
385       <PlayerInfo
386         key="player-info"
387         game={this}
388         first_move={first_move}
389         player={state.player_info}
390         other_players={state.other_players}
391       />,
392       <div key="game" className="game">
393         <div>{status}</div>
394         <div className="game-board">
395           <Board
396             active={board_active}
397             squares={current.squares}
398             onClick={i => this.handle_click(i, first_move)}
399           />
400         </div>
401       </div>
402     ];
403   }
404 }
405
406 ReactDOM.render(<Game
407                   ref={(me) => window.game = me}
408                 />, document.getElementById("tictactoe"));
409
410 function calculate_winner(squares) {
411   const lines = [
412     [0, 1, 2],
413     [3, 4, 5],
414     [6, 7, 8],
415     [0, 3, 6],
416     [1, 4, 7],
417     [2, 5, 8],
418     [0, 4, 8],
419     [2, 4, 6]
420   ];
421   for (let i = 0; i < lines.length; i++) {
422     const [a, b, c] = lines[i];
423     if (squares[a] && squares[a] === squares[b] && squares[a] === squares[c]) {
424       return squares[a];
425     }
426   }
427   return null;
428 }