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

Migrated from github.com/amirmushichge/pixel-plays

Add Pumpkin Patch: the playable page and its place in the catalogue, on the rules in `pumpkin-patch/plots.js` #341

Merged opened this

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

Merged

quill/pixel-plays:quill/331-add-pumpkin-patch-the-playable-page-and → main 8 commits fork

  • Merged. quill/331-add-pumpkin-patch-the-playable-page-and was merged into main as 0145828.

Review

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

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

View file
index.html +1
@@ -64,6 +64,7 @@ footer a::after { content: none; }
6464 <p id="status" role="status"></p>
6565 <ul id="games">
6666 <li data-kind="Throwing" data-input="Drag" style="--accent:#d9a441"><a href="balote/">Balote</a><span>Bocce against a bot, first to 5</span><span class="tags">Throwing · Drag</span></li>
67 <li data-kind="Puzzle" data-input="Tap" style="--accent:#d9822b"><a href="pumpkin-patch/">Pumpkin Patch</a><span>Water each plot and its neighbours until every pumpkin has exactly what it needs</span><span>Game forged by the crew</span><span class="tags">Puzzle · Tap</span></li>
6768 <li data-kind="Timing" data-input="Hold" style="--accent:#3f8fb5"><a href="stari-most/">Stari Most</a><span>Five dives off the old bridge, flip and enter the water vertically</span><span class="tags">Timing · Hold</span></li>
6869 <li data-kind="Catching" data-input="Drag" style="--accent:#c8604a"><a href="laundry/">Rooftop Laundry</a><span>Get the washing into the basket before the storm</span><span class="tags">Catching · Drag</span></li>
6970 <li data-kind="Sport" data-input="Tap" style="--accent:#c9683a"><a href="tie-break/">Tie-Break</a><span>Clay-court tie-break against a bot, the moment you hit decides where the ball lands</span><span class="tags">Sport · Tap</span></li>
og/home.png
Binary file not shown.
og/pumpkin-patch.png added
Binary file not shown.
pumpkin-patch/index.html added +371
@@ -0,0 +1,371 @@
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>Pumpkin Patch</title>
7 <!-- og:begin -->
8 <meta name="description" content="Water each plot and its neighbours until every pumpkin has exactly what it needs">
9 <link rel="canonical" href="https://pixel-plays--forge-trevato-apps.open-platform.sh/pumpkin-patch/">
10 <meta property="og:type" content="website">
11 <meta property="og:site_name" content="Pixel Plays">
12 <meta property="og:title" content="Pumpkin Patch">
13 <meta property="og:description" content="Water each plot and its neighbours until every pumpkin has exactly what it needs">
14 <meta property="og:url" content="https://pixel-plays--forge-trevato-apps.open-platform.sh/pumpkin-patch/">
15 <meta property="og:image" content="https://pixel-plays--forge-trevato-apps.open-platform.sh/og/pumpkin-patch.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="Pumpkin Patch: 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: #d9822b; }
24 * { box-sizing: border-box; }
25 html, body { -webkit-user-select: none; user-select: none; -webkit-touch-callout: none; -webkit-tap-highlight-color: transparent; height: 100%; margin: 0; background: var(--paper); color: var(--ink); }
26 body { font: 13px/1.35 ui-monospace, "Cascadia Mono", Consolas, Menlo, monospace; overflow: hidden; }
27 #stage { position: fixed; inset: 0; display: grid; place-items: center; }
28 canvas { display: block; image-rendering: pixelated; touch-action: none; cursor: pointer; outline: none; }
29 canvas:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; }
30 .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; gap: 16px; pointer-events: none; }
31 #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; }
32 #bottom { bottom: max(14px, env(safe-area-inset-bottom)); align-items: flex-end; }
33 .row { display: flex; align-items: center; gap: 8px; color: var(--accent); }
34 .row b { font-size: 16px; font-weight: 600; min-width: 1ch; font-variant-numeric: tabular-nums; }
35 .row span { color: var(--muted); }
36 #line { color: var(--muted); min-height: 1.35em; }
37 button { pointer-events: auto; font: inherit; color: var(--muted); background: none; border: 0; padding: 13px 10px; margin: -13px -10px; min-height: 44px; cursor: pointer; }
38 button:hover, button:focus-visible { color: var(--ink); }
39 </style>
40 </head>
41 <body>
42 <div id="stage"><canvas id="c" tabindex="0" aria-label="A walled pumpkin patch seen from above at an angle: a grid of soil plots, each with a pumpkin and a number for the waterings it still needs, and grey flagstones that take no water. Tap a plot, or move the cursor with the arrow keys and press Enter, to pour the can on it and its four neighbours. Water every pumpkin to exactly its need; one drop too many rots it. U undoes, R restarts."></canvas></div>
43 <div id="scores">
44 <div class="row"><b id="level">1</b><span id="name">First drop</span></div>
45 <div class="row"><b id="pours">0</b><span>pours</span></div>
46 <div class="row"><b id="par">1</b><span>par</span></div>
47 </div>
48 <div class="hud" id="bottom">
49 <div id="line"><span id="status" role="status" aria-live="polite"></span></div>
50 <button id="mute" type="button" aria-pressed="false">sound</button>
51 </div>
52 <script src="../shell.js"></script>
53 <script>
54 PixelPlays.mount({
55 id: 'pumpkin-patch',
56 rules: [
57 'Tap a plot, or arrows and Enter, to pour on it and its four neighbours.',
58 'Give every pumpkin exactly the waterings its number says; one more rots it.',
59 'U undoes, R restarts. Fewer pours than par is best.',
60 ],
61 });
62 </script>
63 <script>window.PP_SCENE = { originY: 14 };</script>
64 <script src="../engine.js"></script>
65 <script src="plots.js"></script>
66 <script>
67 'use strict';
68
69 const PP = PumpkinPatch;
70 const CELL = 4, ZT = .45; // world units per plot, and the height of the soil
71 const GRASS_A = [92, 134, 62], GRASS_B = [82, 124, 58];
72 const SOIL = [112, 76, 50], STONE = [158, 156, 148], WALL = [176, 168, 154], WALL_TOP = [194, 186, 170];
73 const PUMPKIN = [232, 124, 30], VINE = [72, 132, 50], SPROUT = [150, 190, 80], LEAF = [96, 160, 62];
74 const ROT = [92, 72, 56], MOULD = [120, 128, 96], TIN = [120, 150, 176], DROP = [96, 170, 236];
75
76 /* ---------- state ---------- */
77 let P = PP.start(0);
78 const G = { time: 0, cursor: 4, canTilt: 0 };
79 const nodes = {};
80 for (const id of ['level', 'name', 'pours', 'par', 'status']) nodes[id] = document.getElementById(id);
81 const shown = {};
82 function setText(key, text) {
83 if (shown[key] === text) return;
84 shown[key] = text;
85 nodes[key].textContent = text;
86 }
87 function renderHud() {
88 setText('level', String(P.level + 1));
89 setText('name', `${P.name} · of ${PP.LEVELS.length}`);
90 setText('pours', String(P.pours));
91 setText('par', String(P.par));
92 }
93 // The live region: one line per pour, refusal, rot and win. It is cleared first, so a repeated line is spoken again.
94 function say(text) {
95 nodes.status.textContent = '';
96 nodes.status.textContent = text;
97 }
98
99 /* ---------- geometry ---------- */
100 const plotOrigin = i => [(i % P.cols - P.cols / 2) * CELL, (Math.floor(i / P.cols) - P.rows / 2) * CELL];
101 const plotCentre = i => { const [x, y] = plotOrigin(i); return [x + CELL / 2, y + CELL / 2]; };
102 function cellPoint(i) { const [x, y] = plotCentre(i), p = project(x, y, ZT); return [p.sx, p.sy]; }
103 function cellAt(px, py) {
104 const g = unproject(px, py + ZT * COS * S);
105 const c = Math.floor(g.x / CELL + P.cols / 2), r = Math.floor(g.y / CELL + P.rows / 2);
106 return c < 0 || r < 0 || c >= P.cols || r >= P.rows ? null : r * P.cols + c;
107 }
108 const isStone = i => P.need[i] === null;
109 const left = i => P.need[i] - P.water[i]; // the waterings a plot still needs; negative when it has rotted
110
111 /* ---------- baked scene: the walled patch ---------- */
112 function groundColor(ix, iy) {
113 const g = unproject(ix + .5, iy + .5), e = Math.pow(Math.pow(Math.abs(g.x) / 9.9, 6) + Math.pow(Math.abs(g.y) / 9.9, 6), 1 / 6);
114 if (e > 1.02) return 0;
115 if (e > .93 && hash(ix, iy) < (e - .93) / .09) return 0;
116 const t = .94 + hash(ix, iy) * .1, c = (Math.floor(g.x / 2) + Math.floor(g.y / 2)) & 1 ? GRASS_A : GRASS_B;
117 return rgb(c[0] * t, c[1] * t, c[2] * t);
118 }
119 function bake() {
120 ground.fill(0); shadowMask.fill(0); staticShadow.fill(0); kinds.fill(0);
121 zb.fill(-1e9);
122 pix.fill(rgb(255, 255, 255));
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, c = groundColor(ix, iy);
125 if (!c) continue;
126 pix[i] = c; ground[i] = 1;
127 zb[i] = (g.x + g.y) / R2 * COS;
128 }
129 groundZ.set(zb);
130 const hx = P.cols * CELL / 2, hy = P.rows * CELL / 2, t = .6, o = .15;
131 box(-hx - t - o, -hy - t - o, 0, hx + t + o, -hy - o, 1.1, WALL, 2);
132 box(-hx - t - o, hy + o, 0, hx + t + o, hy + t + o, 1.1, WALL, 3);
133 box(-hx - t - o, -hy - o, 0, -hx - o, hy + o, 1.1, WALL, 4);
134 box(hx + o, -hy - o, 0, hx + t + o, hy + o, 1.1, WALL, 5);
135 for (let i = 0; i < P.need.length; i++) {
136 const [x, y] = plotOrigin(i), m = .06;
137 if (isStone(i)) box(x + m, y + m, 0, x + CELL - m, y + CELL - m, ZT * .6, STONE, i);
138 else box(x + m, y + m, 0, x + CELL - m, y + CELL - m, ZT, SOIL, i);
139 }
140 bakeOcclusion();
141 staticShadow.set(shadowMask);
142 applyShadows(groundZ);
143 bakedPix.set(pix);
144 bakedZ.set(zb);
145 }
146
147 /* ---------- the plants ---------- */
148 const flick = () => STILL ? 0 : Math.floor(G.time * 8);
149 // dry: a small green bud under closed leaves. At its need: a swollen, ribbed pumpkin under an open leaf. Rotten: a collapsed brown heap.
150 function plant(i) {
151 const [x, y] = plotCentre(i), z = ZT, d = left(i);
152 if (d > 0) {
153 sphere(x, y, z + .35, .38, SPROUT, 'solid');
154 dynamicAdd([x, y, z + .5], [x, y, z + .85], .06, .05, () => VINE);
155 castShadow(x, y, z + .5, .4, 1, 0);
156 } else if (d === 0) {
157 sphere(x - .35, y, z + .85, .72, PUMPKIN, 'gloss');
158 sphere(x + .3, y, z + .85, .72, PUMPKIN, 'gloss');
159 sphere(x, y - .1, z + .95, .8, PUMPKIN, 'gloss');
160 dynamicAdd([x, y, z + 1.6], [x, y + .05, z + 2], .1, .08, () => VINE);
161 const sway = ((flick() % 4) === 1 ? .06 : 0);
162 sphere(x + .75, y - .45 + sway, z + 1.45, .42, LEAF, 'solid');
163 sphere(x - .75, y + .45 - sway, z + 1.35, .36, LEAF, 'solid');
164 castShadow(x, y, z + 1.4, .95, 1, 0);
165 } else {
166 dynamicAdd([x - .8, y, z + .2], [x + .8, y, z + .2], .3, .3, () => ROT);
167 sphere(x, y + .3, z + .25, .22, MOULD, 'rough');
168 sphere(x + .3, y - .3, z + .2, .16, MOULD, 'rough');
169 castShadow(x, y, z + .3, .8, 1, 0);
170 }
171 }
172 function can() {
173 const [x, y] = plotCentre(G.cursor), z = ZT + 2.7, k = G.canTilt > 0 ? 1 : 0;
174 dynamicAdd([x - .4, y - .4, z], [x - .4, y - .4, z + .7], .45, .45, () => TIN);
175 dynamicAdd([x - .1, y - .4, z + .3], [x + .55 + k * .1, y - .4, z + .55 - k * .55], .09, .07, () => TIN);
176 dynamicAdd([x - .85, y - .4, z + .1], [x - .85, y - .4, z + .65], .07, .07, () => TIN);
177 if (k) for (let n = 0; n < 4; n++) {
178 const f = (G.canTilt * 6 + n * .25) % 1, dz = z + .1 - f * (z - ZT - .1);
179 sphere(x + .7 + n * .08, y - .4 + n * .06, Math.max(ZT + .1, dz), .09, DROP, 'solid');
180 }
181 castShadow(x - .4, y - .4, z, .5, 1, 0);
182 }
183
184 /* ---------- the digits and the cursor, drawn flat over everything ---------- */
185 const DIGITS = {
186 0: '111101101101111', 1: '010110010010111', 2: '111001111100111', 3: '111001111001111', 4: '101101111001001',
187 5: '111100111001111', 6: '111100111101111', 7: '111001010010010', 8: '111101111101111', 9: '111101111001111',
188 '!': '010010010000010',
189 };
190 function glyph(text, sx, sy, scale) {
191 const bits = DIGITS[text], x0 = Math.round(sx) - Math.floor(3 * scale / 2), y0 = Math.round(sy) - Math.floor(5 * scale / 2);
192 const ink = [], halo = [];
193 for (let k = 0; k < 15; k++) {
194 if (bits[k] !== '1') continue;
195 for (let a = 0; a < scale; a++) for (let b = 0; b < scale; b++) ink.push([x0 + (k % 3) * scale + a, y0 + Math.floor(k / 3) * scale + b]);
196 }
197 for (const [x, y] of ink) for (const [dx, dy] of [[-1, 0], [1, 0], [0, -1], [0, 1], [-1, -1], [1, 1], [-1, 1], [1, -1]]) halo.push([x + dx, y + dy]);
198 drawDots(halo, PAPER);
199 drawDots(ink, INK);
200 }
201 function labels() {
202 for (let i = 0; i < P.need.length; i++) {
203 if (isStone(i)) continue;
204 const [sx, sy] = cellPoint(i), d = left(i);
205 glyph(d < 0 ? '!' : String(Math.min(9, d)), sx, sy - (d === 0 ? 8 : 2), 2);
206 }
207 }
208 function cursor() {
209 const [x, y] = plotOrigin(G.cursor), m = .08, pts = [];
210 const corners = [[x + m, y + m], [x + CELL - m, y + m], [x + CELL - m, y + CELL - m], [x + m, y + CELL - m]];
211 for (let k = 0; k < 4; k++) {
212 const [ax, ay] = corners[k], [bx, by] = corners[(k + 1) % 4];
213 for (let n = 0; n < 30; n++) { const p = project(lerp(ax, bx, n / 30), lerp(ay, by, n / 30), ZT + .02); pts.push([p.sx, p.sy]); }
214 }
215 haloDots(pts);
216 drawDots(pts, SIGNAL);
217 }
218
219 function draw() {
220 pix.set(bakedPix);
221 zb.set(bakedZ);
222 for (let i = 0; i < P.need.length; i++) if (!isStone(i)) plant(i);
223 can();
224 flushShadows();
225 applyShadows(groundZ);
226 shadeEdges();
227 labels();
228 cursor();
229 ctx.putImageData(image, 0, 0);
230 }
231
232 /* ---------- game ---------- */
233 const WHY = {
234 stone: 'A flagstone takes no water',
235 'off-grid': 'That is outside the patch',
236 'nothing-to-undo': 'Nothing to undo',
237 };
238 const where = i => `row ${Math.floor(i / P.cols) + 1}, column ${(i % P.cols) + 1}`;
239 const cap = t => t[0].toUpperCase() + t.slice(1);
240 function plotState(i) {
241 if (isStone(i)) return 'a flagstone';
242 const n = left(i);
243 if (n < 0) return 'rotted';
244 if (n === 0) return 'has its water';
245 return `needs ${n} more watering${n === 1 ? '' : 's'}`;
246 }
247 const NEXT = ' · tap or press Enter for the next level';
248 function finish() {
249 if (P.status === 'won') {
250 const last = P.level === PP.LEVELS.length - 1;
251 say(`${P.line}${last ? ' · tap or press Enter to play again' : NEXT}`);
252 sound('win');
253 } else if (P.status === 'lost') {
254 say(`${P.line} · press U to undo or R to restart`);
255 sound('rot');
256 }
257 }
258 function pourAt(i) {
259 ensureAudio();
260 if (P.status === 'won') { next(); return; }
261 const r = PP.pour(P, i);
262 if (!r.ok) {
263 say(r.why === 'game-over' ? `A pumpkin has rotted: press U to undo or R to restart` : WHY[r.why] || r.why);
264 sound('no');
265 return;
266 }
267 P = r.state;
268 G.cursor = i; G.canTilt = .35;
269 sound('pour');
270 if (P.status === 'playing') say(`Poured at ${where(i)}: ${P.line}`);
271 else finish();
272 renderHud();
273 }
274 function undo() {
275 const r = PP.undo(P);
276 if (!r.ok) { say(WHY[r.why]); sound('no'); return; }
277 P = r.state;
278 say(`Undid a pour: ${P.line}`);
279 renderHud();
280 }
281 function load(level) {
282 P = PP.start(level);
283 G.cursor = Math.min(G.cursor, P.need.length - 1);
284 bake();
285 renderHud();
286 }
287 function restart() {
288 load(P.level);
289 say(`Level ${P.level + 1} restarted: ${P.line}`);
290 }
291 function next() {
292 load((P.level + 1) % PP.LEVELS.length);
293 say(`Level ${P.level + 1}, ${P.name}: ${P.line}`);
294 }
295 function update(dt) {
296 G.time += dt;
297 G.canTilt = Math.max(0, G.canTilt - dt);
298 }
299
300 /* ---------- input ---------- */
301 const point = e => { const r = canvas.getBoundingClientRect(); return [(e.clientX - r.left) / r.width * W, (e.clientY - r.top) / r.height * H]; };
302 canvas.addEventListener('pointerdown', e => {
303 canvas.focus({ preventScroll: true });
304 const i = cellAt(...point(e));
305 if (i === null) { ensureAudio(); if (P.status === 'won') next(); return; }
306 G.cursor = i;
307 pourAt(i);
308 });
309 const STEP = { ArrowRight: [1, 0], ArrowLeft: [-1, 0], ArrowDown: [0, 1], ArrowUp: [0, -1] };
310 canvas.addEventListener('keydown', e => {
311 const step = STEP[e.key], k = e.key.length === 1 ? e.key.toLowerCase() : e.key;
312 if (step) {
313 e.preventDefault();
314 const c = Math.max(0, Math.min(P.cols - 1, G.cursor % P.cols + step[0])), r = Math.max(0, Math.min(P.rows - 1, Math.floor(G.cursor / P.cols) + step[1]));
315 G.cursor = r * P.cols + c;
316 say(`${cap(where(G.cursor))}: ${plotState(G.cursor)}`);
317 } else if (k === 'Enter' || k === ' ') {
318 e.preventDefault();
319 if (!e.repeat) pourAt(G.cursor);
320 } else if (k === 'u') { e.preventDefault(); undo(); }
321 else if (k === 'r') { e.preventDefault(); restart(); }
322 });
323
324 /* ---------- sound ---------- */
325 let audio = null, muted = false;
326 function ensureAudio() {
327 if (audio) { if (audio.state === 'suspended') audio.resume(); return; }
328 const AC = window.AudioContext || window.webkitAudioContext;
329 if (AC) audio = new AC();
330 }
331 function sound(kind) {
332 if (!audio || muted) return;
333 const now = audio.currentTime, gain = audio.createGain(), osc = audio.createOscillator();
334 const spec = {
335 pour: [500, 260, .2, .05, 'sine'],
336 no: [160, 110, .12, .05, 'square'],
337 rot: [200, 60, .35, .06, 'sawtooth'],
338 win: [330, 660, .35, .06, 'sine'],
339 }[kind];
340 osc.type = spec[4];
341 osc.frequency.setValueAtTime(spec[0], now);
342 osc.frequency.exponentialRampToValueAtTime(spec[1], now + spec[2]);
343 gain.gain.setValueAtTime(spec[3], now);
344 gain.gain.exponentialRampToValueAtTime(.0005, now + spec[2]);
345 osc.connect(gain).connect(audio.destination);
346 osc.start(now); osc.stop(now + spec[2] + .01);
347 }
348 const muteButton = document.getElementById('mute');
349 muteButton.addEventListener('click', () => {
350 muted = !muted;
351 muteButton.setAttribute('aria-pressed', String(muted));
352 });
353
354 /* ---------- fit and boot ---------- */
355 addEventListener('resize', fit);
356 fit();
357 bake();
358 say(`${P.line}: tap a plot to pour on it and its neighbours`);
359 renderHud();
360 canvas.focus({ preventScroll: true });
361 let last = performance.now();
362 function frame(now) {
363 update(Math.min(.04, (now - last) / 1000));
364 last = now;
365 draw();
366 requestAnimationFrame(frame);
367 }
368 requestAnimationFrame(frame);
369 </script>
370 </body>
371 </html>
pumpkin-patch/page.test.mjs added +106
@@ -0,0 +1,106 @@
1 import { test, expect, setDefaultTimeout } from 'bun:test';
2 import { boot } from '../playtest.mjs';
3
4 setDefaultTimeout(20000);
5 const tapPlot = (g, i) => g.tap(...g.read(`cellPoint(${i})`));
6
7 test('a tap pours through the rules and the HUD and the live line follow', () => {
8 const g = boot('pumpkin-patch');
9 expect(g.el('pours').textContent).toBe('0');
10 tapPlot(g, 4);
11 expect(g.read('P.pours')).toBe(1);
12 expect(g.el('pours').textContent).toBe('1');
13 expect(g.el('par').textContent).toBe('1');
14 expect(g.el('status').textContent).toContain('You win');
15 });
16
17 test('a pour that rots a pumpkin is announced, and U takes it back', () => {
18 const g = boot('pumpkin-patch');
19 tapPlot(g, 0);
20 expect(g.read('P.status')).toBe('lost');
21 expect(g.el('status').textContent).toContain('rotted');
22 g.press('u');
23 expect(g.read('P.status')).toBe('playing');
24 expect(g.read('P.pours')).toBe(0);
25 });
26
27 test('the arrow keys move the cursor, Enter pours, R restarts', () => {
28 const g = boot('pumpkin-patch');
29 g.press('ArrowUp'); g.press('ArrowLeft');
30 expect(g.read('G.cursor')).toBe(0);
31 g.press('Enter');
32 expect(g.read('P.pours')).toBe(1);
33 g.press('r');
34 expect(g.read('P.pours')).toBe(0);
35 });
36
37 test('a flagstone is refused and said so', () => {
38 const g = boot('pumpkin-patch');
39 g.read('load(2)');
40 tapPlot(g, 4);
41 expect(g.read('P.pours')).toBe(0);
42 expect(g.el('status').textContent).toContain('flagstone');
43 });
44
45 test('a win moves on to the next level on the next tap', () => {
46 const g = boot('pumpkin-patch');
47 tapPlot(g, 4);
48 tapPlot(g, 4);
49 expect(g.read('P.level')).toBe(1);
50 expect(g.read('P.pours')).toBe(0);
51 });
52
53 test('an arrow key announces the plot under the cursor', () => {
54 const g = boot('pumpkin-patch');
55 g.press('ArrowRight');
56 expect(g.el('status').textContent).toMatch(/^Row 2, column 3: needs \d more waterings?$/);
57 g.read('load(2)');
58 g.press('ArrowLeft'); g.press('ArrowLeft');
59 g.press('ArrowRight'); g.press('ArrowDown');
60 expect(g.el('status').textContent).toContain('Row ');
61 });
62
63 test('a flagstone under the cursor is announced, and a repeated refusal is re-spoken', () => {
64 const g = boot('pumpkin-patch');
65 g.read('load(2)');
66 g.press('ArrowUp'); g.press('ArrowDown');
67 expect(g.el('status').textContent).toBe('Row 2, column 2: a flagstone');
68 g.press('Enter'); g.press('Enter');
69 expect(g.el('status').textContent).toContain('flagstone');
70 });
71
72 // Records every write to the live line, so a test can see the clear that makes a repeat be spoken again.
73 function spyStatus(g) {
74 const node = g.el('status'), writes = [];
75 let value = node.textContent;
76 Object.defineProperty(node, 'textContent', { get: () => value, set: v => { writes.push(v); value = v; }, configurable: true });
77 return writes;
78 }
79
80 test('say clears the live node before setting it, so a repeat is announced', () => {
81 const g = boot('pumpkin-patch');
82 g.read("say('same')");
83 const writes = spyStatus(g);
84 g.read("say('same')");
85 expect(writes).toEqual(['', 'same']);
86 });
87
88 test('a flagstone tapped twice is refused twice, and each refusal reaches the live line', () => {
89 const g = boot('pumpkin-patch');
90 g.read('load(2)');
91 const writes = spyStatus(g);
92 tapPlot(g, 4);
93 tapPlot(g, 4);
94 expect(g.read('P.pours')).toBe(0);
95 expect(writes.filter(w => w === 'A flagstone takes no water').length).toBe(2);
96 expect(writes.filter(w => w === '').length).toBeGreaterThanOrEqual(2);
97 });
98
99 test('the mute button toggles aria-pressed', () => {
100 const g = boot('pumpkin-patch');
101 const mute = g.el('mute');
102 mute.fire('click');
103 expect(mute.getAttribute('aria-pressed')).toBe('true');
104 mute.fire('click');
105 expect(mute.getAttribute('aria-pressed')).toBe('false');
106 });
rounds.mjs +5
@@ -430,6 +430,11 @@ export const ROUNDS = {
430430 const cell = g.read("(() => { const c = P.crabs.find(c => c.phase === 'open' && (P.time - c.start) / c.duration < .3); return c && P.caught < P.bucket && P.cooldown <= 0 ? c.cell : -1; })()");
431431 if (cell >= 0) g.tap(...g.read(`cellPoint(${cell})`)); else g.step(1);
432432 } },
433 // the first level's stated solution: pour on its centre plot
434 'pumpkin-patch': { over: /You win/, outcome: /You win: every pumpkin has its water in 1 pour against par 1, at par/, beat: g => {
435 g.tap(...g.read('cellPoint(4)'));
436 g.run(.2);
437 } },
433438 // the rules' own solution, one move a beat: pick a patch and tap it again to turn it, or pick one and tap another to swap
434439 'quilt': { over: /play again/, outcome: /Quilt finished: \d+ points/, beat: g => {
435440 const at = i => g.read(`cellPoint(${i})`);
serve.test.mjs +7
@@ -768,3 +768,10 @@ test('Carrot Pull and its rules script are served without a track', async () =>
768768 expect(page.headers.get('content-type')).toBe('text/html; charset=utf-8');
769769 expect((await fetch(base + '/carrot-pull/bed.js')).status).toBe(200);
770770 });
771
772 test('Pumpkin Patch and its rules script are served without a track', async () => {
773 const page = await fetch(base + '/pumpkin-patch/');
774 expect(page.status).toBe(200);
775 expect(page.headers.get('content-type')).toBe('text/html; charset=utf-8');
776 expect((await fetch(base + '/pumpkin-patch/plots.js')).status).toBe(200);
777 });
shell.js +1
@@ -78,6 +78,7 @@ window.PixelPlays = (() => {
7878 { 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 },
7979 { 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 },
8080 { 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 },
81 { id: 'pumpkin-patch', title: 'Pumpkin Patch', blurb: 'Water each plot and its neighbours until every pumpkin has exactly what it needs', kind: 'Puzzle', input: 'Tap', accent: '#d9822b', music: false, forged: true },
8182 ];
8283 const MUSIC_VOLUME = .4;
8384 const CREDITS = [

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs