]> git.cworth.org Git - lmno.games/blob - empathy/empathy.jsx
b03d697ef1cfd2561ffc1b223d4939e3b616fa4a
[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   for (let prompt of state.prompts) {
59     window.game.add_or_update_prompt(prompt);
60   }
61
62   window.game.set_active_prompt(state.active_prompt);
63
64   window.game.set_scores(state.scores);
65 });
66
67 events.addEventListener("prompt", event => {
68   const prompt = JSON.parse(event.data);
69
70   window.game.add_or_update_prompt(prompt);
71 });
72
73 events.addEventListener("start", event => {
74   const prompt = JSON.parse(event.data);
75
76   window.game.set_active_prompt(prompt);
77 });
78
79 events.addEventListener("answered", event => {
80   const players_answered = JSON.parse(event.data);
81
82   window.game.set_players_answered(players_answered);
83 });
84
85 events.addEventListener("scores", event => {
86   const scores = JSON.parse(event.data);
87
88   window.game.set_scores(scores);
89 });
90
91 /*********************************************************
92  * Game and supporting classes                           *
93  *********************************************************/
94
95 function copy_to_clipboard(id)
96 {
97   const tmp = document.createElement("input");
98   tmp.setAttribute("value", document.getElementById(id).innerHTML);
99   document.body.appendChild(tmp);
100   tmp.select();
101   document.execCommand("copy");
102   document.body.removeChild(tmp);
103 }
104
105 const GameInfo = React.memo(props => {
106   if (! props.id)
107     return null;
108
109   return (
110     <div className="game-info">
111       <span className="game-id">{props.id}</span>
112       {" "}
113       Share this link to invite friends:{" "}
114       <span id="game-share-url">{props.url}</span>
115       {" "}
116       <button
117         className="inline"
118         onClick={() => copy_to_clipboard('game-share-url')}
119       >Copy Link</button>
120     </div>
121   );
122 });
123
124 const PlayerInfo = React.memo(props => {
125   if (! props.player.id)
126     return null;
127
128   return (
129     <div className="player-info">
130       <span className="players-header">Players: </span>
131       {props.player.name}
132       {props.other_players.map(other => (
133         <span key={other.id}>
134           {", "}
135           {other.name}
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   add_or_update_prompt(prompt) {
440     const prompts_copy = [...this.state.prompts];
441     const idx = prompts_copy.findIndex(p => p.id === prompt.id);
442     if (idx >= 0) {
443       prompts_copy[idx] = prompt;
444     } else {
445       prompts_copy.push(prompt);
446     }
447     this.setState({
448       prompts: prompts_copy
449     });
450   }
451
452   set_active_prompt(prompt) {
453     this.setState({
454       active_prompt: prompt
455     });
456   }
457
458   set_players_answered(players_answered) {
459     this.setState({
460       players_answered: players_answered
461     });
462   }
463
464   set_scores(scores) {
465     this.setState({
466       scores: scores
467     });
468   }
469
470   render() {
471     const state = this.state;
472     const players_total = 1 + state.other_players.length;
473
474     if (state.scores) {
475       return (
476         <div className="scores">
477           <h2>Scores</h2>
478           <ul>
479             {state.scores.scores.map(score => {
480               return (
481                 <li key={score.player}>
482                   {score.player}: {score.score}
483                 </li>
484               );
485             })}
486           </ul>
487           <h2>Words submitted</h2>
488           <ul>
489             {state.scores.words.map(word => {
490               return (
491                 <li key={word.word}>
492                   {word.word}:
493                   {word.players.map(p => {
494                     return (
495                       <span key={p}>{p}{" "}</span>
496                     );
497                   })}
498                 </li>
499               );
500             })}
501           </ul>
502           <button
503             className="new-game"
504             onClick={() => fetch_post_json('reset') }
505           >
506             New Game
507           </button>
508         </div>
509       );
510     }
511
512     if (state.active_prompt) {
513       return <ActivePrompt
514                prompt={state.active_prompt}
515                players_answered={state.players_answered}
516                players_total={players_total}
517              />;
518     }
519
520     return [
521       <GameInfo
522         key="game-info"
523         id={state.game_info.id}
524         url={state.game_info.url}
525       />,
526       <PlayerInfo
527         key="player-info"
528         game={this}
529         player={state.player_info}
530         other_players={state.other_players}
531       />,
532       <p key="spacer"></p>,
533       <CategoryRequest
534         key="category-request"
535       />,
536       <PromptOptions
537         key="prompts"
538         prompts={state.prompts}
539       />,
540       <LetsPlay
541         key="lets-play"
542         num_players={1+state.other_players.length}
543         prompts={state.prompts}
544       />
545     ];
546   }
547 }
548
549 ReactDOM.render(<Game
550                   ref={(me) => window.game = me}
551                 />, document.getElementById("empathy"));