Description
Truth or Drink is a party game where players ask each other questions they wouldnโt dare to anywhere else. You get a point for every question another player avoids with a drink. Iโve implemented a set of questions using a custom web component.
Aim
Create a unique web component to display each question.
Milestones
Applying my aim to the game, I created some milestones:
-
Set up a touch-based interface for navigating through questions (since thisโll most likely run on mobile phones).
-
Add filtering so that certain types of question can be removed.
-
Add the ability to see previous questions.
-
Configure PWA functionalty for on and offline use.
Implementation
Classes
Card
The Card class has two main attributes that can be set, text and round. Storing the round alongside the question text allows for easy filtering. To speed up development, I added getters for symbol and color. Coupling the UI properties to the class in this way isnโt strictly best practice, however for a small application with a limited use-case I think I can get away with it for now. Overriding the default toString() method allowed me to set up easy comparison and sorting.
class Card {
constructor(text = null, round = null, extraSymbol = null) {
this._text = text;
this._round = round;
}
get text() {
return this._text || "";
}
set text(val) {
if (val) {
this._text = val;
}
}
set round(val) {
if (val) {
this._text = val;
}
}
get round() {
return this._round || "";
}
get extraSymbol() {
return this._extraSymbol || "";
}
set extraSymbol(val) {
this.extraSymbol = val;
}
get symbol() {
let symbols = {
chill: "๐",
happy: "๐",
afterDark: "๐",
dirty: "๐",
};
let result = symbols[this.round] ? symbols[this.round] + this.extraSymbol : "";
return result || "๐บ๐ท";
}
get color() {
let colors = {
chill: "teal",
happy: "gold",
afterDark: "navy",
dirty: "red",
};
return colors[this.round] || "grey";
}
get json() {
return { text: this.text, round: this.round };
}
toString() {
return JSON.stringify(this.json);
}
valueOf() {
return (this.text + "").normalize().toLowerCase();
}
}
ObjectSet
The default JavaScript Set treats different instances of an object with equal attributes and properties as unique. This means the default Set.has() function cannot be used for comparing Cards. This class acts as a drop-in replacement that can handle objects by comparing their string values. Should the Truth or Drink application expand or this class see use in other places then it would be prudent to perform deep comparison of object attributes and methods instead of just string values.
class ObjectSet extends Set {
has(val) {
if (super.has(val)) {
return true;
} else {
return Array.from(super.values()).some((item) => item + "" === "" + val);
}
}
add(val) {
if (!this.has(val)) {
super.add(val);
}
}
delete(val) {
if (this.has(val)) {
super.delete(val);
}
}
}
Deck
The Deck is a container for ObjectSets of Cards. It tracks which questions have been used and which remain based on the rounds the player has allowed. The Deck endpoint attribute is set so that GPT-3 questions can be fetched remotely. An emoji symbol is also added to these questions from endpointSymbol in order to differentiate them visually from the standard questions.
class Deck {
constructor(
questions,
rounds = ["chill", "happy", "afterDark"],
endpoint = null,
endpointSymbol = null
) {
this._used = new ObjectSet();
this._remaining = new ObjectSet(questions);
this._rounds = new Set(rounds);
this.endpoint = endpoint;
this.endpointSymbol = endpointSymbol;
this._lastUsed = null;
this._autoReset = true;
}
get remaining() {}
get length() {}
get rounds() {}
get endpointSymbol() {}
set endpointSymbol(val) {}
get endpoint() {}
set endpoint(path) {}
addCard(card) {}
removeCard(card) {}
addQuestion(text, round, extraSymbol) {} //a wrapper for addCard()
next() {}
allow(round, isAllowed = true) {}
isAllowed(round) {}
_fetchEndpoint() {}
reset() {}
shuffle() {}
}
Card Component
The application holds up to three cards in the UI at any one point. Using the events provided by flickity, a new Card component is added to the slider every time the user moves right. The oldest card is then removed in order to avoid clutter and maintain performance.
class CardElement extends HTMLElement {
constructor() {
super();
this.useShadowDOM = true;
}
static get observedAttributes() {
return ["text", "round", "face-down"];
}
_upgradeProperty(prop) {
//check for properties set before this component was added to the DOM
if (this.hasOwnProperty(prop)) {
let value = this[prop];
delete this[prop];
this[prop] = value;
}
}
connectedCallback() {
//inserted into DOM
//check we have all of the properties we need
for (let a of CardElement.observedAttributes) {
this._upgradeProperty(a);
}
}
disconnectedCallback() {} //removed from DOM
attributeChangedCallback(name, oldValue, newValue) {
//observed attributes have been changed
this._updateChildren(name);
this._updateStyle(name);
}
_updateStyle() {
var childNodes = this.root.childNodes;
//ensure there is always only one style element on the shadowDOM
if (!Array.from(childNodes).some((child) => child.nodeName === "STYLE")) {
this.root.appendChild(document.createElement("style"));
}
//keys in this object are used as selectors and their values are the CSS attributes applied to them
//this allows the properties to be read and updated with an easy to read syntax
let obj = {
".classname": {
display: "inline",
color: "red",
},
"#id": {
display: "none",
},
};
//the style tag's innerHTML is produced by looping over the object.
for (var i = 0; i < childNodes.length; i++) {
if (childNodes[i].nodeName === "STYLE") {
let str = "";
for (const [selector, props] of Object.entries(obj)) {
let propStr = "";
for (const [key, val] of Object.entries(props)) {
propStr += `${key}:${val};`;
}
str += `${selector}{${propStr}}`;
}
childNodes[i].textContent = str;
}
}
}
get width() {}
get height() {}
get _label() {}
get duration() {}
set duration(val) {} //set the length of the flip using a valid duration string
_addChildren() {
//reset the component
if (this.useShadowDOM) {
this.shadowRoot.childNodes.forEach((child) => this.shadowRoot.removeChild(child));
} else {
this.childNodes.forEach((child) => this.removeChild(child));
}
//recreate the component
let container = document.createElement("div");
container.className = "container";
let inner = document.createElement("div");
inner.className = "inner";
container.appendChild(inner);
//front
let front = document.createElement("div");
front.className = "front";
inner.appendChild(front);
let roundEl = document.createElement("span");
roundEl.innerText = this._label;
let textEl = document.createElement("h1");
textEl.innerText = this.text;
front.appendChild(roundEl);
front.appendChild(textEl);
//back
let back = document.createElement("div");
back.className = "back";
let img = document.createElement("img");
img.src = "/assets/icons/logo.svg";
back.appendChild(img);
inner.appendChild(back);
if (this.useShadowDOM) {
this.shadowRoot.appendChild(container);
} else {
this.appendChild(container);
}
}
_updateChildren(name) {
this._updateStyle(`children (${name})`);
switch (name) {
case "text":
this.root.querySelector("h1").innerText = this.text;
break;
case "round":
this.root.querySelector("span").innerText = this._label;
break;
default:
break;
}
}
get root() {
if (this.useShadowDOM && !this.shadowRoot) {
this.attachShadow({ mode: "open" });
}
return this.useShadowDOM ? this.shadowRoot : this;
}
_draw() {
//remove all existing children so that DOM elements are updated
while (this.root.firstChild) {
let el = this.root.firstChild;
// console.log(el);
this.root.removeChild(el);
}
this._addChildren();
this._updateStyle("drawing");
}
get card() {
return new Card(this.text, this.round);
}
set card(val) {
if (val && val instanceof Card) {
this.text = val.text;
this.round = val.round;
}
}
get text() {
return this.getAttribute("text") || "";
}
get round() {
return this.getAttribute("round") || "";
}
set text(val) {
if (val && val != this.text) {
this.setAttribute("text", val);
}
}
set round(val) {
if (val && val != this.round) {
this.setAttribute("round", val);
}
}
get faceDown() {
return this.hasAttribute("face-down");
}
set faceDown(val) {
val = val ? true : false; //force boolean
if (val != this.faceDown) {
if (val) {
this.setAttribute("face-down", "");
} else {
this.removeAttribute("face-down");
}
}
}
}
window.customElements.define("question-card", CardElement);
![mobile screenshot](/files/truth-or-drink/tod.jack.do_(iPhone 6_7_8).png)