// 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 stackEl = root.querySelector("[data-stack]"); var spotTotalEl = root.querySelector("[data-spot-total]"); var houseEl = root.querySelector("[data-house]"); var bet = 0; // what you're building between games var staked = 0; // what is actually on the spot 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 ------------------------------------------------- // Lifted wholesale from the blackjack table, and deliberately identical: a chip // has to behave the same way in both rooms or it isn't a chip, it's a widget. function renderStack(amount) { staked = amount || 0; stackEl.innerHTML = ""; spotEl.dataset.live = staked > 0 ? "1" : "0"; if (!staked) { spotTotalEl.classList.add("hidden"); return; } FX.chipsFor(staked).forEach(function (d, i) { var c = FX.disc(d); c.style.setProperty("--i", i); c.style.setProperty("--spin", FX.jitter(i, 12).toFixed(1) + "deg"); c.style.animationDelay = pace(i * 40) + "ms"; stackEl.appendChild(c); }); spotTotalEl.textContent = staked.toLocaleString(); spotTotalEl.classList.remove("hidden"); } function pour(from, to, amount, opts) { if (amount <= 0) return Promise.resolve(); var base = staked; var chips = FX.chipsFor(amount, 8); var run = 0; return FX.flyMany(from, to, chips, Object.assign({ onLand: function (d, i) { run += d; renderStack(base + (i === chips.length - 1 ? amount : run)); }, }, opts || {})); } function stake(amount, from) { return pour(from || purseEl, spotEl, amount); } function settleChips(final) { var payout = final.payout || 0; var back = payout - final.bet; if (payout <= 0) { var lost = FX.chipsFor(final.bet, 8); var chain = FX.flyMany(spotEl, houseEl, lost, { gap: 45, lift: 0.6, fade: true }); renderStack(0); return chain; } var pay = pour(houseEl, spotEl, back, { gap: 60 }); return pay .then(function () { return wait(back > 0 ? 380 : 200); }) .then(function () { var home = FX.flyMany(spotEl, purseEl, FX.chipsFor(payout, 8), { gap: 40, lift: 0.8 }); renderStack(0); return home; }); } // ---- 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); renderStack(0); setPhase(null); return; } renderBoard(v.cells); drawGallows((v.wrong || []).length, false); renderWrong(v); renderMeter(v); renderStack(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 > staked) { var extra = final.bet - staked; 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) renderStack(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); }); }); root.querySelectorAll("[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; staked = bet; FX.fly(btn, spotEl, { denom: d }).then(function () { if (bet >= target) renderStack(target); }); }); }); var clearBtn = root.querySelector("[data-bet-clear]"); if (clearBtn) { clearBtn.addEventListener("click", function () { if (busy || !staked) { bet = 0; showBet(); return; } FX.flyMany(spotEl, purseEl, FX.chipsFor(staked, 8), { gap: 40, lift: 0.7 }); bet = 0; renderStack(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(); }); })();