"presets": ["react"],
"env": {
"production": {
+ "plugins": [
+ "transform-object-rest-spread"
+ ]
},
"development": {
"plugins": [
"transform-react-jsx-self",
- "transform-react-jsx-source"
+ "transform-react-jsx-source",
+ "transform-object-rest-spread"
]
}
}
+TODO
+.nogit
+.gitattributes
deps/*.js
.deploy-source
react.js
export BABEL_ENV=production
%.js: %.jsx
$(call quiet,BABEL) $^ --out-file $@
- @chmod a-w $@
else
react.js: deps/react.development.js
$(call quiet,CP) $^ $@
export BABEL_ENV=development
%.js: %.jsx
$(call quiet,BABEL) $^ --out-file $@
- @chmod a-w $@
endif
# The user has not set any verbosity, default to quiet mode and inform the
$(call quiet,SHA512) deps/*.sha512
DOWNLOAD=wget $(WGET_VERBOSE_FLAGS) -nc -P deps
-deps/%.js:
- $(call quiet,DOWNLOAD) https://unpkg.com/react@16/umd/$@
- $(call quiet,SHA512) $(patsubst %,%.sha512,$@)
+deps/react.%.js:
+ $(call quiet,DOWNLOAD) https://unpkg.com/react@16/umd/$(@:deps/%=%)
+ $(call quiet,SHA512) $(@:%=%.sha512)
+
+deps/react-dom.%.js:
+ $(call quiet,DOWNLOAD) https://unpkg.com/react-dom@16/umd/$(@:deps/%=%)
+ $(call quiet,SHA512) $(@:%=%.sha512)
deploy:
rm -rf .deploy-source
-Static resources for the htttps://lmno.games/ website
+Static resources for the https://lmno.games/ website
This repository contains the static HTML, JavaScript, and CSS files
used by the lmno.games website, including support for downloading and
Dependencies
------------
Compiling the source requires the babel (version 6) CLI to be
-available as "babeljs" along with the "react" preset. This can be
-achieved on Debian with:
+available as "babeljs" along with the "react" preset and support for
+"object spread" syntax. This can be achieved on Debian with:
- sudo apt install node-babel-cli node-babel-preset-react
+ sudo apt install \
+ node-babel-cli \
+ node-babel-preset-react \
+ node-babel-plugin-transform-object-rest-spread
--- /dev/null
+empathy.js
--- /dev/null
+# Defer all targets up to the upper-level
+#
+# This requires two recipes. The first to cover the case of no
+# explicit target specifed (so when invoked as "make" we call "make"
+# at the upper-level) and then a .DEFAULT recipe to pass any explicit
+# target up as well, (so that an invocation of "make foo" results in a
+# call to "make foo" above.
+all:
+ $(MAKE) -C ..
+
+.DEFAULT:
+ $(MAKE) -C .. $@
--- /dev/null
+.vote-button {
+ width: 100%;
+ background-color: var(--accent-color);
+ color: var(--text-fg-on-accent);
+ text-align: left;
+ border-radius: 25px;
+ font-size: 200%;
+ padding: 1em;
+ margin-bottom: 0.25em;
+ position: relative;
+}
+
+.vote-choices {
+ display: flex;
+ flex-wrap: wrap;
+}
+
+.vote-choice {
+ font-size: 40%;
+ background-color: var(--accent-color-bright);
+ color: var(--text-fg-on-accent-bright);
+ border-radius: 4px;
+ padding-left: 4px;
+ padding-right: 4px;
+ margin-right: 0.5em;
+}
+
+@media (hover:hover) {
+ button:hover .vote-choice {
+ background-color: var(--accent-color);
+ color: var(--text-fg-on-accent);
+ }
+}
+
+.ambiguity-group {
+ width: 100%;
+ display: flex;
+ flex-wrap: wrap;
+ justify-content: center;
+ align-items: center;
+ background-color: var(--accent-color);
+ margin-bottom: 0.25em;
+ padding: 0;
+ border-radius: 10px;
+ position: relative;
+}
+
+.ambiguity-button {
+ border-radius: 10px;
+ margin: 0;
+ overflow-wrap: break-word;
+ overflow-y: hidden;
+}
+
+.ambiguity-button.selected {
+ background-color: var(--accent-color-bright);
+ color: var(--text-fg-on-accent-bright);
+}
+
+@keyframes bounce {
+ 0% {
+ transform: translateY(0);
+ animation-timing-function: cubic-bezier(0.333, 0.667, 0.667, 1);
+ }
+ 20% {
+ transform: translateY(-5px);
+ animation-timing-function: cubic-bezier(0.333, 0, 0,667, 0.333);
+ }
+ 40% {
+ transform: translateY(0);
+ }
+ 100% {
+ transform: translateY(0);
+ }
+}
+
+.typing span {
+ font-size: 150%;
+ line-height: 0;
+
+ display: inline-block;
+ animation-name: bounce;
+ animation-duration: 2s;
+ animation-iteration-count: infinite;
+ animation-timing-function: linear;
+}
+
+.typing span:nth-child(2) {
+ animation-delay: .2s;
+}
+
+.typing span:nth-child(3) {
+ animation-delay: .4s;
+}
+
+.typing.active span {
+ opacity: 1.0;
+}
+
+.typing.idle span {
+ opacity: 0.0;
+ transition-property: opacity;
+ transition-duration: 2s;
+ transition-delay: 1s;
+}
+
+.achievement {
+ border-radius: 4px;
+ background-color: var(--accent-color-bright);
+ color: var(--text-fg-on-accent-bright);
+ font-size: 72%;
+ font-weight: bold;
+ padding: 0.25em;
+ text-transform: uppercase;
+}
+
+.star-button {
+ color: white;
+ opacity: 0.5;
+ font-size: 125%;
+ font-weight: bold;
+ cursor: pointer;
+ position: absolute;
+ right: 0.5em;
+ top: 0;
+}
+
+@media (hover:hover) {
+ .star-button:hover {
+ color: var(--accent-color-bright);
+ opacity: 1.0;
+ }
+}
+
+.star-button.selected {
+ color: var(--accent-color-bright);
+ opacity: 1.0;
+}
+
+.player-idle {
+ opacity: 0.5;
+}
--- /dev/null
+const MAX_PROMPT_ITEMS = 20;
+
+function undisplay(element) {
+ element.style.display="none";
+}
+
+function add_message(severity, message) {
+ message = `<div class="message ${severity}" onclick="undisplay(this)">
+<span class="hide-button" onclick="undisplay(this.parentElement)">×</span>
+${message}
+</div>`;
+ 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-exit", event => {
+ const info = JSON.parse(event.data);
+
+ window.game.disable_player(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("game-state", event => {
+ const state = JSON.parse(event.data);
+
+ window.game.reset_game_state();
+
+ window.game.set_prompts(state.prompts);
+
+ window.game.set_active_prompt(state.active_prompt);
+
+ window.game.set_players_answered(state.players_answered);
+
+ window.game.set_players_answering(state.players_answering);
+
+ window.game.set_answering_idle(state.answering_idle);
+
+ window.game.set_end_answers(state.end_answers);
+
+ window.game.set_ambiguities(state.ambiguities);
+
+ window.game.set_players_judged(state.players_judged);
+
+ window.game.set_players_judging(state.players_judging);
+
+ window.game.set_judging_idle(state.judging_idle);
+
+ window.game.set_end_judging(state.end_judging);
+
+ window.game.set_scores(state.scores);
+
+ window.game.set_new_game_votes(state.new_game_votes);
+
+ window.game.state_ready();
+});
+
+events.addEventListener("prompt", event => {
+ const prompt = JSON.parse(event.data);
+
+ window.game.add_or_update_prompt(prompt);
+});
+
+events.addEventListener("start", event => {
+ const prompt = JSON.parse(event.data);
+
+ window.game.set_active_prompt(prompt);
+});
+
+events.addEventListener("player-answered", event => {
+ const player = JSON.parse(event.data);
+
+ window.game.set_player_answered(player);
+});
+
+events.addEventListener("player-answering", event => {
+ const player = JSON.parse(event.data);
+
+ window.game.set_player_answering(player);
+});
+
+events.addEventListener("answering-idle", event => {
+ const value = JSON.parse(event.data);
+
+ window.game.set_answering_idle(value);
+});
+
+events.addEventListener("vote-end-answers", event => {
+ const player = JSON.parse(event.data);
+
+ window.game.set_player_vote_end_answers(player);
+});
+
+events.addEventListener("unvote-end-answers", event => {
+ const player = JSON.parse(event.data);
+
+ window.game.set_player_unvote_end_answers(player);
+});
+
+events.addEventListener("ambiguities", event => {
+ const ambiguities = JSON.parse(event.data);
+
+ window.game.set_ambiguities(ambiguities);
+});
+
+events.addEventListener("player-judged", event => {
+ const player = JSON.parse(event.data);
+
+ window.game.set_player_judged(player);
+});
+
+events.addEventListener("player-judging", event => {
+ const player = JSON.parse(event.data);
+
+ window.game.set_player_judging(player);
+});
+
+events.addEventListener("judging-idle", event => {
+ const value = JSON.parse(event.data);
+
+ window.game.set_judging_idle(value);
+});
+
+events.addEventListener("vote-end-judging", event => {
+ const player = JSON.parse(event.data);
+
+ window.game.set_player_vote_end_judging(player);
+});
+
+events.addEventListener("unvote-end-judging", event => {
+ const player = JSON.parse(event.data);
+
+ window.game.set_player_unvote_end_judging(player);
+});
+
+events.addEventListener("scores", event => {
+ const scores = JSON.parse(event.data);
+
+ window.game.set_scores(scores);
+});
+
+events.addEventListener("vote-new-game", event => {
+ const player = JSON.parse(event.data);
+
+ window.game.set_player_vote_new_game(player);
+});
+
+events.addEventListener("unvote-new-game", event => {
+ const player = JSON.parse(event.data);
+
+ window.game.set_player_unvote_new_game(player);
+});
+
+/*********************************************************
+ * Game and supporting classes *
+ *********************************************************/
+
+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);
+}
+
+const GameInfo = React.memo(props => {
+ if (! props.id)
+ return null;
+
+ return (
+ <div className="game-info">
+ <span className="game-id">{props.id}</span>
+ {" "}
+ Share this link to invite friends:{" "}
+ <span id="game-share-url">{props.url}</span>
+ {" "}
+ <button
+ className="inline"
+ onClick={() => copy_to_clipboard('game-share-url')}
+ >Copy Link</button>
+ </div>
+ );
+});
+
+const PlayerInfo = React.memo(props => {
+ if (! props.player.id)
+ return null;
+
+ const all_players = [{...props.player, active:true}, ...props.other_players];
+
+ const sorted_players = all_players.sort((a,b) => {
+ return b.score - a.score;
+ });
+
+ /* Return a new array with the separator interspersed between
+ * each element of the array passed in as the argument.
+ */
+ function intersperse(arr, sep) {
+ return arr.reduce((acc, val) => [...acc, sep, val], []).slice(1);
+ }
+
+ let names_and_scores = sorted_players.map(player => {
+ if (player.score) {
+ return (
+ <span
+ key={player.name}
+ className={player.active ? "player-active" : "player-idle"}
+ >
+ {player.name} ({player.score})
+ </span>
+ );
+ } else {
+ if (player.active)
+ return player.name;
+ else
+ return null;
+ }
+ }).filter(component => component != null);
+
+ names_and_scores = intersperse(names_and_scores, ", ");
+
+ return (
+ <div className="player-info">
+ <span className="players-header">Players: </span>
+ {names_and_scores}
+ </div>
+ );
+});
+
+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 CategoryRequest extends React.PureComponent {
+ constructor(props) {
+ super(props);
+ this.category = React.createRef();
+
+ this.handle_change = this.handle_change.bind(this);
+ this.handle_submit = this.handle_submit.bind(this);
+ }
+
+ handle_change(event) {
+ const category_input = this.category.current;
+ const category = category_input.value;
+
+ const match = category.match(/[0-9]+/);
+ if (match) {
+ const num_items = parseInt(match[0], 10);
+ if (num_items > 0 && num_items <= MAX_PROMPT_ITEMS)
+ category_input.setCustomValidity("");
+ }
+ }
+
+ async handle_submit(event) {
+ const form = event.currentTarget;
+ const category_input = this.category.current;
+ const category = category_input.value;
+
+ /* Prevent the default page-changing form-submission behavior. */
+ event.preventDefault();
+
+ const match = category.match(/[0-9]+/);
+ if (match === null) {
+ category_input.setCustomValidity("Category must include a number");
+ form.reportValidity();
+ return;
+ }
+
+ const num_items = parseInt(match[0], 10);
+
+ if (num_items > MAX_PROMPT_ITEMS) {
+ category_input.setCustomValidity(`Maximum number of items is ${MAX_PROMPT_ITEMS}`);
+ form.reportValidity();
+ return;
+ }
+
+ if (num_items < 1) {
+ category_input.setCustomValidity("Category must require at least one item.");
+ form.reportValidity();
+ return;
+ }
+
+ const response = await fetch_post_json("prompts", {
+ items: num_items,
+ prompt: category
+ });
+
+ if (response.status === 200) {
+ const result = await response.json();
+ if (! result.valid) {
+ add_message("danger", result.message);
+ return;
+ }
+ } else {
+ add_message("danger", "An error occurred submitting your category");
+ }
+
+ form.reset();
+ }
+
+ render() {
+ return (
+ <div className="category-request">
+ <h2>Submit a Category</h2>
+ <p>
+ Suggest a category to play. Don't forget to include the
+ number of items for each person to submit.
+ </p>
+
+ <form onSubmit={this.handle_submit} >
+ <div className="form-field large">
+ <input
+ type="text"
+ id="category"
+ placeholder="6 things at the beach"
+ required
+ autoComplete="off"
+ onChange={this.handle_change}
+ ref={this.category}
+ />
+ </div>
+
+ <div className="form-field large">
+ <button type="submit">
+ Send
+ </button>
+ </div>
+
+ </form>
+ </div>
+ );
+ }
+}
+
+const PromptOption = React.memo(props => {
+
+ const prompt = props.prompt;
+
+ if (prompt.votes_against.find(v => v === props.player.name))
+ return false;
+
+ return (
+ <button
+ className="vote-button"
+ key={prompt.id}
+ onClick={() => fetch_post_json(`vote/${prompt.id}`) }
+ >
+ <span
+ className="hide-button"
+ onClick={(event) => {
+ event.stopPropagation();
+ fetch_post_json(`vote_against/${prompt.id}`);
+ }}
+ >
+ ×
+ </span>
+ {prompt.prompt}
+ <div className="vote-choices">
+ {prompt.votes.map(v => {
+ return (
+ <div
+ key={v}
+ className="vote-choice"
+ >
+ {v}
+ </div>
+ );
+ })}
+ </div>
+ </button>
+ );
+});
+
+const PromptOptions = React.memo(props => {
+
+ if (props.prompts.length === 0)
+ return null;
+
+ return (
+ <div className="prompt-options">
+ <h2>Vote on Categories</h2>
+ <p>
+ Select any categories below that you'd like to play.
+ You can choose as many as you'd like.
+ </p>
+ {props.prompts.map(
+ prompt => <PromptOption
+ key={prompt.id}
+ prompt={prompt}
+ player={props.player}
+ />
+ )}
+ </div>
+ );
+});
+
+const LetsPlay = React.memo(props => {
+
+ const quorum = Math.max(0, props.num_players - props.prompts.length);
+ const max_votes = props.prompts.reduce(
+ (max_so_far, v) => Math.max(max_so_far, v.votes.length), 0);
+
+ if (max_votes < quorum) {
+ let text = `Before we play, we should collect a bit
+ more information about what category would
+ be interesting for this group. So, either
+ type a new category option above, or else`;
+ if (props.prompts.length) {
+ if (props.prompts.length > 1)
+ text += " vote on some of the categories below.";
+ else
+ text += " vote on the category below.";
+ } else {
+ text += " wait for others to submit, and then vote on them below.";
+ }
+
+ return (
+ <div className="before-we-play">
+ <p>
+ {text}
+ </p>
+ </div>
+ );
+ }
+
+ const candidates = props.prompts.filter(p => p.votes.length >= max_votes);
+ const index = Math.floor(Math.random() * candidates.length);
+ const winner = candidates[index];
+
+ return (
+ <div className="lets-play">
+ <h2>Let's Play</h2>
+ <p>
+ That should be enough voting. If you're not waiting for any
+ other players to join, then let's start.
+ </p>
+ <button
+ className="lets-play"
+ onClick={() => fetch_post_json(`start/${winner.id}`) }
+ >
+ Start Game
+ </button>
+ </div>
+ );
+});
+
+class Ambiguities extends React.PureComponent {
+
+ constructor(props) {
+ super(props);
+
+ function canonize(word) {
+ return word.replace(/((a|an|the) )?(.*?)s?$/i, '$3');
+ }
+
+ const word_sets = [];
+
+ for (let word of props.words) {
+ const word_canon = canonize(word);
+ let found_match = false;
+ for (let set of word_sets) {
+ const set_canon = canonize(set.values().next().value);
+ if (word_canon === set_canon) {
+ set.add(word);
+ found_match = true;;
+ break;
+ }
+ }
+ if (! found_match) {
+ const set = new Set();
+ set.add(word);
+ word_sets.push(set);
+ }
+ }
+
+ this.state = {
+ word_sets: word_sets,
+ selected: null,
+ starred: null
+ };
+
+ this.submitted = false;
+ this.judging_sent_recently = false;
+ }
+
+ async handle_submit() {
+
+ /* Don't submit a second time. */
+ if (this.submitted)
+ return;
+
+ const response = await fetch_post_json(
+ `judged/${this.props.prompt.id}`,{
+ word_groups: this.state.word_sets.map(
+ set => ({
+ words: Array.from(set),
+ kudos: this.state.starred === set ? true : false
+ }))
+ }
+ );
+
+ if (response.status === 200) {
+ const result = await response.json();
+ if (! result.valid) {
+ add_message("danger", result.message);
+ return;
+ }
+ } else {
+ add_message("danger", "An error occurred submitting the results of your judging");
+ return;
+ }
+
+ this.submitted = true;
+ }
+
+ handle_click(word) {
+
+ /* Let the server know we are doing some judging, (but rate limit
+ * this so we don't send a "judging" notification more frquently
+ * than necessary.
+ */
+ if (! this.judging_sent_recently) {
+ fetch_post_json(`judging/${this.props.prompt.id}`);
+ this.judging_sent_recently = true;
+ setTimeout(() => { this.judging_sent_recently = false; }, 1000);
+ }
+
+ if (this.state.selected == word) {
+ /* Second click on same word removes the word from the group. */
+ const idx = this.state.word_sets.findIndex(s => s.has(word));
+ const set = this.state.word_sets[idx];
+ if (set.size === 1) {
+ /* When the word is already alone, there's nothing to do but
+ * to un-select it. */
+ this.setState({
+ selected: null
+ });
+ return;
+ }
+
+ const new_set = new Set([...set].filter(w => w !== word));
+ this.setState({
+ selected: null,
+ word_sets: [...this.state.word_sets.slice(0, idx),
+ new_set,
+ new Set().add(word),
+ ...this.state.word_sets.slice(idx+1)]
+ });
+ } else if (this.state.selected) {
+ /* Click of a second word groups the two together. */
+ const idx1 = this.state.word_sets.findIndex(s => s.has(this.state.selected));
+ const idx2 = this.state.word_sets.findIndex(s => s.has(word));
+ const set1 = this.state.word_sets[idx1];
+ const set2 = this.state.word_sets[idx2];
+ const new_set = new Set([...set2, ...set1]);
+ if (idx1 < idx2) {
+ this.setState({
+ selected: null,
+ word_sets: [...this.state.word_sets.slice(0, idx1),
+ ...this.state.word_sets.slice(idx1 + 1, idx2),
+ new_set,
+ ...this.state.word_sets.slice(idx2 + 1)]
+ });
+ } else {
+ this.setState({
+ selected: null,
+ word_sets: [...this.state.word_sets.slice(0, idx2),
+ new_set,
+ ...this.state.word_sets.slice(idx2 + 1, idx1),
+ ...this.state.word_sets.slice(idx1 + 1)]
+ });
+ }
+ } else {
+ /* First click of a word selects it. */
+ this.setState({
+ selected: word
+ });
+ }
+ }
+
+ render() {
+ let move_on_button = null;
+
+ if (this.props.idle) {
+ move_on_button = (
+ <button
+ className="vote-button"
+ onClick={() => fetch_post_json(`end-judging/${this.props.prompt.id}`) }
+ >
+ Move On Without Their Input
+ <div className="vote-choices">
+ {[...this.props.votes].map(v => {
+ return (
+ <div
+ key={v}
+ className="vote-choice"
+ >
+ {v}
+ </div>
+ );
+ })}
+ </div>
+ </button>
+ );
+ }
+
+ let still_waiting = null;
+ const judging_players = Object.keys(this.props.players_judging);
+ if (judging_players.length) {
+ still_waiting = (
+ <div>
+ <p>
+ Still waiting for the following player
+ {judging_players.length > 1 ? 's' : '' }
+ :
+ </p>
+ <ul>
+ {judging_players.map(player => {
+ return (
+ <li
+ key={player}
+ >
+ {player}{' '}
+ <span className=
+ {this.props.players_judging[player].active ?
+ "typing active"
+ :
+ "typing idle"}>
+ <span>{'.'}</span><span>{'.'}</span><span>{'.'}</span>
+ </span>
+ </li>
+ );
+ })}
+ </ul>
+ </div>
+ );
+ }
+
+ if (this.props.players_judged.has(this.props.player.name)) {
+ return (
+ <div className="please-wait">
+ <h2>Submission received</h2>
+ <p>
+ The following players have completed judging:{' '}
+ {[...this.props.players_judged].join(', ')}
+ </p>
+ {still_waiting}
+ {move_on_button}
+
+ </div>
+ );
+ }
+
+ const btn_class = "ambiguity-button";
+ const btn_selected_class = btn_class + " selected";
+
+ return (
+ <div className="ambiguities">
+ <h2>Judging Answers</h2>
+ <p>
+ Click/tap on each pair of answers that should be scored as equivalent,
+ (or click a word twice to split it out from a group). Remember,
+ what goes around comes around, so it's best to be generous when
+ judging.
+ </p>
+ <p>
+ Also, for an especially fun or witty answer, you can give kudos
+ by clicking the star on the right. You may only do this for one
+ word/group.
+ </p>
+ <h2>{this.props.prompt.prompt}</h2>
+ {this.state.word_sets.map(set => {
+ return (
+ <div
+ className="ambiguity-group"
+ key={Array.from(set)[0]}
+ >
+ {Array.from(set).map(word => {
+ return (
+ <button
+ className={this.state.selected === word ?
+ btn_selected_class : btn_class }
+ key={word}
+ onClick={() => this.handle_click(word)}
+ >
+ {word}
+ </button>
+ );
+ })}
+ <span
+ className={this.state.starred === set ?
+ "star-button selected" : "star-button"
+ }
+ onClick={(event) => {
+ event.stopPropagation();
+ if (this.state.starred === set) {
+ this.setState({
+ starred: null
+ });
+ } else {
+ this.setState({
+ starred: set
+ });
+ }
+ }}
+ >
+ {this.state.starred === set ?
+ '★' : '☆'
+ }
+ </span>
+ </div>
+ );
+ })}
+ <p>
+ Click here when done judging:<br/>
+ <button
+ onClick={() => this.handle_submit()}
+ >
+ Send
+ </button>
+ </p>
+ </div>
+ );
+ }
+}
+
+class ActivePrompt extends React.PureComponent {
+
+ constructor(props) {
+ super(props);
+ const items = props.prompt.items;
+
+ this.submitted = false;
+
+ this.answers = [...Array(items)].map(() => React.createRef());
+ this.answering_sent_recently = false;
+
+ this.handle_submit = this.handle_submit.bind(this);
+ this.handle_change = this.handle_change.bind(this);
+ }
+
+ handle_change(event) {
+ /* We don't care (or even look) at what the player is typing at
+ * this point. We simply want to be informed that the player _is_
+ * typing so that we can tell the server (which will tell other
+ * players) that there is activity here.
+ */
+
+ /* Rate limit so that we don't send an "answering" notification
+ * more frequently than necessary.
+ */
+ if (! this.answering_sent_recently) {
+ fetch_post_json(`answering/${this.props.prompt.id}`);
+ this.answering_sent_recently = true;
+ setTimeout(() => { this.answering_sent_recently = false; }, 1000);
+ }
+ }
+
+ async handle_submit(event) {
+ const form = event.currentTarget;
+
+ /* Prevent the default page-changing form-submission behavior. */
+ event.preventDefault();
+
+ /* And don't submit a second time. */
+ if (this.submitted)
+ return;
+
+ const response = await fetch_post_json(`answer/${this.props.prompt.id}`, {
+ answers: this.answers.map(r => r.current.value)
+ });
+ if (response.status === 200) {
+ const result = await response.json();
+ if (! result.valid) {
+ add_message("danger", result.message);
+ return;
+ }
+ } else {
+ add_message("danger", "An error occurred submitting your answers");
+ return;
+ }
+
+ /* Everything worked. Server is happy with our answers. */
+ form.reset();
+ this.submitted = true;
+ }
+
+ render() {
+
+ let still_waiting = null;
+ const answering_players = Object.keys(this.props.players_answering);;
+ if (answering_players.length) {
+ still_waiting = (
+ <div>
+ <p>
+ Still waiting for the following player
+ {answering_players.length > 1 ? 's' : ''}
+ :
+ </p>
+ <ul>
+ {answering_players.map(player => {
+ return (
+ <li
+ key={player}
+ >
+ {player}{' '}
+ <span className=
+ {this.props.players_answering[player].active ?
+ "typing active"
+ :
+ "typing idle"}>
+ <span>{'.'}</span><span>{'.'}</span><span>{'.'}</span>
+ </span>
+ </li>
+ );
+ })}
+ </ul>
+ </div>
+ );
+ }
+
+ let move_on_button = null;
+ if (this.props.idle) {
+ move_on_button =(
+ <button
+ className="vote-button"
+ onClick={() => fetch_post_json(`end-answers/${this.props.prompt.id}`) }
+ >
+ {answering_players.length ?
+ "Move On Without Their Answers" :
+ "Move On Without Anyone Else"}
+ <div className="vote-choices">
+ {[...this.props.votes].map(v => {
+ return (
+ <div
+ key={v}
+ className="vote-choice"
+ >
+ {v}
+ </div>
+ );
+ })}
+ </div>
+ </button>
+ );
+ }
+
+ if (this.props.players_answered.has(this.props.player.name)) {
+ return (
+ <div className="please-wait">
+ <h2>Submission received</h2>
+ <p>
+ The following players have submitted their answers:{' '}
+ {[...this.props.players_answered].join(', ')}
+ </p>
+ {still_waiting}
+ {move_on_button}
+
+ </div>
+ );
+ }
+
+ return (
+ <div className="active-prompt">
+ <h2>The Game of Empathy</h2>
+ <p>
+ Remember, you're trying to match your answers with
+ what the other players submit.
+ Give {this.props.prompt.items} answer
+ {this.props.prompt.items > 1 ? 's' : ''} for the following prompt:
+ </p>
+ <h2>{this.props.prompt.prompt}</h2>
+ <form onSubmit={this.handle_submit}>
+ {[...Array(this.props.prompt.items)].map((whocares,i) => {
+ return (
+ <div
+ key={i}
+ className="form-field large">
+ <input
+ type="text"
+ name={`answer_${i}`}
+ required
+ autoComplete="off"
+ onChange={this.handle_change}
+ ref={this.answers[i]}
+ />
+ </div>
+ );
+ })}
+
+ <div
+ key="submit-button"
+ className="form-field large">
+ <button type="submit">
+ Send
+ </button>
+ </div>
+
+ </form>
+ </div>
+ );
+ }
+}
+
+class Game extends React.PureComponent {
+ constructor(props) {
+ super(props);
+ this.state = {
+ game_info: {},
+ player_info: {},
+ other_players: [],
+ prompts: [],
+ active_prompt: null,
+ players_answered: new Set(),
+ players_answering: {},
+ answering_idle: false,
+ end_answers_votes: new Set(),
+ ambiguities: null,
+ players_judged: new Set(),
+ players_judging: {},
+ judging_idle: false,
+ end_judging_votes: new Set(),
+ scores: null,
+ new_game_votes: new Set(),
+ ready: false
+ };
+ }
+
+ 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
+ });
+ }
+
+ disable_player(info) {
+ const idx = this.state.other_players.findIndex(o => o.id === info.id);
+ if (idx < 0)
+ return;
+
+ const other_players_copy = [...this.state.other_players];
+ other_players_copy[idx].active = false;
+
+ this.setState({
+ other_players: other_players_copy
+ });
+ }
+
+ reset_game_state() {
+ this.setState({
+ prompts: [],
+ active_prompt: null,
+ players_answered: new Set(),
+ players_answering: {},
+ answering_idle: false,
+ end_answers_votes: new Set(),
+ ambiguities: null,
+ players_judged: new Set(),
+ players_judging: {},
+ judging_idle: false,
+ end_judging_votes: new Set(),
+ scores: null,
+ new_game_votes: new Set(),
+ ready: false
+ });
+ }
+
+ set_prompts(prompts) {
+ this.setState({
+ prompts: prompts
+ });
+ }
+
+ add_or_update_prompt(prompt) {
+ const prompts_copy = [...this.state.prompts];
+ const idx = prompts_copy.findIndex(p => p.id === prompt.id);
+ if (idx >= 0) {
+ prompts_copy[idx] = prompt;
+ } else {
+ prompts_copy.push(prompt);
+ }
+ this.setState({
+ prompts: prompts_copy
+ });
+ }
+
+ set_active_prompt(prompt) {
+ this.setState({
+ active_prompt: prompt
+ });
+ }
+
+ set_players_answered(players) {
+ this.setState({
+ players_answered: new Set(players)
+ });
+ }
+
+ set_player_answered(player) {
+ const new_players_answering = {...this.state.players_answering};
+ delete new_players_answering[player];
+
+ this.setState({
+ players_answered: new Set([...this.state.players_answered, player]),
+ players_answering: new_players_answering
+ });
+ }
+
+ set_players_answering(players) {
+ const players_answering = {};
+ for (let player of players) {
+ players_answering[player] = {active: false};
+ }
+ this.setState({
+ players_answering: players_answering
+ });
+ }
+
+ set_player_answering(player) {
+ /* Set the player as actively answering now. */
+ this.setState({
+ players_answering: {
+ ...this.state.players_answering,
+ [player]: {active: true}
+ }
+ });
+ /* And arrange to have them marked idle very shortly.
+ *
+ * Note: This timeout is intentionally very, very short. We only
+ * need it long enough that the browser has latched onto the state
+ * change to "active" above. We actually use a CSS transition
+ * delay to control the user-perceptible length of time after
+ * which an active player appears inactive.
+ */
+ setTimeout(() => {
+ this.setState({
+ players_answering: {
+ ...this.state.players_answering,
+ [player]: {active: false}
+ }
+ });
+ }, 100);
+ }
+
+ set_answering_idle(value) {
+ this.setState({
+ answering_idle: value
+ });
+ }
+
+ set_end_answers(players) {
+ this.setState({
+ end_answers_votes: new Set(players)
+ });
+ }
+
+ set_player_vote_end_answers(player) {
+ this.setState({
+ end_answers_votes: new Set([...this.state.end_answers_votes, player])
+ });
+ }
+
+ set_player_unvote_end_answers(player) {
+ this.setState({
+ end_answers_votes: new Set([...this.state.end_answers_votes].filter(p => p !== player))
+ });
+ }
+
+ set_ambiguities(ambiguities) {
+ this.setState({
+ ambiguities: ambiguities
+ });
+ }
+
+ set_players_judged(players) {
+ this.setState({
+ players_judged: new Set(players)
+ });
+ }
+
+ set_player_judged(player) {
+ const new_players_judging = {...this.state.players_judging};
+ delete new_players_judging[player];
+
+ this.setState({
+ players_judged: new Set([...this.state.players_judged, player]),
+ players_judging: new_players_judging
+ });
+ }
+
+ set_players_judging(players) {
+ const players_judging = {};
+ for (let player of players) {
+ players_judging[player] = {active: false};
+ }
+ this.setState({
+ players_judging: players_judging
+ });
+ }
+
+ set_player_judging(player) {
+ /* Set the player as actively judging now. */
+ this.setState({
+ players_judging: {
+ ...this.state.players_judging,
+ [player]: {active: true}
+ }
+ });
+ /* And arrange to have them marked idle very shortly.
+ *
+ * Note: This timeout is intentionally very, very short. We only
+ * need it long enough that the browser has latched onto the state
+ * change to "active" above. We actually use a CSS transition
+ * delay to control the user-perceptible length of time after
+ * which an active player appears inactive.
+ */
+ setTimeout(() => {
+ this.setState({
+ players_judging: {
+ ...this.state.players_judging,
+ [player]: {active: false}
+ }
+ });
+ }, 100);
+
+ }
+
+ set_judging_idle(value) {
+ this.setState({
+ judging_idle: value
+ });
+ }
+
+ set_end_judging(players) {
+ this.setState({
+ end_judging_votes: new Set(players)
+ });
+ }
+
+ set_player_vote_end_judging(player) {
+ this.setState({
+ end_judging_votes: new Set([...this.state.end_judging_votes, player])
+ });
+ }
+
+ set_player_unvote_end_judging(player) {
+ this.setState({
+ end_judging_votes: new Set([...this.state.end_judging_votes].filter(p => p !== player))
+ });
+ }
+
+ set_scores(scores) {
+ this.setState({
+ scores: scores
+ });
+ }
+
+ set_new_game_votes(players) {
+ this.setState({
+ new_game_votes: new Set(players)
+ });
+ }
+
+ set_player_vote_new_game(player) {
+ this.setState({
+ new_game_votes: new Set([...this.state.new_game_votes, player])
+ });
+ }
+
+ set_player_unvote_new_game(player) {
+ this.setState({
+ new_game_votes: new Set([...this.state.new_game_votes].filter(p => p !== player))
+ });
+ }
+
+ state_ready() {
+ this.setState({
+ ready: true
+ });
+ }
+
+ render() {
+ const state = this.state;
+
+ if (state.scores) {
+
+ const players_total = state.players_answered.size;
+
+ let perfect_score = 0;
+ for (let i = 0;
+ i < state.active_prompt.items &&
+ i < state.scores.words.length;
+ i++)
+ {
+ perfect_score += state.scores.words[i].players.length;
+ }
+
+ return (
+ <div className="scores">
+ <h2>{state.active_prompt.prompt}</h2>
+ <h2>Scores</h2>
+ <ul>
+ {state.scores.scores.map(score => {
+ let perfect = null;
+ if (score.score === perfect_score) {
+ perfect = <span className="achievement">Perfect!</span>;
+ }
+ let quirkster = null;
+ if (score.score === state.active_prompt.items) {
+ quirkster = <span className="achievement">Quirkster!</span>;
+ }
+ let kudos_slam = null;
+ if (score.kudos > 0 && score.kudos >= players_total - 1) {
+ kudos_slam = <span className="achievement">Kudos Slam!</span>;
+ }
+ return (
+ <li key={score.players[0]}>
+ {score.players.join("/")}: {score.score}
+ {score.kudos ? `, ${'★'.repeat(score.kudos)}` : ""}
+ {' '}{perfect} {quirkster} {kudos_slam}
+ </li>
+ );
+ })}
+ </ul>
+ <h2>Words submitted</h2>
+ <ul>
+ {state.scores.words.map(word => {
+ let great_minds = null;
+ if (word.kudos.length && word.players.length > 1) {
+ great_minds = <span className="achievement">Great Minds!</span>;
+ }
+ let kudos_slam = null;
+ if (word.kudos.length > 0 && word.kudos.length >= players_total - 1) {
+ kudos_slam = <span className="achievement">Kudos Slam!</span>;
+ }
+ return (
+ <li key={word.word}>
+ {word.word} ({word.players.length}
+ {word.kudos.length ? `, ${'★'.repeat(word.kudos.length)}` : ""}
+ ): {word.players.join(', ')}
+ {' '}{great_minds}{kudos_slam}
+ </li>
+ );
+ })}
+ </ul>
+ <button
+ className="vote-button"
+ onClick={() => fetch_post_json(`new-game/${state.active_prompt.id}`) }
+ >
+ New Game
+ <div className="vote-choices">
+ {[...state.new_game_votes].map(v => {
+ return (
+ <div
+ key={v}
+ className="vote-choice"
+ >
+ {v}
+ </div>
+ );
+ })}
+ </div>
+ </button>
+ </div>
+ );
+ }
+
+ if (state.ambiguities){
+ return <Ambiguities
+ prompt={state.active_prompt}
+ words={state.ambiguities}
+ player={state.player_info}
+ players_judged={state.players_judged}
+ players_judging={state.players_judging}
+ idle={state.judging_idle}
+ votes={state.end_judging_votes}
+ />;
+ }
+
+ if (state.active_prompt) {
+ return <ActivePrompt
+ prompt={state.active_prompt}
+ player={state.player_info}
+ players_answered={state.players_answered}
+ players_answering={state.players_answering}
+ idle={state.answering_idle}
+ votes={state.end_answers_votes}
+ />;
+ }
+
+ if (! state.ready)
+ return null;
+
+ return [
+ <GameInfo
+ key="game-info"
+ id={state.game_info.id}
+ url={state.game_info.url}
+ />,
+ <PlayerInfo
+ key="player-info"
+ game={this}
+ player={state.player_info}
+ other_players={state.other_players}
+ />,
+ <p key="spacer"></p>,
+ <CategoryRequest
+ key="category-request"
+ />,
+ <LetsPlay
+ key="lets-play"
+ num_players={1+state.other_players.filter(p => p.active).length}
+ prompts={state.prompts}
+ />,
+ <PromptOptions
+ key="prompts"
+ prompts={state.prompts}
+ player={state.player_info}
+ />
+ ];
+ }
+}
+
+ReactDOM.render(<Game
+ ref={(me) => window.game = me}
+ />, document.getElementById("empathy"));
--- /dev/null
+<!DOCTYPE html>
+<html>
+ <head>
+ <meta charset="utf-8"/>
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
+
+ <title>The Game of Empathy</title>
+
+ <link rel="stylesheet" href="/reset.css" type="text/css" />
+ <link rel="stylesheet" href="/style.css" type="text/css" />
+ </head>
+ <body>
+
+ <script src="/lmno.js"></script>
+
+ <div id="page">
+
+ <h1>The Game of Empathy</h1>
+
+ <p>
+ You don't need to be right, you just need to agree with your
+ friends.
+ </p>
+
+ <div id="message-area">
+ </div>
+
+ <form onsubmit="lmno_new('empathy'); return false;">
+ <button type="submit">
+ Host a new game
+ </button>
+ </form>
+
+ </div>
+ </body>
+</html>
/* By default, hide things that are not to be shown
- * until a particular game state is reached. */
-.show-state-join {
+ * until a particular game phase is reached. */
+.show-phase-join {
display:none;
}
-.show-state-reveal {
+.show-phase-reveal {
display:none;
}
-.show-state-capture {
+.show-phase-capture {
display:none;
}
/* And by default, show things that will be hidden
- * when a particular game state is reached. */
-.hide-state-join {
+ * when a particular game phase is reached. */
+.hide-phase-join {
display:block;
}
-.hide-state-reveal {
+.hide-phase-reveal {
display:block;
}
-.hide-state-capture {
+.hide-phase-capture {
display:block;
}
function toggle_host_tools() {
const host_tools = document.getElementById("host-tools");
- console.log("Toggling, host_tools.style.display is '" + host_tools.style.display + "'");
-
if (host_tools.style.display === "block")
host_tools.style.display = "none";
else
state.spectator_id = JSON.parse(this.response);
}
-events.addEventListener("game-state", function(event) {
+events.addEventListener("game-phase", function(event) {
const data = JSON.parse(event.data);
- const old_state = data.old_state;
- const new_state = data.new_state;
+ const old_phase = data.old_phase;
+ const new_phase = data.new_phase;
- const hide_selector = ".show-state-" +old_state+ ",.hide-state-" +new_state;
- const show_selector = ".hide-state-" +old_state+ ",.show-state-" +new_state;
+ const hide_selector = ".show-phase-" +old_phase+ ",.hide-phase-" +new_phase;
+ const show_selector = ".hide-phase-" +old_phase+ ",.show-phase-" +new_phase;
- /* Hide all elements based on the state transition. */
+ /* Hide all elements based on the phase transition. */
var elts = document.querySelectorAll(hide_selector);
for (const elt of elts) {
elt.style.display = "none";
}
- /* And show all elements based on the same state transition. */
+ /* And show all elements based on the same phase transition. */
elts = document.querySelectorAll(show_selector);
for (const elt of elts) {
elt.style.display = "block";
}
- /* Whenever the game enters the "join" state, add ourselves as a spectator. */
- if (new_state === "join") {
+ /* Whenever the game enters the "join" phase, add ourselves as a spectator. */
+ if (new_phase === "join") {
const request = new XMLHttpRequest();
request.addEventListener("load", spectator_on_load);
<li>
<a href="empires">Empires</a>
</li>
+ <li>
+ <a href="empathy">Empathy</a>
+ </li>
</ul>
</p>
--- /dev/null
+# Defer all targets up to the upper-level
+#
+# This requires two recipes. The first to cover the case of no
+# explicit target specifed (so when invoked as "make" we call "make"
+# at the upper-level) and then a .DEFAULT recipe to pass any explicit
+# target up as well, (so that an invocation of "make foo" results in a
+# call to "make foo" above.
+all:
+ $(MAKE) -C ..
+
+.DEFAULT:
+ $(MAKE) -C .. $@
--- /dev/null
+<!DOCTYPE html>
+<html>
+ <head>
+ <meta charset="utf-8"/>
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
+
+ <title>Scribe</title>
+
+ <link rel="stylesheet" href="/reset.css" type="text/css" />
+ <link rel="stylesheet" href="/style.css" type="text/css" />
+
+ <script src="/lmno.js"></script>
+ </head>
+ <body>
+
+ <div id="page">
+
+ <h1>Scribe</h1>
+
+ <p>
+ A game
+ by <a href="http://www.marksteeregames.com/Scribe_rules.html">Mark
+ Steere</a>, implemented by permission.
+ </p>
+
+ <div id="message-area">
+ </div>
+
+ <form onsubmit="lmno_new('scribe'); return false;">
+ <button type="submit">
+ Host a new game
+ </button>
+ </form>
+
+ </div>
+
+ </body>
+</html>
--- /dev/null
+/* We want our board to be the largest square that can
+ * fit. Unfortunately, it requires a bit of CSS magic to make that
+ * happen. We can set a width easily enough, but what we can't easily
+ * do is to set the height to be exactly the same as the width.
+ *
+ * So here's the magic to get that to happen. On the board container
+ * we set the height to 0 and the bottom padding to 100% (which just
+ * happens to be defined as relative to the _width_). So, now we have
+ * a square element. Hurrah!
+ *
+ * The problem is that this element has a nominal height of 0, so if
+ * any child sas "height: 100%" that will result in a 0-height child
+ * and won't be what we want.
+ *
+ * So the last piece of the magic is to use absolute placement of the
+ * board (which requires position:relative on its parent) and set all
+ * of its edges (top, left, bottom, right) to the extents of the
+ * container.
+ *
+ * Ta-da! Now our board element is square and does not have any
+ * dimensions of 0 so child elements can compute their sizes
+ * naturally.
+ */
+.board-container {
+ position: relative;
+ width: 100%;
+ height: 0;
+ padding-bottom: 100%;
+}
+
+.board {
+ position: absolute;
+ top: 0;
+ left: 0;
+ bottom: 0;
+ right: 0;
+
+ display: grid;
+ grid-template-columns: 1fr 1fr 1fr;
+ grid-template-rows: 1fr 1fr 1fr;
+ grid-gap: 1em;
+}
+
+.mini-grid {
+ width: 100%;
+ height: 100%;
+
+ display: grid;
+ grid-template-columns: 1fr 1fr 1fr;
+ grid-template-rows: 1fr 1fr 1fr;
+
+ border-radius: 6px;
+ border: 3px solid #999;
+}
+
+.mini-grid.active {
+ border: 3px solid var(--accent-color-bright);
+}
+
+.square {
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ font-size: calc(min(8vw, .08 * var(--page-max-width)));
+ line-height: 0;
+ font-weight: bold;
+ border-bottom: 1px solid #999;
+ border-right: 1px solid #999;
+}
+
+.square.open {
+ cursor: pointer;
+}
+
+.square.occupied {
+ cursor: default;
+}
+
+.square.open:hover {
+ background-color: var(--accent-color-bright);
+}
+
+.square.last-move {
+ color: var(--accent-color-bright);
+}
+
+.glyphs {
+ padding-top: 0.5em;
+ display: grid;
+ grid-template-columns: 1fr 1fr 1fr 1fr 1fr;
+ grid-column-gap: 0.5em;
+ grid-row-gap: 0.25em;
+}
+
+.glyph-and-name {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+}
+
+.glyph {
+ width: 8vw;
+}
--- /dev/null
+function team_symbol(team) {
+ if (team === "+")
+ return "+";
+ else
+ return "o";
+}
+
+function undisplay(element) {
+ element.style.display="none";
+}
+
+function add_message(severity, message) {
+ message = `<div class="message ${severity}" onclick="undisplay(this)">
+<span class="hide-button" onclick="undisplay(this.parentElement)">×</span>
+${message}
+</div>`;
+ 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 (
+ <div className="game-info">
+ <span className="game-id">{props.id}</span>
+ {" "}
+ Share this link to invite a friend:{" "}
+ <span id="game-share-url">{props.url}</span>
+ {" "}
+ <button
+ className="inline"
+ onClick={() => copy_to_clipboard('game-share-url')}
+ >Copy Link</button>
+ </div>
+ );
+}
+
+function TeamButton(props) {
+ return <button className="inline"
+ onClick={() => props.game.join_team(props.team)}>
+ {props.label}
+ </button>;
+}
+
+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 [
+ <TeamButton key="+" game={props.game} team="+" label="Join 🞥" />,
+ " ",
+ <TeamButton key="o" game={props.game} team="o" label="Join 🞇" />
+ ];
+ }
+ } else {
+ return <TeamButton game={props.game} team={other_team} label="Switch" />;
+ }
+}
+
+function PlayerInfo(props) {
+ if (! props.player.id)
+ return null;
+
+ const choices = <TeamChoices
+ game={props.game}
+ first_move={props.first_move}
+ player={props.player}
+ />;
+
+ return (
+ <div className="player-info">
+ <span className="players-header">Players: </span>
+ {props.player.name}
+ {props.player.team ? ` (${props.player.team})` : ""}
+ {props.first_move ? "" : " "}
+ {choices}
+ {props.other_players.map(other => (
+ <span key={other.id}>
+ {", "}
+ {other.name}
+ {other.team ? ` (${other.team})` : ""}
+ </span>
+ ))}
+ </div>
+ );
+}
+
+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(
+ <circle
+ key={3 * row + col}
+ cx={cx}
+ cy={cy}
+ r="8"
+ />
+ );
+ }
+ }
+ }
+
+ return (<div className="glyph-and-name">
+ {props.name}
+ <div className="glyph">
+ <svg viewBox={viewbox}>
+ <g fill="#287789">
+ {glyph_dots}
+ </g>
+ </svg>
+ </div>
+ </div>
+ );
+}
+
+function Square(props) {
+ let className = "square";
+
+ if (props.value) {
+ className += " occupied";
+ } else if (props.active) {
+ className += " open";
+ }
+
+ if (props.last_move) {
+ className += " last-move";
+ }
+
+ const onClick = props.active ? props.onClick : null;
+
+ return (
+ <div className={className}
+ onClick={onClick}>
+ {props.value}
+ </div>
+ );
+}
+
+function MiniGrid(props) {
+ function grid_square(j) {
+ const value = props.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 === null));
+
+ return (
+ <Square
+ value={value}
+ active={square_active}
+ last_move={last_move}
+ onClick={() => 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;
+ props.squares.forEach(element => {
+ if (element)
+ occupied++;
+ });
+
+ let class_name = "mini-grid";
+ if (props.active && occupied < 9)
+ class_name += " active";
+
+ return (
+ <div className={class_name}>
+ {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)}
+ </div>
+ );
+}
+
+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.squares[target].forEach(element => {
+ if (element)
+ 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 squares = this.props.squares[i];
+ return (
+ <MiniGrid
+ squares={squares}
+ active={grid_active}
+ last_moves={last_moves}
+ onClick={(j) => this.props.onClick(i,j)}
+ />
+ );
+ }
+
+ render() {
+ return (
+ <div className="board-container">
+ <div className="board">
+ {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)}
+ </div>
+ </div>
+ );
+ }
+}
+
+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: [],
+ squares: [...Array(9)].map(() => Array(9).fill(null)),
+ 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: "+"
+ });
+ }
+
+ receive_move(move) {
+ if (this.state.moves.length === 81) {
+ return;
+ }
+ const symbol = team_symbol(this.state.next_to_play);
+ const new_squares = this.state.squares.map(arr => arr.slice());
+ new_squares[move[0]][move[1]] = symbol;
+ const new_moves = [...this.state.moves, move];
+ let next_to_play;
+ if (this.state.next_to_play === "+")
+ next_to_play = "o";
+ else
+ next_to_play = "+";
+ this.setState({
+ squares: new_squares,
+ 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 [
+ <GameInfo
+ key="game-info"
+ id={state.game_info.id}
+ url={state.game_info.url}
+ />,
+ <PlayerInfo
+ key="player-info"
+ game={this}
+ first_move={first_move}
+ player={state.player_info}
+ other_players={state.other_players}
+ />,
+ <div key="game" className="game">
+ <div>{status}</div>
+ <div className="game-board">
+ <Board
+ active={board_active}
+ squares={state.squares}
+ last_two_moves={state.moves.slice(-2)}
+ onClick={(i,j) => this.handle_click(i, j, first_move)}
+ />
+ </div>
+ </div>,
+ <div key="glyphs" className="glyphs">
+ {
+ scribe_glyphs.map(glyph => {
+ return (
+ <Glyph
+ key={glyph.name}
+ name={glyph.name}
+ squares={glyph.squares}
+ />
+ );
+ })
+ }
+ </div>
+ ];
+ }
+}
+
+ReactDOM.render(<Game
+ ref={(me) => window.game = me}
+ />, document.getElementById("scribe"));
+/* Because this is the only sizing that is sane, make it global. */
+* {
+ box-sizing: border-box;
+}
+
/*\
|*|
|*| Properties for the page: colors, etc.
/* A little color to avoid a fully monochromatic theme. */
--accent-color: #287789;
--accent-color-bright: #44c7ef;
+ --text-fg-on-accent: white;
+ --text-fg-on-accent-bright: #333738;
/* Some colors intended to convey semnatics. */
--warning-color: #ffa92a;
--danger-color: #f56257;
--danger-color-dark: #bc2822;
+
+ /* Page layout */
+ --page-max-width: 720px;
+ --page-max-pad: 50px;
+ --page-max-width-padded: calc(var(--page-max-width) + 2 * var(--page-max-pad));
}
/*\
h1,h2 {
color: var(--text-fg-color);
font-weight: bold;
+ margin-top: 0.5em;
}
h1 {
font-size: 110%;
}
+/* Don't underline links in headers */
+h1 a {
+ text-decoration: none;
+}
+
p,dl,dd,form {
margin-bottom: 1em;
}
a:visited {
color: var(--accent-color);
}
-a:hover {
- color: var(--accent-color-bright);
+@media (hover:hover) {
+ a:hover {
+ color: var(--accent-color-bright);
+ }
}
a:active {
color: var(--accent-color-bright);
}
+strong {
+ font-weight: bold;
+}
+
/*\
|*|
|*| Overall page layout
/* We never let the page content get larger than a large fixed width.
*
* And when the screen is wide enough, we can afford some "wasted"
- * space on either side of the page content. This starts at 0 for a
- * 620px wide page up to 50px on either side for a 820px wide page.
+ * space on either side of the page content, (up to --page-max-pad).
*
- * Note: This 820px width for the page includes the padding so the
- * actual content is only ever as wide as 720px.
+ * Note: This --page-max-width-padded for the page includes the
+ * padding so the actual content is only ever as wide as
+ * --page-max-width.
*
* Wider than that and we start to see the background on either side
* of the page content.
*/
#page {
- box-sizing: border-box;
- max-width: 820px;
+ max-width: var(--page-max-width-padded);
margin-left: auto;
margin-right: auto;
padding-top: 0;
- padding-bottom: 0;
+ padding-bottom: 2em;
padding-left: 1em;
padding-right: 1em;
}
-@media screen and (min-width: 720px) and (max-width: 820px) {
+@media screen and (min-width: var(--page-max-width)) and (max-width: var(--page-max-width-padded)) {
#page {
- padding-left: calc(1em + (100% - 720px)/2);
- padding-right: calc(1em + (100% - 720px)/2);
+ padding-left: calc(1em + (100% - var(--page-max-width))/2);
+ padding-right: calc(1em + (100% - var(--page-pax-width))/2);
}
}
-@media screen and (min-width: 820px) {
+@media screen and (min-width: var(--page-pax-width-padded)) {
#page {
- padding-left: calc(1em + 50px);
- padding-right: calc(1em + 50px);
+ padding-left: calc(1em + var(--page-max-pad));
+ padding-right: calc(1em + var(--page-max-pad));
}
}
grid-column-gap: 1em;
}
+.form-field {
+ margin-top: 0.25em;
+ margin-bottom: 0.25em;
+}
+
.form-field.small.left,.form-field.medium.left {
grid-column-start: 1;
}
border-radius: 4px;
background-color: var(--accent-color);
border: none;
- color: white;
+ color: var(--text-fg-on-accent);
text-align: center;
font-size: 125%;
margin-top: .25em;
padding-top: 0.25em;
padding-bottom: 0.25em;
width: 200px;
+ cursor: pointer;
}
-button:hover {
- transform: translateY(-1px);
- background-color: var(--accent-color-bright);
+button.inline {
+ font-size: 72%;
+ font-weight: bold;
+ width: auto;
+}
+
+@media (hover:hover) {
+ button:hover {
+ transform: translateY(-1px);
+ background-color: var(--accent-color-bright);
+ }
}
:focus {
.hide-button {
color: white;
+ opacity: 0.5;
font-size: 125%;
font-weight: bold;
cursor: pointer;
top: 0;
}
-.hide-button:hover {
- color: var(--danger-color-dark);
+@media (hover:hover) {
+ .hide-button:hover {
+ opacity: 1.0;
+ }
+}
+
+/*\
+|*|
+|*| Game-specific markup
+|*|
+\*/
+
+.game-id, .players-header {
+ font-size: 110%;
+ font-weight: bold;
}
color: black;
border: 1px solid #999;
float: left;
- font-size: 24px;
+ font-size: 64px;
font-weight: bold;
- line-height: 34px;
- height: 34px;
+ line-height: 90px;
+ width: 90px;
+ height: 90px;
margin-right: -1px;
margin-top: -1px;
padding: 0;
text-align: center;
- width: 34px;
+ border-radius: 4px;
}
-.square:focus {
- outline: none;
+.square.open {
+ cursor: pointer;
}
-.kbd-navigation .square:focus {
- background: #ddd;
+.square.occupied {
+ cursor: default;
+}
+
+.square.open:hover {
+ background-color: var(--accent-color-bright);
}
-.game {
- display: flex;
- flex-direction: row;
+.square:focus {
+ outline: none;
}
-.game-info {
- margin-left: 20px;
- margin-bottom: 20px;
+.kbd-navigation .square:focus {
+ background: #ddd;
}
+const Team = {
+ X: 0,
+ O: 1,
+ properties: {
+ 0: {name: "X"},
+ 1: {name: "O"}
+ }
+};
+
+function undisplay(element) {
+ element.style.display="none";
+}
+
+function add_message(severity, message) {
+ message = `<div class="message ${severity}" onclick="undisplay(this)">
+<span class="hide-button" onclick="undisplay(this.parentElement)">×</span>
+${message}
+</div>`;
+ 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) {
+ add_message("danger", "Connection to server lost.");
+ }
+};
+
+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 *
+ *********************************************************/
+
+function GameInfo(props) {
+ if (! props.id)
+ return null;
+
+ return (
+ <div className="game-info">
+ <h2>{props.id}</h2>
+ Invite a friend to play by sending this URL: {props.url}
+ </div>
+ );
+}
+
+function TeamButton(props) {
+ return <button className="inline"
+ onClick={() => props.game.join_team(props.team)}>
+ {props.label}
+ </button>;
+}
+
+function TeamChoices(props) {
+ let other_team;
+ if (props.player.team === "X")
+ other_team = "O";
+ else
+ other_team = "X";
+
+ if (props.player.team === "") {
+ if (props.first_move) {
+ return null;
+ } else {
+ return [
+ <TeamButton key="X" game={props.game} team="X" label="Join X" />,
+ " ",
+ <TeamButton key="O" game={props.game} team="O" label="Join O" />
+ ];
+ }
+ } else {
+ return <TeamButton game={props.game} team={other_team} label="Switch" />;
+ }
+}
+
+function PlayerInfo(props) {
+ if (! props.player.id)
+ return null;
+
+ const choices = <TeamChoices
+ game={props.game}
+ first_move={props.first_move}
+ player={props.player}
+ />;
+
+ return (
+ <div className="player-info">
+ <h2>Players</h2>
+ {props.player.name}
+ {props.player.team ? ` (${props.player.team})` : ""}
+ {props.first_move ? "" : " "}
+ {choices}
+ {props.other_players.map(other => (
+ <span key={other.id}>
+ {", "}
+ {other.name}
+ {other.team ? ` (${other.team})` : ""}
+ </span>
+ ))}
+ </div>
+ );
+}
+
function Square(props) {
+ let className = "square";
+
+ if (props.value) {
+ className += " occupied";
+ } else if (props.active) {
+ className += " open";
+ }
+
+ const onClick = props.active ? props.onClick : null;
+
return (
- <button className="square" onClick={props.onClick}>
+ <div className={className}
+ onClick={onClick}>
{props.value}
- </button>
+ </div>
);
}
class Board extends React.Component {
- renderSquare(i) {
+ render_square(i) {
+ const value = this.props.squares[i];
return (
<Square
- value={this.props.squares[i]}
+ value={value}
+ active={this.props.active && ! value}
onClick={() => this.props.onClick(i)}
/>
);
return (
<div>
<div className="board-row">
- {this.renderSquare(0)}
- {this.renderSquare(1)}
- {this.renderSquare(2)}
+ {this.render_square(0)}
+ {this.render_square(1)}
+ {this.render_square(2)}
</div>
<div className="board-row">
- {this.renderSquare(3)}
- {this.renderSquare(4)}
- {this.renderSquare(5)}
+ {this.render_square(3)}
+ {this.render_square(4)}
+ {this.render_square(5)}
</div>
<div className="board-row">
- {this.renderSquare(6)}
- {this.renderSquare(7)}
- {this.renderSquare(8)}
+ {this.render_square(6)}
+ {this.render_square(7)}
+ {this.render_square(8)}
</div>
</div>
);
}
}
+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: [],
history: [
{
squares: Array(9).fill(null)
}
],
- stepNumber: 0,
- xIsNext: true
+ step_number: 0,
+ next_to_play: Team.X
};
}
- handleClick(i) {
- const history = this.state.history.slice(0, this.state.stepNumber + 1);
+ 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({
+ history: [
+ {
+ squares: Array(9).fill(null)
+ }
+ ],
+ step_number: 0,
+ next_to_play: Team.X
+ });
+ }
+
+ receive_move(i) {
+ const history = this.state.history.slice(0, this.state.step_number + 1);
const current = history[history.length - 1];
const squares = current.squares.slice();
- if (calculateWinner(squares) || squares[i]) {
+ if (calculate_winner(squares) || squares[i]) {
return;
}
- squares[i] = this.state.xIsNext ? "X" : "O";
+ squares[i] = Team.properties[this.state.next_to_play].name;
+ let next_to_play;
+ if (this.state.next_to_play === Team.X)
+ next_to_play = Team.O;
+ else
+ next_to_play = Team.X;
this.setState({
history: history.concat([
{
squares: squares
}
]),
- stepNumber: history.length,
- xIsNext: !this.state.xIsNext
+ step_number: history.length,
+ next_to_play: next_to_play
});
}
- jumpTo(step) {
- this.setState({
- stepNumber: step,
- xIsNext: (step % 2) === 0
- });
+ async handle_click(i, first_move) {
+ let move = { move: i };
+ 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 history = this.state.history;
- const current = history[this.state.stepNumber];
- const winner = calculateWinner(current.squares);
-
- const moves = history.map((step, move) => {
- const desc = move ?
- 'Go to move #' + move :
- 'Go to game start';
- return (
- <li key={move}>
- <button onClick={() => this.jumpTo(move)}>{desc}</button>
- </li>
- );
- });
+ const state = this.state;
+ const history = state.history;
+ const current = history[state.step_number];
+ const winner = calculate_winner(current.squares);
+ const first_move = state.step_number === 0;
+ const my_team = state.player_info.team;
+ var board_active;
let status;
- if (winner) {
- status = "Winner: " + winner;
- } else {
- status = "Next player: " + (this.state.xIsNext ? "X" : "O");
+ if (winner)
+ {
+ status = winner + " wins!";
+ if (state.player_info.team != "")
+ {
+ if (my_team === winner)
+ status += " Congratulations!";
+ else
+ status += " Better luck next time.";
+ }
+ 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 === Team.properties[state.next_to_play].name)
+ {
+ 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 (
- <div className="game">
+ return [
+ <GameInfo
+ key="game-info"
+ id={state.game_info.id}
+ url={state.game_info.url}
+ />,
+ <PlayerInfo
+ key="player-info"
+ game={this}
+ first_move={first_move}
+ player={state.player_info}
+ other_players={state.other_players}
+ />,
+ <div key="game" className="game">
+ <div>{status}</div>
<div className="game-board">
<Board
+ active={board_active}
squares={current.squares}
- onClick={i => this.handleClick(i)}
+ onClick={i => this.handle_click(i, first_move)}
/>
</div>
- <div className="game-info">
- <div>{status}</div>
- <ol>{moves}</ol>
- </div>
</div>
- );
+ ];
}
}
-// ========================================
-
-ReactDOM.render(<Game />, document.getElementById("tictactoe"));
+ReactDOM.render(<Game
+ ref={(me) => window.game = me}
+ />, document.getElementById("tictactoe"));
-function calculateWinner(squares) {
+function calculate_winner(squares) {
const lines = [
[0, 1, 2],
[3, 4, 5],