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

Migrated from github.com/amirmushichge/pixel-plays

Add Cheese Rolling: the playable page and its place in the catalogue, on the rules in `cheese-rolling/hill.js` #377

opened this

Check · Review · Land · Live — Its check is red. Next: builder, its check is red.

first in the queue: no estimate: nothing landed in the last hour

Ready to land

quill/pixel-plays:quill/368-add-cheese-rolling-the-playable-page-and → main 2 commits fork 1 thread · 1 unresolved

  • Ready to merge — no conflicts with main. 2 commits will land on main.

    1 approval

    Check faileddeploy b02b6c8

    Website spec: 10 findings, 2 the edge owns (report only)
    • / theme-color needs a light and a dark variant (media=prefers-color-scheme) — theme-color
    • / no color-scheme meta — color-scheme
    • / the first focusable element is not a skip link (an in-page #target link) — skip-link
    • / external script shell.js is not defer, async or module — scripts
    • / external script ramp.js is not defer, async or module — scripts
    • / external script town.js is not defer, async or module — scripts
    • / external script plaza.js is not defer, async or module — scripts
    • / a 29771-byte body was sent without br or gzip though Accept-Encoding: br, gzip — https://specification.website/ (the edge owns this)
    • / no Content-Security-Policy — csp
    • / no Permissions-Policy — permissions-policy
    • / no Cross-Origin-Opener-Policy — coop (the edge owns this)
    • / no ETag on the HTML — caching
    You need write access to merge.

Review

Sign in to review this change.
10 files changed +489 −4
README.md +3 −2

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

View file
cheese-rolling/index.html added +362
@@ -0,0 +1,362 @@
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>Cheese Rolling</title>
7 <!-- og:begin -->
8 <meta name="description" content="Chase the cheese down a steep hill, dodge the tussocks and catch it before the bottom">
9 <link rel="canonical" href="https://pixel-plays--forge-trevato-apps.open-platform.sh/cheese-rolling/">
10 <meta property="og:type" content="website">
11 <meta property="og:site_name" content="Pixel Plays">
12 <meta property="og:title" content="Cheese Rolling">
13 <meta property="og:description" content="Chase the cheese down a steep hill, dodge the tussocks and catch it before the bottom">
14 <meta property="og:url" content="https://pixel-plays--forge-trevato-apps.open-platform.sh/cheese-rolling/">
15 <meta property="og:image" content="https://pixel-plays--forge-trevato-apps.open-platform.sh/og/cheese-rolling.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="Cheese Rolling: 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: #d9a21b; }
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="Cheese Rolling: a steep hill of grass lanes with tussocks, and a round cheese bouncing down it one row every beat. Press the left and right arrow keys to change lane and the down arrow to run down a row; do nothing and you stay put. On touch, tap beside the runner to move that way, or tap the runner to stay. Stand on the cheese's square as it rolls by before it reaches the bottom; a tussock trips you for a few beats. R starts a fresh hill."></canvas></div>
46 <noscript><p>Cheese Rolling needs JavaScript to play.</p></noscript>
47 <div id="scores">
48 <div class="row"><b id="level">1</b><span>level</span><b id="tick">0</b><span>tick</span></div>
49 <div class="row"><b id="trips">0</b><span>trips</span><b id="rows">0</b><span>rows left</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: 'cheese-rolling',
59 rules: [
60 'The cheese bounces down the hill one row a beat. Left, right and down arrows (or a tap beside the runner) pick your move; doing nothing stays put.',
61 'Stand on the cheese\'s square as it rolls by, before it passes the bottom. The dots show its line.',
62 'A tussock trips you: you miss the next few beats. R starts a fresh hill, N goes up a level after a win.',
63 ],
64 });
65 </script>
66 <script>window.PP_SCENE = { originY: 4, shadowX: 1.3, shadowY: -.12 };</script>
67 <script src="../engine.js"></script>
68 <script src="hill.js"></script>
69 <script>
70 'use strict';
71
72 const M = window.CheeseRolling;
73 const TICK_S = .7; // the hill's beat: seconds per tick
74 const LEAD_S = 2; // a moment to look before the first tick
75 const GLIDE_S = .25; // the cheese and runner slide to their new square this long
76 const WIN = 14, PITCH = 1.3, SLOPE = .3, PICK_PX = 24;
77 const ACTION_KEYS = { ArrowLeft: 'left', ArrowRight: 'right', ArrowDown: 'down', a: 'left', A: 'left', d: 'right', D: 'right', s: 'down', S: 'down', ' ': 'stay' };
78 const GRASS_C = [[116, 160, 80], [104, 148, 72]], EDGE_C = [78, 118, 56], TOP_C = [226, 214, 170], FINISH_C = [[236, 236, 230], [196, 60, 50]];
79 const TUFT = [62, 104, 54], BLADE = [112, 162, 70], CHEESE = [238, 190, 58], HOLE = [176, 124, 30], TRAIL = [250, 236, 160];
80 const COAT = [58, 98, 172], SKIN = [238, 200, 160], STAR = [255, 226, 90];
81
82 /* ---------- state ---------- */
83 const nodes = {};
84 for (const k of ['level', 'tick', 'trips', 'rows', 'status']) nodes[k] = document.getElementById(k);
85 const shown = {};
86 function setText(key, text) {
87 if (shown[key] === text) return;
88 shown[key] = text;
89 nodes[key].textContent = text;
90 }
91 function say(text) { shown.status = null; setText('status', text); }
92 const seedNow = () => Math.floor(Math.random() * 4294967296);
93 let level = 0;
94 let P = M.start(seedNow(), level);
95 // time: the page clock, tickAt: when the last tick ran, nextAt: when the next one does, from: where the sprites slid from
96 const G = { time: 0, tickAt: -9, nextAt: LEAD_S, overAt: 0, pending: 'stay', from: null, sway: -1, dirty: true };
97 G.from = { runner: P.runner, cheese: P.cheeseStart };
98
99 function renderHud() {
100 setText('level', String(P.level + 1));
101 setText('tick', String(P.tick));
102 setText('trips', String(P.trips));
103 setText('rows', String(P.rowsLeft));
104 }
105
106 /* ---------- geometry ---------- */
107 const windowStart = () => {
108 const focus = P.tick > P.runner.row ? P.runner.row : Math.floor((P.runner.row + P.tick) / 2);
109 return clamp(focus - (WIN >> 1), 0, Math.max(0, P.rows - WIN));
110 };
111 let LO = 0;
112 const wx = c => (c - (P.lanes - 1) / 2) * PITCH;
113 const wy = r => (r - LO - WIN / 2) * PITCH;
114 const zAt = y => -y * SLOPE;
115 const spot = (r, c) => { const y = wy(r); return [wx(c), y, zAt(y)]; };
116 function screenOf(r, c) { const s = spot(r, c), p = project(s[0], s[1], s[2]); return [p.sx, p.sy]; }
117
118 /* ---------- the hill ---------- */
119 const put = (x, y, c) => { x |= 0; y |= 0; if (x >= 0 && y >= 0 && x < W && y < H) pix[y * W + x] = c; };
120 function darken(x, y, k) {
121 x |= 0; y |= 0;
122 if (x < 0 || y < 0 || x >= W || y >= H) return;
123 const c = pix[y * W + x];
124 if (c === PAPER) return;
125 pix[y * W + x] = rgb((c & 255) * k, (c >> 8 & 255) * k, (c >> 16 & 255) * k);
126 }
127 function tile(r, c) {
128 const x0 = wx(c) - PITCH / 2, y0 = wy(r) - PITCH / 2, last = r === P.rows - 1;
129 const base = r === 0 ? TOP_C : last ? FINISH_C[c & 1] : GRASS_C[c & 1];
130 const edge = rgb(...(last || r === 0 ? [140, 132, 110] : EDGE_C));
131 const n = 13, st = PITCH / n;
132 for (let i = 0; i < n; i++) for (let j = 0; j < n; j++) {
133 const x = x0 + (i + .5) * st, y = y0 + (j + .5) * st, p = project(x, y, zAt(y));
134 const ix = p.sx | 0, iy = p.sy | 0;
135 if (ix < 0 || iy < 0 || ix >= W || iy >= H) continue;
136 const k = iy * W + ix;
137 if (p.d <= zb[k]) continue;
138 zb[k] = p.d;
139 if (i === 0 || j === 0) { pix[k] = edge; continue; }
140 const t = 1 + (bayerAt(ix, iy) - .5) * .1 + (noise(x * 1.6, y * 1.6) - .5) * .22;
141 pix[k] = rgb(base[0] * t, base[1] * t, base[2] * t);
142 }
143 }
144 // a hard shadow on the hill, thrown screen-right of what stands on it
145 function shadow(x, y, h, rx) {
146 const p = project(x + SHADOW_X * h, y + SHADOW_Y * h, zAt(y)), ry = rx * SIN + 1;
147 for (let iy = Math.floor(p.sy - ry); iy <= p.sy + ry; iy++) for (let ix = Math.floor(p.sx - rx); ix <= p.sx + rx; ix++) {
148 const dx = (ix + .5 - p.sx) / rx, dy = (iy + .5 - p.sy) / ry;
149 if (dx * dx + dy * dy <= 1) darken(ix, iy, .6);
150 }
151 }
152 const vis = r => r >= LO && r < LO + WIN;
153
154 /* ---------- things on the hill ---------- */
155 // a tussock is a dark dome with spiky blades: no other thing on the hill looks like it
156 function drawTussock(t) {
157 const [x, y, z] = spot(t.row, t.col);
158 shadow(x, y, 1, 7);
159 sphere(x, y, z + .3, .5, TUFT, 'rough');
160 const sway = STILL ? 0 : [-.07, 0, .07, 0][(G.sway + t.row + t.col) & 3];
161 for (const [dx, dy, h] of [[-.25, -.1, .95], [.05, .2, 1.15], [.3, -.15, .9], [-.05, -.3, 1]]) {
162 capsule([x + dx, y + dy, z + .35], [x + dx * 1.5 + sway, y + dy * 1.5, z + h], .09, .03, () => BLADE, 'solid');
163 }
164 }
165 function drawTrail() { // the cheese's line down the hill: small pale dots
166 for (let r = P.tick + 1; r < P.rows; r++) {
167 if (!vis(r)) continue;
168 const [x, y, z] = spot(r, P.path[r]);
169 sphere(x, y, z + .08, .13, TRAIL, 'solid');
170 }
171 }
172 function glide() { return STILL ? 1 : clamp((G.time - G.tickAt) / GLIDE_S, 0, 1); }
173 const mix = (a, b, k) => [lerp(a.row, b.row, k), lerp(a.col, b.col, k)];
174 const spotAt = (rc) => { const y = (rc[0] - LO - WIN / 2) * PITCH; return [wx(rc[1]), y, zAt(y)]; };
175 function drawCheese() {
176 const k = ease(glide()), now = { row: Math.min(P.tick, P.rows), col: P.path[Math.min(P.tick, P.rows - 1)] };
177 const rc = mix(G.from.cheese, now, k);
178 const [x, y, z0] = spotAt(rc);
179 const hop = STILL ? 0 : Math.sin(Math.PI * k) * .8 * (P.status === 'playing' ? 1 : 0);
180 const z = z0 + hop;
181 shadow(x, y, hop + .6, 8);
182 // a round wheel: wide and flat, a ring of rind with holes in the top face
183 for (let a = 0; a < 10; a++) {
184 const t = Math.PI * 2 * a / 10;
185 sphere(x + Math.cos(t) * .5, y + Math.sin(t) * .5, z + .32, .24, CHEESE, 'solid');
186 sphere(x + Math.cos(t) * .5, y + Math.sin(t) * .5, z + .6, .22, CHEESE, 'solid');
187 }
188 sphere(x, y, z + .5, .52, CHEESE, 'solid');
189 for (const [dx, dy] of [[-.2, .1], [.22, -.15], [.05, .3]]) sphere(x + dx, y + dy, z + .98, .08, HOLE, 'solid');
190 }
191 // the runner stands tall; tripped, they lie flat with stars circling, so a trip is read from the shape and not colour alone
192 function drawRunner() {
193 const k = ease(glide()), rc = mix(G.from.runner, P.runner, k), [x, y, z] = spotAt(rc);
194 if (P.tripped > 0) {
195 shadow(x, y, .5, 9);
196 capsule([x - .55, y, z + .35], [x + .35, y, z + .35], .34, .3, () => COAT, 'solid');
197 sphere(x + .75, y, z + .4, .3, SKIN, 'solid');
198 const beat = STILL ? 0 : Math.floor(G.time * 6);
199 for (let i = 0; i < 3; i++) {
200 const t = Math.PI * 2 * i / 3 + beat * .9;
201 sphere(x + .75 + Math.cos(t) * .45, y + Math.sin(t) * .45, z + 1.15, .12, STAR, 'solid');
202 }
203 return;
204 }
205 shadow(x, y, 1.9, 7);
206 capsule([x, y, z + .2], [x, y, z + 1.05], .36, .3, () => COAT, 'solid');
207 sphere(x, y, z + 1.6, .3, SKIN, 'solid');
208 capsule([x - .3, y, z + .95], [x - .55, y, z + .5], .1, .08, () => SKIN, 'solid');
209 capsule([x + .3, y, z + .95], [x + .55, y, z + .5], .1, .08, () => SKIN, 'solid');
210 }
211 function draw() {
212 LO = windowStart();
213 pix.fill(PAPER);
214 zb.fill(-1e9);
215 for (let r = LO; r < Math.min(P.rows, LO + WIN); r++) for (let c = 0; c < P.lanes; c++) tile(r, c);
216 drawTrail();
217 // far to near, so a nearer thing is never drawn under a farther one
218 P.tussocks.filter(t => vis(t.row)).forEach(drawTussock);
219 drawCheese();
220 drawRunner();
221 ctx.putImageData(image, 0, 0);
222 }
223
224 /* ---------- game: every rule is the rules script's ---------- */
225 const WHY = {
226 'off-hill': 'that is off the hill',
227 'unknown-action': 'that is not a move the runner knows',
228 'game-over': 'the run is over',
229 };
230 const WORDS = { left: 'You step left', right: 'You step right', down: 'You run down a row', stay: 'You wait' };
231 function tick() {
232 const wanted = G.pending;
233 G.pending = 'stay';
234 let r = M.step(P, wanted), note = '';
235 if (!r.ok) {
236 note = `Cannot move ${wanted}: ${WHY[r.why] ?? r.why}. `;
237 r = M.step(P, 'stay');
238 }
239 if (!r.ok) return;
240 const before = P;
241 G.from = { runner: before.runner, cheese: { row: Math.min(before.tick, before.rows), col: before.path[Math.min(before.tick, before.rows - 1)] } };
242 G.tickAt = G.time; G.dirty = true;
243 P = r.state;
244 const verb = before.tripped > 0 ? 'You are still tripped' : note ? 'You wait' : WORDS[wanted];
245 let line = `${note}${verb}. ${P.line}.`;
246 if (P.trips > before.trips) {
247 line = `${note}You trip on a tussock at row ${P.runner.row + 1} and miss the next ${P.tripped} ticks. ${P.line}.`;
248 sound(150, .25, 'sawtooth');
249 } else sound(P.status === 'playing' ? 300 + P.tick * 12 : 300, .05, 'square');
250 if (P.status === 'won') line += ' Tap or press N for the next level, or R to play it again.';
251 else if (P.status === 'lost') line += ' Tap or press R to try again.';
252 say(line);
253 if (P.status !== 'playing') { G.overAt = G.time; sound(P.status === 'won' ? 880 : 110, .4, P.status === 'won' ? 'triangle' : 'sawtooth'); }
254 }
255 function fresh(lv) {
256 level = lv;
257 P = M.start(seedNow(), level);
258 G.from = { runner: P.runner, cheese: P.cheeseStart };
259 G.pending = 'stay'; G.tickAt = -9; G.nextAt = G.time + LEAD_S; G.dirty = true;
260 }
261 function restart() {
262 fresh(level);
263 say(`Level ${P.level + 1}. ${P.line}. Arrow keys to move; doing nothing waits.`);
264 }
265 function nextLevel() {
266 if (P.status !== 'won') { say(`Catch the cheese first. ${P.line}.`); return; }
267 fresh(Math.min(M.MAX_LEVEL, level + 1));
268 say(`Level ${P.level + 1}. ${P.line}. Arrow keys to move; doing nothing waits.`);
269 }
270 function choose(action) {
271 ensureAudio();
272 if (P.status !== 'playing') { say(`${P.line}. ${P.status === 'won' ? 'Press N for the next level' : 'Press R to try again'}.`); return; }
273 G.pending = action;
274 say(`Next beat: ${action === 'stay' ? 'wait' : action}. ${P.line}.`);
275 }
276 function update(dt) {
277 G.time += dt;
278 if (P.status === 'playing' && G.time >= G.nextAt) { G.nextAt += TICK_S; tick(); }
279 if (G.time - G.tickAt < GLIDE_S + .05) G.dirty = true;
280 const sway = STILL ? 0 : Math.floor(G.time * 4) & 3;
281 if (sway !== G.sway) { G.sway = sway; G.dirty = true; }
282 if (!STILL && P.tripped > 0) G.dirty = true;
283 renderHud();
284 }
285
286 /* ---------- input: arrows pick the move for the next beat; a tap beside the runner does the same ---------- */
287 function moveAt(px, py) {
288 const { row, col } = P.runner;
289 let best = null, top = PICK_PX;
290 for (const [a, [dr, dc]] of Object.entries(M.ACTIONS)) {
291 const r = row + dr, c = col + dc;
292 if (r >= P.rows || c < 0 || c >= P.lanes) continue;
293 const [sx, sy] = screenOf(r, c), d = Math.hypot(px - sx, py - sy);
294 if (d < top) { top = d; best = a; }
295 }
296 return best;
297 }
298 canvas.addEventListener('pointerdown', e => {
299 canvas.focus({ preventScroll: true });
300 ensureAudio();
301 if (P.status !== 'playing') {
302 if (G.time - G.overAt > .6) { if (P.status === 'won') nextLevel(); else restart(); }
303 return;
304 }
305 const r = canvas.getBoundingClientRect();
306 const a = moveAt((e.clientX - r.left) / r.width * W, (e.clientY - r.top) / r.height * H);
307 if (!a) { say('Tap beside the runner to move that way, or the runner to wait.'); return; }
308 choose(a);
309 });
310 canvas.addEventListener('contextmenu', e => e.preventDefault());
311 canvas.addEventListener('keydown', e => {
312 if (e.ctrlKey || e.metaKey || e.altKey) return;
313 const k = e.key;
314 if (Object.hasOwn(ACTION_KEYS, k)) { if (!e.repeat) choose(ACTION_KEYS[k]); }
315 else if (k === 'r' || k === 'R') { if (!e.repeat) restart(); }
316 else if (k === 'n' || k === 'N') { if (!e.repeat) nextLevel(); }
317 else return;
318 e.preventDefault();
319 });
320
321 /* ---------- sound ---------- */
322 let audio = null, muted = false;
323 function ensureAudio() {
324 if (audio) { if (audio.state === 'suspended') audio.resume(); return; }
325 const AC = window.AudioContext || window.webkitAudioContext;
326 if (AC) audio = new AC();
327 }
328 function sound(freq, life, type) {
329 if (!audio || muted) return;
330 const now = audio.currentTime, gain = audio.createGain(), osc = audio.createOscillator();
331 osc.type = type;
332 osc.frequency.setValueAtTime(freq, now);
333 gain.gain.setValueAtTime(.0001, now);
334 gain.gain.exponentialRampToValueAtTime(.06, now + .01);
335 gain.gain.exponentialRampToValueAtTime(.0005, now + life);
336 osc.connect(gain).connect(audio.destination);
337 osc.start(now); osc.stop(now + life + .02);
338 }
339 const muteButton = document.getElementById('mute');
340 muteButton.addEventListener('click', () => {
341 muted = !muted;
342 muteButton.textContent = muted ? 'sound off' : 'sound on';
343 muteButton.setAttribute('aria-pressed', String(muted));
344 });
345
346 /* ---------- fit and boot ---------- */
347 addEventListener('resize', fit);
348 fit();
349 renderHud();
350 say(`${P.line}. The cheese rolls on every ${TICK_S} s: arrow keys or a tap beside the runner to move, otherwise you wait.`);
351 canvas.focus({ preventScroll: true });
352 let last = performance.now();
353 function frame(now) {
354 update(Math.min(.04, (now - last) / 1000));
355 last = now;
356 if (G.dirty) { G.dirty = false; draw(); }
357 requestAnimationFrame(frame);
358 }
359 requestAnimationFrame(frame);
360 </script>
361 </body>
362 </html>
cheese-rolling/page.test.mjs added +104
@@ -0,0 +1,104 @@
1 import { test, expect } from 'bun:test';
2 import { boot } from '../playtest.mjs';
3 import M from './hill.js';
4
5 const state = g => JSON.parse(g.read('JSON.stringify(P)'));
6 // a hill with no tussocks and a straight cheese lane 2, the runner at row 4, lane 0
7 const bare = g => g.read(`P = { ...P, tussocks: [], path: P.path.map(() => 2), runner: { row: 4, col: 0 }, runnerStart: { row: 4, col: 0 } }; G.from = { runner: P.runner, cheese: P.cheeseStart }`);
8
9 test('the hill beats on by itself and the HUD reads the rules state', () => {
10 const g = boot('cheese-rolling');
11 expect(g.el('level').textContent).toBe('1');
12 g.run(2 + .7 * 3);
13 const s = state(g);
14 expect(s.tick).toBeGreaterThanOrEqual(3);
15 expect(g.el('tick').textContent).toBe(String(s.tick));
16 expect(g.el('trips').textContent).toBe(String(s.trips));
17 expect(g.el('rows').textContent).toBe(String(s.rowsLeft));
18 });
19
20 test('arrow keys pick the move for the next beat, and doing nothing is stay', () => {
21 const g = boot('cheese-rolling');
22 bare(g);
23 g.press('ArrowRight');
24 expect(g.el('status').textContent).toContain('Next beat: right');
25 g.run(2);
26 expect(state(g).runner.col).toBe(1);
27 expect(g.el('status').textContent).toContain('You step right');
28 g.run(.8);
29 expect(state(g).runner.col).toBe(1); // nothing pressed: stayed
30 expect(g.el('status').textContent).toContain('You wait');
31 g.press('ArrowDown');
32 g.run(.8);
33 expect(state(g).runner.row).toBe(5);
34 });
35
36 test('a tap beside the runner picks that move, a tap on the runner waits', () => {
37 const g = boot('cheese-rolling');
38 bare(g);
39 g.read('G.dirty = true'); g.run(.1);
40 const [x, y] = g.read('screenOf(P.runner.row, P.runner.col + 1)');
41 g.tap(x, y);
42 expect(g.read('G.pending')).toBe('right');
43 const [rx, ry] = g.read('screenOf(P.runner.row, P.runner.col)');
44 g.tap(rx, ry);
45 expect(g.read('G.pending')).toBe('stay');
46 });
47
48 test('a refused move is announced with its reason and the beat still passes', () => {
49 const g = boot('cheese-rolling');
50 bare(g);
51 g.press('ArrowLeft');
52 g.run(2);
53 expect(state(g).tick).toBe(1);
54 expect(state(g).runner.col).toBe(0);
55 expect(g.el('status').textContent).toContain('Cannot move left: that is off the hill');
56 });
57
58 test('a tussock trips the runner, announced, shown on the runner and counted', () => {
59 const g = boot('cheese-rolling');
60 bare(g);
61 g.read('P = { ...P, tussocks: [{ row: 4, col: 1 }] }; G.dirty = true'); g.run(.1);
62 const plain = g.picture();
63 g.press('ArrowRight');
64 g.run(2);
65 expect(state(g).trips).toBe(1);
66 expect(g.el('trips').textContent).toBe('1');
67 expect(g.el('status').textContent).toContain('You trip on a tussock');
68 expect(g.picture()).not.toBe(plain);
69 g.press('ArrowRight');
70 g.run(.8);
71 expect(state(g).runner.col).toBe(1); // tripped: the move is lost
72 });
73
74 test('catching the cheese ends the run with the rules line; N goes up a level, R restarts', () => {
75 const g = boot('cheese-rolling');
76 for (let i = 0; i < 8 && state(g).status === 'playing'; i++) {
77 const a = M.solve(state(g))[0];
78 if (a !== 'stay') g.press({ left: 'ArrowLeft', right: 'ArrowRight', down: 'ArrowDown' }[a], .02);
79 g.run(i ? .7 : 2);
80 }
81 expect(state(g).status).toBe('won');
82 expect(g.el('status').textContent).toContain('Caught the cheese on row');
83 g.keyDown('ArrowLeft');
84 expect(g.el('status').textContent).toContain('Press N for the next level');
85 g.press('n');
86 expect(state(g).level).toBe(1);
87 expect(state(g).status).toBe('playing');
88 g.press('r');
89 expect(state(g).tick).toBe(0);
90 });
91
92 test('missing the cheese loses with the rules line', () => {
93 const g = boot('cheese-rolling');
94 g.read('P = { ...P, tussocks: [], path: P.path.map(() => 4), runner: { row: 4, col: 0 } }');
95 g.run(2 + .7 * (state(g).rows + 1));
96 expect(state(g).status).toBe('lost');
97 expect(g.el('status').textContent).toContain('Lost: the cheese rolled past the bottom');
98 });
99
100 test('reduced motion holds the sway and the hop still', () => {

This compares two reduced-motion boots to each other, so it passes even if reduced motion is ignored. Compare reduced against normal motion, and assert frames over time are static when reduced.

101 const a = boot('cheese-rolling', { reduced: true }), b = boot('cheese-rolling', { reduced: true });
102 a.run(1.5); b.run(1.5);
103 expect(a.picture()).toBe(b.picture());
104 });
chestnut-roast/page.test.mjs +2 −2
@@ -68,7 +68,7 @@ test('letting everything fall ends the round with the rules line, and a tap play
6868 g.tap(180, 200);
6969 expect(g.read('P.status')).toBe('playing');
7070 expect(g.read('P.score')).toBe(0);
71 });
71 }, 30000);
7272
7373 test('the page has a mute button, a how-to-play label and no mp3', async () => {
7474 const html = await Bun.file(new URL('./index.html', import.meta.url)).text();
@@ -131,7 +131,7 @@ test('a round above the saved best is announced as a new best and saved', () =>
131131 expect(g.el('status').textContent).toContain('new best');
132132 expect(g.store[BEST]).toBe(String(g.read('P.score')));
133133 expect(Number(g.store[BEST])).toBeGreaterThan(5);
134 });
134 }, 30000);
135135
136136 test('a round below the saved best is not announced as a new best and leaves the save alone', () => {
137137 const low = boot('chestnut-roast', { stored: { [BEST]: '999' } });
index.html +1
@@ -142,6 +142,7 @@ footer a::after { content: none; }
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>
144144 <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>
145 <li data-kind="Catching" data-input="Keys" style="--accent:#d9a21b"><a href="cheese-rolling/">Cheese Rolling</a><span>Chase the cheese down a steep hill, dodge the tussocks and catch it before the bottom</span><span>Game forged by the crew</span><span class="tags">Catching · Keys</span></li>
145146 <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>
146147 </ul>
147148 <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/cheese-rolling.png added
Binary file not shown.
og/home.png
Binary file not shown.
rounds.mjs +9
@@ -17,6 +17,7 @@ import Carrot from './carrot-pull/bed.js';
1717 import Cars from './bumper-cars/floor.js';
1818 import Bobbing from './apple-bobbing/tub.js';
1919 import Cake from './cake-stall/order.js';
20 import Hill from './cheese-rolling/hill.js';
2021 import Tide from './tide-crossing/causeway.js';
2122
2223 const MID = [180, 140];
@@ -543,6 +544,14 @@ export const ROUNDS = {
543544 if (s.aimed !== pick) { g.press(String(pick + 1), .02); return; }
544545 if (soonest === 0) g.press(' ', .02); else g.run(.04);
545546 } },
547 // each tick, play the first move of the rules' own shortest line to the cheese from the state as it stands
548 'cheese-rolling': { over: /for the next level|to try again/, outcome: /Caught the cheese on row \d+ at tick [1-9]\d*/, limit: 40, beat: g => {
549 const s = JSON.parse(g.read('JSON.stringify(P)'));
550 if (s.status !== 'playing' || decided.get(g) === s.tick) { g.run(.04); return; }
551 decided.set(g, s.tick);
552 const key = { left: 'ArrowLeft', right: 'ArrowRight', down: 'ArrowDown' }[Hill.solve(s)[0]];
553 if (key) g.press(key, .02);
554 } },
546555 // wait out the showing, then stack the order from the rules' own state, one layer a beat; eight orders win
547556 'cake-stall': { over: /tap or press R/i, outcome: /You win: all 8 orders served, score [1-9]\d*/, limit: 60, beat: g => {
548557 const s = JSON.parse(g.read('JSON.stringify(P)'));
serve.test.mjs +7
@@ -803,3 +803,10 @@ test('Tide Crossing and its rules script are served without a track', async () =
803803 expect(page.headers.get('content-type')).toBe('text/html; charset=utf-8');
804804 expect((await fetch(base + '/tide-crossing/causeway.js')).status).toBe(200);
805805 });
806
807 test('Cheese Rolling and its rules script are served without a track', async () => {
808 const page = await fetch(base + '/cheese-rolling/');
809 expect(page.status).toBe(200);
810 expect(page.headers.get('content-type')).toBe('text/html; charset=utf-8');
811 expect((await fetch(base + '/cheese-rolling/hill.js')).status).toBe(200);
812 });
shell.js +1
@@ -78,6 +78,7 @@ window.PixelPlays = (() => {
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 },
8080 { 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 },
81 { id: 'cheese-rolling', title: 'Cheese Rolling', blurb: 'Chase the cheese down a steep hill, dodge the tussocks and catch it before the bottom', kind: 'Catching', input: 'Keys', accent: '#d9a21b', music: false, forged: true },
8182 { 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 },
8283 { 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 },
8384 { 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