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
Check · Review · Land · Live done — Landed and live in production.
quill/pixel-plays:quill/344-add-carrot-pull-the-playable-page-and-it → main 1 commit fork
9 files
changed +439
−4
README.md +3 −2
Diff not shown — a line 3,549 characters wide (limit 2,000). +3 −2
View filecarrot-pull/index.html added +339
| @@ -0,0 +1,339 @@ | ||
| 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>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 { : #1c1b19; : #6b675f; : #fff; : #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(); color: var(); } | |
| 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(); 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(); } | |
| 33 | .row b { font-size: 16px; font-weight: 600; min-width: 2ch; font-variant-numeric: tabular-nums; } | |
| 34 | .row span { color: var(); } | |
| 35 | #line { color: var(); min-height: 1.35em; max-width: max(18ch, calc(50vw - 196px)); } | |
| 36 | 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; } | |
| 37 | button:hover, button:focus-visible { color: var(); } | |
| 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; } | ||
| 136 | 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> |
| 137 | 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> |
| 138 | 138 | <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> | |
| 139 | 140 | </ul> |
| 140 | 141 | <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> |
| 141 | 142 | </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'; | ||
| 12 | 12 | import Shelf from './library-shelf/shelf.js'; |
| 13 | 13 | import Sweep from './chimney-sweep/roofs.js'; |
| 14 | 14 | import Field from './fence-posts/field.js'; |
| 15 | import Carrot from './carrot-pull/bed.js'; | |
| 15 | 16 | |
| 16 | 17 | const MID = [180, 140]; |
| 17 | 18 | const tapping = (wait = .3) => g => { g.tap(...MID); g.run(wait); }; |
| @@ -545,6 +546,17 @@ export const ROUNDS = { | ||
| 545 | 546 | } |
| 546 | 547 | } }, |
| 547 | 548 | '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 | } }, | |
| 548 | 560 | }; |
| 549 | 561 | |
| 550 | 562 | 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' | ||
| 330 | 330 | delete globalThis.PixelPlays; |
| 331 | 331 | } |
| 332 | 332 | 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); | |
| 334 | 334 | expect(['Tap', 'Hold', 'Drag', 'Keys']).toContain(g.input); |
| 335 | 335 | expect(g.accent).toMatch(/^#[0-9a-f]{6}$/i); |
| 336 | 336 | 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 () => | ||
| 754 | 754 | expect(page.headers.get('content-type')).toBe('text/html; charset=utf-8'); |
| 755 | 755 | expect((await fetch(base + '/ticket-booth/till.js')).status).toBe(200); |
| 756 | 756 | }); |
| 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 @@ | ||
| 1 | 1 | 'use strict'; |
| 2 | 2 | // Shared chrome for every game: game menu (top centre) and rules popup (top right). |
| 3 | 3 | 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 | |
| 5 | 5 | const GAMES = [ |
| 6 | 6 | { id: 'balote', title: 'Balote', blurb: 'Bocce against a bot, first to 5', kind: 'Throwing', input: 'Drag', accent: '#d9a441' }, |
| 7 | 7 | { 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 = (() => { | ||
| 76 | 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 }, |
| 77 | 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 }, |
| 78 | 78 | { 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 }, | |
| 79 | 80 | ]; |
| 80 | 81 | const MUSIC_VOLUME = .4; |
| 81 | 82 | const CREDITS = [ |
new above
offline