Empathy: Display player scores at the main screen of the game
[lmno.games] / empathy / empathy.jsx
1 function undisplay(element) {
2   element.style.display="none";
3 }
4
5 function add_message(severity, message) {
6   message = `<div class="message ${severity}" onclick="undisplay(this)">
7 <span class="hide-button" onclick="undisplay(this.parentElement)">&times;</span>
8 ${message}
9 </div>`;
10   const message_area = document.getElementById('message-area');
11   message_area.insertAdjacentHTML('beforeend', message);
12 }
13
14 /*********************************************************
15  * Handling server-sent event stream                     *
16  *********************************************************/
17
18 const events = new EventSource("events");
19
20 events.onerror = function(event) {
21   if (event.target.readyState === EventSource.CLOSED) {
22     setTimeout(() => {
23       add_message("danger", "Connection to server lost.");
24     }, 1000);
25   }
26 };
27
28 events.addEventListener("game-info", event => {
29   const info = JSON.parse(event.data);
30
31   window.game.set_game_info(info);
32 });
33
34 events.addEventListener("player-info", event => {
35   const info = JSON.parse(event.data);
36
37   window.game.set_player_info(info);
38 });
39
40 events.addEventListener("player-enter", event => {
41   const info = JSON.parse(event.data);
42
43   window.game.set_other_player_info(info);
44 });
45
46 events.addEventListener("player-update", event => {
47   const info = JSON.parse(event.data);
48
49   if (info.id === window.game.state.player_info.id)
50     window.game.set_player_info(info);
51   else
52     window.game.set_other_player_info(info);
53 });
54
55 events.addEventListener("game-state", event => {
56   const state = JSON.parse(event.data);
57
58   window.game.set_prompts(state.prompts);
59
60   window.game.set_active_prompt(state.active_prompt);
61
62   window.game.set_scores(state.scores);
63 });
64
65 events.addEventListener("prompt", event => {
66   const prompt = JSON.parse(event.data);
67
68   window.game.add_or_update_prompt(prompt);
69 });
70
71 events.addEventListener("start", event => {
72   const prompt = JSON.parse(event.data);
73
74   window.game.set_active_prompt(prompt);
75 });
76
77 events.addEventListener("answered", event => {
78   const players_answered = JSON.parse(event.data);
79
80   window.game.set_players_answered(players_answered);
81 });
82
83 events.addEventListener("scores", event => {
84   const scores = JSON.parse(event.data);
85
86   window.game.set_scores(scores);
87 });
88
89 /*********************************************************
90  * Game and supporting classes                           *
91  *********************************************************/
92
93 function copy_to_clipboard(id)
94 {
95   const tmp = document.createElement("input");
96   tmp.setAttribute("value", document.getElementById(id).innerHTML);
97   document.body.appendChild(tmp);
98   tmp.select();
99   document.execCommand("copy");
100   document.body.removeChild(tmp);
101 }
102
103 const GameInfo = React.memo(props => {
104   if (! props.id)
105     return null;
106
107   return (
108     <div className="game-info">
109       <span className="game-id">{props.id}</span>
110       {" "}
111       Share this link to invite friends:{" "}
112       <span id="game-share-url">{props.url}</span>
113       {" "}
114       <button
115         className="inline"
116         onClick={() => copy_to_clipboard('game-share-url')}
117       >Copy Link</button>
118     </div>
119   );
120 });
121
122 const PlayerInfo = React.memo(props => {
123   if (! props.player.id)
124     return null;
125
126   return (
127     <div className="player-info">
128       <span className="players-header">Players: </span>
129       {props.player.name}
130       {props.player.score > 0 ? ` (${props.player.score})` : ""}
131       {props.other_players.map(other => (
132         <span key={other.id}>
133           {", "}
134           {other.name}
135           {other.score > 0 ? ` (${other.score})` : ""}
136         </span>
137       ))}
138     </div>
139   );
140 });
141
142 function fetch_method_json(method, api = '', data = {}) {
143   const response = fetch(api, {
144     method: method,
145     headers: {
146       'Content-Type': 'application/json'
147     },
148     body: JSON.stringify(data)
149   });
150   return response;
151 }
152
153 function fetch_post_json(api = '', data = {}) {
154   return fetch_method_json('POST', api, data);
155 }
156
157 async function fetch_put_json(api = '', data = {}) {
158   return fetch_method_json('PUT', api, data);
159 }
160
161 class CategoryRequest extends React.PureComponent {
162   constructor(props) {
163     super(props);
164     this.category = React.createRef();
165
166     this.handle_change = this.handle_change.bind(this);
167     this.handle_submit = this.handle_submit.bind(this);
168   }
169
170   handle_change(event) {
171     const category_input = this.category.current;
172     const category = category_input.value;
173
174     if (/[0-9]/.test(category))
175       category_input.setCustomValidity("");
176   }
177
178   handle_submit(event) {
179     const form = event.currentTarget;
180     const category_input = this.category.current;
181     const category = category_input.value;
182
183     /* Prevent the default page-changing form-submission behavior. */
184     event.preventDefault();
185
186     const match = category.match(/[0-9]+/);
187     if (match === null) {
188       category_input.setCustomValidity("Category must include a number");
189       form.reportValidity();
190       return;
191     }
192
193     fetch_post_json("prompts", {
194       items: parseInt(match[0], 10),
195       prompt: category
196     });
197
198     form.reset();
199   }
200
201   render() {
202     return (
203       <div className="category-request">
204         <h2>Submit a Category</h2>
205         <p>
206           Suggest a category to play. Don't forget to include the
207           number of items for each person to submit.
208         </p>
209
210         <form onSubmit={this.handle_submit} >
211           <div className="form-field large">
212             <input
213               type="text"
214               id="category"
215               placeholder="6 things at the beach"
216               required
217               autoComplete="off"
218               onChange={this.handle_change}
219               ref={this.category}
220             />
221           </div>
222
223           <div className="form-field large">
224             <button type="submit">
225               Send
226             </button>
227           </div>
228
229         </form>
230       </div>
231     );
232   }
233 }
234
235 const PromptOptions = React.memo(props => {
236
237   if (props.prompts.length === 0)
238     return null;
239
240   return (
241     <div className="prompt-options">
242       <h2>Vote on Categories</h2>
243       <p>
244         Select any categories below that you'd like to play.
245         You can choose as many as you'd like.
246       </p>
247       {props.prompts.map(p => {
248         return (
249           <button
250             className="vote-button"
251             key={p.id}
252             onClick={() => fetch_post_json(`vote/${p.id}`) }
253           >
254             {p.prompt}
255             <div className="vote-choices">
256               {p.votes.map(v => {
257                 return (
258                   <div
259                     key={v}
260                     className="vote-choice"
261                   >
262                     {v}
263                   </div>
264                 );
265               })}
266             </div>
267           </button>
268         );
269       })}
270     </div>
271   );
272 });
273
274 const LetsPlay = React.memo(props => {
275
276   function handle_click(prompt_id) {
277     fetch_post_json
278   }
279
280   const quorum = Math.round((props.num_players + 1) / 2);
281   const max_votes = props.prompts.reduce(
282     (max_so_far, v) => Math.max(max_so_far, v.votes.length), 0);
283
284   if (max_votes < quorum)
285     return null;
286
287   const candidates = props.prompts.filter(p => p.votes.length >= quorum);
288   const index = Math.floor(Math.random() * candidates.length);
289   const winner = candidates[index];
290
291   return (
292     <div className="lets-play">
293       <h2>Let's Play</h2>
294       <p>
295         That should be enough voting. If you're not waiting for any
296         other players to join, then let's start.
297       </p>
298       <button
299         className="lets-play"
300         onClick={() => fetch_post_json(`start/${winner.id}`) }
301       >
302         Start Game
303       </button>
304     </div>
305   );
306 });
307
308 class ActivePrompt extends React.PureComponent {
309
310   constructor(props) {
311     super(props);
312     const items = props.prompt.items;
313
314     this.state = {
315       submitted: false
316     };
317
318     this.answers = [...Array(items)].map(() => React.createRef());
319     this.handle_submit = this.handle_submit.bind(this);
320   }
321
322   async handle_submit(event) {
323     const form = event.currentTarget;
324
325     /* Prevent the default page-changing form-submission behavior. */
326     event.preventDefault();
327
328     const response = await fetch_post_json(`answer/${this.props.prompt.id}`, {
329       answers: this.answers.map(r => r.current.value)
330     });
331     if (response.status == 200) {
332       const result = await response.json();
333       if (! result.valid) {
334         add_message("danger", result.message);
335         return;
336       }
337     } else {
338       add_message("danger", "An error occurred submitting your answers");
339       return;
340     }
341
342     /* Everything worked. Server is happy with our answers. */
343     form.reset();
344     this.setState({
345         submitted: true
346     });
347   }
348
349   render() {
350     if (this.state.submitted)
351       return (
352         <div className="please-wait">
353           <h2>{this.props.players_answered}/
354             {this.props.players_total} players have responded</h2>
355           <p>
356             Please wait for the rest of the players to submit their answers.
357           </p>
358         </div>
359       );
360
361     return (
362       <div className="active-prompt">
363         <h2>The Game of Empathy</h2>
364         <p>
365           Remember, you're trying to match your answers with
366           what the other players submit.
367           Give {this.props.prompt.items} answers for the following prompt:
368         </p>
369         <h2>{this.props.prompt.prompt}</h2>
370         <form onSubmit={this.handle_submit}>
371           {Array(this.props.prompt.items).fill(null).map((whocares,i) => {
372             return (
373               <div
374                 key={i}
375                 className="form-field large">
376                 <input
377                   type="text"
378                   name={`answer_${i}`}
379                   required
380                   autoComplete="off"
381                   ref={this.answers[i]}
382                 />
383               </div>
384             );
385           })}
386
387           <div
388             key="submit-button"
389             className="form-field large">
390             <button type="submit">
391               Send
392             </button>
393           </div>
394
395         </form>
396       </div>
397     );
398   }
399 }
400
401 class Game extends React.PureComponent {
402   constructor(props) {
403     super(props);
404     this.state = {
405       game_info: {},
406       player_info: {},
407       other_players: [],
408       prompts: [],
409       active_prompt: null,
410       players_answered: 0
411     };
412   }
413
414   set_game_info(info) {
415     this.setState({
416       game_info: info
417     });
418   }
419
420   set_player_info(info) {
421     this.setState({
422       player_info: info
423     });
424   }
425
426   set_other_player_info(info) {
427     const other_players_copy = [...this.state.other_players];
428     const idx = other_players_copy.findIndex(o => o.id === info.id);
429     if (idx >= 0) {
430       other_players_copy[idx] = info;
431     } else {
432       other_players_copy.push(info);
433     }
434     this.setState({
435       other_players: other_players_copy
436     });
437   }
438
439   set_prompts(prompts) {
440     this.setState({
441       prompts: prompts
442     });
443   }
444
445   add_or_update_prompt(prompt) {
446     const prompts_copy = [...this.state.prompts];
447     const idx = prompts_copy.findIndex(p => p.id === prompt.id);
448     if (idx >= 0) {
449       prompts_copy[idx] = prompt;
450     } else {
451       prompts_copy.push(prompt);
452     }
453     this.setState({
454       prompts: prompts_copy
455     });
456   }
457
458   set_active_prompt(prompt) {
459     this.setState({
460       active_prompt: prompt
461     });
462   }
463
464   set_players_answered(players_answered) {
465     this.setState({
466       players_answered: players_answered
467     });
468   }
469
470   set_scores(scores) {
471     this.setState({
472       scores: scores
473     });
474   }
475
476   render() {
477     const state = this.state;
478     const players_total = 1 + state.other_players.length;
479
480     if (state.scores) {
481       return (
482         <div className="scores">
483           <h2>Scores</h2>
484           <ul>
485             {state.scores.scores.map(score => {
486               return (
487                 <li key={score.player}>
488                   {score.player}: {score.score}
489                 </li>
490               );
491             })}
492           </ul>
493           <h2>Words submitted</h2>
494           <ul>
495             {state.scores.words.map(word => {
496               return (
497                 <li key={word.word}>
498                   {word.word}:
499                   {word.players.map(p => {
500                     return (
501                       <span key={p}>{p}{" "}</span>
502                     );
503                   })}
504                 </li>
505               );
506             })}
507           </ul>
508           <button
509             className="new-game"
510             onClick={() => fetch_post_json('reset') }
511           >
512             New Game
513           </button>
514         </div>
515       );
516     }
517
518     if (state.active_prompt) {
519       return <ActivePrompt
520                prompt={state.active_prompt}
521                players_answered={state.players_answered}
522                players_total={players_total}
523              />;
524     }
525
526     return [
527       <GameInfo
528         key="game-info"
529         id={state.game_info.id}
530         url={state.game_info.url}
531       />,
532       <PlayerInfo
533         key="player-info"
534         game={this}
535         player={state.player_info}
536         other_players={state.other_players}
537       />,
538       <p key="spacer"></p>,
539       <CategoryRequest
540         key="category-request"
541       />,
542       <PromptOptions
543         key="prompts"
544         prompts={state.prompts}
545       />,
546       <LetsPlay
547         key="lets-play"
548         num_players={1+state.other_players.length}
549         prompts={state.prompts}
550       />
551     ];
552   }
553 }
554
555 ReactDOM.render(<Game
556                   ref={(me) => window.game = me}
557                 />, document.getElementById("empathy"));