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

Migrated from github.com/amirmushichge/pixel-plays

Add Cake Stall: the playable page and its place in the catalogue, on the rules in `cake-stall/order.js` #371

Merged opened this

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

Merged

vesper/pixel-plays:vesper/361-add-cake-stall-the-playable-page-and-its → main 1 commit fork

  • Merged. vesper/361-add-cake-stall-the-playable-page-and-its was merged into main as 5b5fb07.

Review

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

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

View file
cake-stall/index.html added +351
@@ -0,0 +1,351 @@
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>Cake Stall</title>
7 <!-- og:begin -->
8 <meta name="description" content="Memorise the customer's stack of cake layers, then stack them back in the same order">
9 <link rel="canonical" href="https://pixel-plays--forge-trevato-apps.open-platform.sh/cake-stall/">
10 <meta property="og:type" content="website">
11 <meta property="og:site_name" content="Pixel Plays">
12 <meta property="og:title" content="Cake Stall">
13 <meta property="og:description" content="Memorise the customer's stack of cake layers, then stack them back in the same order">
14 <meta property="og:url" content="https://pixel-plays--forge-trevato-apps.open-platform.sh/cake-stall/">
15 <meta property="og:image" content="https://pixel-plays--forge-trevato-apps.open-platform.sh/og/cake-stall.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="Cake Stall: 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: #c75b8c; }
24 * { box-sizing: border-box; }
25 html, body { -webkit-user-select: none; user-select: none; -webkit-touch-callout: none; -webkit-tap-highlight-color: transparent; }
26 html, body { height: 100%; margin: 0; background: var(--paper); color: var(--ink); }
27 body { font: 13px/1.35 ui-monospace, "Cascadia Mono", Consolas, Menlo, monospace; overflow: hidden; }
28 #stage { position: fixed; inset: 0; display: grid; place-items: center; }
29 canvas { display: block; image-rendering: pixelated; touch-action: none; cursor: pointer; outline: none; }
30 canvas:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; }
31 .hud { position: fixed; left: max(16px, env(safe-area-inset-left)); right: max(16px, env(safe-area-inset-right)); display: flex; justify-content: space-between; align-items: flex-end; gap: 16px; pointer-events: none; }
32 #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; }
33 #bottom { bottom: max(20px, env(safe-area-inset-bottom)); }
34 .row { display: flex; align-items: center; gap: 8px; }
35 .row b { font-size: 16px; font-weight: 600; min-width: 1ch; font-variant-numeric: tabular-nums; }
36 .row span { color: var(--muted); }
37 #status { color: var(--muted); min-height: 2.7em; max-width: 60ch; }
38 button { pointer-events: auto; font: inherit; color: var(--muted); background: none; border: 0; padding: 13px 10px; margin: -13px -10px; min-height: 44px; min-width: 44px; cursor: pointer; }
39 button:hover, button:focus-visible { color: var(--ink); }
40 button:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
41 noscript p { position: fixed; inset: 40% 16px auto; margin: 0; text-align: center; color: var(--muted); }
42 </style>
43 </head>
44 <body>
45 <div id="stage"><canvas id="c" tabindex="0" aria-label="Cake Stall: a customer's order is a stack of cake layers, shown on the left plate for a few ticks and then hidden under a cloche. Stack the same layers in the same order on the right plate. Tap a layer button, or press 1 for sponge (crumb dots), 2 for jam (diagonal stripe), 3 for cream (wavy swirl) or 4 for icing (checks); the arrow keys mark a button and Space or Enter places it. A wrong layer costs a life of three and shows the order again. R restarts."></canvas></div>
46 <noscript><p>Cake Stall needs JavaScript to play.</p></noscript>
47 <div id="scores">
48 <div class="row"><b id="round">1</b><span>round of 8</span><b id="score">0</b><span>score</span></div>
49 <div class="row"><b id="lives">3</b><span>lives</span></div>
50 </div>
51 <div class="hud" id="bottom">
52 <div id="status" role="status" aria-live="polite"></div>
53 <button id="mute" type="button" aria-pressed="false">sound on</button>
54 </div>
55 <script src="../shell.js"></script>
56 <script>
57 PixelPlays.mount({
58 id: 'cake-stall',
59 rules: [
60 'The order is a stack of cake layers, shown for a few ticks and then hidden under a cloche.',
61 'Tap a layer button, press 1 to 4, or the arrows and Space, to stack the layers back in the same order: 10 points a layer.',
62 'A wrong layer costs a life of three and shows the order again. Serve 8 orders to win. R restarts.',
63 ],
64 });
65 </script>
66 <script>window.PP_SCENE = { originY: 30, shadowX: 1.3, shadowY: -.12 };</script>
67 <script src="../engine.js"></script>
68 <script src="order.js"></script>
69 <script>
70 'use strict';
71
72 const M = window.CakeStall;
73 const TICK_S = .3; // the stall's pace: seconds per tick of showing time
74 const LEAD_S = 1.2; // a moment to look before the order starts to count down
75 const CLOTH = 8.4, HALF = 1.7, LAYER_H = .9, PLATE = 2.4, BELL_R = 2.2;
76 const ORDER_AT = [-4, 4], YOURS_AT = [4, -4]; // plate centres: left and right of the picture
77 const BTN = { y: 232, h: 38, w: 78, gap: 8 };
78 const KINDS = {
79 sponge: { colour: [224, 180, 96], mark: [150, 98, 48], word: 'crumb dots' },
80 jam: { colour: [196, 40, 72], mark: [240, 130, 150], word: 'diagonal stripe' },
81 cream: { colour: [248, 242, 222], mark: [196, 182, 142], word: 'wavy swirl' },
82 icing: { colour: [236, 140, 186], mark: [255, 255, 255], word: 'checks' },
83 };
84 const RAMP = {}, MARKRAMP = {};
85 for (const k of M.LAYERS) { RAMP[k] = rampOf(KINDS[k].colour); MARKRAMP[k] = rampOf(KINDS[k].mark); }
86 const CLOTH_C = [[214, 84, 96], [250, 244, 240]], SILVER = [186, 196, 210], KNOB = [120, 126, 138], CHINA = [238, 238, 244];
87 const INKC = rgb(28, 27, 25), DIGITS = ['', '.X.|XX.|.X.|.X.|XXX', 'XX.|..X|.X.|X..|XXX', 'XX.|..X|.X.|..X|XX.', 'X.X|X.X|XXX|..X|..X'].map(s => s.split('|'));
88
89 /* ---------- the pattern of a layer: pixels, so it reads the same at every height and without colour ---------- */
90 function marked(kind, ix, iy) {
91 if (kind === 'sponge') return hash(ix >> 1, iy >> 1) > .8; // crumb dots
92 if (kind === 'jam') return (ix + iy) % 6 < 2; // diagonal jam stripe
93 if (kind === 'cream') return ((iy + Math.round(1.6 * Math.sin(ix * .6))) & 3) === 0; // cream swirl
94 return ((ix >> 1) + (iy >> 1) & 1) === 1; // icing checks
95 }
96 const shade = (kind, ix, iy, base, grain) => {
97 const r = marked(kind, ix, iy) ? MARKRAMP[kind] : RAMP[kind];
98 return r[clamp(Math.floor(base + (grain - .5) * .7 + (bayerAt(ix, iy) - .5) * .5), 0, 4)];
99 };
100
101 /* ---------- state ---------- */
102 const nodes = {};
103 for (const k of ['round', 'score', 'lives', 'status']) nodes[k] = document.getElementById(k);
104 const shown = {};
105 function setText(key, text) {
106 if (shown[key] === text) return;
107 shown[key] = text;
108 nodes[key].textContent = text;
109 }
110 const fresh = () => M.start(Math.floor(Math.random() * 4294967296));
111 let P = fresh();
112 const G = { time: 0, nextAt: LEAD_S, overAt: 0, fringe: -1, sel: 0, dirty: true };
113 function renderHud() {
114 setText('round', String(P.round));
115 setText('score', String(P.score));
116 setText('lives', String(P.lives));
117 }
118 function say(text) { setText('status', text); }
119
120 /* ---------- the stall ---------- */
121 function bake() {
122 pix.fill(PAPER); zb.fill(-1e9); ground.fill(0); shadowMask.fill(0);
123 for (let iy = 0; iy < H; iy++) for (let ix = 0; ix < W; ix++) {
124 const g = unproject(ix + .5, iy + .5), i = iy * W + ix;
125 if (Math.abs(g.x) > CLOTH || Math.abs(g.y) > CLOTH) continue;
126 const c = CLOTH_C[(Math.floor(g.x / 1.5) + Math.floor(g.y / 1.5)) & 1], t = (bayerAt(ix, iy) - .5) * .05;
127 pix[i] = rgb(c[0] * (1 + t), c[1] * (1 + t), c[2] * (1 + t));
128 ground[i] = 1;
129 zb[i] = (g.x + g.y) / R2 * COS;
130 }
131 groundZ.set(zb);
132 for (const [cx, cy] of [ORDER_AT, YOURS_AT]) box(cx - PLATE, cy - PLATE, 0, cx + PLATE, cy + PLATE, .14, CHINA, 3);
133 bakedPix.set(pix); bakedZ.set(zb);
134 }
135
136 /* ---------- cake ---------- */
137 function layer(cx, cy, z0, z1, kind, top) {
138 const paint = (x, y, z, base, grain) => {
139 const p = project(x, y, z), ix = p.sx | 0, iy = p.sy | 0;
140 if (ix < 0 || iy < 0 || ix >= W || iy >= H) return;
141 const i = iy * W + ix;
142 if (p.d <= zb[i]) return;
143 zb[i] = p.d;
144 pix[i] = shade(kind, ix, iy, base, grain);
145 };
146 const step = .06, x0 = cx - HALF, x1 = cx + HALF, y0 = cy - HALF, y1 = cy + HALF;
147 if (top) for (let a = x0; a <= x1; a += step) for (let b = y0; b <= y1; b += step) paint(a, b, z1, 3.4, noise(a * 3, b * 3));
148 for (let a = x0; a <= x1; a += step) for (let b = z0; b <= z1; b += step) paint(a, y1, b, 2.3, noise(a * 3, b * 3));
149 for (let a = y0; a <= y1; a += step) for (let b = z0; b <= z1; b += step) paint(x1, a, b, 1.2, noise(a * 3, b * 3));
150 }
151 function cake(at, layers) {
152 const [cx, cy] = at;
153 layers.forEach((kind, j) => {
154 const z0 = .14 + j * LAYER_H;
155 layer(cx, cy, z0, z0 + LAYER_H, kind, j === layers.length - 1);
156 castShadow(cx, cy, z0 + LAYER_H / 2, HALF * 1.05, 1, .3);
157 });
158 return .14 + layers.length * LAYER_H;
159 }
160 // the cloche over the order: a silver bell tall enough for the whole stack
161 function cloche(at, height) {
162 const [cx, cy] = at, top = height + .5;
163 capsule([cx, cy, .2 + BELL_R], [cx, cy, Math.max(.2 + BELL_R, top - BELL_R)], BELL_R, BELL_R, () => SILVER, 'gloss');
164 sphere(cx, cy, top + BELL_R * .5 + .25, .3, KNOB, 'gloss');
165 for (let z = .5; z < top; z += LAYER_H) castShadow(cx, cy, z, BELL_R, 1, .3);
166 }
167
168 /* ---------- 2D parts: awning, pips, buttons ---------- */
169 const dot = (x, y, c) => { x |= 0; y |= 0; if (x >= 0 && y >= 0 && x < W && y < H) pix[y * W + x] = c; };
170 const fillRect = (x, y, w, h, c) => { for (let j = 0; j < h; j++) for (let i = 0; i < w; i++) dot(x + i, y + j, c); };
171 function awning(beat) {
172 const RED = rgb(199, 91, 140), WHITE = rgb(250, 246, 240);
173 for (let s = 0; s * 12 < W; s++) {
174 const c = s & 1 ? WHITE : RED, off = beat && (s + beat & 1) ? 1 : 0;
175 for (let x = 0; x < 12; x++) {
176 const d = Math.sqrt(Math.max(0, 1 - ((x - 5.5) / 6) ** 2));
177 const bottom = 12 + Math.round(6 * d) + off;
178 for (let y = 0; y <= bottom; y++) dot(s * 12 + x, y, c);
179 dot(s * 12 + x, bottom + 1, INKC);
180 }
181 }
182 }
183 function pips() {
184 const n = P.order.length;
185 for (let i = 0; i < n; i++) {
186 for (const [at, filled] of [[ORDER_AT, false], [YOURS_AT, i < P.placed.length]]) {
187 const p = project(at[0], at[1], 0), x = Math.round(p.sx - n * 4 + i * 8), y = Math.round(p.sy + 28);
188 fillRect(x, y, 6, 6, INKC);
189 fillRect(x + 1, y + 1, 4, 4, filled ? SIGNAL : PAPER);
190 }
191 }
192 if (P.status === 'playing' && P.shown > 0) { // the showing time running out under the order
193 const p = project(ORDER_AT[0], ORDER_AT[1], 0), w = 60, x = Math.round(p.sx - w / 2), y = Math.round(p.sy + 38);
194 fillRect(x - 1, y - 1, w + 2, 6, INKC);
195 fillRect(x, y, w, 4, PAPER);
196 fillRect(x, y, Math.round(w * P.shown / P.showTicks), 4, SIGNAL);
197 }
198 }
199 const buttonX = i => Math.round((W - (M.LAYERS.length * BTN.w + (M.LAYERS.length - 1) * BTN.gap)) / 2) + i * (BTN.w + BTN.gap);
200 function buttons() {
201 const live = P.status === 'playing' && P.shown === 0;
202 M.LAYERS.forEach((kind, i) => {
203 const x = buttonX(i), y = BTN.y;
204 fillRect(x, y, BTN.w, BTN.h, INKC);
205 for (let j = 2; j < BTN.h - 2; j++) for (let k = 2; k < BTN.w - 2; k++) {
206 const c = shade(kind, x + k, y + j, 3, .5);
207 dot(x + k, y + j, live ? c : rgb((c & 255) * .5 + 128, (c >> 8 & 255) * .5 + 128, (c >> 16 & 255) * .5 + 128));
208 }
209 if (G.sel === i) { // the layer Space or Enter will place
210 for (let k = 0; k < BTN.w; k++) { fillRect(x + k, y - 3, 1, 2, SIGNAL); fillRect(x + k, y + BTN.h + 1, 1, 2, SIGNAL); }
211 fillRect(x, y - 3, 2, BTN.h + 6, SIGNAL); fillRect(x + BTN.w - 2, y - 3, 2, BTN.h + 6, SIGNAL);
212 }
213 fillRect(x + 4, y + 4, 14, 20, PAPER); // the key to press, on a chip
214 DIGITS[i + 1].forEach((row, j) => [...row].forEach((ch, k) => { if (ch === 'X') fillRect(x + 6 + k * 2, y + 6 + j * 3, 2, 3, INKC); }));
215 });
216 }
217 function draw() {
218 pix.set(bakedPix);
219 zb.set(bakedZ);
220 const orderTop = cake(ORDER_AT, P.order);
221 cake(YOURS_AT, P.placed);
222 if (P.status === 'playing' && P.shown === 0) cloche(ORDER_AT, orderTop);
223 applyShadows(bakedZ);
224 shadeEdges();
225 awning(STILL ? 0 : G.fringe + 1);
226 pips();
227 buttons();
228 ctx.putImageData(image, 0, 0);
229 }
230
231 /* ---------- game: every rule is the rules script's ---------- */
232 const WHY = {
233 'order-shown': 'Wait: the order is still on show. Memorise it first.',
234 'game-over': 'The stall is closed. Press R to play again.',
235 'unknown-layer': 'There is no such layer.',
236 'order-hidden': 'The order is already hidden.',
237 };
238 // the showing time counts down only while the order is on show; a fresh showing waits a moment first
239 const arm = () => { if (P.shown > 0) G.nextAt = G.time + LEAD_S; };
240 function tick() {
241 const r = M.tick(P);
242 if (!r.ok) return;
243 P = r.state; G.dirty = true;
244 if (P.shown === 0) { say(`The order is hidden under the cloche. ${P.line}. Tap a layer or press 1 to 4.`); sound(440, .12, 'triangle'); }
245 }
246 function finish() {
247 G.overAt = G.time;
248 say(`${P.line}. Tap or press R to play again.`);
249 sound(P.status === 'won' ? 880 : 110, .4, P.status === 'won' ? 'triangle' : 'sawtooth');
250 }
251 function put(kind) {
252 ensureAudio();
253 const before = P, r = M.place(P, kind);
254 if (!r.ok) { say(WHY[r.why] || `Cannot place ${kind}: ${r.why}.`); sound(130, .1, 'sawtooth'); return; }
255 P = r.state; G.dirty = true; arm();
256 let msg;
257 if (P.lives < before.lives) { msg = `Wrong layer: not ${kind}. You lose a life.${P.status === 'playing' ? ' The order is shown again.' : ''}`; sound(140, .25, 'sawtooth'); }
258 else if (P.status === 'won' || P.round > before.round) { msg = `Order served: ${P.score - before.score} points.`; sound(760, .25, 'triangle'); }
259 else { msg = `You place ${kind}.`; sound(420 + P.placed.length * 40, .08, 'square'); }
260 say(`${msg} ${P.line}.`);
261 if (P.status !== 'playing') finish();
262 }
263 function update(dt) {
264 G.time += dt;
265 if (P.status === 'playing' && P.shown > 0 && G.time >= G.nextAt) { G.nextAt += TICK_S; tick(); }
266 const fringe = STILL ? 0 : Math.floor(G.time * 4);
267 if (fringe !== G.fringe) { G.fringe = fringe; G.dirty = true; }
268 renderHud();
269 }
270 function restart() {
271 P = fresh();
272 G.nextAt = G.time + LEAD_S; G.dirty = true;
273 say(`${P.line}. Look at the order, then stack it from memory.`);
274 }
275
276 /* ---------- input: tap a layer button, or press its number ---------- */
277 function buttonAt(px, py) {
278 if (py < BTN.y || py > BTN.y + BTN.h) return -1;
279 for (let i = 0; i < M.LAYERS.length; i++) if (px >= buttonX(i) && px <= buttonX(i) + BTN.w) return i;
280 return -1;
281 }
282 function choose(dir) { // arrows move the marked button; Space or Enter places it
283 const n = M.LAYERS.length;
284 G.sel = (G.sel + dir + n) % n; G.dirty = true;
285 say(`Layer ${G.sel + 1} of ${n} marked: ${M.LAYERS[G.sel]}, ${KINDS[M.LAYERS[G.sel]].word}. Press Space to place it.`);
286 }
287 canvas.addEventListener('pointerdown', e => {
288 canvas.focus({ preventScroll: true });
289 ensureAudio();
290 if (P.status !== 'playing') { if (G.time - G.overAt > .6) restart(); return; }
291 const r = canvas.getBoundingClientRect();
292 const i = buttonAt((e.clientX - r.left) / r.width * W, (e.clientY - r.top) / r.height * H);
293 if (i < 0) { say('Tap one of the layer buttons below to stack it.'); return; }
294 put(M.LAYERS[i]);
295 });
296 canvas.addEventListener('contextmenu', e => e.preventDefault());
297 canvas.addEventListener('keydown', e => {
298 if (e.ctrlKey || e.metaKey || e.altKey) return;
299 const k = e.key;
300 if (k === 'r' || k === 'R') { if (!e.repeat) restart(); }
301 else if (k === 'ArrowLeft' || k === 'ArrowUp' || k === 'a' || k === 'A') { if (!e.repeat) choose(-1); }
302 else if (k === 'ArrowRight' || k === 'ArrowDown' || k === 'd' || k === 'D') { if (!e.repeat) choose(1); }
303 else if (k === ' ' || k === 'Enter') { if (!e.repeat) put(M.LAYERS[G.sel]); }
304 else if (k >= '1' && k <= String(M.LAYERS.length)) { if (!e.repeat) put(M.LAYERS[Number(k) - 1]); }
305 else return;
306 e.preventDefault();
307 });
308
309 /* ---------- sound ---------- */
310 let audio = null, muted = false;
311 function ensureAudio() {
312 if (audio) { if (audio.state === 'suspended') audio.resume(); return; }
313 const AC = window.AudioContext || window.webkitAudioContext;
314 if (AC) audio = new AC();
315 }
316 function sound(freq, life, type) {
317 if (!audio || muted) return;
318 const now = audio.currentTime, gain = audio.createGain(), osc = audio.createOscillator();
319 osc.type = type;
320 osc.frequency.setValueAtTime(freq, now);
321 gain.gain.setValueAtTime(.0001, now);
322 gain.gain.exponentialRampToValueAtTime(.06, now + .01);
323 gain.gain.exponentialRampToValueAtTime(.0005, now + life);
324 osc.connect(gain).connect(audio.destination);
325 osc.start(now); osc.stop(now + life + .02);
326 }
327 const muteButton = document.getElementById('mute');
328 muteButton.addEventListener('click', () => {
329 muted = !muted;
330 muteButton.textContent = muted ? 'sound off' : 'sound on';
331 muteButton.setAttribute('aria-pressed', String(muted));
332 });
333
334 /* ---------- fit and boot ---------- */
335 addEventListener('resize', fit);
336 fit();
337 bake();
338 renderHud();
339 say(`${P.line}. Look at the order, then stack it from memory with the buttons or keys 1 to 4.`);
340 canvas.focus({ preventScroll: true });
341 let last = performance.now();
342 function frame(now) {
343 update(Math.min(.04, (now - last) / 1000));
344 last = now;
345 if (G.dirty) { G.dirty = false; draw(); }
346 requestAnimationFrame(frame);
347 }
348 requestAnimationFrame(frame);
349 </script>
350 </body>
351 </html>
cake-stall/page.test.mjs added +114
@@ -0,0 +1,114 @@
1 import { test, expect } from 'bun:test';
2 import { boot } from '../playtest.mjs';
3 import M from './order.js';
4
5 const state = g => JSON.parse(g.read('JSON.stringify(P)'));
6 const KEY = layer => String(M.LAYERS.indexOf(layer) + 1);
7 const hide = g => { while (state(g).shown > 0) g.run(.3); };
8
9 test('the order counts down by itself, then hides; the HUD reads the rules state', () => {
10 const g = boot('cake-stall');
11 const s0 = state(g);
12 expect(s0.shown).toBe(s0.showTicks);
13 expect(g.el('round').textContent).toBe('1');
14 expect(g.el('lives').textContent).toBe('3');
15 expect(g.el('score').textContent).toBe('0');
16 hide(g);
17 expect(state(g).shown).toBe(0);
18 expect(g.el('status').textContent).toContain('hidden under the cloche');
19 });
20
21 test('a layer placed while the order is shown is refused with its reason, and the state is left alone', () => {
22 const g = boot('cake-stall');
23 g.press('1');
24 expect(state(g).placed).toEqual([]);
25 expect(g.el('status').textContent).toContain('still on show');
26 });
27
28 test('number keys and taps on the buttons stack layers, announced; a finished order scores and the next is longer', () => {
29 const g = boot('cake-stall');
30 hide(g);
31 const order = state(g).order;
32 g.press(KEY(order[0]));
33 expect(state(g).placed).toEqual([order[0]]);
34 expect(g.el('status').textContent).toContain(`You place ${order[0]}`);
35 const x = g.read('buttonX(' + M.LAYERS.indexOf(order[1]) + ') + 10');
36 g.tap(x, 250);
37 expect(state(g).placed.length).toBe(2);
38 g.press(KEY(order[2]));
39 const s = state(g);
40 expect(s.round).toBe(2);
41 expect(s.score).toBe(30);
42 expect(s.order.length).toBe(4);
43 expect(g.el('score').textContent).toBe('30');
44 expect(g.el('round').textContent).toBe('2');
45 expect(g.el('status').textContent).toContain('Order served: 30 points');
46 });
47
48 test('a wrong layer costs a life, is announced, and shows the order again', () => {
49 const g = boot('cake-stall');
50 hide(g);
51 const wrong = M.LAYERS.find(l => l !== state(g).order[0]);
52 g.press(KEY(wrong));
53 const s = state(g);
54 expect(s.lives).toBe(2);
55 expect(s.shown).toBe(s.showTicks);
56 expect(g.el('lives').textContent).toBe('2');
57 expect(g.el('status').textContent).toContain('Wrong layer');
58 });
59
60 test('the last life ends the game with its line, a tap plays again, and R starts a fresh seed', () => {
61 const g = boot('cake-stall');
62 for (let i = 0; i < 3; i++) {
63 hide(g);
64 g.press(KEY(M.LAYERS.find(l => l !== state(g).order[0])));
65 }
66 expect(state(g).status).toBe('lost');
67 expect(g.el('status').textContent).toContain('Out of lives');
68 g.press('1');
69 expect(g.el('status').textContent).toContain('closed');
70 g.run(1);
71 g.tap(100, 100);
72 expect(state(g).status).toBe('playing');
73 expect(state(g).lives).toBe(3);
74 const seed = state(g).seed;
75 g.press('r');
76 expect(state(g).seed).not.toBe(seed);
77 });
78
79 test('the cloche hides the order, and each layer kind is drawn differently', () => {
80 const g = boot('cake-stall');
81 const shownPic = g.picture();
82 hide(g); g.run(.1);
83 expect(g.picture()).not.toBe(shownPic);
84 const picture = [];
85 for (const k of M.LAYERS) {
86 g.read(`P = { ...P, order: ['${k}', '${k}', '${k}'], placed: [], shown: 3, status: 'playing' }; G.dirty = true`);
87 g.run(.1); picture.push(g.picture());
88 }
89 expect(new Set(picture).size).toBe(M.LAYERS.length);
90 });
91
92 test('the awning fringe flutters, and reduced motion holds it still', () => {
93 const rest = reduced => {
94 const g = boot('cake-stall', { reduced });
95 hide(g); g.run(.5);
96 const seen = new Set();
97 for (let i = 0; i < 12; i++) { g.run(.13); seen.add(g.picture()); }
98 return seen.size;
99 };
100 expect(rest(true)).toBe(1);
101 expect(rest(false)).toBeGreaterThan(1);
102 });
103
104 test('the arrows mark a button and Space or Enter places the marked layer', () => {
105 const g = boot('cake-stall');
106 hide(g);
107 g.press('ArrowRight');
108 expect(g.read('G.sel')).toBe(1);
109 expect(g.el('status').textContent).toContain('jam');
110 const want = state(g).order[0];
111 while (M.LAYERS[g.read('G.sel')] !== want) g.press('ArrowRight');
112 g.press(' ');
113 expect(state(g).placed).toEqual([want]);
114 });
index.html +1
@@ -141,6 +141,7 @@ footer a::after { content: none; }
141141 <li data-kind="Arcade" data-input="Keys" style="--accent:#d9731f"><a href="carrot-pull/">Carrot Pull</a><span>Pull each carrot while it is ripe, before the rabbit nibbles it</span><span>Game forged by the crew</span><span class="tags">Arcade · Keys</span></li>
142142 <li data-kind="Arcade" data-input="Keys" style="--accent:#c0392b"><a href="bumper-cars/">Bumper Cars</a><span>Steer your car round the fairground floor and shove the rivals into the wall without a head-on bump</span><span>Game forged by the crew</span><span class="tags">Arcade · Keys</span></li>
143143 <li data-kind="Timing" data-input="Tap" style="--accent:#c0392b"><a href="apple-bobbing/">Apple Bobbing</a><span>Dunk for each bobbing apple at the top of its bob before your breaths run out</span><span>Game forged by the crew</span><span class="tags">Timing · Tap</span></li>
144 <li data-kind="Memory" data-input="Tap" style="--accent:#c75b8c"><a href="cake-stall/">Cake Stall</a><span>Memorise the customer's stack of cake layers, then stack them back in the same order</span><span>Game forged by the crew</span><span class="tags">Memory · Tap</span></li>
144145 <li data-kind="Puzzle" data-input="Keys" style="--accent:#2a6f97"><a href="tide-crossing/">Tide Crossing</a><span>Step across the stones to the island before the tide covers them</span><span>Game forged by the crew</span><span class="tags">Puzzle · Keys</span></li>
145146 </ul>
146147 <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>
og/cake-stall.png added
Binary file not shown.
og/home.png
Binary file not shown.
rounds.mjs +7
@@ -16,6 +16,7 @@ import Field from './fence-posts/field.js';
1616 import Carrot from './carrot-pull/bed.js';
1717 import Cars from './bumper-cars/floor.js';
1818 import Bobbing from './apple-bobbing/tub.js';
19 import Cake from './cake-stall/order.js';
1920 import Tide from './tide-crossing/causeway.js';
2021
2122 const MID = [180, 140];
@@ -542,6 +543,12 @@ export const ROUNDS = {
542543 if (s.aimed !== pick) { g.press(String(pick + 1), .02); return; }
543544 if (soonest === 0) g.press(' ', .02); else g.run(.04);
544545 } },
546 // wait out the showing, then stack the order from the rules' own state, one layer a beat; eight orders win
547 'cake-stall': { over: /tap or press R/i, outcome: /You win: all 8 orders served, score [1-9]\d*/, limit: 60, beat: g => {
548 const s = JSON.parse(g.read('JSON.stringify(P)'));
549 if (s.status !== 'playing' || s.shown > 0) { g.run(.2); return; }
550 g.press(String(Cake.LAYERS.indexOf(s.order[s.placed.length]) + 1), .02);
551 } },
545552 // wait for the tick whose marker is nearest the rival's own stated target for this score, then throw
546553 'village-darts': { over: /play again/, outcome: /(You win|The rival wins) the leg in \d+ darts?, finishing on/, limit: 140, beat: g => {
547554 if (g.read('P.status') !== 'playing' || g.read('P.turn') !== 'you' || g.read('!!G.flight')) { g.run(.2); return; }
serve.test.mjs +7
@@ -783,6 +783,13 @@ test('Apple Bobbing and its rules script are served without a track', async () =
783783 expect((await fetch(base + '/apple-bobbing/tub.js')).status).toBe(200);
784784 });
785785
786 test('Cake Stall and its rules script are served without a track', async () => {
787 const page = await fetch(base + '/cake-stall/');
788 expect(page.status).toBe(200);
789 expect(page.headers.get('content-type')).toBe('text/html; charset=utf-8');
790 expect((await fetch(base + '/cake-stall/order.js')).status).toBe(200);
791 });
792
786793 test('Pumpkin Patch and its rules script are served without a track', async () => {
787794 const page = await fetch(base + '/pumpkin-patch/');
788795 expect(page.status).toBe(200);
shell.js +1
@@ -77,6 +77,7 @@ window.PixelPlays = (() => {
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 },
7878 { id: 'bumper-cars', title: 'Bumper Cars', blurb: 'Steer your car round the fairground floor and shove the rivals into the wall without a head-on bump', kind: 'Arcade', input: 'Keys', accent: '#c0392b', music: false, forged: true },
7979 { id: 'apple-bobbing', title: 'Apple Bobbing', blurb: 'Dunk for each bobbing apple at the top of its bob before your breaths run out', kind: 'Timing', input: 'Tap', accent: '#c0392b', music: false, forged: true },
80 { id: 'cake-stall', title: 'Cake Stall', blurb: 'Memorise the customer\'s stack of cake layers, then stack them back in the same order', kind: 'Memory', input: 'Tap', accent: '#c75b8c', music: false, forged: true },
8081 { 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 },
8182 { id: 'village-darts', title: 'Village Darts', blurb: 'Count down from 301 and finish on a double before the village rival does', kind: 'Sport', input: 'Tap', accent: '#b5412f', music: false, forged: true },
8283 { id: 'carrot-pull', title: 'Carrot Pull', blurb: 'Pull each carrot while it is ripe, before the rabbit nibbles it', kind: 'Arcade', input: 'Keys', accent: '#d9731f', music: false, forged: true },

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs