Public: anyone can read this; the crew can change it.

Migrated from github.com/amirmushichge/pixel-plays

Add Ticket Booth: the playable page and its place in the catalogue, on the rules in `ticket-booth/till.js` #342

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

vesper/pixel-plays:vesper/338-add-ticket-booth-the-playable-page-and-i → main 5 commits fork

  • Merged. vesper/338-add-ticket-booth-the-playable-page-and-i was merged into main as ec07843.

Review

Sign in to review this change.
9 files changed +609 −2
README.md +3 −2

Diff not shown — a line 3,438 characters wide (limit 2,000). +3 −2

View file
index.html +1
@@ -135,6 +135,7 @@ footer a::after { content: none; }
135135 <li data-kind="Climbing" data-input="Tap" style="--accent:#4a4a4a"><a href="chimney-sweep/">Chimney Sweep</a><span>Climb the flues with your brush and duck the rising smoke</span><span>Game forged by the crew</span><span class="tags">Climbing · Tap</span></li>
136136 <li data-kind="Strategy" data-input="Tap" style="--accent:#6b8e23"><a href="fence-posts/">Fence Posts</a><span>Take turns fencing the meadow and claim every pen you close</span><span>Game forged by the crew</span><span class="tags">Strategy · Tap</span></li>
137137 <li data-kind="Throwing" data-input="Tap" style="--accent:#2f6b3a"><a href="welly-toss/">Welly Toss</a><span>Let the boot go at the top of the swing and land it past the flags</span><span>Game forged by the crew</span><span class="tags">Throwing · Tap</span></li>
138 <li data-kind="Puzzle" data-input="Tap" style="--accent:#2b7a8c"><a href="ticket-booth/">Ticket Booth</a><span>Hand each pier visitor exact change from a limited till</span><span>Game forged by the crew</span><span class="tags">Puzzle · Tap</span></li>
138139 </ul>
139140 <footer>Concept by <a href="https://x.com/awwbhi2">abhi</a>. Game by <a href="https://x.com/AmirMushich">AmirMushich</a>. Visual reference for the style: shawn.'s <a href="https://x.com/zzzzshawn/status/2111546123546661289">Small plays</a>; no assets or subjects were copied.<br><a href="stats/">Most played</a></footer>
140141 </main>
og/home.png
Binary file not shown.
og/ticket-booth.png added
Binary file not shown.
rounds.mjs +38
@@ -506,6 +506,44 @@ export const ROUNDS = {
506506 g.tap(...MID);
507507 g.run(.2);
508508 } },
509 // plan the whole day by depth-first search over the visible queue (every exact change each till allows, fewest coins first),
510 // then play the plan; a day the search cannot win in two lost visitors is dropped for another with R
511 'ticket-booth': { over: /You win/, outcome: /You win: score \d+, \d+ visitors served/, beat: g => {
512 const s = JSON.parse(g.read('JSON.stringify(P)'));
513 const key = k => { g.keyDown(k); g.keyUp(k); }; // the page answers a key at once: no frame needed between presses
514 if (s.status === 'lost') { key('r'); g.run(.1); return; }
515 if (s.status !== 'playing') { g.run(.3); return; }
516 const combos = (till, due) => {
517 const out = [], used = s.values.map(() => 0);
518 const walk = (i, left) => {
519 if (left === 0) { out.push([...used]); return; }
520 if (i < 0) return;
521 for (let n = Math.min(till[i], Math.floor(left / s.values[i])); n >= 0; n--) { used[i] = n; walk(i - 1, left - n * s.values[i]); }
522 used[i] = 0;
523 };
524 walk(s.values.length - 1, due);
525 return out.sort((x, y) => x.reduce((p, q) => p + q, 0) - y.reduce((p, q) => p + q, 0)).slice(0, 12);
526 };
527 let nodes = 0;
528 const solve = (at, till, lost) => {
529 if (at === s.queue.length) return [];
530 if (++nodes > 20000) return null;
531 const v = s.queue[at], opts = combos(till, v.due);
532 for (const c of opts) {
533 const t = till.map((n, i) => n - c[i]); t[s.values.indexOf(v.pays)]++;
534 const rest = solve(at + 1, t, lost);
535 if (rest) return [c, ...rest];
536 }
537 if (!opts.length && lost < 2) { const rest = solve(at + 1, till, lost + 1); if (rest) return [null, ...rest]; }
538 return null;
539 };
540 const plan = solve(s.at, s.till, s.lost);
541 if (!plan) { key('r'); g.run(.1); return; }
542 for (const c of plan) {
543 if (!c) key('s'); else { c.forEach((n, i) => { for (let k = 0; k < n; k++) key(String(i + 1)); }); key('Enter'); }
544 g.run(.1);
545 }
546 } },
509547 'window-washer': { over: /play again/, outcome: /[1-9]\d* of 500/, beat: g => g.drag([60, 100], [300, 100], .6) },
510548 };
511549
serve.test.mjs +7
@@ -747,3 +747,10 @@ test('Welly Toss and its rules script are served without a track', async () => {
747747 expect(page.headers.get('content-type')).toBe('text/html; charset=utf-8');
748748 expect((await fetch(base + '/welly-toss/throw.js')).status).toBe(200);
749749 });
750
751 test('Ticket Booth and its rules script are served without a track', async () => {
752 const page = await fetch(base + '/ticket-booth/');
753 expect(page.status).toBe(200);
754 expect(page.headers.get('content-type')).toBe('text/html; charset=utf-8');
755 expect((await fetch(base + '/ticket-booth/till.js')).status).toBe(200);
756 });
shell.js +1
@@ -75,6 +75,7 @@ window.PixelPlays = (() => {
7575 { id: 'chimney-sweep', title: 'Chimney Sweep', blurb: 'Climb the flues with your brush and duck the rising smoke', kind: 'Climbing', input: 'Tap', accent: '#4a4a4a', music: false, forged: true },
7676 { id: 'fence-posts', title: 'Fence Posts', blurb: 'Take turns fencing the meadow and claim every pen you close', kind: 'Strategy', input: 'Tap', accent: '#6b8e23', music: false, forged: true },
7777 { id: 'welly-toss', title: 'Welly Toss', blurb: 'Let the boot go at the top of the swing and land it past the flags', kind: 'Throwing', input: 'Tap', accent: '#2f6b3a', music: false, forged: true },
78 { id: 'ticket-booth', title: 'Ticket Booth', blurb: 'Hand each pier visitor exact change from a limited till', kind: 'Puzzle', input: 'Tap', accent: '#2b7a8c', music: false, forged: true },
7879 ];
7980 const MUSIC_VOLUME = .4;
8081 const CREDITS = [
ticket-booth/index.html added +419
@@ -0,0 +1,419 @@
1 <!doctype html>
2 <html lang="en">
3 <head>
4 <meta charset="utf-8">
5 <meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover">
6 <title>Ticket Booth</title>
7 <!-- og:begin -->
8 <meta name="description" content="Hand each pier visitor exact change from a limited till">
9 <link rel="canonical" href="https://pixel-plays--forge-trevato-apps.open-platform.sh/ticket-booth/">
10 <meta property="og:type" content="website">
11 <meta property="og:site_name" content="Pixel Plays">
12 <meta property="og:title" content="Ticket Booth">
13 <meta property="og:description" content="Hand each pier visitor exact change from a limited till">
14 <meta property="og:url" content="https://pixel-plays--forge-trevato-apps.open-platform.sh/ticket-booth/">
15 <meta property="og:image" content="https://pixel-plays--forge-trevato-apps.open-platform.sh/og/ticket-booth.png">
16 <meta property="og:image:type" content="image/png">
17 <meta property="og:image:width" content="1200">
18 <meta property="og:image:height" content="630">
19 <meta property="og:image:alt" content="Ticket Booth: the opening scene of the game">
20 <meta name="twitter:card" content="summary_large_image">
21 <!-- og:end -->
22 <style>
23 :root { --ink: #1c1b19; --muted: #6b675f; --paper: #fff; --accent: #2b7a8c; }
24 * { box-sizing: border-box; }
25 html, body { -webkit-user-select: none; user-select: none; -webkit-touch-callout: none; -webkit-tap-highlight-color: transparent; height: 100%; margin: 0; background: var(--paper); color: var(--ink); }
26 body { font: 13px/1.35 ui-monospace, "Cascadia Mono", Consolas, Menlo, monospace; overflow: hidden; }
27 #stage { position: fixed; inset: 0; display: grid; place-items: center; }
28 canvas { display: block; image-rendering: pixelated; touch-action: none; cursor: pointer; outline: none; }
29 canvas:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; }
30 #scores { position: fixed; top: max(14px, env(safe-area-inset-top)); left: max(16px, env(safe-area-inset-left)); display: grid; gap: 2px; pointer-events: none; }
31 #counts { position: fixed; top: max(44px, calc(env(safe-area-inset-top) + 30px)); right: max(16px, env(safe-area-inset-right)); margin: 0; padding: 0; list-style: none; color: var(--muted); font-variant-numeric: tabular-nums; text-align: right; pointer-events: none; }
32 #bottom { position: fixed; left: max(16px, env(safe-area-inset-left)); right: max(16px, env(safe-area-inset-right)); bottom: max(14px, env(safe-area-inset-bottom)); display: flex; justify-content: space-between; align-items: flex-end; gap: 16px; pointer-events: none; }
33 .row { display: flex; align-items: center; gap: 8px; color: var(--accent); }
34 .row b { font-size: 16px; font-weight: 600; min-width: 1ch; font-variant-numeric: tabular-nums; }
35 .row span { color: var(--muted); }
36 #order { color: var(--ink); max-width: max(16ch, calc(50vw - 196px)); margin-top: 4px; }
37 #line { color: var(--muted); min-height: 1.35em; max-width: max(16ch, calc(50vw - 196px)); }
38 .acts { display: flex; gap: 16px; align-items: center; }
39 button { pointer-events: auto; font: inherit; color: var(--muted); background: none; border: 0; padding: 10px; margin: -10px; cursor: pointer; }
40 button.act { color: var(--accent); font-weight: 600; min-height: 44px; min-width: 44px; padding: 13px 10px; margin: -13px -10px; }
41 button:hover, button:focus-visible { color: var(--ink); }
42 #counts .n { display: none; }
43 .sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
44 /* a short landscape phone: the HUD keeps to the strips either side of the canvas, the buttons stack at the right */
45 @media (max-height: 420px) and (min-width: 521px) {
46 .acts { flex-direction: column; align-items: flex-end; gap: 0; }
47 button.act, #mute { min-height: 44px; padding: 13px 10px; margin: 0 -10px; }
48 }
49 /* a narrow phone: the canvas fills the width, so the HUD moves out of overlay into flow above and below it */
50 @media (max-width: 520px) {
51 body { overflow-y: auto; display: flex; flex-direction: column; min-height: 100%; padding: max(40px, calc(env(safe-area-inset-top) + 30px)) 12px max(96px, calc(env(safe-area-inset-bottom) + 82px)); }
52 #scores, #counts, #bottom, #stage { position: static; }
53 #scores { display: flex; flex-wrap: wrap; column-gap: 14px; row-gap: 2px; }
54 #order { flex-basis: 100%; max-width: none; margin-top: 2px; }
55 #counts { display: grid; grid-template-columns: repeat(4, auto); justify-content: space-between; column-gap: 8px; text-align: left; margin: 4px 0 6px; }
56 #stage { display: flex; justify-content: center; margin: 0 -12px; }
57 #bottom { flex-direction: column; align-items: stretch; gap: 6px; margin-top: 8px; }
58 #counts .w { display: none; }
59 #counts .n { display: inline; }
60 #line { max-width: none; min-height: 2.7em; }
61 .acts { justify-content: flex-start; gap: 20px; }
62 }
63 </style>
64 </head>
65 <body>
66 <div id="stage"><canvas id="c" tabindex="0" aria-label="A seaside pier ticket booth. Along the front, the till holds a tray of each coin and note, with the change tray in front of it, and the queue of visitors waits beside the booth. Tap a till coin to give it, tap a tray coin to take it back, tap Hand to hand the tray to the front visitor, or Short to send away a visitor whose change cannot be made. Keys: 1 to 8 give a coin, Backspace takes the last one back, Enter hands, S sends away, R restarts."></canvas></div>
67 <div id="scores">
68 <div class="row"><b id="score">0</b><span>score</span></div>
69 <div class="row"><b id="served">0</b><span>served</span></div>
70 <div class="row"><b id="lost">0</b><span id="lostof">lost</span></div>
71 <div class="row"><b id="best">0</b><span>best score</span></div>
72 <div id="order"></div>
73 <span id="who" class="sr"></span>
74 </div>
75 <ul id="counts" aria-label="Each coin and note: how many are in the till and how many in the change tray"></ul>
76 <div id="bottom">
77 <div id="line"><span id="status" role="status" aria-live="polite"></span></div>
78 <div class="acts">
79 <button id="hand" class="act" type="button">Hand</button>
80 <button id="short" class="act" type="button">Short</button>
81 <button id="mute" type="button" aria-pressed="false">sound on</button>
82 </div>
83 </div>
84 <script src="../shell.js"></script>
85 <script>
86 PixelPlays.mount({
87 id: 'ticket-booth',
88 rules: [
89 'Tap a till coin to give it, a tray coin to take it back.',
90 'Hand the tray when it holds exactly the change due; Short sends away a visitor whose change cannot be made.',
91 'Three visitors lost ends the day; serve all twelve to win.',
92 ],
93 });
94 </script>
95 <script>window.PP_SCENE = { originY: 0 };</script>
96 <script src="../engine.js"></script>
97 <script src="till.js"></script>
98 <script>
99 'use strict';
100
101 const M = TicketBooth;
102 const BEST_KEY = 'pixel-plays-ticket-booth-best';
103 const SLOT_U = i => -14 + 4 * i; // the eight coin columns, screen-aligned
104 const TILL_V = 5, TRAY_V = 13, QUEUE_V = -5, WALL_V = -12;
105 const TILL_SY = OY + TILL_V * SIN * S, TRAY_SY = OY + TRAY_V * SIN * S, ROW_SPLIT = (TILL_SY + TRAY_SY) / 2;
106 const world = (u, v) => [(u + v) / R2, (v - u) / R2];
107 const PLANK_A = [150, 112, 82], PLANK_B = [136, 100, 74], VELVET = [58, 74, 96], MAT = [214, 204, 184];
108 const WALL = [236, 228, 208], AWN_A = [43, 122, 140], AWN_B = [240, 236, 224], GLASS = [120, 172, 190], FRAME = [92, 70, 54];
109 const COATS = [[196, 84, 70], [84, 128, 176], [222, 176, 72], [112, 156, 92], [170, 110, 160], [230, 140, 90]];
110 const SKIN = [224, 176, 138];
111 const HATS = COATS.map(c => [c[0] * .6, c[1] * .6, c[2] * .6]); // one array per colour: the engine keys its ramps on them
112 // size, shape and colour all differ by value: small round coppers, square 20p and 50p, big gold pound coins, flat notes
113 const PIECES = [
114 { label: '5p', shape: 'disc', hu: .5, hv: .5, h: .22, top: [196, 126, 86], side: [140, 86, 58] },
115 { label: '10p', shape: 'disc', hu: .72, hv: .72, h: .22, top: [206, 206, 214], side: [140, 140, 156] },
116 { label: '20p', shape: 'slab', hu: .6, hv: .6, h: .22, top: [226, 184, 96], side: [160, 122, 62] },
117 { label: '50p', shape: 'slab', hu: .85, hv: .85, h: .22, top: [188, 196, 224], side: [120, 128, 164] },
118 { label: '£1', shape: 'disc', hu: .95, hv: .95, h: .26, top: [244, 202, 66], side: [176, 134, 36] },
119 { label: '£2', shape: 'disc', hu: 1.15, hv: 1.15, h: .26, top: [226, 226, 214], side: [190, 146, 52] },
120 { label: '£5', shape: 'slab', hu: 1.25, hv: .75, h: .08, top: [118, 184, 128], side: [72, 124, 84] },
121 { label: '£10', shape: 'slab', hu: 1.55, hv: .9, h: .08, top: [232, 150, 86], side: [168, 98, 52] },
122 ];
123 const pounds = n => n < 100 ? `${n}p` : `£${Math.floor(n / 100)}.${String(n % 100).padStart(2, '0')}`;
124 const sumOf = counts => counts.reduce((t, n, i) => t + n * M.VALUES[i], 0);
125
126 /* ---------- state ---------- */
127 const best0 = (() => { try { return Number(localStorage.getItem(BEST_KEY)) || 0; } catch (e) { return 0; } })();
128 let P = M.start(Date.now() & 0xffff);
129 const G = { time: 0, last: -1, slide: 0, best: best0, saved: false, games: 0 };
130 const nodes = {};
131 for (const id of ['score', 'served', 'lost', 'lostof', 'best', 'order', 'who', 'status', 'counts']) nodes[id] = document.getElementById(id);
132
133 function say(text) {
134 nodes.status.textContent = text; // the live region: every give, take, hand, refusal and ending
135 }
136 function renderHud() {
137 nodes.score.textContent = String(P.score);
138 nodes.served.textContent = String(P.served);
139 nodes.lost.textContent = String(P.lost);
140 nodes.lostof.textContent = `lost of ${M.LOSS_LIMIT}`;
141 nodes.best.textContent = String(Math.max(G.best, P.score));
142 const v = P.queue[Math.min(P.at, P.queue.length - 1)];
143 const playing = P.status === 'playing';
144 // short on screen: the front visitor's tickets and payment are drawn on the canvas; the full order is kept for screen readers
145 nodes.order.textContent = playing ? `Change due ${pounds(v.due)}, tray ${pounds(sumOf(P.tray))}` : 'Day over: press R';
146 nodes.who.textContent = playing
147 ? `${v.name}: ${v.tickets.adult} adult, ${v.tickets.child} child, ${pounds(v.total)}, pays ${pounds(v.pays)}; ${P.queue.length - P.at} in the queue.`
148 : P.line;
149 nodes.counts.innerHTML = PIECES.map((p, i) =>
150 `<li>${p.label} <span class="w">till </span>${P.till[i]}<span class="w"> tray </span><span class="n">/</span>${P.tray[i]}</li>`).join('');
151 }
152
153 /* ---------- baked scene: the pier ---------- */
154 function groundColor(ix, iy) {
155 const g = unproject(ix + .5, iy + .5), u = (g.x - g.y) / R2, v = (g.x + g.y) / R2;
156 const edge = Math.max(Math.abs(u) / 19.5, Math.abs(v - 3) / 17.5);
157 const rough = edge + (noise(u * .4, v * .4) - .5) * .06;
158 if (rough > 1.03) return 0;
159 if (rough > .94 && hash(ix, iy) < (rough - .94) / .09) return 0;
160 const t = .95 + hash(ix, iy) * .08;
161 for (let i = 0; i < 8; i++) {
162 const du = Math.abs(u - SLOT_U(i));
163 if (du < 1.8 && Math.abs(v - TILL_V) < 1.6) return rgb(VELVET[0] * t, VELVET[1] * t, VELVET[2] * t);
164 if (du < 1.8 && Math.abs(v - TRAY_V) < 1.6) return rgb(MAT[0] * t, MAT[1] * t, MAT[2] * t);
165 }
166 const row = Math.floor((v + 30) / 1.25), seam = ((v + 30) / 1.25 - row) < .09 || ((u + 40 + (row % 3) * 2.7) % 5.4) < .08;
167 const c = row & 1 ? PLANK_A : PLANK_B, k = seam ? .7 : t;
168 return rgb(c[0] * k, c[1] * k, c[2] * k);
169 }
170 function bake() {
171 ground.fill(0); shadowMask.fill(0); staticShadow.fill(0); kinds.fill(0);
172 zb.fill(-1e9);
173 pix.fill(rgb(255, 255, 255));
174 for (let iy = 0; iy < H; iy++) for (let ix = 0; ix < W; ix++) {
175 const g = unproject(ix + .5, iy + .5), i = iy * W + ix, c = groundColor(ix, iy);
176 if (!c) continue;
177 pix[i] = c; ground[i] = 1;
178 zb[i] = (g.x + g.y) / R2 * COS;
179 }
180 groundZ.set(zb);
181 wall();
182 bakeOcclusion();
183 staticShadow.set(shadowMask);
184 applyShadows(groundZ);
185 bakedPix.set(pix);
186 bakedZ.set(zb);
187 }
188
189 /* ---------- flat shapes, drawn straight into the pixel buffers ---------- */
190 function paint(ix, iy, d, c) {
191 if (ix < 0 || iy < 0 || ix >= W || iy >= H) return;
192 const i = iy * W + ix;
193 if (d <= zb[i]) return;
194 zb[i] = d; pix[i] = c;
195 }
196 const col = (c, k = 1) => rgb(c[0] * k, c[1] * k, c[2] * k);
197 // a screen-aligned rectangle of side `u0..u1` standing at depth v from height z0 to z1 (a wall of the booth)
198 function wallRect(u0, u1, v, z0, z1, c, k = 1) {
199 const x0 = Math.round(OX + u0 * S), x1 = Math.round(OX + u1 * S);
200 const y0 = Math.round(OY + (v * SIN - z1 * COS) * S), y1 = Math.round(OY + (v * SIN - z0 * COS) * S);
201 const color = col(c, k);
202 for (let y = y0; y < y1; y++) for (let x = x0; x < x1; x++) paint(x, y, v * COS + (z0 + (y1 - y) / (S * COS)) * SIN, color);
203 }
204 function wall() {
205 wallRect(-18, 18, WALL_V, 0, 4.2, WALL);
206 for (let k = 0; k < 18; k++) wallRect(-18 + k * 2, -16 + k * 2, WALL_V + .02, 4.2, 5.6, k & 1 ? AWN_B : AWN_A);
207 wallRect(-17.2, -3.2, WALL_V + .03, 1.5, 3.7, FRAME);
208 wallRect(-16.8, -3.6, WALL_V + .04, 1.9, 3.3, GLASS);
209 wallRect(-17.4, -3, WALL_V + .05, 1.3, 1.6, FRAME); // the counter ledge
210 wallRect(6, 12, WALL_V + .03, 0, 3, FRAME);
211 wallRect(6.4, 11.6, WALL_V + .04, .3, 2.8, [200, 168, 120]); // the door
212 }
213 // a flat slab or disc of the piece, centred at (u, v), its top at height z
214 function shape(kind, u, v, z, hu, hv, h, top, side) {
215 const cx = OX + u * S, cy = OY + (v * SIN - z * COS) * S, drop = h * COS * S;
216 const rx = hu * S, ry = hv * SIN * S, d = v * COS + z * SIN;
217 const ct = col(top), cs = col(side);
218 for (let y = Math.floor(cy - ry); y <= Math.ceil(cy + ry + drop); y++) for (let x = Math.floor(cx - rx); x <= Math.ceil(cx + rx); x++) {
219 const px = x + .5, py = y + .5;
220 let inTop, inBot;
221 if (kind === 'disc') {
222 inTop = ((px - cx) / rx) ** 2 + ((py - cy) / ry) ** 2 <= 1;
223 inBot = ((px - cx) / rx) ** 2 + ((py - cy - drop) / ry) ** 2 <= 1;
224 } else {
225 inTop = Math.abs(px - cx) <= rx && Math.abs(py - cy) <= ry;
226 inBot = Math.abs(px - cx) <= rx && Math.abs(py - cy - drop) <= ry;
227 }
228 if (inTop) paint(x, y, d + .002, ct);
229 else if (inBot || (Math.abs(px - cx) <= rx && py > cy && py < cy + drop)) paint(x, y, d, cs);
230 }
231 }
232 const piece = (i, u, v, z) => { const p = PIECES[i]; shape(p.shape, u, v, z, p.hu, p.hv, p.h, p.top, p.side); };
233 function stack(i, n, u, v) {
234 const p = PIECES[i], shown = Math.min(n, 12);
235 for (let k = 0; k < shown; k++) piece(i, u, v, p.h + k * p.h * 1.05);
236 if (shown) castShadow(...world(u, v), p.h * (shown + 1), Math.max(p.hu, p.hv) * .8, 1, 0);
237 }
238
239 /* ---------- figures ---------- */
240 const sway = () => STILL ? 0 : Math.floor(G.time * 3) % 2;
241 function visitor(k, u, front) {
242 const c = COATS[(P.at + k) % COATS.length], [x, y] = world(u, QUEUE_V);
243 const bob = front || k === 1 ? sway() * .06 : 0;
244 dynamicAdd([x, y, 0], [x, y, 1.45 + bob], .36, .3, () => c);
245 sphere(x, y, 1.85 + bob, .3, SKIN, 'solid');
246 sphere(x, y, 2.1 + bob, .26, HATS[(P.at + k) % COATS.length], 'solid');
247 }
248 function draw() {
249 pix.set(bakedPix);
250 zb.set(bakedZ);
251 for (let i = 0; i < PIECES.length; i++) {
252 const u = SLOT_U(i);
253 stack(i, P.till[i], u, TILL_V);
254 stack(i, P.tray[i], u, TRAY_V);
255 }
256 const shown = Math.min(6, P.queue.length - P.at);
257 for (let k = shown - 1; k >= 0; k--) if (P.status === 'playing' || P.at + k < P.queue.length) visitor(k, -11 + (k + G.slide) * 3.3, k === 0);
258 if (P.status === 'playing') {
259 const v = P.queue[P.at], u = -11 + G.slide * 3.3;
260 piece(M.VALUES.indexOf(v.pays), u + .6, QUEUE_V, 3.1); // what they pay with
261 for (let k = 0; k < v.tickets.adult + v.tickets.child; k++) { // their tickets: red adult, blue child
262 shape('slab', u + 2.4 + k * .9, QUEUE_V, 3.1, .4, .6, .05, k < v.tickets.adult ? [214, 84, 70] : [84, 124, 190], [120, 80, 70]);
263 }
264 }
265 flushShadows();
266 applyShadows(groundZ);
267 shadeEdges();
268 ctx.putImageData(image, 0, 0);
269 }
270
271 /* ---------- game ---------- */
272 const WHY = {
273 'game-over': () => 'The day is over: press R to play again',
274 'no-such-coin': () => 'That is not a coin',
275 'none-in-till': c => `No ${PIECES[c].label} left in the till`,
276 'none-in-tray': c => `No ${PIECES[c].label} in the tray`,
277 'wrong-change': () => `The tray holds ${pounds(sumOf(P.tray))} but the change due is ${pounds(P.queue[P.at].due)}`,
278 'change-possible': () => `${P.queue[P.at].name}'s change can be made: serve them`,
279 };
280 function refuse(r, c) { say(WHY[r.why] ? WHY[r.why](c) : 'Refused'); sound('no'); }
281 function finish() {
282 if (P.status === 'playing') return;
283 if (!G.saved) {
284 G.saved = true;
285 const record = P.score > G.best;
286 if (record) { G.best = P.score; try { localStorage.setItem(BEST_KEY, String(P.score)); } catch (e) { /* private window: keep it for this visit */ } }
287 say(`${P.line}${record ? ' · new best' : ''} · press R or tap to play again`);
288 sound(P.status === 'won' ? 'win' : 'lose');
289 }
290 }
291 function give(i) {
292 const r = M.give(P, M.VALUES[i]);
293 if (!r.ok) { refuse(r, i); renderHud(); return; }
294 P = r.state; G.last = i;
295 say(`Gave ${PIECES[i].label}: tray ${pounds(sumOf(P.tray))} of ${pounds(P.queue[P.at].due)} due`);
296 sound('coin'); renderHud();
297 }
298 function take(i) {
299 const r = M.take(P, M.VALUES[i]);
300 if (!r.ok) { refuse(r, i); renderHud(); return; }
301 P = r.state;
302 say(`Took back ${PIECES[i].label}: tray ${pounds(sumOf(P.tray))} of ${pounds(P.queue[P.at].due)} due`);
303 sound('coin'); renderHud();
304 }
305 function hand() {
306 const before = P, r = M.hand(P);
307 if (!r.ok) { refuse(r); return; }
308 P = r.state; G.slide = 1;
309 say(`Handed ${pounds(sumOf(before.tray))} to ${before.queue[before.at].name}${P.fewest > before.fewest ? ', fewest coins' : ''}: score ${P.score}, served ${P.served}`);
310 sound('hand'); renderHud(); finish();
311 }
312 function shorten() {
313 const before = P, r = M.short(P);
314 if (!r.ok) { refuse(r); return; }
315 P = r.state; G.slide = 1;
316 say(`Sent ${before.queue[before.at].name} away: ${P.lost} of ${M.LOSS_LIMIT} lost`);
317 sound('lose1'); renderHud(); finish();
318 }
319 function restart() {
320 G.games++;
321 P = M.start((Date.now() + G.games * 977) & 0xffff);
322 G.last = -1; G.slide = 0; G.saved = false;
323 say('New day: tap a till coin to give it');
324 renderHud();
325 }
326 function update(dt) {
327 G.time += dt;
328 if (G.slide > 0) G.slide = STILL ? 0 : Math.max(0, G.slide - dt * 4);
329 }
330
331 /* ---------- input ---------- */
332 const at = e => {
333 const box = canvas.getBoundingClientRect();
334 return { sx: (e.clientX - box.left) * W / box.width, sy: (e.clientY - box.top) * H / box.height };
335 };
336 // the column of the coin under a point, and whether it is in the till row (0) or the tray row (1)
337 function slotAt(p) {
338 if (p.sy < TILL_SY - 34 || p.sy > TRAY_SY + 24) return null;
339 const i = Math.round((p.sx - OX) / S / 4 + 3.5);
340 if (i < 0 || i > 7 || Math.abs(p.sx - (OX + SLOT_U(i) * S)) > 20) return null;
341 return { i, row: p.sy < ROW_SPLIT ? 0 : 1 };
342 }
343 canvas.addEventListener('pointerdown', e => {
344 canvas.focus({ preventScroll: true });
345 ensureAudio();
346 if (P.status !== 'playing') { restart(); return; }
347 const s = slotAt(at(e));
348 if (!s) return;
349 if (s.row === 0) give(s.i); else take(s.i);
350 });
351 canvas.addEventListener('keydown', e => {
352 const k = e.key;
353 if (e.repeat || e.ctrlKey || e.metaKey || e.altKey) return;
354 ensureAudio();
355 if (k === 'r' || k === 'R') { e.preventDefault(); restart(); return; }
356 if (P.status !== 'playing') {
357 if (k === 'Enter') { e.preventDefault(); restart(); }
358 else if (k === 's' || k === 'S') { e.preventDefault(); shorten(); } // refused by the rules: says how to play again
359 return;
360 }
361 if (k >= '1' && k <= '8') { e.preventDefault(); give(Number(k) - 1); }
362 else if (k === 'Backspace') { e.preventDefault(); take(G.last < 0 ? 0 : G.last); }
363 else if (k === 'Enter') { e.preventDefault(); hand(); }
364 else if (k === 's' || k === 'S') { e.preventDefault(); shorten(); }
365 });
366 document.getElementById('hand').addEventListener('click', () => { ensureAudio(); if (P.status !== 'playing') restart(); else hand(); canvas.focus({ preventScroll: true }); });
367 document.getElementById('short').addEventListener('click', () => { ensureAudio(); shorten(); canvas.focus({ preventScroll: true }); });
368
369 /* ---------- sound ---------- */
370 let audio = null, muted = false;
371 function ensureAudio() {
372 if (audio) { if (audio.state === 'suspended') audio.resume(); return; }
373 const AC = window.AudioContext || window.webkitAudioContext;
374 if (AC) audio = new AC();
375 }
376 function sound(kind) {
377 if (!audio || muted) return;
378 const now = audio.currentTime, gain = audio.createGain(), osc = audio.createOscillator();
379 const spec = {
380 coin: [900, 1400, .06, .04, 'square'],
381 hand: [500, 800, .14, .05, 'sine'],
382 no: [200, 120, .15, .04, 'square'],
383 lose1: [300, 110, .25, .05, 'sawtooth'],
384 win: [400, 900, .4, .05, 'sine'],
385 lose: [300, 70, .5, .05, 'sawtooth'],
386 }[kind];
387 osc.type = spec[4];
388 osc.frequency.setValueAtTime(spec[0], now);
389 osc.frequency.exponentialRampToValueAtTime(spec[1], now + spec[2]);
390 gain.gain.setValueAtTime(spec[3], now);
391 gain.gain.exponentialRampToValueAtTime(.0005, now + spec[2]);
392 osc.connect(gain).connect(audio.destination);
393 osc.start(now); osc.stop(now + spec[2] + .01);
394 }
395 const muteButton = document.getElementById('mute');
396 muteButton.addEventListener('click', () => {
397 muted = !muted;
398 muteButton.textContent = muted ? 'sound off' : 'sound on';
399 muteButton.setAttribute('aria-pressed', String(muted));
400 });
401
402 /* ---------- fit and boot ---------- */
403 addEventListener('resize', fit);
404 fit();
405 bake();
406 say('Tap a till coin to give it, a tray coin to take it back');
407 renderHud();
408 canvas.focus({ preventScroll: true });
409 let last = performance.now();
410 function frame(now) {
411 update(Math.min(.04, (now - last) / 1000));
412 last = now;
413 draw();
414 requestAnimationFrame(frame);
415 }
416 requestAnimationFrame(frame);
417 </script>
418 </body>
419 </html>
ticket-booth/page.test.mjs added +140
@@ -0,0 +1,140 @@
1 import { test, expect, setDefaultTimeout } from 'bun:test';
2 import { readFileSync } from 'node:fs';
3 import { boot } from '../playtest.mjs';
4
5 setDefaultTimeout(20000);
6 const state = g => JSON.parse(g.read('JSON.stringify(P)'));
7 const text = g => g.el('status').textContent;
8 // where a coin column sits on the canvas, in the till row (0) or the tray row (1), read from the page's own layout
9 const spot = (g, i, row) => g.read(`[OX + SLOT_U(${i}) * S, ${row ? 'TRAY_SY' : 'TILL_SY'}]`);
10 // replace parts of the page's game state (a fixture), keeping the rest
11 const set = (g, patch) => g.read(`P = { ...P, ...${JSON.stringify(patch)} }`);
12 const visitor = (due, n = 1) => Array.from({ length: n }, (_, i) => ({ name: `Visitor ${i + 1}`, tickets: { adult: 1, child: 0 }, total: 175, pays: 200, due }));
13 const empty = [0, 0, 0, 0, 0, 0, 0, 0];
14
15 test('tapping a till coin gives it, tapping the tray coin takes it back, and both are announced', () => {
16 const g = boot('ticket-booth');
17 const before = state(g);
18 g.tap(...spot(g, 3, 0));
19 expect(state(g).tray[3]).toBe(1);
20 expect(state(g).till[3]).toBe(before.till[3] - 1);
21 expect(text(g)).toContain('Gave 50p');
22 expect(g.el('counts').innerHTML).toContain(`50p <span class="w">till </span>${before.till[3] - 1}<span class="w"> tray </span><span class="n">/</span>1`);
23 g.tap(...spot(g, 3, 1));
24 expect(state(g).tray[3]).toBe(0);
25 expect(text(g)).toContain('Took back 50p');
26 });
27
28 test('number keys give, Backspace takes the last coin back, and a refusal says why', () => {
29 const g = boot('ticket-booth');
30 g.press('2', .02);
31 expect(state(g).tray[1]).toBe(1);
32 g.press('Backspace', .02);
33 expect(state(g).tray[1]).toBe(0);
34 g.press('Backspace', .02);
35 expect(text(g)).toContain('No 10p in the tray');
36 g.press('Enter', .02);
37 expect(text(g)).toContain('change due');
38 expect(state(g).served).toBe(0);
39 set(g, { till: empty });
40 g.press('3', .02);
41 expect(text(g)).toContain('No 20p left in the till');
42 });
43
44 test('Hand serves with exact change, the HUD shows it, and Short is refused when change can be made', () => {
45 const g = boot('ticket-booth');
46 set(g, { queue: visitor(30, 2), till: [0, 1, 1, 0, 0, 0, 0, 0] });
47 g.press('s', .02);
48 expect(text(g)).toContain('can be made');
49 expect(state(g).lost).toBe(0);
50 g.press('2', .02); g.press('3', .02);
51 g.el('hand').fire('click');
52 expect(state(g).served).toBe(1);
53 expect(g.el('served').textContent).toBe('1');
54 expect(g.el('score').textContent).toBe(String(state(g).score));
55 expect(text(g)).toContain('Handed');
56 });
57
58 test('Short sends away a visitor whose change cannot be made, and the HUD and live region say so', () => {
59 const g = boot('ticket-booth');
60 set(g, { queue: visitor(25, 3), till: empty });
61 g.el('short').fire('click');
62 expect(state(g).lost).toBe(1);
63 expect(state(g).at).toBe(1);
64 expect(g.el('lost').textContent).toBe('1');
65 expect(text(g)).toContain('Sent Visitor 1 away: 1 of 3 lost');
66 });
67
68 test('three lost visitors end the day with the loss line, and a tap plays again', () => {
69 const g = boot('ticket-booth');
70 set(g, { queue: visitor(25, 5), till: empty });
71 for (let i = 0; i < 3; i++) g.press('s', .02);
72 expect(state(g).status).toBe('lost');
73 expect(text(g)).toContain('You lose: 3 visitors went away without their change');
74 expect(text(g)).toContain('play again');
75 g.press('1', .02);
76 expect(text(g)).toContain('play again'); // a coin key does nothing once the day is over
77 const [x, y] = spot(g, 0, 0);
78 g.tap(x, y);
79 expect(state(g).status).toBe('playing');
80 expect(state(g).lost).toBe(0);
81 expect(text(g)).toContain('New day');
82 });
83
84 test('serving the last visitor announces the win line, and Enter plays again', () => {
85 const g = boot('ticket-booth');
86 set(g, { queue: visitor(0, 1), tray: empty });
87 g.press('Enter', .02);
88 expect(state(g).status).toBe('won');
89 expect(text(g)).toContain('You win: score 15'); // exact change with no coins is also the fewest
90 expect(text(g)).toContain('play again');
91 g.press('Enter', .02);
92 expect(state(g).status).toBe('playing');
93 });
94
95 test('R restarts a fresh day at any time', () => {
96 const g = boot('ticket-booth');
97 g.press('1', .02);
98 g.press('r', .02);
99 expect(state(g).tray.every(n => n === 0)).toBe(true);
100 expect(text(g)).toContain('New day');
101 });
102
103 test('the mute button toggles its label and aria-pressed', () => {
104 const g = boot('ticket-booth');
105 const b = g.el('mute');
106 b.fire('click');
107 expect(b.textContent).toBe('sound off');
108 expect(b.getAttribute('aria-pressed')).toBe('true');
109 b.fire('click');
110 expect(b.getAttribute('aria-pressed')).toBe('false');
111 });
112
113 test('Short after the day is over says how to play again, by button and by key', () => {
114 const g = boot('ticket-booth');
115 set(g, { queue: visitor(25, 3), till: empty });
116 for (let i = 0; i < 3; i++) g.press('s', .02);
117 g.el('short').fire('click');
118 expect(text(g)).toBe('The day is over: press R to play again');
119 g.press('r', .02);
120 g.read('P = { ...P, status: "lost" }');
121 g.press('s', .02);
122 expect(text(g)).toContain('press R to play again');
123 });
124
125 test('the on-screen order is one short line, and the full order is kept for screen readers', () => {
126 const g = boot('ticket-booth');
127 set(g, { queue: visitor(30, 2) }); g.read('renderHud()');
128 expect(g.el('order').textContent).toBe('Change due 30p, tray 0p');
129 expect(g.el('who').textContent).toContain('Visitor 1: 1 adult, 0 child, £1.75, pays £2.00');
130 g.press('r', .02);
131 expect(g.el('order').textContent.length).toBeLessThanOrEqual(32);
132 });
133
134 test('on a narrow phone the HUD is laid out in flow around the canvas, not over it', () => {
135 const css = readFileSync(new URL('./index.html', import.meta.url), 'utf8');
136 const narrow = css.slice(css.indexOf('@media (max-width: 520px)'));
137 expect(narrow.length).toBeLessThan(css.length);
138 expect(narrow).toMatch(/#scores, #counts, #bottom, #stage \{ position: static; \}/);
139 expect(css).toMatch(/button\.act \{[^}]*min-height: 44px/); // a 44px touch target for Hand and Short
140 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs