547 lines
18 KiB
JavaScript
547 lines
18 KiB
JavaScript
// The trivia table.
|
||
//
|
||
// Same bargain as every other table in the room: the browser holds no game. It
|
||
// sends an answer, and the server says how it went. The four buttons arrive
|
||
// without any mark on which of them is right — that index is in the engine
|
||
// state, on the server — and the reveal only comes back in the event that
|
||
// decides the question, by which point knowing it is worth nothing.
|
||
//
|
||
// The countdown here is decoration, and it is important to be clear about that.
|
||
// Nothing it does scores anything: the server timed the answer the moment it
|
||
// arrived, against the clock it started when it served the question. Stopping
|
||
// this bar, or reloading to restart it, changes nothing at all. What the bar
|
||
// owes the player is *honesty* — so it is seeded from the seconds the server
|
||
// says are left, not from when the browser got round to painting.
|
||
(function () {
|
||
"use strict";
|
||
|
||
var root = document.querySelector("[data-trivia]");
|
||
if (!root) return;
|
||
|
||
var FX = window.PeteFX;
|
||
|
||
var questionEl = root.querySelector("[data-question]");
|
||
var categoryEl = root.querySelector("[data-category]");
|
||
var answersEl = root.querySelector("[data-answers]");
|
||
var clockEl = root.querySelector("[data-clock]");
|
||
var clockFillEl = root.querySelector("[data-clock-fill]");
|
||
var countdownEl = root.querySelector("[data-countdown]");
|
||
var ladderEl = root.querySelector("[data-ladder]");
|
||
var multEl = root.querySelector("[data-multiple]");
|
||
var meterEl = root.querySelector("[data-meter]");
|
||
var standsEl = root.querySelector("[data-stands]");
|
||
var standsLbl = root.querySelector("[data-stands-label]");
|
||
var rungEl = root.querySelector("[data-rung]");
|
||
var verdictEl = root.querySelector("[data-verdict]");
|
||
|
||
var betting = root.querySelector("[data-betting]");
|
||
var playing = root.querySelector("[data-playing]");
|
||
var walkBtn = root.querySelector("[data-walk]");
|
||
var walkAmtEl = root.querySelector("[data-walk-amount]");
|
||
var betAmount = root.querySelector("[data-bet-amount]");
|
||
var startBtn = root.querySelector("[data-start]");
|
||
var msgEl = root.querySelector("[data-table-msg]");
|
||
var gameMsgEl = root.querySelector("[data-game-msg]");
|
||
|
||
var purseEl = document.querySelector("[data-chips]");
|
||
var spotEl = root.querySelector("[data-spot]");
|
||
var houseEl = root.querySelector("[data-house]");
|
||
|
||
// The bet spot, and the rule that comes with it: the number under the pile is
|
||
// a readout of the pile, never the other way round.
|
||
var spot = FX.spot({
|
||
spot: spotEl,
|
||
stack: root.querySelector("[data-stack]"),
|
||
total: root.querySelector("[data-spot-total]"),
|
||
});
|
||
|
||
var bet = 0; // what you're building between games
|
||
var busy = false;
|
||
var game = null; // the round as the server last described it
|
||
var tier = "medium";
|
||
|
||
var reduced = FX.reduced;
|
||
function pace(ms) { return reduced ? 0 : ms; }
|
||
function wait(ms) { return new Promise(function (r) { setTimeout(r, pace(ms)); }); }
|
||
|
||
function say(text, tone, where) {
|
||
var el = where || msgEl;
|
||
if (!el) return;
|
||
if (!text) { el.classList.add("hidden"); return; }
|
||
el.textContent = text;
|
||
el.classList.remove("hidden");
|
||
el.style.color = tone === "bad" ? "#cc3d4a" : "";
|
||
}
|
||
|
||
// ---- the clock -------------------------------------------------------------
|
||
|
||
var raf = null;
|
||
var clockDeadline = 0; // performance.now() ms at which this question dies
|
||
var clockLimit = 1;
|
||
var timedOut = false;
|
||
|
||
// HOT is when the bar stops being a progress meter and starts being a warning.
|
||
var HOT = 5;
|
||
|
||
// GRACE is how long past its own zero the browser waits before reporting the
|
||
// timeout. It cannot be negative: the browser's countdown starts when the
|
||
// response *arrives*, so it is already behind the server's by the latency of
|
||
// that response, and it therefore always reaches zero after the server has.
|
||
// The grace is only there so that "always" survives a rounding error.
|
||
var GRACE = 400;
|
||
|
||
function stopClock() {
|
||
if (raf) cancelAnimationFrame(raf);
|
||
raf = null;
|
||
}
|
||
|
||
function startClock(left, limit) {
|
||
stopClock();
|
||
timedOut = false;
|
||
clockLimit = limit > 0 ? limit : 1;
|
||
clockDeadline = performance.now() + left * 1000;
|
||
tick();
|
||
}
|
||
|
||
function tick() {
|
||
var left = (clockDeadline - performance.now()) / 1000;
|
||
if (left < 0) left = 0;
|
||
|
||
// A transform, not a width: the browser can run this one without laying the
|
||
// page out again on every frame.
|
||
clockFillEl.style.transform = "scaleX(" + (left / clockLimit).toFixed(4) + ")";
|
||
countdownEl.textContent = left.toFixed(1) + "s";
|
||
clockEl.dataset.hot = left <= HOT && left > 0 ? "1" : "0";
|
||
|
||
if (left <= 0) {
|
||
countdownEl.textContent = "0.0s";
|
||
clockEl.dataset.hot = "0";
|
||
timeUp();
|
||
return;
|
||
}
|
||
raf = requestAnimationFrame(tick);
|
||
}
|
||
|
||
// timeUp reports the clock running out. It is not the browser *deciding* the
|
||
// question — it is the browser telling the server the player never answered,
|
||
// and the server (which has been holding the real clock all along) agreeing.
|
||
function timeUp() {
|
||
stopClock();
|
||
if (timedOut || busy || !game || game.phase !== "playing") return;
|
||
timedOut = true;
|
||
lockAnswers();
|
||
setTimeout(function () {
|
||
// -1 is "no answer". The engine checks the clock before it checks the
|
||
// choice, so this resolves as the timeout it is rather than a bad move.
|
||
send("/api/games/trivia/answer", { choice: -1 }, gameMsgEl);
|
||
}, GRACE);
|
||
}
|
||
|
||
function clearClock() {
|
||
stopClock();
|
||
clockFillEl.style.transform = "scaleX(0)";
|
||
countdownEl.textContent = "";
|
||
clockEl.dataset.hot = "0";
|
||
}
|
||
|
||
// ---- the question ----------------------------------------------------------
|
||
|
||
var KEYS = ["1", "2", "3", "4"];
|
||
|
||
function renderQuestion(v) {
|
||
answersEl.innerHTML = "";
|
||
if (!v || v.phase !== "playing" || !v.answers) {
|
||
questionEl.textContent = "";
|
||
categoryEl.textContent = "";
|
||
return;
|
||
}
|
||
categoryEl.textContent = v.category || "";
|
||
questionEl.textContent = v.question || "";
|
||
|
||
v.answers.forEach(function (text, i) {
|
||
var b = document.createElement("button");
|
||
b.type = "button";
|
||
b.className = "pete-answer";
|
||
b.dataset.at = String(i);
|
||
|
||
var key = document.createElement("span");
|
||
key.className = "pete-answer-key";
|
||
key.textContent = KEYS[i] || "";
|
||
key.setAttribute("aria-hidden", "true");
|
||
|
||
var label = document.createElement("span");
|
||
label.textContent = text;
|
||
|
||
b.appendChild(key);
|
||
b.appendChild(label);
|
||
b.addEventListener("click", function () { answer(i); });
|
||
answersEl.appendChild(b);
|
||
});
|
||
}
|
||
|
||
function lockAnswers() {
|
||
answersEl.querySelectorAll(".pete-answer").forEach(function (b) { b.disabled = true; });
|
||
}
|
||
|
||
// reveal marks the board once the server has decided. Nothing in here is known
|
||
// until it comes back: the right answer arrives in the event, not in the view.
|
||
function reveal(choice, correct) {
|
||
answersEl.querySelectorAll(".pete-answer").forEach(function (b) {
|
||
var i = parseInt(b.dataset.at, 10);
|
||
b.disabled = true;
|
||
if (i === choice && i === correct) b.dataset.state = "right";
|
||
else if (i === choice) b.dataset.state = "wrong";
|
||
else if (i === correct) b.dataset.state = "missed";
|
||
else b.dataset.state = "dim";
|
||
});
|
||
}
|
||
|
||
// ---- the meters ------------------------------------------------------------
|
||
|
||
function renderLadder(v) {
|
||
ladderEl.innerHTML = "";
|
||
var rungs = (v && v.rungs) || 12;
|
||
var done = (v && v.rung) || 0;
|
||
for (var i = 0; i < rungs; i++) {
|
||
var pip = document.createElement("span");
|
||
pip.className = "pete-rung";
|
||
pip.dataset.on = i < done ? "1" : "0";
|
||
ladderEl.appendChild(pip);
|
||
}
|
||
}
|
||
|
||
function renderMeter(v) {
|
||
if (!v) {
|
||
multEl.textContent = "—";
|
||
standsEl.textContent = "—";
|
||
standsLbl.textContent = "if you walk";
|
||
meterEl.dataset.cold = "1";
|
||
rungEl.textContent = "";
|
||
return;
|
||
}
|
||
multEl.textContent = v.multiple.toFixed(2) + "×";
|
||
standsEl.textContent = (v.stands || 0).toLocaleString();
|
||
meterEl.dataset.cold = v.rung === 0 ? "1" : "0";
|
||
|
||
if (v.phase === "done") {
|
||
standsLbl.textContent = v.net > 0 ? "banked" : "gone";
|
||
rungEl.textContent = "";
|
||
return;
|
||
}
|
||
standsLbl.textContent = v.can_walk ? "if you walk" : "answer one to unlock";
|
||
rungEl.textContent = "Question " + (v.rung + 1) + " of " + v.rungs;
|
||
}
|
||
|
||
// knock rolls the multiple up to its new value rather than swapping it, so a
|
||
// right answer reads as the total *growing* — which is the thing you're
|
||
// deciding whether to risk.
|
||
function climb(v) {
|
||
var from = parseFloat(multEl.textContent) || 1;
|
||
var to = v.multiple;
|
||
if (reduced) { renderMeter(v); return; }
|
||
var t0 = performance.now();
|
||
meterEl.dataset.hit = "0";
|
||
|
||
(function step(now) {
|
||
var p = Math.min(1, (now - t0) / 420);
|
||
var eased = 1 - Math.pow(1 - p, 3);
|
||
multEl.textContent = (from + (to - from) * eased).toFixed(2) + "×";
|
||
if (p < 1) requestAnimationFrame(step);
|
||
else renderMeter(v);
|
||
})(t0);
|
||
}
|
||
|
||
// ---- the money -------------------------------------------------------------
|
||
|
||
function settleChips(final) {
|
||
var payout = final.payout || 0;
|
||
var back = payout - final.bet;
|
||
|
||
if (payout <= 0) {
|
||
var chain = spot.sweep(houseEl, final.bet, { gap: 45, lift: 0.6, fade: true });
|
||
return chain;
|
||
}
|
||
return spot
|
||
.pour(houseEl, back, { gap: 60 })
|
||
.then(function () { return wait(back > 0 ? 380 : 200); })
|
||
.then(function () { return spot.sweep(purseEl, payout, { gap: 40, lift: 0.8 }); });
|
||
}
|
||
|
||
// standing puts the stake back on the spot for the next ladder, the way every
|
||
// other table in the room leaves your bet up.
|
||
function standing(amount) {
|
||
var money = window.PeteGames.view();
|
||
if (!amount || !money || money.chips < amount) {
|
||
bet = 0;
|
||
showBet();
|
||
return;
|
||
}
|
||
bet = amount;
|
||
showBet();
|
||
spot.amount = amount;
|
||
return spot.pour(purseEl, amount);
|
||
}
|
||
|
||
// ---- phases ----------------------------------------------------------------
|
||
|
||
var VERDICTS = {
|
||
walked: "Banked it.",
|
||
cleared: "Cleared the board! 🎉",
|
||
wrong: "Wrong.",
|
||
timeout: "Out of time.",
|
||
};
|
||
|
||
function verdict(v) {
|
||
var text = VERDICTS[v.outcome] || "";
|
||
if (!text) { verdictEl.classList.add("hidden"); return; }
|
||
if (v.net > 0) text += " +" + v.net.toLocaleString();
|
||
else if (v.net < 0) text += " " + v.net.toLocaleString();
|
||
verdictEl.textContent = text;
|
||
verdictEl.classList.remove("hidden");
|
||
|
||
// Confetti only for clearing all twelve — the one thing in here worth it.
|
||
if (v.outcome === "cleared") FX.burst(verdictEl, { count: 34 });
|
||
}
|
||
|
||
function setPhase(v) {
|
||
game = v;
|
||
var live = !!v && v.phase === "playing";
|
||
betting.classList.toggle("hidden", live);
|
||
playing.classList.toggle("hidden", !live);
|
||
if (walkBtn) {
|
||
walkBtn.disabled = !live || !v.can_walk;
|
||
walkAmtEl.textContent = (v && v.can_walk ? v.stands : 0).toLocaleString();
|
||
}
|
||
if (!v || !v.outcome) verdictEl.classList.add("hidden");
|
||
}
|
||
|
||
// paint puts a round up with no animation: the resume path, after a reload or a
|
||
// redeploy. The clock picks up exactly where the server says it is — which is
|
||
// the whole point of it being the server's clock.
|
||
function paint(v) {
|
||
if (!v) {
|
||
clearClock();
|
||
renderQuestion(null);
|
||
renderLadder(null);
|
||
renderMeter(null);
|
||
spot.render(0);
|
||
setPhase(null);
|
||
return;
|
||
}
|
||
renderQuestion(v);
|
||
renderLadder(v);
|
||
renderMeter(v);
|
||
spot.render(v.phase === "done" ? 0 : v.bet);
|
||
setPhase(v);
|
||
|
||
if (v.phase === "playing") startClock(v.left, v.limit);
|
||
else clearClock();
|
||
}
|
||
|
||
// ---- the script ------------------------------------------------------------
|
||
|
||
// play walks the server's events. Same rule as the other tables: on a live
|
||
// round the money is already right (your stake left your pile when you pressed
|
||
// Play, and it's on the spot), but on a settling one the chip bar is held back
|
||
// until the chips have physically come home. A counter that pays you before
|
||
// the reveal is a counter that has told you the ending.
|
||
function play(view, money) {
|
||
var events = view.triv_events || [];
|
||
var final = view.trivia;
|
||
var settles = !!final && final.phase === "done";
|
||
var chain = Promise.resolve();
|
||
|
||
if (!settles) money();
|
||
|
||
stopClock();
|
||
|
||
events.forEach(function (e) {
|
||
chain = chain.then(function () {
|
||
switch (e.kind) {
|
||
case "ask":
|
||
// A fresh question. Everything about the last one goes.
|
||
verdictEl.classList.add("hidden");
|
||
renderQuestion(final);
|
||
renderLadder(final);
|
||
if (final && final.phase === "playing") startClock(final.left, final.limit);
|
||
return;
|
||
|
||
case "right":
|
||
reveal(e.choice, e.correct);
|
||
if (final) {
|
||
// The rung lighting and the multiple climbing are one event,
|
||
// because they are one event: this is what the answer was worth.
|
||
climb({ multiple: e.multiple, rung: final.rung, rungs: final.rungs,
|
||
stands: final.stands, can_walk: true, phase: "playing" });
|
||
renderLadder(final);
|
||
}
|
||
return wait(900);
|
||
|
||
case "wrong":
|
||
reveal(e.choice, e.correct);
|
||
return wait(1100);
|
||
|
||
case "timeout":
|
||
reveal(-1, e.correct);
|
||
return wait(1100);
|
||
|
||
case "settle":
|
||
return;
|
||
}
|
||
});
|
||
});
|
||
|
||
return chain.then(function () {
|
||
if (!final) { paint(null); money(); return; }
|
||
|
||
if (!settles) {
|
||
renderMeter(final);
|
||
setPhase(final);
|
||
return;
|
||
}
|
||
|
||
// Over: the clock stops, the money moves, and only then does the bar catch up.
|
||
clearClock();
|
||
playing.classList.add("hidden");
|
||
renderMeter(final);
|
||
renderLadder(final);
|
||
verdict(final);
|
||
return settleChips(final)
|
||
.then(money)
|
||
.then(function () { return standing(final.bet); })
|
||
.then(function () { setPhase(final); });
|
||
});
|
||
}
|
||
|
||
// ---- talking to the table ---------------------------------------------------
|
||
|
||
function send(path, body, where) {
|
||
if (busy) return;
|
||
busy = true;
|
||
say("", null, where);
|
||
return window.PeteGames.post(path, body)
|
||
.then(function (view) {
|
||
return play(view, function () { window.PeteGames.apply(view); });
|
||
})
|
||
.catch(function (err) {
|
||
say(err.message, "bad", where);
|
||
return window.PeteGames.refresh().then(function (v) {
|
||
if (v && v.trivia) paint(v.trivia);
|
||
else { paint(null); spot.render(0); }
|
||
});
|
||
})
|
||
.then(function () { busy = false; });
|
||
}
|
||
|
||
function answer(i) {
|
||
if (busy || timedOut || !game || game.phase !== "playing") return;
|
||
stopClock();
|
||
lockAnswers();
|
||
send("/api/games/trivia/answer", { choice: i }, gameMsgEl);
|
||
}
|
||
|
||
if (walkBtn) {
|
||
walkBtn.addEventListener("click", function () {
|
||
if (busy || !game || game.phase !== "playing" || !game.can_walk) return;
|
||
stopClock();
|
||
lockAnswers();
|
||
send("/api/games/trivia/answer", { walk: true }, gameMsgEl);
|
||
});
|
||
}
|
||
|
||
// 1–4 answers the question. The key is printed on the button it answers.
|
||
document.addEventListener("keydown", function (e) {
|
||
if (e.metaKey || e.ctrlKey || e.altKey) return;
|
||
if (/^(input|textarea|select)$/i.test(e.target.tagName || "")) return;
|
||
if (!game || game.phase !== "playing" || busy) return;
|
||
var i = KEYS.indexOf(e.key);
|
||
if (i === -1) return;
|
||
var btn = answersEl.querySelector('.pete-answer[data-at="' + i + '"]');
|
||
if (!btn || btn.disabled) return;
|
||
e.preventDefault();
|
||
answer(i);
|
||
});
|
||
|
||
// ---- betting ----------------------------------------------------------------
|
||
|
||
function showBet() {
|
||
betAmount.textContent = bet.toLocaleString();
|
||
var money = window.PeteGames.view();
|
||
if (startBtn) startBtn.disabled = bet <= 0 || !tier || !money || money.chips < bet;
|
||
}
|
||
|
||
function pickTier(slug) {
|
||
tier = slug;
|
||
root.querySelectorAll("[data-tier]").forEach(function (b) {
|
||
b.dataset.on = b.dataset.tier === slug ? "1" : "0";
|
||
});
|
||
showBet();
|
||
}
|
||
|
||
root.querySelectorAll("[data-tier]").forEach(function (b) {
|
||
b.addEventListener("click", function () {
|
||
if (busy) return;
|
||
pickTier(b.dataset.tier);
|
||
});
|
||
});
|
||
|
||
// The chip you click is the chip that flies. Scoped to buttons: the bare
|
||
// [data-chip] spans in the corner are the house's rack, and it is not betting.
|
||
root.querySelectorAll("button[data-chip]").forEach(function (btn) {
|
||
btn.addEventListener("click", function () {
|
||
if (busy) return;
|
||
var d = parseInt(btn.dataset.chip, 10);
|
||
var money = window.PeteGames.view();
|
||
if (money && bet + d > money.chips) {
|
||
say("You haven't got that many chips.", "bad");
|
||
return;
|
||
}
|
||
bet += d;
|
||
showBet();
|
||
|
||
var target = bet;
|
||
spot.amount = bet;
|
||
FX.fly(btn, spotEl, { denom: d }).then(function () {
|
||
if (bet >= target) spot.render(target); // unless Clear got there first
|
||
});
|
||
});
|
||
});
|
||
|
||
var clearBtn = root.querySelector("[data-bet-clear]");
|
||
if (clearBtn) {
|
||
clearBtn.addEventListener("click", function () {
|
||
if (busy) return;
|
||
if (spot.amount) spot.sweep(purseEl, null, { gap: 40, lift: 0.7 });
|
||
bet = 0;
|
||
showBet();
|
||
});
|
||
}
|
||
|
||
if (startBtn) {
|
||
startBtn.addEventListener("click", function () {
|
||
if (busy) return;
|
||
if (!tier) { say("Pick a difficulty first.", "bad"); return; }
|
||
if (bet <= 0) { say("Put something on it first.", "bad"); return; }
|
||
// The stake stays on the spot for the whole ladder: it is what's at risk,
|
||
// and it is riding on every question until you take it back or lose it.
|
||
send("/api/games/trivia/start", { bet: bet, tier: tier });
|
||
});
|
||
}
|
||
|
||
pickTier(tier);
|
||
|
||
var resumed = false;
|
||
window.PeteGames.onUpdate(function (v) {
|
||
if (!resumed) {
|
||
resumed = true;
|
||
if (v.trivia) {
|
||
paint(v.trivia);
|
||
if (v.trivia.phase === "done") verdict(v.trivia);
|
||
} else {
|
||
paint(null);
|
||
}
|
||
}
|
||
showBet();
|
||
});
|
||
})();
|