Three front-end changes to the casino games:
- uno: a draw-card stack now names the seat it's actually pointed at
("+N on Beep") instead of always reading "+N on you". The bill reads
v.turn, which the engine advances to the target when a draw card lands.
- uno: the No Mercy rule buries a seat with some ceremony now. bury()
rolls one of four send-offs over the seat (or your hand, if it's you) —
a rockslide that piles up, a tombstone, a coffin, or the Mega Man death
burst — each with its own synth sound, and a static headstone under
reduced motion. The "Buried on N" badge still rides alongside.
- all games: winning makes it rain. FX.moneyRain drops a curtain of bills
and coins with a jackpot coin-cascade sound, gated by significance so it
reads as a win and not as noise: hold'em keeps its light per-pot confetti
and only rains on a real haul (>= 20bb), while the confetti stays the
rare cherry (natural 21, full board clear, phrase solved outright).
563 lines
19 KiB
JavaScript
563 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;
|
||
// One note per tile, climbing: a letter that turns up three times should
|
||
// sound like it's worth three times as much, because it is.
|
||
FX.sfx("tick", { delay: pace(n * 90) / 1000, v: n });
|
||
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));
|
||
});
|
||
FX.sfx("flip");
|
||
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");
|
||
|
||
// A phrase guessed outright keeps the confetti — the one call you make on your
|
||
// own rather than by grinding the alphabet — and any win at all makes it rain.
|
||
if (v.outcome === "solved" && v.net > 0) { FX.burst(verdictEl, { count: 30 }); FX.moneyRain({ count: 28 }); }
|
||
else if (v.net > 0) FX.moneyRain();
|
||
else if (v.net < 0) FX.sfx("lose");
|
||
}
|
||
|
||
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();
|
||
FX.sfx("bad");
|
||
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();
|
||
});
|
||
})();
|