]> git.cworth.org Git - lmno.games/blob - scribe/scribe.jsx
548a0edc826c44258bf05af1e2cdc73325d7da18
[lmno.games] / scribe / scribe.jsx
1 function team_symbol(team) {
2   if (team === "+")
3     return "+";
4   else
5     return "o";
6 }
7
8 function undisplay(element) {
9   element.style.display="none";
10 }
11
12 function add_message(severity, message) {
13   message = `<div class="message ${severity}" onclick="undisplay(this)">
14 <span class="hide-button" onclick="undisplay(this.parentElement)">&times;</span>
15 ${message}
16 </div>`;
17   const message_area = document.getElementById('message-area');
18   message_area.insertAdjacentHTML('beforeend', message);
19 }
20
21 /*********************************************************
22  * Handling server-sent event stream                     *
23  *********************************************************/
24
25 const events = new EventSource("events");
26
27 events.onerror = function(event) {
28   if (event.target.readyState === EventSource.CLOSED) {
29     setTimeout(() => {
30       add_message("danger", "Connection to server lost.");
31     }, 1000);
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 const scribe_glyphs = [
83   {
84     name: "Single",
85     squares: [1,0,0,
86               0,0,0,
87               0,0,0]
88   },
89   {
90     name: "Double",
91     squares: [1,1,0,
92               0,0,0,
93               0,0,0]
94   },
95   {
96     name: "Line",
97     squares: [1,1,1,
98               0,0,0,
99               0,0,0]
100   },
101   {
102     name: "Pipe",
103     squares: [0,0,1,
104               1,1,1,
105               0,0,0]
106   },
107   {
108     name: "Squat-T",
109     squares: [1,1,1,
110               0,1,0,
111               0,0,0]
112   },
113   {
114     name: "4-block",
115     squares: [1,1,0,
116               1,1,0,
117               0,0,0]
118   },
119   {
120     name: "T",
121     squares: [1,1,1,
122               0,1,0,
123               0,1,0]
124   },
125   {
126     name: "Cross",
127     squares: [0,1,0,
128               1,1,1,
129               0,1,0]
130   },
131   {
132     name: "6-block",
133     squares: [1,1,1,
134               1,1,1,
135               0,0,0]
136   },
137   {
138     name: "Bomber",
139     squares: [1,1,1,
140               0,1,1,
141               0,0,1]
142   },
143   {
144     name: "Chair",
145     squares: [0,0,1,
146               1,1,1,
147               1,0,1]
148   },
149   {
150     name: "J",
151     squares: [0,0,1,
152               1,0,1,
153               1,1,1]
154   },
155   {
156     name: "Earring",
157     squares: [0,1,1,
158               1,0,1,
159               1,1,1]
160   },
161   {
162     name: "House",
163     squares: [0,1,0,
164               1,1,1,
165               1,1,1]
166   },
167   {
168     name: "H",
169     squares: [1,0,1,
170               1,1,1,
171               1,0,1]
172   },
173   {
174     name: "U",
175     squares: [1,0,1,
176               1,0,1,
177               1,1,1]
178   },
179   {
180     name: "Ottoman",
181     squares: [1,1,1,
182               1,1,1,
183               1,0,1]
184   },
185   {
186     name: "O",
187     squares: [1,1,1,
188               1,0,1,
189               1,1,1]
190   },
191   {
192     name: "9-block",
193     squares: [1,1,1,
194               1,1,1,
195               1,1,1]
196   }
197 ];
198
199 function copy_to_clipboard(id)
200 {
201   const tmp = document.createElement("input");
202   tmp.setAttribute("value", document.getElementById(id).innerHTML);
203   document.body.appendChild(tmp);
204   tmp.select();
205   document.execCommand("copy");
206   document.body.removeChild(tmp);
207 }
208
209 function GameInfo(props) {
210   if (! props.id)
211     return null;
212
213   return (
214     <div className="game-info">
215       <span className="game-id">{props.id}</span>
216       {" "}
217       Share this link to invite a friend:{" "}
218       <span id="game-share-url">{props.url}</span>
219       {" "}
220       <button
221         className="inline"
222         onClick={() => copy_to_clipboard('game-share-url')}
223       >Copy Link</button>
224     </div>
225   );
226 }
227
228 function TeamButton(props) {
229   return <button className="inline"
230                  onClick={() => props.game.join_team(props.team)}>
231            {props.label}
232          </button>;
233 }
234
235 function TeamChoices(props) {
236   let other_team;
237   if (props.player.team === "+")
238     other_team = "o";
239   else
240     other_team = "+";
241
242   if (props.player.team === "") {
243     if (props.first_move) {
244       return null;
245     } else {
246       return [
247         <TeamButton key="+" game={props.game} team="+" label="Join ðŸž¥" />,
248         " ",
249         <TeamButton key="o" game={props.game} team="o" label="Join ðŸž‡" />
250       ];
251     }
252   } else {
253     return <TeamButton game={props.game} team={other_team} label="Switch" />;
254   }
255 }
256
257 function PlayerInfo(props) {
258   if (! props.player.id)
259     return null;
260
261   const choices = <TeamChoices
262                     game={props.game}
263                     first_move={props.first_move}
264                     player={props.player}
265                   />;
266
267   return (
268     <div className="player-info">
269       <span className="players-header">Players: </span>
270       {props.player.name}
271       {props.player.team ? ` (${props.player.team})` : ""}
272       {props.first_move ? "" : " "}
273       {choices}
274       {props.other_players.map(other => (
275         <span key={other.id}>
276           {", "}
277           {other.name}
278           {other.team ? ` (${other.team})` : ""}
279         </span>
280       ))}
281     </div>
282   );
283 }
284
285 function Glyph(props) {
286
287   const glyph_dots = [];
288
289   let last_square = 0;
290   for (let i = 0; i < 9; i++) {
291     if (props.squares[i])
292       last_square = i;
293   }
294
295   const height = Math.floor(20 * (Math.floor(last_square / 3) + 1));
296
297   const viewbox=`0 0 60 ${height}`;
298
299   for (let row = 0; row < 3; row++) {
300     for (let col = 0; col < 3; col++) {
301       if (props.squares[3 * row + col]) {
302         let cy = 10 + 20 * row;
303         let cx = 10 + 20 * col;
304         glyph_dots.push(
305           <circle
306             key={3 * row + col}
307             cx={cx}
308             cy={cy}
309             r="8"
310           />
311         );
312       }
313     }
314   }
315
316   return (<div className="glyph-and-name">
317             {props.name}
318             <div className="glyph">
319               <svg viewBox={viewbox}>
320                 <g fill="#287789">
321                   {glyph_dots}
322                 </g>
323               </svg>
324             </div>
325           </div>
326          );
327 }
328
329 function Square(props) {
330   let className = "square";
331
332   if (props.value) {
333     className += " occupied";
334   } else if (props.active) {
335     className += " open";
336   }
337
338   const onClick = props.active ? props.onClick : null;
339
340   return (
341     <div className={className}
342          onClick={onClick}>
343       {props.value}
344     </div>
345   );
346 }
347
348 function MiniGrid(props) {
349   function grid_square(j) {
350     const value = props.squares[j];
351     return (
352       <Square
353         value={value}
354         active={props.active}
355         onClick={() => props.onClick(j)}
356       />
357     );
358   }
359
360   return (
361     <div className="mini-grid">
362       {grid_square(0)}
363       {grid_square(1)}
364       {grid_square(2)}
365       {grid_square(3)}
366       {grid_square(4)}
367       {grid_square(5)}
368       {grid_square(6)}
369       {grid_square(7)}
370       {grid_square(8)}
371     </div>
372   );
373 }
374
375 class Board extends React.Component {
376   mini_grid(i) {
377     const squares = this.props.squares[i];
378     return (
379       <MiniGrid
380         squares={squares}
381         active={this.props.active}
382         onClick={(j) => this.props.onClick(i,j)}
383       />
384     );
385   }
386
387   render() {
388     return (
389       <div className="board-container">
390         <div className="board">
391           {this.mini_grid(0)}
392           {this.mini_grid(1)}
393           {this.mini_grid(2)}
394           {this.mini_grid(3)}
395           {this.mini_grid(4)}
396           {this.mini_grid(5)}
397           {this.mini_grid(6)}
398           {this.mini_grid(7)}
399           {this.mini_grid(8)}
400         </div>
401       </div>
402     );
403   }
404 }
405
406 function fetch_method_json(method, api = '', data = {}) {
407   const response = fetch(api, {
408     method: method,
409     headers: {
410       'Content-Type': 'application/json'
411     },
412     body: JSON.stringify(data)
413   });
414   return response;
415 }
416
417 function fetch_post_json(api = '', data = {}) {
418   return fetch_method_json('POST', api, data);
419 }
420
421 async function fetch_put_json(api = '', data = {}) {
422   return fetch_method_json('PUT', api, data);
423 }
424
425 class Game extends React.Component {
426   constructor(props) {
427     super(props);
428     this.state = {
429       game_info: {},
430       player_info: {},
431       other_players: [],
432       squares: [...Array(9)].map(() => Array(9).fill(null)),
433       moves: 0,
434       next_to_play: "+"
435     };
436   }
437
438   set_game_info(info) {
439     this.setState({
440       game_info: info
441     });
442   }
443
444   set_player_info(info) {
445     this.setState({
446       player_info: info
447     });
448   }
449
450   set_other_player_info(info) {
451     const other_players_copy = [...this.state.other_players];
452     const idx = other_players_copy.findIndex(o => o.id === info.id);
453     if (idx >= 0) {
454       other_players_copy[idx] = info;
455     } else {
456       other_players_copy.push(info);
457     }
458     this.setState({
459       other_players: other_players_copy
460     });
461   }
462
463   reset_board() {
464     this.setState({
465       next_to_play: "+"
466     });
467   }
468
469   receive_move(move) {
470     if (this.state.moves === 81) {
471       return;
472     }
473     const symbol = team_symbol(this.state.next_to_play);
474     const new_squares = this.state.squares.map(arr => arr.slice());
475     new_squares[move[0]][move[1]] = symbol;
476     let next_to_play;
477     if (this.state.next_to_play === "+")
478       next_to_play = "o";
479     else
480       next_to_play = "+";
481     this.setState({
482       squares: new_squares,
483       moves: this.state.moves + 1,
484       next_to_play: next_to_play
485     });
486   }
487
488   async handle_click(i, j, first_move) {
489     let move = {
490       move: [i, j]
491     };
492     if (first_move) {
493       move.assert_first_move = true;
494     }
495     const response = await fetch_post_json("move", move);
496     if (response.status == 200) {
497       const result = await response.json();
498       if (! result.legal)
499         add_message("danger", result.message);
500     } else {
501       add_message("danger", `Error occurred sending move`);
502     }
503   }
504
505   join_team(team) {
506     fetch_put_json("player", {team: team});
507   }
508
509   render() {
510     const state = this.state;
511     const first_move = state.moves === 0;
512     const my_team = state.player_info.team;
513     var board_active;
514
515     let status;
516     if (this.state.moves.length === 81)
517     {
518       status = "Game over";
519       board_active = false;
520     }
521     else if (first_move)
522     {
523       if (state.other_players.length == 0) {
524         status = "You can move or wait for another player to join.";
525       } else {
526         let qualifier;
527         if (state.other_players.length == 1) {
528           qualifier = "Either";
529         } else {
530           qualifier = "Any";
531         }
532         status = `${qualifier} player can make the first move.`;
533       }
534       board_active = true;
535     }
536     else if (my_team === "")
537     {
538       status = "You're just watching the game.";
539       board_active = false;
540     }
541     else if (my_team === state.next_to_play)
542     {
543       status = "Your turn. Make a move.";
544       board_active = true;
545     }
546     else
547     {
548       status = "Waiting for another player to ";
549       if (state.other_players.length == 0) {
550         status += "join.";
551       } else {
552         status += "move.";
553       }
554       board_active = false;
555     }
556
557     return [
558       <GameInfo
559         key="game-info"
560         id={state.game_info.id}
561         url={state.game_info.url}
562       />,
563       <PlayerInfo
564         key="player-info"
565         game={this}
566         first_move={first_move}
567         player={state.player_info}
568         other_players={state.other_players}
569       />,
570       <div key="game" className="game">
571         <div>{status}</div>
572         <div className="game-board">
573           <Board
574             active={board_active}
575             squares={state.squares}
576             onClick={(i,j) => this.handle_click(i, j, first_move)}
577           />
578         </div>
579       </div>,
580       <div key="glyphs" className="glyphs">
581         {
582           scribe_glyphs.map(glyph => {
583             return (
584               <Glyph
585                 key={glyph.name}
586                 name={glyph.name}
587                 squares={glyph.squares}
588               />
589             );
590           })
591         }
592       </div>
593     ];
594   }
595 }
596
597 ReactDOM.render(<Game
598                   ref={(me) => window.game = me}
599                 />, document.getElementById("scribe"));