The trivia ladder handled a walk before it looked at the clock, so the timeout only ever bit if the browser volunteered it. Sit on a question, look it up, answer if you find it and walk if you don't, and you never lose a ladder. The clock is now the first thing that happens to a move. The house's chip rack was wired up as bet buttons on blackjack and hangman: it's four spans with data-chip on them and nothing said the handler only wanted the real ones. Clicking the house's money raised your bet. Hangman had two definitions of "a letter you'd guess" — unicode in the engine, ASCII in the renderer — and a phrase with an accent in it would have had no tile to fill and no key to fill it with. One definition now. Plus: trivia's countdown no longer freezes at zero when the server turns down a timeout report it was early for, questions whose wrong answer decodes into the right one are dropped at the door, and hangman bets on PeteFX's spot like every other table instead of its own copy of it.
556 lines
19 KiB
JavaScript
556 lines
19 KiB
JavaScript
// The hangman table.
|
||
//
|
||
// Same bargain as the blackjack table: the browser holds no game. It sends a
|
||
// letter, and the server answers with the board you're allowed to see — the
|
||
// phrase with the letters you haven't earned still blank — plus the script of
|
||
// what just happened. The phrase itself only ever arrives once the game is over
|
||
// and it no longer decides anything.
|
||
//
|
||
// The gallows is the meter. It counts your lives down and your winnings down at
|
||
// the same time, which is why a miss draws a limb *and* knocks the multiple back
|
||
// in the same beat: they are the same event, and showing them as one thing is
|
||
// the whole reason to bet on this rather than play it on paper.
|
||
(function () {
|
||
"use strict";
|
||
|
||
var root = document.querySelector("[data-hangman]");
|
||
if (!root) return;
|
||
|
||
var FX = window.PeteFX;
|
||
|
||
var boardEl = root.querySelector("[data-board]");
|
||
var gallowsEl = root.querySelector("[data-gallows]");
|
||
var wrongEl = root.querySelector("[data-wrong]");
|
||
var wrongLbl = root.querySelector("[data-wrong-label]");
|
||
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 livesEl = root.querySelector("[data-lives]");
|
||
var verdictEl = root.querySelector("[data-verdict]");
|
||
|
||
var betting = root.querySelector("[data-betting]");
|
||
var guessing = root.querySelector("[data-guessing]");
|
||
var keysEl = root.querySelector("[data-keyboard]");
|
||
var betAmount = root.querySelector("[data-bet-amount]");
|
||
var startBtn = root.querySelector("[data-start]");
|
||
var solveIn = root.querySelector("[data-solve-input]");
|
||
var solveBtn = root.querySelector("[data-solve]");
|
||
var msgEl = root.querySelector("[data-table-msg]");
|
||
var gameMsgEl = root.querySelector("[data-game-msg]");
|
||
|
||
// The three places a chip can be, exactly as at the other table.
|
||
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 board as the server last described it
|
||
var tier = "medium";
|
||
|
||
var FLIP_MS = 320;
|
||
var MISS_MS = 520;
|
||
|
||
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 board -------------------------------------------------------------
|
||
|
||
// renderBoard lays the phrase out as tiles. A tile is a letter you have to earn;
|
||
// a space or a piece of punctuation is scaffolding and gets no tile, because a
|
||
// row of blanks with the word breaks hidden is a puzzle about typography.
|
||
//
|
||
// Words are kept whole: the board wraps between words, never inside one.
|
||
function renderBoard(cells) {
|
||
boardEl.innerHTML = "";
|
||
if (!cells) return;
|
||
|
||
var word = document.createElement("div");
|
||
word.className = "pete-word";
|
||
|
||
cells.forEach(function (c, i) {
|
||
if (!c.slot && (c.ch === " " || c.ch === "")) {
|
||
// A space: end the word and start the next one.
|
||
if (word.childNodes.length) boardEl.appendChild(word);
|
||
word = document.createElement("div");
|
||
word.className = "pete-word";
|
||
return;
|
||
}
|
||
var t = document.createElement("span");
|
||
t.className = "pete-tile";
|
||
t.dataset.at = String(i);
|
||
if (!c.slot) {
|
||
t.dataset.punct = "1"; // an exclamation mark is not a blank to fill
|
||
t.textContent = c.ch;
|
||
} else {
|
||
t.dataset.up = c.ch ? "1" : "0";
|
||
t.textContent = c.ch || "";
|
||
}
|
||
word.appendChild(t);
|
||
});
|
||
if (word.childNodes.length) boardEl.appendChild(word);
|
||
}
|
||
|
||
// turnUp flips the tiles a hit just earned, one after the other so a letter that
|
||
// appears three times reads as three finds rather than one repaint.
|
||
function turnUp(at, ch) {
|
||
if (!at || !at.length) return Promise.resolve();
|
||
at.forEach(function (i, n) {
|
||
var t = boardEl.querySelector('.pete-tile[data-at="' + i + '"]');
|
||
if (!t) return;
|
||
setTimeout(function () {
|
||
// Left as it comes: the tile is uppercased in CSS, and doing it here too
|
||
// would mean the resume path (which paints the phrase's own casing) and
|
||
// this one put different text in the same tile.
|
||
t.textContent = ch;
|
||
t.dataset.up = "1";
|
||
t.classList.add("pete-tile-hit");
|
||
}, pace(n * 90));
|
||
});
|
||
return wait(FLIP_MS + (at.length - 1) * 90);
|
||
}
|
||
|
||
// ---- the gallows -----------------------------------------------------------
|
||
|
||
// drawGallows shows the first n parts. Each one draws itself in along its own
|
||
// length rather than fading up — the difference between a limb being *drawn* and
|
||
// a limb appearing is the whole character of the game.
|
||
function drawGallows(n, animateLast) {
|
||
var parts = gallowsEl.querySelectorAll("[data-part]");
|
||
parts.forEach(function (p, i) {
|
||
var on = i < n;
|
||
var was = p.dataset.on === "1";
|
||
p.dataset.on = on ? "1" : "0";
|
||
if (on && !was && animateLast && i === n - 1 && !reduced) {
|
||
// Restart the draw-in animation on the part that was just earned.
|
||
p.classList.remove("pete-part-draw");
|
||
void p.getBoundingClientRect();
|
||
p.classList.add("pete-part-draw");
|
||
} else if (on && !animateLast) {
|
||
p.classList.remove("pete-part-draw");
|
||
}
|
||
});
|
||
}
|
||
|
||
function shake() {
|
||
if (reduced) return;
|
||
gallowsEl.classList.remove("pete-shake");
|
||
void gallowsEl.getBoundingClientRect();
|
||
gallowsEl.classList.add("pete-shake");
|
||
}
|
||
|
||
// ---- the meter -------------------------------------------------------------
|
||
|
||
function renderMeter(v) {
|
||
if (!v) {
|
||
multEl.textContent = "—";
|
||
standsEl.textContent = "—";
|
||
standsLbl.textContent = "if you get it";
|
||
livesEl.textContent = "";
|
||
meterEl.dataset.cold = "0";
|
||
return;
|
||
}
|
||
multEl.textContent = v.multiple.toFixed(2) + "×";
|
||
standsEl.textContent = (v.stands || 0).toLocaleString();
|
||
standsLbl.textContent = v.phase === "done" ? "was on it" : "if you get it";
|
||
livesEl.textContent = v.lives + (v.lives === 1 ? " life left" : " lives left");
|
||
// The meter goes cold once the multiple is down at its floor: from here a win
|
||
// hands back the stake and nothing more.
|
||
meterEl.dataset.cold = v.multiple <= 1.001 ? "1" : "0";
|
||
}
|
||
|
||
// knock ticks the multiple down to its new value, so the number falls rather
|
||
// than simply being different.
|
||
function knock(v) {
|
||
if (reduced) { renderMeter(v); return; }
|
||
var from = parseFloat(multEl.textContent) || v.multiple;
|
||
var to = v.multiple;
|
||
var t0 = performance.now();
|
||
meterEl.dataset.hit = "1";
|
||
setTimeout(function () { meterEl.dataset.hit = "0"; }, 400);
|
||
|
||
(function step(now) {
|
||
var p = Math.min(1, (now - t0) / 380);
|
||
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 keyboard ----------------------------------------------------------
|
||
|
||
var ROWS = ["qwertyuiop", "asdfghjkl", "zxcvbnm", "0123456789"];
|
||
|
||
function buildKeys() {
|
||
keysEl.innerHTML = "";
|
||
ROWS.forEach(function (row, r) {
|
||
var line = document.createElement("div");
|
||
line.className = "pete-key-row";
|
||
if (r === 3) line.dataset.digits = "1";
|
||
row.split("").forEach(function (ch) {
|
||
var b = document.createElement("button");
|
||
b.type = "button";
|
||
b.className = "pete-key";
|
||
b.dataset.key = ch;
|
||
b.textContent = ch.toUpperCase();
|
||
b.addEventListener("click", function () { guessLetter(ch); });
|
||
line.appendChild(b);
|
||
});
|
||
keysEl.appendChild(line);
|
||
});
|
||
}
|
||
|
||
// paintKeys marks every letter that's been tried, and how it went. A key that
|
||
// has been spent should look spent — otherwise you spend it again.
|
||
function paintKeys(v) {
|
||
var tried = (v && v.tried) || [];
|
||
var wrong = (v && v.wrong) || [];
|
||
keysEl.querySelectorAll(".pete-key").forEach(function (b) {
|
||
var ch = b.dataset.key;
|
||
var used = tried.indexOf(ch) !== -1;
|
||
b.disabled = used || !v || v.phase !== "playing";
|
||
b.dataset.state = !used ? "" : wrong.indexOf(ch) !== -1 ? "miss" : "hit";
|
||
});
|
||
}
|
||
|
||
function renderWrong(v) {
|
||
wrongEl.innerHTML = "";
|
||
var wrong = (v && v.wrong) || [];
|
||
// A wrong *solve* is recorded as a miss with no letter on it — it cost a life
|
||
// and it's on the gallows, but there's no key to grey out for it.
|
||
var letters = wrong.filter(function (c) { return c !== "·"; });
|
||
wrongLbl.classList.toggle("hidden", letters.length === 0);
|
||
letters.forEach(function (ch) {
|
||
var s = document.createElement("span");
|
||
s.className = "pete-missed";
|
||
s.textContent = ch.toUpperCase();
|
||
wrongEl.appendChild(s);
|
||
});
|
||
}
|
||
|
||
// ---- the money on the felt -------------------------------------------------
|
||
// The spot is PeteFX's, the same one every other table in the room bets onto: a
|
||
// chip has to behave the same way in both rooms or it isn't a chip, it's a
|
||
// widget.
|
||
|
||
function stake(amount, from) {
|
||
return spot.pour(from || purseEl, amount);
|
||
}
|
||
|
||
function settleChips(final) {
|
||
var payout = final.payout || 0;
|
||
var back = payout - final.bet;
|
||
|
||
if (payout <= 0) {
|
||
// The house takes it. The stack goes to the rack and doesn't come back.
|
||
return spot.sweep(houseEl, final.bet, { gap: 45, lift: 0.6, fade: true });
|
||
}
|
||
// Paid into the spot beside your stake, then the whole lot swept home.
|
||
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 }); });
|
||
}
|
||
|
||
// ---- phases ----------------------------------------------------------------
|
||
|
||
var VERDICTS = {
|
||
solved: "Got it! 🎉",
|
||
filled: "That's the lot!",
|
||
hung: "Hung.",
|
||
};
|
||
|
||
function verdict(v) {
|
||
var text = VERDICTS[v.outcome] || "";
|
||
if (!text) { verdictEl.classList.add("hidden"); return; }
|
||
if (v.outcome === "hung" && v.phrase) text = "Hung. It was “" + v.phrase + "”.";
|
||
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 for a phrase guessed outright — the one call you make on your own
|
||
// rather than by grinding the alphabet.
|
||
if (v.outcome === "solved" && v.net > 0) FX.burst(verdictEl, { count: 30 });
|
||
}
|
||
|
||
function setPhase(v) {
|
||
game = v;
|
||
var live = !!v && v.phase === "playing";
|
||
betting.classList.toggle("hidden", live);
|
||
guessing.classList.toggle("hidden", !live);
|
||
paintKeys(v);
|
||
if (!live && solveIn) solveIn.value = "";
|
||
if (!v || !v.outcome) verdictEl.classList.add("hidden");
|
||
}
|
||
|
||
// paint puts a board up with no animation: the resume path, after a reload or a
|
||
// redeploy. Your stake is still on the spot because the server still has it.
|
||
function paint(v) {
|
||
if (!v) {
|
||
renderBoard(null);
|
||
drawGallows(0, false);
|
||
renderWrong(null);
|
||
renderMeter(null);
|
||
spot.render(0);
|
||
setPhase(null);
|
||
return;
|
||
}
|
||
renderBoard(v.cells);
|
||
drawGallows((v.wrong || []).length, false);
|
||
renderWrong(v);
|
||
renderMeter(v);
|
||
spot.render(v.phase === "done" ? 0 : v.bet);
|
||
setPhase(v);
|
||
}
|
||
|
||
// ---- the script ------------------------------------------------------------
|
||
|
||
// play walks the server's events. Same rule as the other table: on a live game
|
||
// 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.
|
||
function play(view, money) {
|
||
var events = view.hang_events || [];
|
||
var final = view.hangman;
|
||
var settles = !!final && final.phase === "done";
|
||
var chain = Promise.resolve();
|
||
|
||
if (!settles) money();
|
||
|
||
if (final && final.bet > spot.amount) {
|
||
var extra = final.bet - spot.amount;
|
||
chain = chain.then(function () { return stake(extra); });
|
||
}
|
||
|
||
events.forEach(function (e) {
|
||
chain = chain.then(function () {
|
||
switch (e.kind) {
|
||
case "start":
|
||
verdictEl.classList.add("hidden");
|
||
renderBoard(final && final.cells);
|
||
drawGallows(0, false);
|
||
renderWrong(null);
|
||
renderMeter(final);
|
||
return;
|
||
|
||
case "hit":
|
||
return turnUp(e.at, e.letter);
|
||
|
||
case "miss":
|
||
// The limb, the shake and the multiple falling are one event, because
|
||
// they are one event: this is what a wrong guess costs, all of it.
|
||
drawGallows(countMisses(events, e), true);
|
||
shake();
|
||
if (final) knock(final);
|
||
return wait(MISS_MS);
|
||
|
||
case "solve":
|
||
return wait(220);
|
||
|
||
case "settle":
|
||
return;
|
||
}
|
||
});
|
||
});
|
||
|
||
return chain.then(function () {
|
||
if (!final) { paint(null); money(); return; }
|
||
|
||
renderWrong(final);
|
||
if (!settles) {
|
||
renderMeter(final);
|
||
setPhase(final);
|
||
return;
|
||
}
|
||
|
||
// Over: the board goes fully face up (the server has finally sent the
|
||
// phrase), then the money moves, and only then does the bar catch up.
|
||
guessing.classList.add("hidden");
|
||
renderBoard(final.cells);
|
||
renderMeter(final);
|
||
verdict(final);
|
||
return settleChips(final)
|
||
.then(money)
|
||
.then(function () { return standing(final.bet); })
|
||
.then(function () { setPhase(final); });
|
||
});
|
||
}
|
||
|
||
// countMisses works out how many limbs should be on the gallows by the time
|
||
// this miss has been played — the misses already on the board when the request
|
||
// went out, plus every miss in this batch up to and including this one.
|
||
function countMisses(events, upTo) {
|
||
var before = game ? (game.wrong || []).length : 0;
|
||
var n = 0;
|
||
for (var i = 0; i < events.length; i++) {
|
||
if (events[i].kind === "miss") n++;
|
||
if (events[i] === upTo) break;
|
||
}
|
||
return before + n;
|
||
}
|
||
|
||
// standing puts the stake back on the spot for the next phrase, the way the
|
||
// blackjack table 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();
|
||
return stake(amount);
|
||
}
|
||
|
||
// ---- 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.hangman) spot.render(0);
|
||
});
|
||
})
|
||
.then(function () { busy = false; });
|
||
}
|
||
|
||
function guessLetter(ch) {
|
||
if (busy || !game || game.phase !== "playing") return;
|
||
if ((game.tried || []).indexOf(ch) !== -1) return;
|
||
send("/api/games/hangman/guess", { letter: ch }, gameMsgEl);
|
||
}
|
||
|
||
// ---- betting ----------------------------------------------------------------
|
||
|
||
function showBet() {
|
||
betAmount.textContent = bet.toLocaleString();
|
||
var money = window.PeteGames.view();
|
||
if (startBtn) startBtn.disabled = bet <= 0 || !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";
|
||
});
|
||
}
|
||
|
||
root.querySelectorAll("[data-tier]").forEach(function (b) {
|
||
b.addEventListener("click", function () {
|
||
if (busy) return;
|
||
pickTier(b.dataset.tier);
|
||
});
|
||
});
|
||
|
||
// Scoped to buttons: the bare [data-chip] spans in the corner are the house's
|
||
// rack, and the house 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 || !spot.amount) { bet = 0; showBet(); return; }
|
||
spot.sweep(purseEl, null, { gap: 40, lift: 0.7 });
|
||
bet = 0;
|
||
showBet();
|
||
});
|
||
}
|
||
|
||
if (startBtn) {
|
||
startBtn.addEventListener("click", function () {
|
||
if (bet <= 0) { say("Put something on it first.", "bad"); return; }
|
||
send("/api/games/hangman/start", { bet: bet, tier: tier });
|
||
});
|
||
}
|
||
|
||
function solve() {
|
||
if (busy || !game || game.phase !== "playing") return;
|
||
var attempt = (solveIn.value || "").trim();
|
||
if (!attempt) { say("Say what it is, then.", "bad", gameMsgEl); return; }
|
||
send("/api/games/hangman/guess", { solve: attempt }, gameMsgEl);
|
||
}
|
||
|
||
if (solveBtn) solveBtn.addEventListener("click", solve);
|
||
if (solveIn) {
|
||
solveIn.addEventListener("keydown", function (e) {
|
||
if (e.key === "Enter") { e.preventDefault(); solve(); }
|
||
});
|
||
}
|
||
|
||
// Type a letter to guess it — but not while you're typing a solution into the
|
||
// box, which is the whole reason this checks what has focus.
|
||
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 ch = (e.key || "").toLowerCase();
|
||
if (!/^[a-z0-9]$/.test(ch)) return;
|
||
e.preventDefault();
|
||
guessLetter(ch);
|
||
});
|
||
|
||
buildKeys();
|
||
pickTier(tier);
|
||
|
||
var resumed = false;
|
||
window.PeteGames.onUpdate(function (v) {
|
||
if (!resumed) {
|
||
resumed = true;
|
||
if (v.hangman) {
|
||
paint(v.hangman);
|
||
if (v.hangman.phase === "done") verdict(v.hangman);
|
||
} else {
|
||
paint(null);
|
||
}
|
||
}
|
||
showBet();
|
||
});
|
||
})();
|