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
Check · Review · Land · Live done — Landed and live in production.
vesper/pixel-plays:vesper/304-add-fence-posts-the-playable-page-and-it → main 2 commits fork
9 files
changed +507
−2
README.md +3 −2
Diff not shown — a line 3,315 characters wide (limit 2,000). +3 −2
View filefence-posts/index.html added +405
| @@ -0,0 +1,405 @@ | ||
| 1 | ||
| 2 | <html lang="en"> | |
| 3 | <head> | |
| 4 | <meta charset="utf-8"> | |
| 5 | <meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover"> | |
| 6 | <title>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 { : #1c1b19; : #6b675f; : #fff; : #6b8e23; : #3f6fc4; : #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(); color: var(); } | |
| 27 | body { font: 13px/1.35 ui-monospace, "Cascadia Mono", Consolas, Menlo, monospace; overflow: hidden; } | |
| 28 | #stage { position: fixed; inset: 0; display: grid; place-items: center; } | |
| 29 | canvas { display: block; image-rendering: pixelated; touch-action: none; cursor: pointer; outline: none; } | |
| 30 | canvas:focus-visible { outline: 2px solid var(); outline-offset: -2px; } | |
| 31 | .hud { position: fixed; left: max(16px, env(safe-area-inset-left)); right: max(16px, env(safe-area-inset-right)); display: flex; justify-content: space-between; align-items: flex-end; gap: 16px; pointer-events: none; } | |
| 32 | #scores { position: fixed; top: max(14px, env(safe-area-inset-top)); left: max(16px, env(safe-area-inset-left)); display: grid; gap: 2px; pointer-events: none; } | |
| 33 | #bottom { bottom: max(20px, env(safe-area-inset-bottom)); } | |
| 34 | .row { display: flex; align-items: center; gap: 8px; } | |
| 35 | .row b { font-size: 16px; font-weight: 600; min-width: 1ch; font-variant-numeric: tabular-nums; } | |
| 36 | .row span { color: var(); } | |
| 37 | .row.you { color: var(); } | |
| 38 | .row.farmer { color: var(); } | |
| 39 | .mark { display: inline-block; width: 18px; height: 12px; border: 1px solid currentColor; } | |
| 40 | .you .mark { background: repeating-linear-gradient(0deg, var() 0 3px, #fff 3px 6px); } | |
| 41 | .farmer .mark { background: conic-gradient(var() 25%, #fff 0 50%, var() 0 75%, #fff 0) 0 0 / 6px 6px; } | |
| 42 | #turn { color: var(); } | |
| 43 | #status { color: var(); min-height: 2.7em; max-width: 60ch; } | |
| 44 | button { pointer-events: auto; font: inherit; color: var(); background: none; border: 0; padding: 13px 10px; margin: -13px -10px; min-height: 44px; min-width: 44px; cursor: pointer; } | |
| 45 | button:hover, button:focus-visible { color: var(); } | |
| 46 | button:focus-visible { outline: 2px solid var(); outline-offset: 2px; } | |
| 47 | noscript p { position: fixed; inset: 40% 16px auto; margin: 0; text-align: center; color: var(); } | |
| 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; } | ||
| 133 | 133 | <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> |
| 134 | 134 | <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> |
| 135 | 135 | <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> | |
| 136 | 137 | <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> |
| 137 | 138 | </ul> |
| 138 | 139 | <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'; | ||
| 11 | 11 | import Jars from './jam-jars/shelf.js'; |
| 12 | 12 | import Shelf from './library-shelf/shelf.js'; |
| 13 | 13 | import Sweep from './chimney-sweep/roofs.js'; |
| 14 | import Field from './fence-posts/field.js'; | |
| 14 | 15 | |
| 15 | 16 | const MID = [180, 140]; |
| 16 | 17 | const tapping = (wait = .3) => g => { g.tap(...MID); g.run(wait); }; |
| @@ -490,6 +491,13 @@ export const ROUNDS = { | ||
| 490 | 491 | } |
| 491 | 492 | g.press(' ', .02); |
| 492 | 493 | } }, |
| 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 | } }, | |
| 493 | 501 | // let go at the tick the rules' own `fly` says lands furthest in this throw's wind, then wait out the flight |
| 494 | 502 | 'welly-toss': { over: /play again/, outcome: /Final: [\d.]+ m from the best 3 of 5/, beat: g => { |
| 495 | 503 | 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 () = | ||
| 734 | 734 | expect((await fetch(base + '/chimney-sweep/roofs.js')).status).toBe(200); |
| 735 | 735 | }); |
| 736 | 736 | |
| 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 | ||
| 737 | 744 | test('Welly Toss and its rules script are served without a track', async () => { |
| 738 | 745 | const page = await fetch(base + '/welly-toss/'); |
| 739 | 746 | expect(page.status).toBe(200); |
shell.js +1
| @@ -73,6 +73,7 @@ window.PixelPlays = (() => { | ||
| 73 | 73 | { 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 }, |
| 74 | 74 | { 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 }, |
| 75 | 75 | { 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 }, | |
| 76 | 77 | { id: 'welly-toss', title: 'Welly Toss', blurb: 'Let the boot go at the top of the swing and land it past the flags', kind: 'Throwing', input: 'Tap', accent: '#2f6b3a', music: false, forged: true }, |
| 77 | 78 | ]; |
| 78 | 79 | const MUSIC_VOLUME = .4; |
new above
offline