1 function team_symbol(team) {
8 function undisplay(element) {
9 element.style.display="none";
12 function add_message(severity, message) {
13 message = `<div class="message ${severity}" onclick="undisplay(this)">
14 <span class="hide-button" onclick="undisplay(this.parentElement)">×</span>
17 const message_area = document.getElementById('message-area');
18 message_area.insertAdjacentHTML('beforeend', message);
21 /*********************************************************
22 * Handling server-sent event stream *
23 *********************************************************/
25 const events = new EventSource("events");
27 events.onerror = function(event) {
28 if (event.target.readyState === EventSource.CLOSED) {
30 add_message("danger", "Connection to server lost.");
35 events.addEventListener("game-info", event => {
36 const info = JSON.parse(event.data);
38 window.game.set_game_info(info);
41 events.addEventListener("player-info", event => {
42 const info = JSON.parse(event.data);
44 window.game.set_player_info(info);
47 events.addEventListener("player-enter", event => {
48 const info = JSON.parse(event.data);
50 window.game.set_other_player_info(info);
53 events.addEventListener("player-update", event => {
54 const info = JSON.parse(event.data);
56 if (info.id === window.game.state.player_info.id)
57 window.game.set_player_info(info);
59 window.game.set_other_player_info(info);
62 events.addEventListener("move", event => {
63 const move = JSON.parse(event.data);
65 window.game.receive_move(move);
68 events.addEventListener("game-state", event => {
69 const state = JSON.parse(event.data);
71 window.game.reset_board();
73 for (let square of state.moves) {
74 window.game.receive_move(square);
78 /*********************************************************
79 * Game and supporting classes *
80 *********************************************************/
82 const scribe_glyphs = [
199 function copy_to_clipboard(id)
201 const tmp = document.createElement("input");
202 tmp.setAttribute("value", document.getElementById(id).innerHTML);
203 document.body.appendChild(tmp);
205 document.execCommand("copy");
206 document.body.removeChild(tmp);
209 function GameInfo(props) {
214 <div className="game-info">
215 <span className="game-id">{props.id}</span>
217 Share this link to invite a friend:{" "}
218 <span id="game-share-url">{props.url}</span>
222 onClick={() => copy_to_clipboard('game-share-url')}
228 function TeamButton(props) {
229 return <button className="inline"
230 onClick={() => props.game.join_team(props.team)}>
235 function TeamChoices(props) {
237 if (props.player.team === "+")
242 if (props.player.team === "") {
243 if (props.first_move) {
247 <TeamButton key="+" game={props.game} team="+" label="Join 🞥" />,
249 <TeamButton key="o" game={props.game} team="o" label="Join 🞇" />
253 return <TeamButton game={props.game} team={other_team} label="Switch" />;
257 function PlayerInfo(props) {
258 if (! props.player.id)
261 const choices = <TeamChoices
263 first_move={props.first_move}
264 player={props.player}
268 <div className="player-info">
269 <span className="players-header">Players: </span>
271 {props.player.team ? ` (${props.player.team})` : ""}
272 {props.first_move ? "" : " "}
274 {props.other_players.map(other => (
275 <span key={other.id}>
278 {other.team ? ` (${other.team})` : ""}
285 function Glyph(props) {
287 const glyph_dots = [];
290 for (let i = 0; i < 9; i++) {
291 if (props.squares[i])
295 const height = Math.floor(20 * (Math.floor(last_square / 3) + 1));
297 const viewbox=`0 0 60 ${height}`;
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;
316 return (<div className="glyph-and-name">
318 <div className="glyph">
319 <svg viewBox={viewbox}>
329 function Square(props) {
330 let className = "square";
332 if (props.value.symbol) {
333 className += " occupied";
334 } else if (props.active) {
335 className += " open";
338 if (props.value.glyph) {
339 if (props.value.symbol === '+')
340 className += " glyph-plus";
342 className += " glyph-o";
345 if (props.last_move) {
346 className += " last-move";
349 const onClick = props.active ? props.onClick : null;
352 <div className={className}
359 function MiniGrid(props) {
361 const mini_grid = props.mini_grid;
362 const squares = mini_grid.squares;
364 function grid_square(j) {
365 const value = squares[j];
366 const last_move = props.last_moves.includes(j);
368 /* Even if the grid is active, the square is only active if
370 const square_active = (props.active && (value.symbol === null));
375 active={square_active}
376 last_move={last_move}
377 onClick={() => props.onClick(j)}
382 /* Even if my parent thinks I'm active because of the last move, I
383 * might not _really_ be active if I'm full. */
385 mini_grid.squares.forEach(element => {
390 let class_name = "mini-grid";
391 if (props.active && occupied < 9)
392 class_name += " active";
395 if (mini_grid.winner) {
396 winner = <div className="winner">{mini_grid.winner}</div>;
400 <div className={class_name}>
415 class Board extends React.Component {
417 /* This mini grid is active only if both:
419 * 1. It is our turn (this.props.active === true)
421 * 2. One of the following conditions is met:
423 * a. This is this players first turn (last_two_moves[0] === null)
424 * b. This mini grid corresponds to this players last turn
425 * c. The mini grid that corresponds to the players last turn is full
427 let grid_active = false;
428 if (this.props.active) {
430 if (this.props.last_two_moves.length > 1) {
431 /* First index (0) gives us our last move, (that is, of the
432 * last two moves, it's the first one, so two moves ago).
434 * Second index (1) gives us the second number from that move,
435 * (that is, the index within the mini-grid that we last
438 const target = this.props.last_two_moves[0][1];
440 this.props.mini_grids[target].squares.forEach(element => {
444 /* If the target mini-grid isn't full then this grid is
445 * only active if it is that target. */
447 grid_active = (i === target);
451 /* We want to highlight each of the last two moves (both "+" and
452 * "o"). So we filter the last two moves that have a first index
453 * that matches this mini_grid and pass down their second index
456 const last_moves = this.props.last_two_moves.filter(move => move[0] === i)
457 .map(move => move[1]);
459 const mini_grid = this.props.mini_grids[i];
462 mini_grid={mini_grid}
464 last_moves={last_moves}
465 onClick={(j) => this.props.onClick(i,j)}
472 <div className="board-container">
473 <div className="board">
489 function fetch_method_json(method, api = '', data = {}) {
490 const response = fetch(api, {
493 'Content-Type': 'application/json'
495 body: JSON.stringify(data)
500 function fetch_post_json(api = '', data = {}) {
501 return fetch_method_json('POST', api, data);
504 async function fetch_put_json(api = '', data = {}) {
505 return fetch_method_json('PUT', api, data);
508 class Game extends React.Component {
515 mini_grids: [...Array(9)].map(() => ({
519 squares: Array(9).fill({
529 set_game_info(info) {
535 set_player_info(info) {
541 set_other_player_info(info) {
542 const other_players_copy = [...this.state.other_players];
543 const idx = other_players_copy.findIndex(o => o.id === info.id);
545 other_players_copy[idx] = info;
547 other_players_copy.push(info);
550 other_players: other_players_copy
560 find_connected_recursive(recursion_state, position) {
562 if (position < 0 || position >= 9)
565 if (recursion_state.visited[position])
568 recursion_state.visited[position] = true;
570 if (recursion_state.mini_grid[position].symbol !== recursion_state.target)
573 recursion_state.connected[position] = true;
576 if (position % 3 !== 0)
577 this.find_connected_recursive(recursion_state, position - 1);
579 if (position % 3 !== 2)
580 this.find_connected_recursive(recursion_state, position + 1);
582 this.find_connected_recursive(recursion_state, position - 3);
584 this.find_connected_recursive(recursion_state, position + 3);
587 /* Find all cells within a mini-grid that are 4-way connected to the
589 find_connected(mini_grid, position) {
590 const connected = Array(9).fill(false);
592 /* If the given cell is empty then there is nothing connected. */
593 if (mini_grid[position] === null)
596 const cell = mini_grid[position].symbol;
598 let recursion_state = {
599 mini_grid: mini_grid,
600 connected: connected,
601 visited: Array(9).fill(false),
604 this.find_connected_recursive(recursion_state, position);
609 /* Determine whether a connected group of cells is a glyph.
611 * Here, 'connected' is a length-9 array of Booleans, true
612 * for the connected cells in a mini-grid.
614 is_glyph(connected) {
616 /* Now that we have a set of connected cells, let's collect some
617 * stats on them, (width, height, number of cells, configuration
618 * of corner cells, etc.).
626 for (let i = 0; i < 9; i++) {
627 const row = Math.floor(i/3);
635 min_row = Math.min(row, min_row);
636 min_col = Math.min(col, min_col);
637 max_row = Math.max(row, max_row);
638 max_col = Math.max(col, max_col);
641 const width = max_col - min_col + 1;
642 const height = max_row - min_row + 1;
644 /* Corners, (top-left, top-right, bottom-left, and bottom-right) */
645 const tl = connected[3 * min_row + min_col];
646 const tr = connected[3 * min_row + max_col];
647 const bl = connected[3 * max_row + min_col];
648 const br = connected[3 * max_row + max_col];
650 const count_true = (acc, val) => acc + (val ? 1 : 0);
651 const corners_count = [tl, tr, bl, br].reduce(count_true, 0);
652 const top_corners_count = [tl, tr].reduce(count_true, 0);
653 const bottom_corners_count = [bl, br].reduce(count_true, 0);
654 const left_corners_count = [tl, bl].reduce(count_true, 0);
655 const right_corners_count = [tr, br].reduce(count_true, 0);
657 let two_corners_in_a_line = false;
658 if (top_corners_count === 2 ||
659 bottom_corners_count === 2 ||
660 left_corners_count === 2 ||
661 right_corners_count === 2)
663 two_corners_in_a_line = true;
666 let zero_corners_in_a_line = false;
667 if (top_corners_count === 0 ||
668 bottom_corners_count === 0 ||
669 left_corners_count === 0 ||
670 right_corners_count === 0)
672 zero_corners_in_a_line = true;
675 /* Now we have the information we need to determine glyphs. */
685 return (width === 3 || height === 3);
687 /* Pipe, Squat-T, and 4-block, but not Tetris S */
688 return two_corners_in_a_line;
690 if (width !== 3 || height !== 3 || ! connected[4])
692 /* Pentomino P and U are not glyphs (not 3x3) */
693 /* Pentomino V is not a glyph (center not connected) */
696 else if (corners_count === 0 || two_corners_in_a_line)
698 /* Pentomino X is glyph Cross (no corners) */
699 /* Pentomino T is glyph T (has a row or column with 2 corners) */
702 /* The corner counting above excludes pentomino F, W, and Z
703 * which are not glyphs. */
708 /* 6-Block has width or height of 2. */
709 /* Bomber, Chair, and J have 3 corners occupied. */
710 if (width === 2 || height === 2 || corners_count === 3)
714 /* Earring and U have no center square occupied */
715 /* H has 4 corners occupied */
716 /* House has a row or column with 0 corners occupied */
717 if ((! connected[4]) || corners_count === 4 || zero_corners_in_a_line)
722 if (corners_count === 4)
729 /* Should be unreachable */
734 const mini_grid_index = move[0];
735 const position = move[1];
737 /* Don't allow any moves after the board is full */
738 if (this.state.moves.length === 81) {
742 /* Set the team's symbol into the board state. */
743 const symbol = team_symbol(this.state.next_to_play);
744 const new_mini_grids = this.state.mini_grids.map(obj => {
745 const new_obj = {...obj};
746 new_obj.squares = obj.squares.slice();
749 const new_mini_grid = new_mini_grids[mini_grid_index];
750 new_mini_grid.squares[position] = {
755 /* With the symbol added to the squares, we need to see if this
756 * newly-placed move forms a glyph or not. */
757 const connected = this.find_connected(new_mini_grid.squares, position);
758 const is_glyph = this.is_glyph(connected);
760 /* Either set (or clear) the glyph Boolean for each connected square. */
761 for (let i = 0; i < 9; i++) {
763 new_mini_grid.squares[i].glyph = is_glyph;
766 /* If this is the last cell of played in a mini-grid then it's
767 * time to score it. */
768 const occupied = new_mini_grid.squares.reduce(
769 (acc, val) => acc + (val.symbol !== null ? 1 : 0), 0);
770 if (occupied === 9) {
771 for (let i = 0; i < 9; i++) {
772 if (new_mini_grid.squares[i].glyph) {
773 if (new_mini_grid.squares[i].symbol === '+')
774 new_mini_grid.score_plus++;
776 new_mini_grid.score_o++;
779 if (new_mini_grid.score_plus > new_mini_grid.score_o)
780 new_mini_grid.winner = '+';
782 new_mini_grid.winner = 'o';
785 /* And append the move to the list of moves. */
786 const new_moves = [...this.state.moves, move];
788 /* Finally, compute the next player to move. */
790 if (this.state.next_to_play === "+")
795 /* And shove all those state modifications toward React. */
797 mini_grids: new_mini_grids,
799 next_to_play: next_to_play
803 async handle_click(i, j, first_move) {
808 move.assert_first_move = true;
810 const response = await fetch_post_json("move", move);
811 if (response.status == 200) {
812 const result = await response.json();
814 add_message("danger", result.message);
816 add_message("danger", `Error occurred sending move`);
821 fetch_put_json("player", {team: team});
825 const state = this.state;
826 const first_move = state.moves.length === 0;
827 const my_team = state.player_info.team;
831 if (this.state.moves.length === 81)
833 status = "Game over";
834 board_active = false;
838 if (state.other_players.length == 0) {
839 status = "You can move or wait for another player to join.";
842 if (state.other_players.length == 1) {
843 qualifier = "Either";
847 status = `${qualifier} player can make the first move.`;
851 else if (my_team === "")
853 status = "You're just watching the game.";
854 board_active = false;
856 else if (my_team === state.next_to_play)
858 status = "Your turn. Make a move.";
863 status = "Waiting for another player to ";
864 if (state.other_players.length == 0) {
869 board_active = false;
875 id={state.game_info.id}
876 url={state.game_info.url}
881 first_move={first_move}
882 player={state.player_info}
883 other_players={state.other_players}
885 <div key="game" className="game">
887 <div className="game-board">
889 active={board_active}
890 mini_grids={state.mini_grids}
891 last_two_moves={state.moves.slice(-2)}
892 onClick={(i,j) => this.handle_click(i, j, first_move)}
896 <div key="glyphs" className="glyphs">
898 scribe_glyphs.map(glyph => {
903 squares={glyph.squares}
913 ReactDOM.render(<Game
914 ref={(me) => window.game = me}
915 />, document.getElementById("scribe"));