Three things, and the first one was a bug. Your own hand didn't move until the lap ended. bump() keeps the bots' fans honest and has always refused seat zero, and nothing else touched yours — so a +4 landing on you at the top of a lap put four backs into your hand and then nothing, and the cards themselves turned up seconds later when the script finished and paint() finally ran. You spent the whole lap looking at a hand you no longer held. The engine now stamps your hand onto every event that changes it (Event.Hand, seat zero only, which is the one hand the browser is already entitled to see) and the table redraws as the cards land. Measured in the running app: 2 -> 3 cards at 414ms into a 1791ms lap. You couldn't call UNO, and not because the button was missing: going down to one card *was* the call. discard() fired the uno event by itself, which made it a thing that happened to you rather than a thing you did, and a rule nobody can fail is not a rule. So now you say it or you don't (Move.Uno), and if you don't, every bot still in the game gets one look at you before any of them plays — because a bot that has moved on is a bot that has stopped watching your hand. It runs the other way too, and that half is the fun one: a bot forgets often enough to be worth watching for, and when it does it says *nothing*. No event, no badge, no tell on the felt except the count beside its fan reading "1 card". Catch it and it takes two; call a seat that had nothing to hide and you take two yourself, which is what stops the catch button from being a thing you simply mash. Which cards owe the call is the engine's answer, not a count of your hand: No Mercy's "discard all" takes every card of its colour with it, so a six-card hand can land on one, and a browser subtracting one from six walks you into a catch it never warned you about. And the room was silent. Every sound in here is *made* — an oscillator, a burst of filtered noise, an envelope — the same bargain the weather engine takes with its clouds. A card is a slap of noise through a bandpass, a chip is two detuned sines with a knock on the front, a win is four notes going up. No asset files, no round trips, and a sound can be pitched and detuned per call instead of being the same wav three hundred times. Hooked into the FX layer rather than into the games, so every table that throws a chip or turns a card got it at once. The multiples moved, and the test that exists to catch that caught it. The naive strategy now calls UNO, because calling is a button and not a strategy — what these tiers price is bad card play, not a player who ignores the felt shouting at them — and on that footing the normal tables come back to where they were (40.1 / 28.5 / 23.1). No Mercy Full House did not: it was paying a *negative* house edge, which is the house paying you to sit down. Re-priced 3.8 -> 3.5. Claude-Session: https://claude.ai/code/session_013M5nD7PgUboJXoDcYHzpuJ
179 lines
7.9 KiB
JavaScript
179 lines
7.9 KiB
JavaScript
// The deck, as the room draws it.
|
||
//
|
||
// A card is drawn, not typed. The first attempt set the pips as text — "♠" in a
|
||
// span — and at the size a card actually is, a suit character renders as a speck:
|
||
// the shape is whatever font happened to answer, it doesn't scale, and it can't
|
||
// be positioned to the half-row a real pip layout needs.
|
||
//
|
||
// So each face is one SVG on a 100×140 field (the proportions of a real card),
|
||
// with the suits as vector shapes. Everything below is coordinates on that field,
|
||
// which is why the pips land where a printed deck puts them instead of where a
|
||
// flexbox felt like putting them.
|
||
//
|
||
// This started life inside blackjack.js. It's out here because solitaire deals
|
||
// off the same deck, and the second table in a casino is exactly the moment a
|
||
// copied card renderer becomes two card renderers that drift.
|
||
//
|
||
// Exposed as window.PeteCards. Nothing in here knows what game is being played.
|
||
(function () {
|
||
"use strict";
|
||
|
||
var SUIT_ART = {
|
||
"♠": '<path d="M50 6C50 6 16 34 16 58a17 17 0 0 0 28 13c-1 12-5 19-12 25h36c-7-6-11-13-12-25a17 17 0 0 0 28-13C84 34 50 6 50 6Z"/>',
|
||
"♥": '<path d="M50 96C50 96 8 66 8 38A22 22 0 0 1 50 24 22 22 0 0 1 92 38c0 28-42 58-42 58Z"/>',
|
||
"♦": '<path d="M50 4 88 50 50 96 12 50Z"/>',
|
||
"♣": '<g><circle cx="50" cy="28" r="19"/><circle cx="24" cy="62" r="19"/><circle cx="76" cy="62" r="19"/>' +
|
||
'<path d="M44 60h12l7 36H37Z"/></g>',
|
||
};
|
||
|
||
// Pip layouts, the way a real deck lays them out — which is not "N suits in a
|
||
// row". [x, y] on the 100×140 field. The seven canonical rows sit at y = 27,
|
||
// 41, 56, 70, 84, 99, 113; sevens, eights and tens carry a pip *between* two of
|
||
// them, which is the whole reason this is a table of coordinates and not a
|
||
// grid. Anything below the middle is printed upside down, so it is.
|
||
var R = [0, 27, 41.4, 55.7, 70, 84.3, 98.6, 113]; // 1-indexed, R[4] is the middle
|
||
var L = 30, C = 50, Rr = 70; // the three columns
|
||
|
||
var PIPS = {
|
||
"A": [[C, 70, 2.1]],
|
||
"2": [[C, R[1]], [C, R[7]]],
|
||
"3": [[C, R[1]], [C, R[4]], [C, R[7]]],
|
||
"4": [[L, R[1]], [Rr, R[1]], [L, R[7]], [Rr, R[7]]],
|
||
"5": [[L, R[1]], [Rr, R[1]], [C, R[4]], [L, R[7]], [Rr, R[7]]],
|
||
"6": [[L, R[1]], [Rr, R[1]], [L, R[4]], [Rr, R[4]], [L, R[7]], [Rr, R[7]]],
|
||
"7": [[L, R[1]], [Rr, R[1]], [C, 48.5], [L, R[4]], [Rr, R[4]], [L, R[7]], [Rr, R[7]]],
|
||
"8": [[L, R[1]], [Rr, R[1]], [C, 48.5], [L, R[4]], [Rr, R[4]], [C, 91.5], [L, R[7]], [Rr, R[7]]],
|
||
"9": [[L, R[1]], [Rr, R[1]], [L, R[3]], [Rr, R[3]], [C, R[4]], [L, R[5]], [Rr, R[5]], [L, R[7]], [Rr, R[7]]],
|
||
"10": [[L, R[1]], [Rr, R[1]], [C, 48.5], [L, R[3]], [Rr, R[3]], [L, R[5]], [Rr, R[5]], [C, 91.5], [L, R[7]], [Rr, R[7]]],
|
||
};
|
||
|
||
var COURT = { "J": "Jack", "Q": "Queen", "K": "King" };
|
||
var SUIT_NAMES = { "♠": "spades", "♥": "hearts", "♦": "diamonds", "♣": "clubs" };
|
||
|
||
// One pip: the suit art, scaled and dropped at [x, y], turned over if it sits
|
||
// below the middle of the card.
|
||
function pipAt(suit, x, y, scale) {
|
||
var s = (scale || 1) * 0.17;
|
||
var turn = y > 70 ? " rotate(180 50 50)" : "";
|
||
return '<g transform="translate(' + x + ' ' + y + ') scale(' + s + ') translate(-50 -50)' + turn + '">' +
|
||
SUIT_ART[suit] + "</g>";
|
||
}
|
||
|
||
// The corner index: rank over suit. Printed in both corners, the second one
|
||
// upside down, which is what lets you read a card from a fanned hand — and in
|
||
// solitaire, from a column where all you can see is the top eighth of it.
|
||
function index(face) {
|
||
var g =
|
||
'<g>' +
|
||
'<text x="12" y="24" class="pete-card-idx">' + face.rank + "</text>" +
|
||
'<g transform="translate(12 36) scale(0.13) translate(-50 -50)">' + SUIT_ART[face.suit] + "</g>" +
|
||
"</g>";
|
||
return g + '<g transform="rotate(180 50 70)">' + g + "</g>";
|
||
}
|
||
|
||
// paint draws the face. Every table uses this one, because they all deal out of
|
||
// the same deck.
|
||
function paint(front, face) {
|
||
front.dataset.red = face.red ? "1" : "0";
|
||
|
||
var body = "";
|
||
if (COURT[face.rank]) {
|
||
// Court cards: a framed panel, the suit above the letter and again below it
|
||
// the other way up. A real court mirrors a *figure*; mirroring a letter just
|
||
// stacks two of them into a blob, which is exactly what the first attempt
|
||
// did. No portrait either — a drawn king would fight the room, and this
|
||
// reads instantly at the size a card actually is.
|
||
body =
|
||
'<rect x="20" y="22" width="60" height="96" rx="6" class="pete-card-panel"/>' +
|
||
pipAt(face.suit, 50, 38, 0.95) +
|
||
'<text x="50" y="82" class="pete-card-court">' + face.rank + "</text>" +
|
||
pipAt(face.suit, 50, 102, 0.95);
|
||
} else {
|
||
var spots = PIPS[face.rank] || [];
|
||
for (var i = 0; i < spots.length; i++) {
|
||
body += pipAt(face.suit, spots[i][0], spots[i][1], spots[i][2]);
|
||
}
|
||
}
|
||
|
||
front.innerHTML =
|
||
'<svg class="pete-card-svg" viewBox="0 0 100 140" xmlns="http://www.w3.org/2000/svg" ' +
|
||
'role="img" aria-label="' + aria(face) + '">' + index(face) + body + "</svg>";
|
||
}
|
||
|
||
// "A♠" is not something a screen reader should be asked to pronounce.
|
||
function aria(face) {
|
||
var name = COURT[face.rank] || (face.rank === "A" ? "Ace" : face.rank);
|
||
var suit = SUIT_NAMES[face.suit];
|
||
return suit ? name + " of " + suit : face.label;
|
||
}
|
||
|
||
var dealt = 0; // how many cards this page has put down, ever — the tilt seed
|
||
|
||
// el builds one card. face === null means face-down: the card is on the table,
|
||
// but this browser has not been told what it is.
|
||
//
|
||
// opts.deal — fly it in from the shoe (blackjack). Solitaire turns this off:
|
||
// it animates its own cards from wherever they actually came from,
|
||
// and a board that re-renders would otherwise re-deal itself out of
|
||
// the corner on every single move.
|
||
// opts.tilt — a degree or two of resting angle. A dealt hand wants it; a
|
||
// solitaire column does not, because thirteen tilted cards stacked
|
||
// an eighth of an inch apart read as a mistake rather than as a
|
||
// hand that was handled.
|
||
function el(face, opts) {
|
||
opts = opts || {};
|
||
var deal = opts.deal !== false;
|
||
var tilt = opts.tilt !== false;
|
||
|
||
var wrap = document.createElement("div");
|
||
wrap.className = "pete-card";
|
||
wrap.dataset.face = face ? "up" : "down";
|
||
if (face) wrap.dataset.key = face.label; // one deck, so the label is an id
|
||
|
||
if (deal) {
|
||
// Every card flies out of the shoe, which sits in the top-right of the felt.
|
||
wrap.style.setProperty("--deal-x", "14rem");
|
||
wrap.style.setProperty("--deal-y", "-6rem");
|
||
} else {
|
||
wrap.style.animation = "none";
|
||
}
|
||
wrap.style.setProperty("--tilt", tilt ? window.PeteFX.jitter(dealt++, 2.4).toFixed(2) + "deg" : "0deg");
|
||
|
||
var inner = document.createElement("div");
|
||
inner.className = "pete-card-inner";
|
||
|
||
var front = document.createElement("div");
|
||
front.className = "pete-card-front";
|
||
var back = document.createElement("div");
|
||
back.className = "pete-card-back";
|
||
|
||
inner.appendChild(front);
|
||
inner.appendChild(back);
|
||
wrap.appendChild(inner);
|
||
if (face) paint(front, face);
|
||
return wrap;
|
||
}
|
||
|
||
// turnOver flips a face-down card up, now that we've been told what it is.
|
||
//
|
||
// Unlike el(), this is only ever a *gesture* — a card that was down and is now
|
||
// up, because something happened. It never runs on a repaint, which is why the
|
||
// flip sound can live here and be right for every table at once, and why the
|
||
// deal sound cannot: el() runs every time a board is redrawn.
|
||
function turnOver(wrap, face) {
|
||
if (!wrap) return;
|
||
paint(wrap.querySelector(".pete-card-front"), face);
|
||
wrap.dataset.face = "up";
|
||
wrap.dataset.key = face.label;
|
||
if (window.PeteSFX) window.PeteSFX.play("flip");
|
||
}
|
||
|
||
window.PeteCards = {
|
||
el: el,
|
||
paint: paint,
|
||
turnOver: turnOver,
|
||
aria: aria,
|
||
art: SUIT_ART,
|
||
};
|
||
})();
|