games: a ladder you climb against the clock

This commit is contained in:
prosolis
2026-07-14 02:11:09 -07:00
parent feb353f789
commit c62d736223
17 changed files with 2292 additions and 4 deletions

View File

@@ -1081,6 +1081,122 @@ html[data-phase="night"] {
border-color: var(--accent);
}
/* ---- trivia --------------------------------------------------------------
The clock is the game, so the clock is the biggest thing on the felt: a bar
that drains across the top of the question, going hot as it runs out. It is
*decoration* — the server timed the answer the moment it arrived — but it
has to be honest decoration, so it's driven from the seconds the server said
were left rather than from when the browser happened to paint. */
.pete-clock {
position: relative;
height: 0.6rem;
border-radius: 999px;
background: rgba(0, 0, 0, 0.3);
overflow: hidden;
box-shadow: inset 0 0 0 2px rgba(255, 255, 255, 0.08);
}
.pete-clock-fill {
height: 100%;
width: 100%;
border-radius: 999px;
background: var(--accent);
transform-origin: left center;
/* Driven by a transform the browser can run on its own: a width animated on
every frame from JS is a layout on every frame. */
transform: scaleX(1);
}
/* The last few seconds. The bar stops being a progress meter and starts being
a warning. */
.pete-clock[data-hot="1"] .pete-clock-fill { background: #cc3d4a; }
.pete-clock[data-hot="1"] { animation: pete-clock-pulse 0.7s ease-in-out infinite; }
@keyframes pete-clock-pulse {
0%, 100% { box-shadow: inset 0 0 0 2px rgba(204, 61, 74, 0.35); }
50% { box-shadow: inset 0 0 0 2px rgba(204, 61, 74, 0.95); }
}
/* The four answers. Big targets, because the clock is already the hard part —
a question you lose to a mis-tap is a question about pointing. */
.pete-answer {
position: relative;
display: flex;
align-items: center;
gap: 0.75rem;
width: 100%;
border-radius: 1rem;
padding: 0.85rem 1rem;
text-align: left;
font-weight: 600;
color: #fff;
background: rgba(0, 0, 0, 0.26);
box-shadow: inset 0 0 0 2px rgba(255, 255, 255, 0.1);
transition: background 0.15s ease, transform 0.1s ease, box-shadow 0.2s ease;
}
.pete-answer:hover:not(:disabled) {
background: rgba(0, 0, 0, 0.36);
box-shadow: inset 0 0 0 2px rgba(255, 255, 255, 0.28);
}
.pete-answer:active:not(:disabled) { transform: translateY(1px); }
.pete-answer:disabled { cursor: default; }
/* The letter down the side, so an answer can be picked with the keyboard and
the key you press is printed on the thing you're pressing it for. */
.pete-answer-key {
display: grid;
place-items: center;
height: 1.75rem;
width: 1.75rem;
flex: none;
border-radius: 0.6rem;
background: rgba(255, 255, 255, 0.12);
font-family: "Fredoka", ui-sans-serif, system-ui, sans-serif;
font-size: 0.8rem;
font-weight: 700;
color: rgba(255, 255, 255, 0.7);
}
/* How it went. Only ever set once the server has decided — the browser has no
idea which of these is right until it's told. */
.pete-answer[data-state="right"] {
background: rgba(46, 160, 103, 0.9);
box-shadow: inset 0 0 0 2px rgba(255, 255, 255, 0.5);
}
.pete-answer[data-state="wrong"] {
background: rgba(204, 61, 74, 0.9);
box-shadow: inset 0 0 0 2px rgba(255, 255, 255, 0.35);
animation: pete-answer-no 0.45s ease;
}
/* The one they *should* have picked, shown alongside the one they did. */
.pete-answer[data-state="missed"] {
box-shadow: inset 0 0 0 2px rgba(46, 160, 103, 0.95);
}
.pete-answer[data-state="dim"] { opacity: 0.4; }
@keyframes pete-answer-no {
0%, 100% { transform: translateX(0); }
20% { transform: translateX(-6px); }
45% { transform: translateX(5px); }
70% { transform: translateX(-3px); }
}
/* The ladder: one pip per rung, filling as they're climbed. It is the only
picture of how far there is left to fall. */
.pete-ladder {
display: flex;
flex-wrap: wrap;
gap: 0.3rem;
}
.pete-rung {
height: 0.5rem;
width: 1.1rem;
border-radius: 999px;
background: rgba(255, 255, 255, 0.14);
transition: background 0.3s ease, transform 0.3s ease;
}
.pete-rung[data-on="1"] {
background: var(--accent);
transform: scaleY(1.35);
}
/* ---- solitaire -----------------------------------------------------------
Seven columns, four foundations, a stock and a waste, all of which have to
fit across the felt on a phone as well as a desk. So the card size is one
@@ -1299,6 +1415,11 @@ html[data-phase="night"] {
.pete-nope,
.pete-home-flash { animation: none; }
.pete-card[data-held="1"] { transition: none; }
/* The clock still drains — it is information, not decoration — but it stops
pulsing at you, and a wrong answer stops shaking. */
.pete-clock[data-hot="1"],
.pete-answer[data-state="wrong"] { animation: none; }
.pete-rung { transition: none; }
}
}

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,546 @@
// 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);
});
}
// 14 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();
});
})();