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