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
Check · Review · Land · Live done — Landed and live in production.
vesper/pixel-plays:vesper/361-add-cake-stall-the-playable-page-and-its → main 1 commit fork
9 files
changed +484
−2
README.md +3 −2
Diff not shown — a line 4,084 characters wide (limit 2,000). +3 −2
View filecake-stall/index.html added +351
| @@ -0,0 +1,351 @@ | ||
| 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>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 { : #1c1b19; : #6b675f; : #fff; : #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(); color: var(); } | |
| 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(); 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(); } | |
| 37 | #status { color: var(); min-height: 2.7em; max-width: 60ch; } | |
| 38 | button { pointer-events: auto; font: inherit; color: var(); 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(); } | |
| 40 | button:focus-visible { outline: 2px solid var(); outline-offset: 2px; } | |
| 41 | noscript p { position: fixed; inset: 40% 16px auto; margin: 0; text-align: center; color: var(); } | |
| 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; } | ||
| 141 | 141 | <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> |
| 142 | 142 | <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> |
| 143 | 143 | <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> | |
| 144 | 145 | <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> |
| 145 | 146 | </ul> |
| 146 | 147 | <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'; | ||
| 16 | 16 | import Carrot from './carrot-pull/bed.js'; |
| 17 | 17 | import Cars from './bumper-cars/floor.js'; |
| 18 | 18 | import Bobbing from './apple-bobbing/tub.js'; |
| 19 | import Cake from './cake-stall/order.js'; | |
| 19 | 20 | import Tide from './tide-crossing/causeway.js'; |
| 20 | 21 | |
| 21 | 22 | const MID = [180, 140]; |
| @@ -542,6 +543,12 @@ export const ROUNDS = { | ||
| 542 | 543 | if (s.aimed !== pick) { g.press(String(pick + 1), .02); return; } |
| 543 | 544 | if (soonest === 0) g.press(' ', .02); else g.run(.04); |
| 544 | 545 | } }, |
| 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 | } }, | |
| 545 | 552 | // wait for the tick whose marker is nearest the rival's own stated target for this score, then throw |
| 546 | 553 | 'village-darts': { over: /play again/, outcome: /(You win|The rival wins) the leg in \d+ darts?, finishing on/, limit: 140, beat: g => { |
| 547 | 554 | 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 () = | ||
| 783 | 783 | expect((await fetch(base + '/apple-bobbing/tub.js')).status).toBe(200); |
| 784 | 784 | }); |
| 785 | 785 | |
| 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 | ||
| 786 | 793 | test('Pumpkin Patch and its rules script are served without a track', async () => { |
| 787 | 794 | const page = await fetch(base + '/pumpkin-patch/'); |
| 788 | 795 | expect(page.status).toBe(200); |
shell.js +1
| @@ -77,6 +77,7 @@ window.PixelPlays = (() => { | ||
| 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 | 78 | { 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 }, |
| 79 | 79 | { 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 }, | |
| 80 | 81 | { 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 }, |
| 81 | 82 | { 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 }, |
| 82 | 83 | { 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 }, |
new above
offline