function team_symbol(team) { if (team === "+") return "+"; else return "o"; } function undisplay(element) { element.style.display="none"; } function add_message(severity, message) { message = `
× ${message}
`; const message_area = document.getElementById('message-area'); message_area.insertAdjacentHTML('beforeend', message); } /********************************************************* * Handling server-sent event stream * *********************************************************/ const events = new EventSource("events"); events.onerror = function(event) { if (event.target.readyState === EventSource.CLOSED) { setTimeout(() => { add_message("danger", "Connection to server lost."); }, 1000); } }; events.addEventListener("game-info", event => { const info = JSON.parse(event.data); window.game.set_game_info(info); }); events.addEventListener("player-info", event => { const info = JSON.parse(event.data); window.game.set_player_info(info); }); events.addEventListener("player-enter", event => { const info = JSON.parse(event.data); window.game.set_other_player_info(info); }); events.addEventListener("player-update", event => { const info = JSON.parse(event.data); if (info.id === window.game.state.player_info.id) window.game.set_player_info(info); else window.game.set_other_player_info(info); }); events.addEventListener("move", event => { const move = JSON.parse(event.data); window.game.receive_move(move); }); events.addEventListener("game-state", event => { const state = JSON.parse(event.data); window.game.reset_board(); for (let square of state.moves) { window.game.receive_move(square); } }); /********************************************************* * Game and supporting classes * *********************************************************/ const scribe_glyphs = [ { name: "Single", squares: [1,0,0, 0,0,0, 0,0,0] }, { name: "Double", squares: [1,1,0, 0,0,0, 0,0,0] }, { name: "Line", squares: [1,1,1, 0,0,0, 0,0,0] }, { name: "Pipe", squares: [0,0,1, 1,1,1, 0,0,0] }, { name: "Squat-T", squares: [1,1,1, 0,1,0, 0,0,0] }, { name: "4-block", squares: [1,1,0, 1,1,0, 0,0,0] }, { name: "T", squares: [1,1,1, 0,1,0, 0,1,0] }, { name: "Cross", squares: [0,1,0, 1,1,1, 0,1,0] }, { name: "6-block", squares: [1,1,1, 1,1,1, 0,0,0] }, { name: "Bomber", squares: [1,1,1, 0,1,1, 0,0,1] }, { name: "Chair", squares: [0,0,1, 1,1,1, 1,0,1] }, { name: "J", squares: [0,0,1, 1,0,1, 1,1,1] }, { name: "Earring", squares: [0,1,1, 1,0,1, 1,1,1] }, { name: "House", squares: [0,1,0, 1,1,1, 1,1,1] }, { name: "H", squares: [1,0,1, 1,1,1, 1,0,1] }, { name: "U", squares: [1,0,1, 1,0,1, 1,1,1] }, { name: "Ottoman", squares: [1,1,1, 1,1,1, 1,0,1] }, { name: "O", squares: [1,1,1, 1,0,1, 1,1,1] }, { name: "9-block", squares: [1,1,1, 1,1,1, 1,1,1] } ]; function copy_to_clipboard(id) { const tmp = document.createElement("input"); tmp.setAttribute("value", document.getElementById(id).innerHTML); document.body.appendChild(tmp); tmp.select(); document.execCommand("copy"); document.body.removeChild(tmp); } function GameInfo(props) { if (! props.id) return null; return (
{props.id} {" "} Share this link to invite a friend:{" "} {props.url} {" "}
); } function TeamButton(props) { return ; } function TeamChoices(props) { let other_team; if (props.player.team === "+") other_team = "o"; else other_team = "+"; if (props.player.team === "") { if (props.first_move) { return null; } else { return [ , " ", ]; } } else { return ; } } function PlayerInfo(props) { if (! props.player.id) return null; const choices = ; return (
Players: {props.player.name} {props.player.team ? ` (${props.player.team})` : ""} {props.first_move ? "" : " "} {choices} {props.other_players.map(other => ( {", "} {other.name} {other.team ? ` (${other.team})` : ""} ))}
); } function Glyph(props) { const glyph_dots = []; let last_square = 0; for (let i = 0; i < 9; i++) { if (props.squares[i]) last_square = i; } const height = Math.floor(20 * (Math.floor(last_square / 3) + 1)); const viewbox=`0 0 60 ${height}`; for (let row = 0; row < 3; row++) { for (let col = 0; col < 3; col++) { if (props.squares[3 * row + col]) { let cy = 10 + 20 * row; let cx = 10 + 20 * col; glyph_dots.push( ); } } } return (
{props.name}
{glyph_dots}
); } function Square(props) { let className = "square"; if (props.value.symbol) { className += " occupied"; } else if (props.active) { className += " open"; } if (props.value.glyph) { if (props.value.symbol === '+') className += " glyph-plus"; else className += " glyph-o"; } if (props.last_move) { className += " last-move"; } const onClick = props.active ? props.onClick : null; return (
{props.value.symbol}
); } function MiniGrid(props) { const mini_grid = props.mini_grid; const squares = mini_grid.squares; function grid_square(j) { const value = squares[j]; const last_move = props.last_moves.includes(j); /* Even if the grid is active, the square is only active if * unoccupied. */ const square_active = (props.active && (value.symbol === null)); return ( props.onClick(j)} /> ); } /* Even if my parent thinks I'm active because of the last move, I * might not _really_ be active if I'm full. */ let occupied = 0; mini_grid.squares.forEach(element => { if (element.symbol) occupied++; }); let class_name = "mini-grid"; if (props.active && occupied < 9) class_name += " active"; let winner = null; if (mini_grid.winner) { winner =
{mini_grid.winner}
; } return (
{grid_square(0)} {grid_square(1)} {grid_square(2)} {grid_square(3)} {grid_square(4)} {grid_square(5)} {grid_square(6)} {grid_square(7)} {grid_square(8)} {winner}
); } class Board extends React.Component { mini_grid(i) { /* This mini grid is active only if both: * * 1. It is our turn (this.props.active === true) * * 2. One of the following conditions is met: * * a. This is this players first turn (last_two_moves[0] === null) * b. This mini grid corresponds to this players last turn * c. The mini grid that corresponds to the players last turn is full */ let grid_active = false; if (this.props.active) { grid_active = true; if (this.props.last_two_moves.length > 1) { /* First index (0) gives us our last move, (that is, of the * last two moves, it's the first one, so two moves ago). * * Second index (1) gives us the second number from that move, * (that is, the index within the mini-grid that we last * played). */ const target = this.props.last_two_moves[0][1]; let occupied = 0; this.props.mini_grids[target].squares.forEach(element => { if (element.symbol) occupied++; }); /* If the target mini-grid isn't full then this grid is * only active if it is that target. */ if (occupied < 9) grid_active = (i === target); } } /* We want to highlight each of the last two moves (both "+" and * "o"). So we filter the last two moves that have a first index * that matches this mini_grid and pass down their second index * be highlighted. */ const last_moves = this.props.last_two_moves.filter(move => move[0] === i) .map(move => move[1]); const mini_grid = this.props.mini_grids[i]; return ( this.props.onClick(i,j)} /> ); } render() { return (
{this.mini_grid(0)} {this.mini_grid(1)} {this.mini_grid(2)} {this.mini_grid(3)} {this.mini_grid(4)} {this.mini_grid(5)} {this.mini_grid(6)} {this.mini_grid(7)} {this.mini_grid(8)}
); } } function fetch_method_json(method, api = '', data = {}) { const response = fetch(api, { method: method, headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(data) }); return response; } function fetch_post_json(api = '', data = {}) { return fetch_method_json('POST', api, data); } async function fetch_put_json(api = '', data = {}) { return fetch_method_json('PUT', api, data); } class Game extends React.Component { constructor(props) { super(props); this.state = { game_info: {}, player_info: {}, other_players: [], mini_grids: [...Array(9)].map(() => ({ score_plus: null, score_o: null, winner: null, squares: Array(9).fill({ symbol: null, glyph: false }) })), moves: [], next_to_play: "+", }; } set_game_info(info) { this.setState({ game_info: info }); } set_player_info(info) { this.setState({ player_info: info }); } set_other_player_info(info) { const other_players_copy = [...this.state.other_players]; const idx = other_players_copy.findIndex(o => o.id === info.id); if (idx >= 0) { other_players_copy[idx] = info; } else { other_players_copy.push(info); } this.setState({ other_players: other_players_copy }); } reset_board() { this.setState({ next_to_play: "+" }); } find_connected_recursive(recursion_state, position) { if (position < 0 || position >= 9) return; if (recursion_state.visited[position]) return; recursion_state.visited[position] = true; if (recursion_state.mini_grid[position].symbol !== recursion_state.target) return; recursion_state.connected[position] = true; /* Left */ if (position % 3 !== 0) this.find_connected_recursive(recursion_state, position - 1); /* Right */ if (position % 3 !== 2) this.find_connected_recursive(recursion_state, position + 1); /* Up */ this.find_connected_recursive(recursion_state, position - 3); /* Down */ this.find_connected_recursive(recursion_state, position + 3); } /* Find all cells within a mini-grid that are 4-way connected to the * given cell. */ find_connected(mini_grid, position) { const connected = Array(9).fill(false); /* If the given cell is empty then there is nothing connected. */ if (mini_grid[position] === null) return connected; const cell = mini_grid[position].symbol; let recursion_state = { mini_grid: mini_grid, connected: connected, visited: Array(9).fill(false), target: cell, }; this.find_connected_recursive(recursion_state, position); return connected; } /* Determine whether a connected group of cells is a glyph. * * Here, 'connected' is a length-9 array of Booleans, true * for the connected cells in a mini-grid. */ is_glyph(connected) { /* Now that we have a set of connected cells, let's collect some * stats on them, (width, height, number of cells, configuration * of corner cells, etc.). */ let min_row = 2; let min_col = 2; let max_row = 0; let max_col = 0; let count = 0; for (let i = 0; i < 9; i++) { const row = Math.floor(i/3); const col = i % 3; if (! connected[i]) continue; count++; min_row = Math.min(row, min_row); min_col = Math.min(col, min_col); max_row = Math.max(row, max_row); max_col = Math.max(col, max_col); } const width = max_col - min_col + 1; const height = max_row - min_row + 1; /* Corners, (top-left, top-right, bottom-left, and bottom-right) */ const tl = connected[3 * min_row + min_col]; const tr = connected[3 * min_row + max_col]; const bl = connected[3 * max_row + min_col]; const br = connected[3 * max_row + max_col]; const count_true = (acc, val) => acc + (val ? 1 : 0); const corners_count = [tl, tr, bl, br].reduce(count_true, 0); const top_corners_count = [tl, tr].reduce(count_true, 0); const bottom_corners_count = [bl, br].reduce(count_true, 0); const left_corners_count = [tl, bl].reduce(count_true, 0); const right_corners_count = [tr, br].reduce(count_true, 0); let two_corners_in_a_line = false; if (top_corners_count === 2 || bottom_corners_count === 2 || left_corners_count === 2 || right_corners_count === 2) { two_corners_in_a_line = true; } let zero_corners_in_a_line = false; if (top_corners_count === 0 || bottom_corners_count === 0 || left_corners_count === 0 || right_corners_count === 0) { zero_corners_in_a_line = true; } /* Now we have the information we need to determine glyphs. */ switch (count) { case 1: /* Single */ return true; case 2: /* Double */ return true; case 3: /* Line */ return (width === 3 || height === 3); case 4: /* Pipe, Squat-T, and 4-block, but not Tetris S */ return two_corners_in_a_line; case 5: if (width !== 3 || height !== 3 || ! connected[4]) { /* Pentomino P and U are not glyphs (not 3x3) */ /* Pentomino V is not a glyph (center not connected) */ return false; } else if (corners_count === 0 || two_corners_in_a_line) { /* Pentomino X is glyph Cross (no corners) */ /* Pentomino T is glyph T (has a row or column with 2 corners) */ return true; } else { /* The corner counting above excludes pentomino F, W, and Z * which are not glyphs. */ return false; } break; case 6: /* 6-Block has width or height of 2. */ /* Bomber, Chair, and J have 3 corners occupied. */ if (width === 2 || height === 2 || corners_count === 3) return true; return false; case 7: /* Earring and U have no center square occupied */ /* H has 4 corners occupied */ /* House has a row or column with 0 corners occupied */ if ((! connected[4]) || corners_count === 4 || zero_corners_in_a_line) return true; return false; case 8: /* Ottoman or O */ if (corners_count === 4) return true; return false; case 9: return true; } /* Should be unreachable */ return false; } receive_move(move) { const mini_grid_index = move[0]; const position = move[1]; /* Don't allow any moves after the board is full */ if (this.state.moves.length === 81) { return; } /* Set the team's symbol into the board state. */ const symbol = team_symbol(this.state.next_to_play); const new_mini_grids = this.state.mini_grids.map(obj => { const new_obj = {...obj}; new_obj.squares = obj.squares.slice(); return new_obj; }); const new_mini_grid = new_mini_grids[mini_grid_index]; new_mini_grid.squares[position] = { symbol: symbol, glyph: false }; /* With the symbol added to the squares, we need to see if this * newly-placed move forms a glyph or not. */ const connected = this.find_connected(new_mini_grid.squares, position); const is_glyph = this.is_glyph(connected); /* Either set (or clear) the glyph Boolean for each connected square. */ for (let i = 0; i < 9; i++) { if (connected[i]) new_mini_grid.squares[i].glyph = is_glyph; } /* If this is the last cell of played in a mini-grid then it's * time to score it. */ const occupied = new_mini_grid.squares.reduce( (acc, val) => acc + (val.symbol !== null ? 1 : 0), 0); if (occupied === 9) { for (let i = 0; i < 9; i++) { if (new_mini_grid.squares[i].glyph) { if (new_mini_grid.squares[i].symbol === '+') new_mini_grid.score_plus++; else new_mini_grid.score_o++; } } if (new_mini_grid.score_plus > new_mini_grid.score_o) new_mini_grid.winner = '+'; else new_mini_grid.winner = 'o'; } /* And append the move to the list of moves. */ const new_moves = [...this.state.moves, move]; /* Finally, compute the next player to move. */ let next_to_play; if (this.state.next_to_play === "+") next_to_play = "o"; else next_to_play = "+"; /* And shove all those state modifications toward React. */ this.setState({ mini_grids: new_mini_grids, moves: new_moves, next_to_play: next_to_play }); } async handle_click(i, j, first_move) { let move = { move: [i, j] }; if (first_move) { move.assert_first_move = true; } const response = await fetch_post_json("move", move); if (response.status == 200) { const result = await response.json(); if (! result.legal) add_message("danger", result.message); } else { add_message("danger", `Error occurred sending move`); } } join_team(team) { fetch_put_json("player", {team: team}); } render() { const state = this.state; const first_move = state.moves.length === 0; const my_team = state.player_info.team; var board_active; let status; if (this.state.moves.length === 81) { status = "Game over"; board_active = false; } else if (first_move) { if (state.other_players.length == 0) { status = "You can move or wait for another player to join."; } else { let qualifier; if (state.other_players.length == 1) { qualifier = "Either"; } else { qualifier = "Any"; } status = `${qualifier} player can make the first move.`; } board_active = true; } else if (my_team === "") { status = "You're just watching the game."; board_active = false; } else if (my_team === state.next_to_play) { status = "Your turn. Make a move."; board_active = true; } else { status = "Waiting for another player to "; if (state.other_players.length == 0) { status += "join."; } else { status += "move."; } board_active = false; } return [ , ,
{status}
this.handle_click(i, j, first_move)} />
,
{ scribe_glyphs.map(glyph => { return ( ); }) }
]; } } ReactDOM.render( window.game = me} />, document.getElementById("scribe"));