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

Migrated from github.com/amirmushichge/pixel-plays

Add Carrot Pull: the playable page and its place in the catalogue, on the rules in `carrot-pull/bed.js` #348

Merged opened this

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

Merged

quill/pixel-plays:quill/344-add-carrot-pull-the-playable-page-and-it → main 1 commit fork

  • Merged. quill/344-add-carrot-pull-the-playable-page-and-it was merged into main as 52f104e.

Review

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

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

View file
carrot-pull/index.html added +339
@@ -0,0 +1,339 @@
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>Carrot Pull</title>
7 <!-- og:begin -->
8 <meta name="description" content="Pull each carrot while it is ripe, before the rabbit nibbles it">
9 <link rel="canonical" href="https://pixel-plays--forge-trevato-apps.open-platform.sh/carrot-pull/">
10 <meta property="og:type" content="website">
11 <meta property="og:site_name" content="Pixel Plays">
12 <meta property="og:title" content="Carrot Pull">
13 <meta property="og:description" content="Pull each carrot while it is ripe, before the rabbit nibbles it">
14 <meta property="og:url" content="https://pixel-plays--forge-trevato-apps.open-platform.sh/carrot-pull/">
15 <meta property="og:image" content="https://pixel-plays--forge-trevato-apps.open-platform.sh/og/carrot-pull.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="Carrot Pull: 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: #d9731f; }
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 #scores { position: fixed; top: max(14px, env(safe-area-inset-top)); left: max(16px, env(safe-area-inset-left)); display: grid; gap: 2px; pointer-events: none; }
31 #bottom { position: fixed; left: max(16px, env(safe-area-inset-left)); right: max(16px, env(safe-area-inset-right)); bottom: max(14px, env(safe-area-inset-bottom)); display: flex; justify-content: space-between; align-items: flex-end; gap: 16px; pointer-events: none; }
32 .row { display: flex; align-items: center; gap: 8px; color: var(--accent); }
33 .row b { font-size: 16px; font-weight: 600; min-width: 2ch; font-variant-numeric: tabular-nums; }
34 .row span { color: var(--muted); }
35 #line { color: var(--muted); min-height: 1.35em; max-width: max(18ch, calc(50vw - 196px)); }
36 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; }
37 button:hover, button:focus-visible { color: var(--ink); }
38 /* a narrow phone: the canvas fills the width, so the HUD moves out of overlay into flow above and below it */
39 @media (max-width: 520px) {
40 body { overflow-y: auto; display: flex; flex-direction: column; min-height: 100%; padding: max(40px, calc(env(safe-area-inset-top) + 30px)) 12px max(96px, calc(env(safe-area-inset-bottom) + 82px)); }
41 #scores, #bottom, #stage { position: static; }
42 #scores { display: flex; flex-wrap: wrap; column-gap: 14px; row-gap: 2px; }
43 #stage { display: flex; justify-content: center; margin: 0 -12px; }
44 #bottom { flex-direction: column; align-items: stretch; gap: 6px; margin-top: 8px; }
45 #line { max-width: none; min-height: 2.7em; }
46 }
47 </style>
48 </head>
49 <body>
50 <div id="stage"><canvas id="c" tabindex="0" aria-label="A vegetable bed of four rows and six columns of carrots, each at its growth stage: a pale seed, a sprout of round leaves, a ripe carrot with upright fronds and an orange shoulder, or a woody one with splayed brown leaves and a tall pale root. You are the gardener, in the straw hat; a grey rabbit hops along the rows nibbling ripe and woody carrots. Arrow keys or WASD walk one square, Space pulls the carrot you stand on, R restarts. On touch, tap a square next to you to walk there and tap your own square to pull."></canvas></div>
51 <div id="scores">
52 <div class="row"><b id="score">0</b><span>score</span></div>
53 <div class="row"><b id="left">0</b><span>ticks left</span></div>
54 <div class="row"><b id="ripe">0</b><span>pulled ripe</span></div>
55 <div class="row"><b id="lost">0</b><span>lost to the rabbit</span></div>
56 </div>
57 <div id="bottom">
58 <div id="line"><span id="status" role="status" aria-live="polite"></span></div>
59 <button id="mute" type="button" aria-pressed="false">sound on</button>
60 </div>
61 <script src="../shell.js"></script>
62 <script>
63 PixelPlays.mount({
64 id: 'carrot-pull',
65 rules: [
66 'Arrows or WASD walk, Space pulls; on touch tap a neighbouring square to walk, your own to pull.',
67 'Pull a carrot while it is ripe for 5; a woody one is only 1, a sprout is lost for nothing.',
68 'The rabbit nibbles any ripe or woody carrot it hops onto. The day ticks on every 0.6 s for 40 ticks.',
69 ],
70 });
71 </script>
72 <script>window.PP_SCENE = { originY: 6 };</script>
73 <script src="../engine.js"></script>
74 <script src="bed.js"></script>
75 <script>
76 'use strict';
77
78 const M = CarrotPull;
79 const TICK_SECONDS = 0.6;
80 const CU = 3.6, CV = 4.2; // a square is CU wide and CV deep on the ground
81 const cellU = col => (col - (M.COLS - 1) / 2) * CU, cellV = row => (row - (M.ROWS - 1) / 2) * CV;
82 const world = (u, v) => [(u + v) / R2, (v - u) / R2];
83 const cellScreen = (row, col, z = 0) => { const [x, y] = world(cellU(col), cellV(row)); const p = project(x, y, z); return [p.sx, p.sy]; };
84 const cellAt = (sx, sy) => { // the (row, col) under a canvas point, or null off the bed
85 const g = unproject(sx, sy), u = (g.x - g.y) / R2, v = (g.x + g.y) / R2;
86 const col = Math.floor(u / CU + M.COLS / 2), row = Math.floor(v / CV + M.ROWS / 2);
87 return row < 0 || row >= M.ROWS || col < 0 || col >= M.COLS ? null : { row, col };
88 };
89
90 const SOIL_A = [118, 84, 60], SOIL_B = [104, 74, 54], FURROW = [78, 54, 42], PLANK = [150, 112, 82], GRASS_A = [86, 134, 66], GRASS_B = [74, 120, 60];
91 const SEED = [240, 230, 196], LEAF = [92, 168, 76], SPROUT = [148, 208, 96], FROND = [64, 150, 66], CARROT = [238, 124, 34];
92 const WOOD_LEAF = [176, 146, 66], WOOD_ROOT = [208, 184, 134], STUB = [100, 156, 70];
93 const COAT = [60, 110, 176], HAT = [238, 202, 84], SKIN = [226, 178, 140];
94 const FUR = [170, 162, 154], EAR = [236, 172, 172], TAIL = [246, 246, 240];
95 const OUTLINE = rgb(255, 244, 170);
96
97 /* ---------- state ---------- */
98 let P = M.start(Date.now() & 0xffff);
99 const G = { time: 0, acc: 0, games: 0, hop: 0, said: false };
100 const nodes = {};
101 for (const id of ['score', 'left', 'ripe', 'lost', 'status']) nodes[id] = document.getElementById(id);
102
103 function say(text) { nodes.status.textContent = text; } // the live region: every move, refusal, nibble and the ending
104 function renderHud() {
105 nodes.score.textContent = String(P.score);
106 nodes.left.textContent = String(P.day - P.tick);
107 nodes.ripe.textContent = String(P.ripePulled);
108 nodes.lost.textContent = String(P.lost);
109 }
110
111 /* ---------- baked scene: the vegetable bed ---------- */
112 function groundColor(ix, iy) {
113 const g = unproject(ix + .5, iy + .5), u = (g.x - g.y) / R2, v = (g.x + g.y) / R2;
114 const rough = Math.max(Math.abs(u) / 15.4, Math.abs(v) / 12.4) + (noise(u * .4, v * .4) - .5) * .06;
115 if (rough > 1.03) return 0;
116 if (rough > .94 && hash(ix, iy) < (rough - .94) / .09) return 0;
117 const t = .95 + hash(ix, iy) * .08;
118 const bu = M.COLS * CU / 2, bv = M.ROWS * CV / 2;
119 if (Math.abs(u) <= bu + .6 && Math.abs(v) <= bv + .6) {
120 if (Math.abs(u) > bu || Math.abs(v) > bv) return rgb(PLANK[0] * t, PLANK[1] * t, PLANK[2] * t);
121 const fu = (u + bu) / CU, fv = (v + bv) / CV, col = Math.floor(fu), row = Math.floor(fv);
122 const edge = fu - col < .06 || fu - col > .94 || fv - row < .06 || fv - row > .94;
123 const c = edge ? FURROW : (row + col) & 1 ? SOIL_A : SOIL_B;
124 return rgb(c[0] * t, c[1] * t, c[2] * t);
125 }
126 const c = (Math.floor(u / 2) + Math.floor(v / 2)) & 1 ? GRASS_A : GRASS_B;
127 return rgb(c[0] * t, c[1] * t, c[2] * t);
128 }
129 function bake() {
130 ground.fill(0); shadowMask.fill(0); staticShadow.fill(0); kinds.fill(0);
131 zb.fill(-1e9);
132 pix.fill(rgb(255, 255, 255));
133 for (let iy = 0; iy < H; iy++) for (let ix = 0; ix < W; ix++) {
134 const g = unproject(ix + .5, iy + .5), i = iy * W + ix, c = groundColor(ix, iy);
135 if (!c) continue;
136 pix[i] = c; ground[i] = 1;
137 zb[i] = (g.x + g.y) / R2 * COS;
138 }
139 groundZ.set(zb);
140 bakeOcclusion();
141 staticShadow.set(shadowMask);
142 applyShadows(groundZ);
143 bakedPix.set(pix);
144 bakedZ.set(zb);
145 }
146
147 /* ---------- the carrots: the stage shows in the leaves and in how much of the root shows ---------- */
148 const sway = () => STILL ? 0 : Math.floor(G.time * 3) % 2 ? .12 : -.12;
149 function blob(x, y, z, r, c) { sphere(x, y, z, r, c, 'solid'); castShadow(x, y, z, r * .8, 1, 0); }
150 function frond(x, y, dx, dy, top, c) { // a thin upright leaf, leaning by (dx, dy) world units over `top` height
151 dynamicAdd([x, y, .3], [x + dx, y + dy, top], .1, .05, () => c);
152 }
153 function carrot(c, row, col) {
154 const u = cellU(col), v = cellV(row), [x, y] = world(u, v);
155 const stage = c.fate === null ? M.stageOf(c.sowAt, P.tick) : null;
156 if (c.fate === 'pulled') { castShadow(x, y, 0, .55, 2, 0); return; } // a dark hole
157 if (c.fate === 'nibbled') { castShadow(x, y, 0, .5, 2, 0); blob(x, y, .22, .22, STUB); return; }
158 if (stage === 'seed') blob(x, y, .12, .2, SEED); // one pale seed
159 else if (stage === 'sprout') { // two round leaves, no root
160 blob(x - .22, y, .34, .3, SPROUT); blob(x + .2, y + .12, .3, .26, SPROUT);
161 } else if (stage === 'ripe') { // upright feathery fronds over a low orange shoulder
162 const s = sway();
163 blob(x, y, .2, .52, CARROT);
164 frond(x, y, -.35 + s, .1, 2.0, FROND); frond(x, y, 0, -.1, 2.3, LEAF); frond(x, y, .38 + s, .1, 1.9, FROND);
165 } else if (stage === 'woody') { // brown leaves splayed flat round a tall pale root
166 dynamicAdd([x, y, .3], [x, y, 1.5], .55, .45, () => WOOD_ROOT);
167 for (const [dx, dy] of [[-1.3, 0], [1.3, .1], [.1, -1.3], [0, 1.2]]) dynamicAdd([x, y, .7], [x + dx, y + dy, .3], .13, .1, () => WOOD_LEAF);
168 }
169 }
170
171 /* ---------- figures ---------- */
172 function gardener() {
173 const { row, col } = P.gardener, [x, y] = world(cellU(col) - 1.05, cellV(row) + .95);
174 dynamicAdd([x, y, 0], [x, y, 1.3], .42, .36, () => COAT);
175 blob(x, y, 1.75, .3, SKIN);
176 const [ax, ay] = world(cellU(col) - 1.65, cellV(row) + .95), [bx, by] = world(cellU(col) - .45, cellV(row) + .95);
177 dynamicAdd([ax, ay, 2.05], [bx, by, 2.05], .1, .1, () => HAT); // a straw hat: brim and crown
178 sphere(x, y, 2.1, .26, HAT, 'solid');
179 }
180 function rabbit() {
181 const { row, col } = P.rabbit, hop = STILL ? 0 : Math.sin(Math.min(1, G.hop) * Math.PI) * .8;
182 const [x, y] = world(cellU(col) + .95, cellV(row) - .1), z = .45 + hop;
183 blob(x, y, z, .5, FUR);
184 const [hx, hy] = world(cellU(col) + 1.45, cellV(row) - .1);
185 sphere(hx, hy, z + .35, .32, FUR, 'solid');
186 const [e1x, e1y] = world(cellU(col) + 1.3, cellV(row) - .3), [e2x, e2y] = world(cellU(col) + 1.3, cellV(row) + .1);
187 dynamicAdd([e1x, e1y, z + .5], [e1x, e1y, z + 1.4], .1, .08, () => EAR); dynamicAdd([e2x, e2y, z + .5], [e2x, e2y, z + 1.4], .1, .08, () => EAR);
188 const [tx, ty] = world(cellU(col) + .4, cellV(row) - .1);
189 sphere(tx, ty, z + .1, .17, TAIL, 'solid');
190 }
191 function outline(row, col) { // the gardener's square, ringed on the soil
192 const pts = [], u0 = cellU(col) - CU / 2, v0 = cellV(row) - CV / 2;
193 for (let k = 0; k <= 40; k++) {
194 const t = k / 40;
195 for (const [u, v] of [[u0 + t * CU, v0], [u0 + t * CU, v0 + CV], [u0, v0 + t * CV], [u0 + CU, v0 + t * CV]]) {
196 const [x, y] = world(u, v), p = project(x, y, 0); pts.push([p.sx, p.sy]);
197 }
198 }
199 drawDots(pts, OUTLINE);
200 }
201 function draw() {
202 pix.set(bakedPix);
203 zb.set(bakedZ);
204 pendingShadows.length = 0;
205 outline(P.gardener.row, P.gardener.col);
206 for (const c of P.carrots) carrot(c, c.row, c.col);
207 gardener();
208 rabbit();
209 flushShadows();
210 applyShadows(groundZ);
211 shadeEdges();
212 ctx.putImageData(image, 0, 0);
213 }
214
215 /* ---------- game: the rules do everything, the page only asks ---------- */
216 const WHY = {
217 'day-over': 'The day is over: press R to play again',
218 'unknown-direction': 'That is not a way to walk',
219 'off-bed': 'That is the edge of the bed',
220 'nothing-to-pull': 'Nothing to pull here yet',
221 };
222 const DIR_WORD = { up: 'up', down: 'down', left: 'left', right: 'right' };
223 function refuse(r) { say(WHY[r.why] || 'Refused'); sound('no'); }
224 function finish() {
225 if (P.status === 'over') { say(`${P.line} · press R or tap to play again`); sound('win'); }
226 }
227 function walk(dir) {
228 const r = M.walk(P, dir);
229 if (!r.ok) { refuse(r); return; }
230 P = r.state;
231 const { row, col } = P.gardener, c = P.carrots[row * P.cols + col];
232 const stage = c.fate === null ? M.stageOf(c.sowAt, P.tick) : null;
233 say(`Walked ${DIR_WORD[dir]} to row ${row + 1}, column ${col + 1}: ${c.fate ? 'bare soil' : stage ? 'a ' + stage + ' carrot' : 'empty soil'}`);
234 sound('step');
235 }
236 function pullHere() {
237 const before = P.carrots[P.gardener.row * P.cols + P.gardener.col];
238 const stage = M.stageOf(before.sowAt, P.tick), r = M.pull(P);
239 if (!r.ok) { refuse(r); return; }
240 P = r.state;
241 say(`Pulled a ${stage} carrot for ${P.scores[stage]}: score ${P.score}`);
242 sound(stage === 'ripe' ? 'pull' : 'meh'); renderHud();
243 }
244 function tick() {
245 const before = P, r = M.step(P);
246 if (!r.ok) return;
247 P = r.state; G.hop = 0;
248 if (P.lost > before.lost) { say(`The rabbit nibbled a carrot: ${P.lost} lost`); sound('no'); }
249 renderHud(); finish();
250 }
251 function restart() {
252 G.games++;
253 P = M.start((Date.now() + G.games * 977) & 0xffff);
254 G.acc = 0; G.hop = 0;
255 say(`New day: ${P.day} ticks, a tick every ${TICK_SECONDS} s`);
256 renderHud();
257 }
258 function update(dt) {
259 G.time += dt;
260 G.hop += dt * 4;
261 if (P.status !== 'playing') return;
262 G.acc += dt;
263 while (G.acc >= TICK_SECONDS && P.status === 'playing') { G.acc -= TICK_SECONDS; tick(); }
264 }
265
266 /* ---------- input ---------- */
267 const KEYS = { ArrowUp: 'up', ArrowDown: 'down', ArrowLeft: 'left', ArrowRight: 'right', w: 'up', s: 'down', a: 'left', d: 'right' };
268 canvas.addEventListener('keydown', e => {
269 if (e.ctrlKey || e.metaKey || e.altKey) return;
270 const k = e.key.length === 1 ? e.key.toLowerCase() : e.key;
271 ensureAudio();
272 if (k === 'r') { e.preventDefault(); restart(); return; }
273 if (P.status !== 'playing') { if (k === 'Enter' || k === ' ') { e.preventDefault(); restart(); } return; }
274 if (Object.hasOwn(KEYS, k)) { e.preventDefault(); walk(KEYS[k]); }
275 else if (k === ' ') { e.preventDefault(); if (!e.repeat) pullHere(); }
276 });
277 canvas.addEventListener('pointerdown', e => {
278 canvas.focus({ preventScroll: true });
279 ensureAudio();
280 if (P.status !== 'playing') { restart(); return; }
281 const box = canvas.getBoundingClientRect();
282 const cell = cellAt((e.clientX - box.left) * W / box.width, (e.clientY - box.top) * H / box.height);
283 if (!cell) return;
284 const dr = cell.row - P.gardener.row, dc = cell.col - P.gardener.col;
285 if (dr === 0 && dc === 0) pullHere();
286 else if (Math.abs(dr) + Math.abs(dc) === 1) walk(dr < 0 ? 'up' : dr > 0 ? 'down' : dc < 0 ? 'left' : 'right');
287 else say('Tap a square next to you to walk there, or your own square to pull');
288 });
289
290 /* ---------- sound ---------- */
291 let audio = null, muted = false;
292 function ensureAudio() {
293 if (audio) { if (audio.state === 'suspended') audio.resume(); return; }
294 const AC = window.AudioContext || window.webkitAudioContext;
295 if (AC) audio = new AC();
296 }
297 function sound(kind) {
298 if (!audio || muted) return;
299 const now = audio.currentTime, gain = audio.createGain(), osc = audio.createOscillator();
300 const spec = {
301 step: [300, 360, .05, .03, 'sine'],
302 pull: [300, 900, .15, .05, 'sine'],
303 meh: [260, 200, .12, .04, 'triangle'],
304 no: [200, 120, .15, .04, 'square'],
305 win: [400, 900, .4, .05, 'sine'],
306 }[kind];
307 osc.type = spec[4];
308 osc.frequency.setValueAtTime(spec[0], now);
309 osc.frequency.exponentialRampToValueAtTime(spec[1], now + spec[2]);
310 gain.gain.setValueAtTime(spec[3], now);
311 gain.gain.exponentialRampToValueAtTime(.0005, now + spec[2]);
312 osc.connect(gain).connect(audio.destination);
313 osc.start(now); osc.stop(now + spec[2] + .01);
314 }
315 const muteButton = document.getElementById('mute');
316 muteButton.addEventListener('click', () => {
317 muted = !muted;
318 muteButton.textContent = muted ? 'sound off' : 'sound on';
319 muteButton.setAttribute('aria-pressed', String(muted));
320 });
321
322 /* ---------- fit and boot ---------- */
323 addEventListener('resize', fit);
324 fit();
325 bake();
326 say(`Walk to a ripe carrot and pull it before the rabbit does: a tick every ${TICK_SECONDS} s`);
327 renderHud();
328 canvas.focus({ preventScroll: true });
329 let last = performance.now();
330 function frame(now) {
331 update(Math.min(.04, (now - last) / 1000));
332 last = now;
333 draw();
334 requestAnimationFrame(frame);
335 }
336 requestAnimationFrame(frame);
337 </script>
338 </body>
339 </html>
carrot-pull/page.test.mjs added +74
@@ -0,0 +1,74 @@
1 import { test, expect, setDefaultTimeout } from 'bun:test';
2 import { boot } from '../playtest.mjs';
3
4 setDefaultTimeout(20000);
5 const state = g => JSON.parse(g.read('JSON.stringify(P)'));
6 const text = g => g.el('status').textContent;
7 const at = (g, row, col) => g.read(`cellScreen(${row}, ${col})`);
8
9 test('arrow keys and WASD walk the gardener, and each move and refusal is announced', () => {
10 const g = boot('carrot-pull');
11 g.press('ArrowRight', .02);
12 expect(state(g).gardener).toEqual({ row: 0, col: 1 });
13 expect(text(g)).toContain('Walked right to row 1, column 2');
14 g.press('s', .02);
15 expect(state(g).gardener).toEqual({ row: 1, col: 1 });
16 g.press('ArrowUp', .02); g.press('w', .02);
17 expect(text(g)).toContain('edge of the bed');
18 expect(state(g).gardener).toEqual({ row: 0, col: 1 });
19 });
20
21 test('Space pulls: a sown carrot scores by stage and a second pull is refused, aloud', () => {
22 const g = boot('carrot-pull');
23 g.read('P = { ...P, carrots: P.carrots.map((c, i) => i === 0 ? { ...c, sowAt: P.tick - 8 } : c) }');
24 g.press(' ', .02);
25 expect(state(g).score).toBe(5);
26 expect(g.el('score').textContent).toBe('5');
27 expect(g.el('ripe').textContent).toBe('1');
28 expect(text(g)).toContain('Pulled a ripe carrot for 5');
29 g.press(' ', .02);
30 expect(text(g)).toContain('Nothing to pull');
31 });
32
33 test('on touch, tapping a neighbouring square walks there and tapping the gardener square pulls', () => {
34 const g = boot('carrot-pull');
35 g.tap(...at(g, 0, 1));
36 expect(state(g).gardener).toEqual({ row: 0, col: 1 });
37 g.tap(...at(g, 3, 5));
38 expect(state(g).gardener).toEqual({ row: 0, col: 1 });
39 expect(text(g)).toContain('next to you');
40 g.read('P = { ...P, carrots: P.carrots.map((c, i) => i === 1 ? { ...c, sowAt: P.tick - 8 } : c) }');
41 g.tap(...at(g, 0, 1));
42 expect(state(g).score).toBe(5);
43 });
44
45 test('the day ticks on at a steady pace, the HUD reads the rules state, and the end line is announced', () => {
46 const g = boot('carrot-pull');
47 const t0 = state(g).tick;
48 g.run(1.3);
49 expect(state(g).tick).toBe(t0 + 2);
50 expect(g.el('left').textContent).toBe(String(state(g).day - state(g).tick));
51 g.run(40);
52 expect(state(g).status).toBe('over');
53 expect(text(g)).toContain('Day over: score');
54 expect(text(g)).toContain('press R');
55 g.press('r', .02);
56 expect(state(g).status).toBe('playing');
57 expect(state(g).tick).toBeLessThan(2);
58 });
59
60 test('under reduced motion the leaves do not sway between frames', () => {
61 const g = boot('carrot-pull', { reduced: true });
62 g.read('P = { ...P, carrots: P.carrots.map(c => ({ ...c, sowAt: P.tick - 8 })), rabbitPath: P.rabbitPath.map(() => [3, 5]), rabbit: { row: 3, col: 5 } }');
63 g.run(.05);
64 const a = g.picture();
65 g.run(.3);
66 expect(g.picture()).toBe(a);
67 });
68
69 test('the mute button toggles and is labelled', () => {
70 const g = boot('carrot-pull');
71 g.el('mute').fire('click');
72 expect(g.el('mute').textContent).toBe('sound off');
73 expect(g.el('mute').getAttribute('aria-pressed')).toBe('true');
74 });
index.html +1
@@ -136,6 +136,7 @@ footer a::after { content: none; }
136136 <li data-kind="Strategy" data-input="Tap" style="--accent:#6b8e23"><a href="fence-posts/">Fence Posts</a><span>Take turns fencing the meadow and claim every pen you close</span><span>Game forged by the crew</span><span class="tags">Strategy · Tap</span></li>
137137 <li data-kind="Throwing" data-input="Tap" style="--accent:#2f6b3a"><a href="welly-toss/">Welly Toss</a><span>Let the boot go at the top of the swing and land it past the flags</span><span>Game forged by the crew</span><span class="tags">Throwing · Tap</span></li>
138138 <li data-kind="Puzzle" data-input="Tap" style="--accent:#2b7a8c"><a href="ticket-booth/">Ticket Booth</a><span>Hand each pier visitor exact change from a limited till</span><span>Game forged by the crew</span><span class="tags">Puzzle · Tap</span></li>
139 <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>
139140 </ul>
140141 <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>
141142 </main>
og/carrot-pull.png added
Binary file not shown.
og/home.png
Binary file not shown.
rounds.mjs +12
@@ -12,6 +12,7 @@ import Jars from './jam-jars/shelf.js';
1212 import Shelf from './library-shelf/shelf.js';
1313 import Sweep from './chimney-sweep/roofs.js';
1414 import Field from './fence-posts/field.js';
15 import Carrot from './carrot-pull/bed.js';
1516
1617 const MID = [180, 140];
1718 const tapping = (wait = .3) => g => { g.tap(...MID); g.run(wait); };
@@ -545,6 +546,17 @@ export const ROUNDS = {
545546 }
546547 } },
547548 'window-washer': { over: /play again/, outcome: /[1-9]\d* of 500/, beat: g => g.drag([60, 100], [300, 100], .6) },
549 // walk to the nearest carrot that is ripe now and pull it; with none ripe, wait for the next tick
550 'carrot-pull': { over: /Day over/, outcome: /Day over: score [1-9]\d*, \d+ carrots pulled ripe, \d+ lost to the rabbit/, beat: g => {
551 const s = JSON.parse(g.read('JSON.stringify(P)'));
552 if (s.status !== 'playing') { g.run(.3); return; }
553 const ripe = s.carrots.filter(c => c.fate === null && Carrot.stageOf(c.sowAt, s.tick) === 'ripe');
554 const { row, col } = s.gardener, dist = c => Math.abs(c.row - row) + Math.abs(c.col - col);
555 const t = ripe.sort((a, b) => dist(a) - dist(b))[0];
556 if (!t) { g.run(.2); return; }
557 if (dist(t) === 0) { g.press(' ', .02); return; }
558 g.press(t.row < row ? 'ArrowUp' : t.row > row ? 'ArrowDown' : t.col < col ? 'ArrowLeft' : 'ArrowRight', .02);
559 } },
548560 };
549561
550562 const LIMIT = 150; // seconds of play, a game that has not ended by then is stuck (a round may name its own `limit`)
serve.test.mjs +8 −1
@@ -330,7 +330,7 @@ test('every game has a kind, input and accent, and the static list carries them'
330330 delete globalThis.PixelPlays;
331331 }
332332 for (const g of games) {
333 expect(['Sport', 'Throwing', 'Timing', 'Catching', 'Precision', 'Racing', 'Memory', 'Strategy', 'Puzzle', 'Climbing']).toContain(g.kind);
333 expect(['Sport', 'Throwing', 'Timing', 'Catching', 'Precision', 'Racing', 'Memory', 'Strategy', 'Puzzle', 'Climbing', 'Arcade']).toContain(g.kind);
334334 expect(['Tap', 'Hold', 'Drag', 'Keys']).toContain(g.input);
335335 expect(g.accent).toMatch(/^#[0-9a-f]{6}$/i);
336336 expect(html).toContain(`<li data-kind="${g.kind}" data-input="${g.input}" style="--accent:${g.accent}"><a href="${g.id}/">`);
@@ -754,3 +754,10 @@ test('Ticket Booth and its rules script are served without a track', async () =>
754754 expect(page.headers.get('content-type')).toBe('text/html; charset=utf-8');
755755 expect((await fetch(base + '/ticket-booth/till.js')).status).toBe(200);
756756 });
757
758 test('Carrot Pull and its rules script are served without a track', async () => {
759 const page = await fetch(base + '/carrot-pull/');
760 expect(page.status).toBe(200);
761 expect(page.headers.get('content-type')).toBe('text/html; charset=utf-8');
762 expect((await fetch(base + '/carrot-pull/bed.js')).status).toBe(200);
763 });
shell.js +2 −1
@@ -1,7 +1,7 @@
11 'use strict';
22 // Shared chrome for every game: game menu (top centre) and rules popup (top right).
33 window.PixelPlays = (() => {
4 // add a game here and give it a folder, a one-line `blurb`, a `kind` (Sport, Throwing, Timing, Catching, Precision, Racing, Memory, Strategy, Puzzle, Climbing), an `input` (Tap, Hold, Drag, Keys) and an `accent` colour for its card (the home page lists it from here) (`music: false` when it has no track, `forged: true` when it was made on the forge rather than by the original creators); `soon` entries show in the menu but do not link
4 // add a game here and give it a folder, a one-line `blurb`, a `kind` (Sport, Throwing, Timing, Catching, Precision, Racing, Memory, Strategy, Puzzle, Climbing, Arcade), an `input` (Tap, Hold, Drag, Keys) and an `accent` colour for its card (the home page lists it from here) (`music: false` when it has no track, `forged: true` when it was made on the forge rather than by the original creators); `soon` entries show in the menu but do not link
55 const GAMES = [
66 { id: 'balote', title: 'Balote', blurb: 'Bocce against a bot, first to 5', kind: 'Throwing', input: 'Drag', accent: '#d9a441' },
77 { id: 'stari-most', title: 'Stari Most', blurb: 'Five dives off the old bridge, flip and enter the water vertically', kind: 'Timing', input: 'Hold', accent: '#3f8fb5' },
@@ -76,6 +76,7 @@ window.PixelPlays = (() => {
7676 { id: 'fence-posts', title: 'Fence Posts', blurb: 'Take turns fencing the meadow and claim every pen you close', kind: 'Strategy', input: 'Tap', accent: '#6b8e23', music: false, forged: true },
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: '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 },
79 { 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 },
7980 ];
8081 const MUSIC_VOLUME = .4;
8182 const CREDITS = [

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs