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
Check · Review · Land · Live done — Landed and live in production.
vesper/pixel-plays:vesper/338-add-ticket-booth-the-playable-page-and-i → main 5 commits fork
9 files
changed +609
−2
README.md +3 −2
Diff not shown — a line 3,438 characters wide (limit 2,000). +3 −2
View fileindex.html +1
| @@ -135,6 +135,7 @@ footer a::after { content: none; } | ||
| 135 | 135 | <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> |
| 136 | 136 | <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> |
| 137 | 137 | <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> | |
| 138 | 139 | </ul> |
| 139 | 140 | <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> |
| 140 | 141 | </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 = { | ||
| 506 | 506 | g.tap(...MID); |
| 507 | 507 | g.run(.2); |
| 508 | 508 | } }, |
| 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 | } }, | |
| 509 | 547 | 'window-washer': { over: /play again/, outcome: /[1-9]\d* of 500/, beat: g => g.drag([60, 100], [300, 100], .6) }, |
| 510 | 548 | }; |
| 511 | 549 |
serve.test.mjs +7
| @@ -747,3 +747,10 @@ test('Welly Toss and its rules script are served without a track', async () => { | ||
| 747 | 747 | expect(page.headers.get('content-type')).toBe('text/html; charset=utf-8'); |
| 748 | 748 | expect((await fetch(base + '/welly-toss/throw.js')).status).toBe(200); |
| 749 | 749 | }); |
| 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 = (() => { | ||
| 75 | 75 | { 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 }, |
| 76 | 76 | { 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 }, |
| 77 | 77 | { 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 }, | |
| 78 | 79 | ]; |
| 79 | 80 | const MUSIC_VOLUME = .4; |
| 80 | 81 | const CREDITS = [ |
ticket-booth/index.html added +419
| @@ -0,0 +1,419 @@ | ||
| 1 | ||
| 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 { : #1c1b19; : #6b675f; : #fff; : #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(); color: var(); } | |
| 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(); 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(); 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(); } | |
| 34 | .row b { font-size: 16px; font-weight: 600; min-width: 1ch; font-variant-numeric: tabular-nums; } | |
| 35 | .row span { color: var(); } | |
| 36 | #order { color: var(); max-width: max(16ch, calc(50vw - 196px)); margin-top: 4px; } | |
| 37 | #line { color: var(); 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(); background: none; border: 0; padding: 10px; margin: -10px; cursor: pointer; } | |
| 40 | button.act { color: var(); font-weight: 600; min-height: 44px; min-width: 44px; padding: 13px 10px; margin: -13px -10px; } | |
| 41 | button:hover, button:focus-visible { color: var(); } | |
| 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 | }); |
new above
offline