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

Migrated from github.com/amirmushichge/pixel-plays

Add Fence Posts: the playable page and its place in the catalogue, on the rules in `fence-posts/field.js` #335

Merged opened this

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

Merged

vesper/pixel-plays:vesper/304-add-fence-posts-the-playable-page-and-it → main 2 commits fork

  • Merged. vesper/304-add-fence-posts-the-playable-page-and-it was merged into main as 76f43b2.

Review

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

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

View file
fence-posts/index.html added +405
@@ -0,0 +1,405 @@
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>Fence Posts</title>
7 <!-- og:begin -->
8 <meta name="description" content="Take turns fencing the meadow and claim every pen you close">
9 <link rel="canonical" href="https://pixel-plays--forge-trevato-apps.open-platform.sh/fence-posts/">
10 <meta property="og:type" content="website">
11 <meta property="og:site_name" content="Pixel Plays">
12 <meta property="og:title" content="Fence Posts">
13 <meta property="og:description" content="Take turns fencing the meadow and claim every pen you close">
14 <meta property="og:url" content="https://pixel-plays--forge-trevato-apps.open-platform.sh/fence-posts/">
15 <meta property="og:image" content="https://pixel-plays--forge-trevato-apps.open-platform.sh/og/fence-posts.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="Fence Posts: 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: #6b8e23; --you: #3f6fc4; --farmer: #c4562a; }
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 .row.you { color: var(--you); }
38 .row.farmer { color: var(--farmer); }
39 .mark { display: inline-block; width: 18px; height: 12px; border: 1px solid currentColor; }
40 .you .mark { background: repeating-linear-gradient(0deg, var(--you) 0 3px, #fff 3px 6px); }
41 .farmer .mark { background: conic-gradient(var(--farmer) 25%, #fff 0 50%, var(--farmer) 0 75%, #fff 0) 0 0 / 6px 6px; }
42 #turn { color: var(--muted); }
43 #status { color: var(--muted); min-height: 2.7em; max-width: 60ch; }
44 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; }
45 button:hover, button:focus-visible { color: var(--ink); }
46 button:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
47 noscript p { position: fixed; inset: 40% 16px auto; margin: 0; text-align: center; color: var(--muted); }
48 </style>
49 </head>
50 <body>
51 <div id="stage"><canvas id="c" tabindex="0" aria-label="Fence Posts: a meadow staked with a grid of posts, four by four pens, with sheep grazing in each pen. Tap between two posts to build a length of fence there, or use the arrow keys to move the highlight and Enter to build. Close the fourth side of a pen to claim its sheep and fence again; the farmer then takes a turn. Your fences are blue-grey rails and your flags striped; the farmer's are woven wattle and checked flags. The most sheep wins."></canvas></div>
52 <noscript><p>Fence Posts needs JavaScript to play.</p></noscript>
53 <div id="scores">
54 <div class="row you"><i class="mark"></i><b id="ysheep">0</b><span>sheep</span><b id="ypens">0</b><span>pens, you</span></div>
55 <div class="row farmer"><i class="mark"></i><b id="fsheep">0</b><span>sheep</span><b id="fpens">0</b><span>pens, farmer</span></div>
56 <div class="row"><span id="turn"></span></div>
57 </div>
58 <div class="hud" id="bottom">
59 <div id="status" role="status" aria-live="polite"></div>
60 <button id="mute" type="button" aria-pressed="false">sound on</button>
61 </div>
62 <script src="../shell.js"></script>
63 <script>
64 PixelPlays.mount({
65 id: 'fence-posts',
66 rules: [
67 'Tap between two posts, or move the highlight with the arrow keys and press Enter, to build a length of fence.',
68 'Close the fourth side of a pen to claim it and its sheep, then fence again; otherwise the farmer takes a turn.',
69 'Your rails and striped flags are blue; the farmer\'s wattle and checked flags are orange.',
70 'When all 40 lengths stand, the most sheep wins; a tie goes to more pens.',
71 ],
72 });
73 </script>
74 <script>window.PP_SCENE = { originY: 14, shadowX: 1.3, shadowY: -.12 };</script>
75 <script src="../engine.js"></script>
76 <script src="field.js"></script>
77 <script>
78 'use strict';
79
80 const M = window.FencePosts;
81 const SP = 4.8, HALF = SP * M.ROWS / 2; // distance between posts, and half the grid, in world units
82 const POST_H = 1.5;
83 const WOOD = [150, 110, 72], RAIL = [120, 156, 214], WATTLE = [196, 124, 58], WOOL = [240, 236, 226], FACE = [58, 50, 50];
84 const YOU_FLAG = [[64, 112, 200], [250, 250, 244]], FARMER_FLAG = [[200, 84, 44], [250, 236, 200]];
85 const TURN_MS = .55; // seconds between the farmer's lengths
86 const LIGHT = [255, 230, 90];
87
88 /* ---------- geometry: where the rules' names sit on the meadow ---------- */
89 const postAt = (r, c) => [c * SP - HALF, r * SP - HALF];
90 function ends(edge) {
91 const { k, r, c } = M.parse(edge);
92 return k === 'h' ? [postAt(r, c), postAt(r, c + 1)] : [postAt(r, c), postAt(r + 1, c)];
93 }
94 const midOf = edge => { const [a, b] = ends(edge); return [(a[0] + b[0]) / 2, (a[1] + b[1]) / 2]; };
95 const gridMid = edge => { const { k, r, c } = M.parse(edge); return k === 'h' ? [c + .5, r] : [c, r + .5]; };
96 const centreOf = i => postAt(Math.floor(i / M.COLS) + .5, i % M.COLS + .5);
97 // the canvas pixel of the foot of a length, for taps
98 function screenOf(edge) { const m = midOf(edge), p = project(m[0], m[1], 0); return [p.sx, p.sy]; }
99 const post = (r, c) => 'ABCDE'[c] + (r + 1);
100 const nameOf = edge => { const [a, b] = ends(edge).map(p => post(Math.round((p[1] + HALF) / SP), Math.round((p[0] + HALF) / SP))); return `${a} to ${b}`; };
101 // the unbuilt length nearest a world point, if one lies within reach
102 function pick(x, y) {
103 let best = null, bd = 1.5;
104 for (const e of M.fences(P)) {
105 const [a, b] = ends(e), dx = b[0] - a[0], dy = b[1] - a[1];
106 const t = clamp(((x - a[0]) * dx + (y - a[1]) * dy) / (dx * dx + dy * dy), 0, 1);
107 const d = Math.hypot(x - a[0] - dx * t, y - a[1] - dy * t);
108 if (d < bd) { bd = d; best = e; }
109 }
110 return best;
111 }
112 // arrows move on the screen: right is +column and -row, because the camera looks along the grid's diagonal
113 const DIRS = { ArrowLeft: [-1, 1], ArrowRight: [1, -1], ArrowUp: [-1, -1], ArrowDown: [1, 1] };
114 function neighbour(from, key, open) {
115 const d = DIRS[key], n = Math.hypot(d[0], d[1]), m = gridMid(from);
116 let best = null, bs = 1e9;
117 for (const e of open) {
118 if (e === from) continue;
119 const g = gridMid(e), dx = g[0] - m[0], dy = g[1] - m[1];
120 const along = (dx * d[0] + dy * d[1]) / n, across = Math.abs(dx * d[1] - dy * d[0]) / n;
121 if (along < .3) continue;
122 const s = along + 2 * across;
123 if (s < bs) { bs = s; best = e; }
124 }
125 return best;
126 }
127 function nearest(from, open) {
128 const m = gridMid(from);
129 let best = open[0], bd = 1e9;
130 for (const e of open) { const g = gridMid(e), d = Math.hypot(g[0] - m[0], g[1] - m[1]); if (d < bd) { bd = d; best = e; } }
131 return best;
132 }
133
134 /* ---------- state ---------- */
135 const nodes = {};
136 for (const k of ['ysheep', 'ypens', 'fsheep', 'fpens', 'turn', 'status']) nodes[k] = document.getElementById(k);
137 const shown = {};
138 function setText(key, text) {
139 if (shown[key] === text) return;
140 shown[key] = text;
141 nodes[key].textContent = text;
142 }
143 let P = M.start(Math.floor(Math.random() * 4294967296));
144 // cursor: the highlighted length; queue: the farmer's lengths still to show, final: the state they end in
145 const G = { time: 0, cursor: 'h:2:1', queue: [], final: null, said: '', nextAt: 0, dirty: true, wind: -1, overAt: 0 };
146
147 function renderHud() {
148 const t = M.tally(P);
149 setText('ysheep', String(t.you.sheep)); setText('ypens', String(t.you.pens));
150 setText('fsheep', String(t.farmer.sheep)); setText('fpens', String(t.farmer.pens));
151 setText('turn', P.status !== 'playing' ? 'The game is over' : P.turn === M.YOU ? 'Your turn' : "The farmer's turn");
152 }
153 function say(text) { setText('status', text); }
154
155 /* ---------- the meadow ---------- */
156 function groundColor(x, y, ix, iy, i) {
157 const edge = Math.pow(Math.pow(Math.abs(x) / (HALF + 2.2), 8) + Math.pow(Math.abs(y) / (HALF + 2.2), 8), .125);
158 const rough = edge + (noise(x * .6, y * .6) - .5) * .08;
159 if (rough > 1.03) return 0;
160 if (rough > .93 && hash(ix, iy) < (rough - .93) / .1) return 0;
161 kinds[i] = KIND_GRASS;
162 const t = noise(x * .35, y * .35) * .8 + (hash(ix, iy) - .5) * .3 + (bayerAt(ix, iy) - .5) * .25 - .1;
163 return rgb(...GRASS[clamp(Math.floor(t * 3.4) + 1, 1, 3)]);
164 }
165 function bake() {
166 tufts.length = 0;
167 ground.fill(0); shadowMask.fill(0); staticShadow.fill(0); kinds.fill(0);
168 zb.fill(-1e9);
169 pix.fill(PAPER);
170 for (let iy = 0; iy < H; iy++) for (let ix = 0; ix < W; ix++) {
171 const g = unproject(ix + .5, iy + .5), i = iy * W + ix, c = groundColor(g.x, g.y, ix, iy, i);
172 if (!c) continue;
173 pix[i] = c; ground[i] = 1;
174 zb[i] = (g.x + g.y) / R2 * COS;
175 }
176 growGrass();
177 groundZ.set(zb);
178 for (let r = 0; r <= M.ROWS; r++) for (let c = 0; c <= M.COLS; c++) {
179 const [x, y] = postAt(r, c);
180 capsule([x, y, 0], [x, y, POST_H], .17, .15, () => WOOD, 'solid');
181 sphere(x, y, POST_H + .05, .2, WOOD, 'solid');
182 for (let z = .3; z <= POST_H; z += .4) castShadow(x, y, z, .22, 1, .4);
183 }
184 bakeOcclusion();
185 staticShadow.set(shadowMask);
186 applyShadows(groundZ);
187 bakedPix.set(pix);
188 bakedZ.set(zb);
189 }
190
191 /* ---------- fences, sheep, flags ---------- */
192 const bone = (a, b, r, c) => dynamicAdd(a, b, r, r, () => c);
193 const along = (a, b, t, z) => [a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t, z];
194 // your fence: two straight blue-grey rails
195 function rails(a, b) {
196 for (const z of [.5, 1.0]) bone(along(a, b, .06, z), along(a, b, .94, z), .12, RAIL);
197 }
198 // the farmer's fence: upright stakes with a weave of short rails, high then low
199 function wattle(a, b) {
200 const N = 8;
201 for (let k = 0; k <= N; k++) bone(along(a, b, .06 + .88 * k / N, 0), along(a, b, .06 + .88 * k / N, 1.15), .1, WATTLE);
202 for (let k = 0; k < N; k++) {
203 const lo = k % 2 ? .3 : .65;
204 for (const z of [lo, lo + .45]) bone(along(a, b, .06 + .88 * k / N, z), along(a, b, .06 + .88 * (k + 1) / N, z), .09, WATTLE);
205 }
206 }
207 function drawFences() {
208 for (const e in P.built) {
209 const [a, b] = ends(e);
210 (P.built[e] === M.YOU ? rails : wattle)(a, b);
211 }
212 }
213 function drawSheep(t) {
214 P.pens.forEach((pen, i) => {
215 const [cx, cy] = centreOf(i);
216 for (let k = 0; k < pen.sheep; k++) {
217 const h = hash(i * 7 + k, 3 + k), ang = (k / pen.sheep) * Math.PI * 2 + i * 1.7 + h, rad = 1.0 + h * .35;
218 const x = cx + Math.cos(ang) * rad, y = cy + Math.sin(ang) * rad, face = ang + (k % 2 ? 1.2 : -1.2);
219 // grazing: the head dips and lifts, a frame at a time
220 const dip = STILL ? 0 : (Math.floor(t * 2 + h * 9 + k) % 3 === 0 ? .32 : 0);
221 sphere(x, y, .52, .4, WOOL, 'rough');
222 sphere(x + Math.cos(face) * .42, y + Math.sin(face) * .42, .6 - dip, .2, FACE, 'solid');
223 bone([x - .15, y, 0], [x - .15, y, .22], .06, FACE);
224 bone([x + .15, y, 0], [x + .15, y, .22], .06, FACE);
225 }
226 });
227 }
228 // the cloth is painted straight into the picture so its pattern is crisp; it faces the camera
229 function cloth(cx, cy, z0, w, h, colours, pattern, flutter) {
230 for (let a = 0; a < w; a += .06) for (let z = 0; z < h; z += .06) {
231 const dz = flutter ? Math.round(Math.sin(a * 3 + flutter) * .5) * .12 * (a / w) : 0;
232 const p = project(cx + (a + .1) / R2, cy - (a + .1) / R2, z0 + z + dz), ix = p.sx | 0, iy = p.sy | 0;
233 if (ix < 0 || iy < 0 || ix >= W || iy >= H) continue;
234 const i = iy * W + ix;
235 if (p.d <= zb[i]) continue;
236 zb[i] = p.d;
237 pix[i] = rgb(...colours[pattern(a, z) ? 1 : 0]);
238 }
239 }
240 function drawFlags(t) {
241 P.pens.forEach((pen, i) => {
242 if (!pen.owner) return;
243 const [cx, cy] = centreOf(i), mine = pen.owner === M.YOU;
244 bone([cx, cy, 0], [cx, cy, 2.7], .07, [70, 56, 46]);
245 const flutter = STILL ? 0 : Math.floor(t * 8) * .9;
246 // yours: stripes along the cloth; the farmer's: a check, so the two differ without colour
247 cloth(cx, cy, 1.75, 1.5, .85, mine ? YOU_FLAG : FARMER_FLAG, mine ? (a, z) => Math.floor(z / .21) % 2 : (a, z) => (Math.floor(a / .3) + Math.floor(z / .21)) % 2, flutter);
248 });
249 }
250 function drawCursor() {
251 if (P.status !== 'playing' || P.turn !== M.YOU) return;
252 for (const e of M.fences(P)) {
253 const m = midOf(e);
254 sphere(m[0], m[1], .08, .14, [250, 250, 236], 'solid');
255 }
256 if (!P.built[G.cursor]) {
257 const [a, b] = ends(G.cursor);
258 capsule([a[0], a[1], .1], [b[0], b[1], .1], .22, .22, () => LIGHT, 'solid');
259 }
260 }
261 function draw() {
262 pix.set(bakedPix);
263 zb.set(bakedZ);
264 pendingShadows.length = 0;
265 const t = STILL ? 0 : Math.floor(G.time * WIND_FPS) / WIND_FPS;
266 drawFences();
267 drawSheep(t);
268 drawFlags(t);
269 drawTufts(t);
270 drawCursor();
271 flushShadows();
272 applyShadows(bakedZ);
273 shadeEdges();
274 ctx.putImageData(image, 0, 0);
275 }
276
277 /* ---------- game: every rule is the rules script's ---------- */
278 function toOpen() {
279 const open = M.fences(P);
280 if (open.length && !open.includes(G.cursor)) G.cursor = nearest(G.cursor, open);
281 }
282 function finish() {
283 G.overAt = G.time;
284 say(`${G.said ? G.said + '. ' : ''}${P.line}. Tap or press Enter to play again.`);
285 sound(P.status === 'won' ? 880 : P.status === 'lost' ? 110 : 440, .4, P.status === 'lost' ? 'sawtooth' : 'triangle', .15);
286 }
287 function announce(who, edge, closed, pen) {
288 const sheep = closed ? ` and ${who === M.YOU ? 'claim' : 'claims'} a pen of ${closed === 1 ? pen.sheep : 'two pens'}${closed === 1 ? ' sheep' : ''}` : '';
289 return `${who === M.YOU ? 'You fence' : 'The farmer fences'} ${nameOf(edge)}${sheep}`;
290 }
291 function place(edge, who) {
292 const before = P, r = M.build(P, edge, who);
293 if (!r.ok) return r;
294 P = r.state;
295 G.dirty = true;
296 const pen = P.pens.find((p, i) => p.owner && !before.pens[i].owner);
297 G.said = announce(who, edge, r.closed, pen);
298 say(`${G.said}. ${P.line}.`);
299 sound(r.closed ? 660 : who === M.YOU ? 300 : 220, r.closed ? .25 : .07, r.closed ? 'triangle' : 'square');
300 toOpen();
301 return r;
302 }
303 function build(edge) {
304 ensureAudio();
305 G.dirty = true;
306 if (P.status !== 'playing') { if (G.time - G.overAt > .6) restart(); return; }
307 if (!edge) { say('Tap between two posts where a length is missing.'); return; }
308 G.cursor = edge;
309 const r = place(edge, M.YOU);
310 if (!r.ok) { say(r.why === 'not your turn' ? "Wait: it is the farmer's turn." : `Cannot build: ${r.why}.`); return; }
311 if (P.status !== 'playing') { finish(); return; }
312 if (P.turn === M.FARMER) {
313 const rep = M.reply(P);
314 G.queue = rep.moves.slice(); G.final = rep.state; G.nextAt = G.time + TURN_MS;
315 }
316 }
317 function update(dt) {
318 G.time += dt;
319 if (G.queue.length && G.time >= G.nextAt) {
320 const m = G.queue.shift();
321 place(m.edge, M.FARMER);
322 G.nextAt = G.time + TURN_MS;
323 if (!G.queue.length) {
324 P = G.final; G.final = null; // the same state with the stream the rules left it in
325 if (P.status !== 'playing') finish();
326 }
327 }
328 const wind = STILL ? 0 : Math.floor(G.time * WIND_FPS);
329 if (wind !== G.wind) { G.wind = wind; G.dirty = true; }
330 renderHud();
331 }
332 function restart() {
333 P = M.start(Math.floor(Math.random() * 4294967296));
334 G.queue = []; G.final = null; G.said = ''; G.cursor = 'h:2:1'; G.dirty = true;
335 say(`${P.line}. Tap between two posts to build.`);
336 }
337
338 /* ---------- input: a tap builds where it lands, the arrows move a highlight ---------- */
339 const point = e => { const r = canvas.getBoundingClientRect(); return unproject((e.clientX - r.left) / r.width * W, (e.clientY - r.top) / r.height * H); };
340 canvas.addEventListener('pointerdown', () => canvas.focus({ preventScroll: true }));
341 canvas.addEventListener('pointermove', e => {
342 if (e.buttons || P.status !== 'playing' || P.turn !== M.YOU) return;
343 const p = point(e), edge = pick(p.x, p.y);
344 if (edge && edge !== G.cursor) { G.cursor = edge; G.dirty = true; }
345 });
346 canvas.addEventListener('pointerup', e => {
347 const p = point(e);
348 build(pick(p.x, p.y));
349 });
350 canvas.addEventListener('contextmenu', e => e.preventDefault());
351 canvas.addEventListener('keydown', e => {
352 if (e.ctrlKey || e.metaKey || e.altKey) return;
353 if (DIRS[e.key]) {
354 if (P.status === 'playing') {
355 const to = neighbour(G.cursor, e.key, M.fences(P));
356 if (to) { G.cursor = to; G.dirty = true; say(`Highlight on ${nameOf(to)}.`); } else say('No length of fence that way.');
357 }
358 } else if (e.key === 'Enter' || e.key === ' ') { if (!e.repeat) build(P.built[G.cursor] ? null : G.cursor); }
359 else return;
360 e.preventDefault();
361 });
362
363 /* ---------- sound ---------- */
364 let audio = null, muted = false;
365 function ensureAudio() {
366 if (audio) { if (audio.state === 'suspended') audio.resume(); return; }
367 const AC = window.AudioContext || window.webkitAudioContext;
368 if (AC) audio = new AC();
369 }
370 function sound(freq, life, type, delay = 0) {
371 if (!audio || muted) return;
372 const now = audio.currentTime + delay, gain = audio.createGain(), osc = audio.createOscillator();
373 osc.type = type;
374 osc.frequency.setValueAtTime(freq, now);
375 gain.gain.setValueAtTime(.0001, now);
376 gain.gain.exponentialRampToValueAtTime(.06, now + .01);
377 gain.gain.exponentialRampToValueAtTime(.0005, now + life);
378 osc.connect(gain).connect(audio.destination);
379 osc.start(now); osc.stop(now + life + .02);
380 }
381 const muteButton = document.getElementById('mute');
382 muteButton.addEventListener('click', () => {
383 muted = !muted;
384 muteButton.textContent = muted ? 'sound off' : 'sound on';
385 muteButton.setAttribute('aria-pressed', String(muted));
386 });
387
388 /* ---------- fit and boot ---------- */
389 addEventListener('resize', fit);
390 fit();
391 bake();
392 renderHud();
393 say(`${P.line}. Tap between two posts to build.`);
394 canvas.focus({ preventScroll: true });
395 let last = performance.now();
396 function frame(now) {
397 update(Math.min(.04, (now - last) / 1000));
398 last = now;
399 if (G.dirty) { G.dirty = false; draw(); }
400 requestAnimationFrame(frame);
401 }
402 requestAnimationFrame(frame);
403 </script>
404 </body>
405 </html>
fence-posts/page.test.mjs added +82
@@ -0,0 +1,82 @@
1 import { test, expect } from 'bun:test';
2 import { boot } from '../playtest.mjs';
3
4 const tapEdge = (g, edge) => { const [x, y] = g.read(`screenOf('${edge}')`); g.tap(x, y); };
5
6 test('a tap between two posts builds there through the rules, then the farmer fences a length at a time', () => {
7 const g = boot('fence-posts');
8 expect(g.el('turn').textContent).toBe('Your turn');
9 tapEdge(g, 'v:1:2');
10 expect(g.read("P.built['v:1:2']")).toBe('you');
11 expect(g.el('status').textContent).toContain('You fence C2 to C3');
12 expect(g.read('G.queue.length')).toBe(1);
13 expect(Object.keys(g.read('P.built')).length).toBe(1);
14 g.run(.7);
15 expect(Object.keys(g.read('P.built')).length).toBe(2);
16 expect(g.el('status').textContent).toContain('The farmer fences');
17 expect(g.read('P.turn')).toBe('you');
18 expect(g.el('turn').textContent).toBe('Your turn');
19 });
20
21 test('the arrows move the highlight over unbuilt lengths and Enter builds it', () => {
22 const g = boot('fence-posts');
23 const from = g.read('G.cursor');
24 g.press('ArrowRight');
25 expect(g.read('G.cursor')).not.toBe(from);
26 expect(g.el('status').textContent).toMatch(/^Highlight on /);
27 const at = g.read('G.cursor');
28 g.press('Enter');
29 expect(g.read(`P.built['${at}']`)).toBe('you');
30 });
31
32 test('closing a pen claims it, shows in the HUD and is announced, and you fence again', () => {
33 const g = boot('fence-posts');
34 // three sides of pen (0,0) already stand, then the fourth
35 g.read(`P = { ...P, built: { 'h:0:0': 'you', 'h:1:0': 'farmer', 'v:0:0': 'you' } }`);
36 tapEdge(g, 'v:0:1');
37 expect(g.read('P.pens[0].owner')).toBe('you');
38 expect(g.read('P.turn')).toBe('you');
39 expect(g.read('G.queue.length')).toBe(0);
40 expect(g.el('status').textContent).toContain('and claim a pen of');
41 expect(g.el('ypens').textContent).toBe('1');
42 expect(g.el('ysheep').textContent).toBe(String(g.read('P.pens[0].sheep')));
43 });
44
45 test('a length that stands, or a tap on bare grass, is refused and says so', () => {
46 const g = boot('fence-posts');
47 g.tap(5, 5);
48 expect(g.read('Object.keys(P.built).length')).toBe(0);
49 expect(g.el('status').textContent).toContain('Tap between two posts');
50 });
51
52 test('the end shows the rules line and a tap plays again', () => {
53 const g = boot('fence-posts');
54 g.read(`P = { ...P, status: 'won', line: 'You win: test' }; finish()`);
55 g.run(1);
56 expect(g.el('status').textContent).toContain('You win: test');
57 g.press('Enter');
58 expect(g.read('P.status')).toBe('playing');
59 expect(g.read('Object.keys(P.built).length')).toBe(0);
60 });
61
62 test('mute toggles its label and aria-pressed', () => {
63 const g = boot('fence-posts');
64 g.el('mute').fire('click');
65 expect(g.el('mute').textContent).toBe('sound off');
66 expect(g.el('mute').attrs['aria-pressed']).toBe('true');
67 });
68
69 test('the page source says how to play, stills the grazing, tells the sides apart by pattern, holds the rules to four lines and names no .mp3', async () => {
70 const html = await Bun.file(new URL('./index.html', import.meta.url)).text();
71 expect(html).toMatch(/<canvas id="c"[^>]*aria-label="[^"]*Tap[^"]*arrow keys[^"]*Enter/);
72 expect(html).toMatch(/min-height: 44px; min-width: 44px/);
73 expect(html).toMatch(/id="mute"[^>]*aria-pressed="false"/);
74 expect(html).toMatch(/role="status" aria-live="polite"/);
75 expect(html).toContain('const dip = STILL ? 0');
76 expect(html).toContain('const flutter = STILL ? 0');
77 expect(html).toContain('function rails');
78 expect(html).toContain('function wattle');
79 expect(html).not.toContain('.mp3');
80 const rules = html.match(/rules: \[([\s\S]*?)\n \],/)[1].split('\n').filter(l => l.trim());
81 expect(rules.length).toBeLessThanOrEqual(4);
82 });
index.html +1
@@ -133,6 +133,7 @@ footer a::after { content: none; }
133133 <li data-kind="Timing" data-input="Tap" style="--accent:#d0573c"><a href="skipping-rope/">Skipping Rope</a><span>Jump the turning rope as it quickens and friends join the line</span><span>Game forged by the crew</span><span class="tags">Timing · Tap</span></li>
134134 <li data-kind="Memory" data-input="Tap" style="--accent:#c98aa0"><a href="tea-party/">Tea Party</a><span>Remember every guest's order and pour it right as the table grows</span><span>Game forged by the crew</span><span class="tags">Memory · Tap</span></li>
135135 <li data-kind="Climbing" data-input="Tap" style="--accent:#4a4a4a"><a href="chimney-sweep/">Chimney Sweep</a><span>Climb the flues with your brush and duck the rising smoke</span><span>Game forged by the crew</span><span class="tags">Climbing · Tap</span></li>
136 <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>
136137 <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>
137138 </ul>
138139 <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/fence-posts.png added
Binary file not shown.
og/home.png
Binary file not shown.
rounds.mjs +8
@@ -11,6 +11,7 @@ import Quilt from './quilt/patch.js';
1111 import Jars from './jam-jars/shelf.js';
1212 import Shelf from './library-shelf/shelf.js';
1313 import Sweep from './chimney-sweep/roofs.js';
14 import Field from './fence-posts/field.js';
1415
1516 const MID = [180, 140];
1617 const tapping = (wait = .3) => g => { g.tap(...MID); g.run(wait); };
@@ -490,6 +491,13 @@ export const ROUNDS = {
490491 }
491492 g.press(' ', .02);
492493 } },
494 // on your turn tap the foot of the length the farmer's own rule would pick, and wait out the farmer's lengths
495 'fence-posts': { over: /play again/, outcome: /You (win|lose): \d+ sheep to \d+, \d+ pens to \d+|A draw: /, beat: g => {
496 const s = JSON.parse(g.read('JSON.stringify(P)'));
497 if (s.status !== 'playing' || s.turn !== 'you' || g.read('G.queue.length')) { g.run(.3); return; }
498 const [x, y] = g.read(`screenOf(${JSON.stringify(Field.choose(s).edge)})`);
499 g.tap(x, y);
500 } },
493501 // let go at the tick the rules' own `fly` says lands furthest in this throw's wind, then wait out the flight
494502 'welly-toss': { over: /play again/, outcome: /Final: [\d.]+ m from the best 3 of 5/, beat: g => {
495503 if (g.read('P.status') !== 'swinging') { g.run(.3); return; }
serve.test.mjs +7
@@ -734,6 +734,13 @@ test('Chimney Sweep and its rules script are served without a track', async () =
734734 expect((await fetch(base + '/chimney-sweep/roofs.js')).status).toBe(200);
735735 });
736736
737 test('Fence Posts and its rules script are served without a track', async () => {
738 const page = await fetch(base + '/fence-posts/');
739 expect(page.status).toBe(200);
740 expect(page.headers.get('content-type')).toBe('text/html; charset=utf-8');
741 expect((await fetch(base + '/fence-posts/field.js')).status).toBe(200);
742 });
743
737744 test('Welly Toss and its rules script are served without a track', async () => {
738745 const page = await fetch(base + '/welly-toss/');
739746 expect(page.status).toBe(200);
shell.js +1
@@ -73,6 +73,7 @@ window.PixelPlays = (() => {
7373 { id: 'tea-party', title: 'Tea Party', blurb: 'Remember every guest\'s order and pour it right as the table grows', kind: 'Memory', input: 'Tap', accent: '#c98aa0', music: false, forged: true },
7474 { id: 'chestnut-roast', title: 'Chestnut Roast', blurb: 'Catch the roasted chestnuts in your paper cone and let the burnt ones drop', kind: 'Catching', input: 'Drag', accent: '#8b4513', music: false, forged: true },
7575 { id: 'chimney-sweep', title: 'Chimney Sweep', blurb: 'Climb the flues with your brush and duck the rising smoke', kind: 'Climbing', input: 'Tap', accent: '#4a4a4a', music: false, forged: true },
76 { 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 },
7677 { 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 },
7778 ];
7879 const MUSIC_VOLUME = .4;

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs