Public: anyone can read this; the crew can change it.
Migrated from github.com/amirmushichge/pixel-plays
Add Apple Bobbing: the playable page and its place in the catalogue, on the rules in `apple-bobbing/tub.js` #365
Check · Review · Land · Live done — Landed and live in production.
vesper/pixel-plays:vesper/360-add-apple-bobbing-the-playable-page-and → main 1 commit fork
9 files
changed +443
−2
README.md +3 −2
Diff not shown — a line 4,007 characters wide (limit 2,000). +3 −2
View fileapple-bobbing/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>Apple Bobbing</title> | |
| 7 | <!-- og:begin --> | |
| 8 | <meta name="description" content="Dunk for each bobbing apple at the top of its bob before your breaths run out"> | |
| 9 | <link rel="canonical" href="https://pixel-plays--forge-trevato-apps.open-platform.sh/apple-bobbing/"> | |
| 10 | <meta property="og:type" content="website"> | |
| 11 | <meta property="og:site_name" content="Pixel Plays"> | |
| 12 | <meta property="og:title" content="Apple Bobbing"> | |
| 13 | <meta property="og:description" content="Dunk for each bobbing apple at the top of its bob before your breaths run out"> | |
| 14 | <meta property="og:url" content="https://pixel-plays--forge-trevato-apps.open-platform.sh/apple-bobbing/"> | |
| 15 | <meta property="og:image" content="https://pixel-plays--forge-trevato-apps.open-platform.sh/og/apple-bobbing.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="Apple Bobbing: 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; : #c0392b; } | |
| 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 | #status { color: var(); min-height: 2.7em; max-width: 60ch; } | |
| 38 | 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; } | |
| 39 | button:hover, button:focus-visible { color: var(); } | |
| 40 | button:focus-visible { outline: 2px solid var(); outline-offset: 2px; } | |
| 41 | noscript p { position: fixed; inset: 40% 16px auto; margin: 0; text-align: center; color: var(); } | |
| 42 | </style> | |
| 43 | </head> | |
| 44 | <body> | |
| 45 | <div id="stage"><canvas id="c" tabindex="0" aria-label="Apple Bobbing: a fete water tub with five apples bobbing in it on their own beats. Press the left and right arrow keys, or tap an apple, to aim at it; the aimed apple has a ring round it. Dunk with Space or Enter, or tap the aimed apple again, when it is at the top of its bob, marked by a sparkle above it. A miss costs a breath. R starts a fresh tub."></canvas></div> | |
| 46 | <noscript><p>Apple Bobbing needs JavaScript to play.</p></noscript> | |
| 47 | <div id="scores"> | |
| 48 | <div class="row"><b id="score">0</b><span>score</span><b id="caught">0</b><span>apples caught</span></div> | |
| 49 | <div class="row"><b id="breaths">3</b><span>breaths</span><b id="ticks">60</b><span>ticks left</span></div> | |
| 50 | </div> | |
| 51 | <div class="hud" id="bottom"> | |
| 52 | <div id="status" role="status" aria-live="polite"></div> | |
| 53 | <button id="mute" type="button" aria-pressed="false">sound on</button> | |
| 54 | </div> | |
| 55 | <script src="../shell.js"></script> | |
| 56 | <script> | |
| 57 | PixelPlays.mount({ | |
| 58 | id: 'apple-bobbing', | |
| 59 | rules: [ | |
| 60 | 'Five apples bob in the tub, each on its own beat; the tub ticks on every half second. Aim with the arrows or a tap.', | |
| 61 | 'Dunk with Space, Enter or a second tap when the aimed apple is at the top of its bob (a sparkle): 30 points, 10 one tick off.', | |
| 62 | 'Any other dunk is a splash and costs a breath of three. Catch all five in 60 ticks. R starts a fresh tub.', | |
| 63 | ], | |
| 64 | }); | |
| 65 | </script> | |
| 66 | <script>window.PP_SCENE = { originY: 6, shadowX: 1.3, shadowY: -.12 };</script> | |
| 67 | <script src="../engine.js"></script> | |
| 68 | <script src="tub.js"></script> | |
| 69 | <script> | |
| 70 | 'use strict'; | |
| 71 | ||
| 72 | const M = window.AppleBobbing; | |
| 73 | const TICK_S = .5; // the tub's pace: seconds per tick | |
| 74 | const LEAD_S = 1.5; // a moment to look before the first tick | |
| 75 | const GLIDE_S = .2; // apples slide to their new height this long | |
| 76 | const LAWN = 8.4, TUB_R = 6, RING_R = 3.4, APPLE_R = .75, PICK_PX = 22; | |
| 77 | const LOW = .35, RISE = 1.5; // the lowest an apple sits, and how far it climbs to the top of its bob | |
| 78 | const NAMES = ['red', 'green', 'gold', 'pink', 'russet']; | |
| 79 | const FRUIT = [[200, 48, 40], [120, 170, 60], [222, 182, 52], [226, 120, 140], [150, 70, 44]]; | |
| 80 | const STALK = [84, 62, 44], LEAF = [70, 130, 60], WOOD = [150, 104, 64], WATER = [[70, 140, 196], [86, 156, 206]]; | |
| 81 | const LAWN_C = [[120, 164, 84], [112, 156, 78]], SPARK = [255, 252, 220], RING = [250, 236, 110]; | |
| 82 | ||
| 83 | /* ---------- geometry ---------- */ | |
| 84 | const spot = i => { const a = Math.PI * 2 * i / M.APPLES - Math.PI / 2; return [Math.cos(a) * RING_R, Math.sin(a) * RING_R]; }; | |
| 85 | const basketSpot = k => [-4.6 + k * 1.9, 7.2]; | |
| 86 | // the canvas pixel of an apple at the water | |
| 87 | function screenOf(i) { const c = spot(i), p = project(c[0], c[1], LOW); return [p.sx, p.sy]; } | |
| 88 | ||
| 89 | /* ---------- state ---------- */ | |
| 90 | const nodes = {}; | |
| 91 | for (const k of ['score', 'caught', 'breaths', 'ticks', 'status']) nodes[k] = document.getElementById(k); | |
| 92 | const shown = {}; | |
| 93 | function setText(key, text) { | |
| 94 | if (shown[key] === text) return; | |
| 95 | shown[key] = text; | |
| 96 | nodes[key].textContent = text; | |
| 97 | } | |
| 98 | const fresh = () => M.start(Math.floor(Math.random() * 4294967296)); | |
| 99 | let P = fresh(); | |
| 100 | // time: the page clock, tickAt: when the last tick ran, nextAt: when the next one does, prevTick: the tick before it | |
| 101 | const G = { time: 0, tickAt: -9, nextAt: LEAD_S, prevTick: 0, overAt: 0, wind: -1, dirty: true }; | |
| 102 | ||
| 103 | function renderHud() { | |
| 104 | setText('score', String(P.score)); | |
| 105 | setText('caught', String(P.caught)); | |
| 106 | setText('breaths', String(P.breaths)); | |
| 107 | setText('ticks', String(P.ticks - P.tick)); | |
| 108 | } | |
| 109 | function say(text) { setText('status', text); } | |
| 110 | ||
| 111 | /* ---------- the tub ---------- */ | |
| 112 | function groundColor(x, y, ix, iy) { | |
| 113 | const r = Math.hypot(x, y); | |
| 114 | if (Math.abs(x) > LAWN || Math.abs(y) > LAWN) return 0; | |
| 115 | const t = (bayerAt(ix, iy) - .5) * .06 + (noise(x * .8, y * .8) - .5) * .06; | |
| 116 | const c = r < TUB_R ? WATER[(Math.floor(x * 1.5) + Math.floor(y * 1.5)) & 1] : LAWN_C[(Math.floor(x / 1.2) + Math.floor(y / 1.2)) & 1]; | |
| 117 | return rgb(c[0] * (1 + t), c[1] * (1 + t), c[2] * (1 + t)); | |
| 118 | } | |
| 119 | function bake() { | |
| 120 | ground.fill(0); shadowMask.fill(0); staticShadow.fill(0); | |
| 121 | zb.fill(-1e9); | |
| 122 | pix.fill(PAPER); | |
| 123 | for (let iy = 0; iy < H; iy++) for (let ix = 0; ix < W; ix++) { | |
| 124 | const g = unproject(ix + .5, iy + .5), i = iy * W + ix, c = groundColor(g.x, g.y, ix, iy); | |
| 125 | if (!c) continue; | |
| 126 | pix[i] = c; ground[i] = 1; | |
| 127 | zb[i] = (g.x + g.y) / R2 * COS; | |
| 128 | } | |
| 129 | groundZ.set(zb); | |
| 130 | // the tub's rim: a ring of staves round the water | |
| 131 | for (let k = 0; k < 56; k++) { | |
| 132 | const a = Math.PI * 2 * k / 56, x = Math.cos(a) * (TUB_R + .3), y = Math.sin(a) * (TUB_R + .3); | |
| 133 | sphere(x, y, .5, .55, k & 1 ? WOOD : [132, 90, 56], 'rough'); | |
| 134 | if (k % 2) castShadow(x, y, .6, .55, 1, .3); | |
| 135 | } | |
| 136 | staticShadow.set(shadowMask); | |
| 137 | applyShadows(groundZ); | |
| 138 | bakedPix.set(pix); | |
| 139 | bakedZ.set(zb); | |
| 140 | } | |
| 141 | ||
| 142 | /* ---------- apples ---------- */ | |
| 143 | const bone = (a, b, r0, r1, c) => dynamicAdd(a, b, r0, r1, () => c); | |
| 144 | // the height of an apple: from the offset the rules give at a tick, blended from the previous tick while it glides | |
| 145 | function heightOf(apple) { | |
| 146 | const k = clamp((G.time - G.tickAt) / GLIDE_S, 0, 1); | |
| 147 | const half = apple.period / 2; | |
| 148 | const now = M.offset(apple, P.tick) / half, before = M.offset(apple, G.prevTick) / half; | |
| 149 | const o = k >= 1 ? now : lerp(before, now, ease(k)); | |
| 150 | return LOW + (1 - o) * RISE; | |
| 151 | } | |
| 152 | function drawApple(i) { | |
| 153 | const a = P.apples[i], c = spot(i), z = heightOf(a); | |
| 154 | sphere(c[0], c[1], z, APPLE_R, FRUIT[i], 'gloss'); | |
| 155 | bone([c[0], c[1], z + APPLE_R * .7], [c[0] + .08, c[1], z + APPLE_R * 1.5], .07, .05, STALK); | |
| 156 | sphere(c[0] + .3, c[1], z + APPLE_R * 1.4, .2, LEAF, 'solid'); | |
| 157 | castShadow(c[0], c[1], z, APPLE_R, 1, .2); | |
| 158 | // an apple at the top of its bob wears a sparkle above it: more than colour says "now" | |
| 159 | if (M.offset(a, P.tick) === 0) { | |
| 160 | const top = z + APPLE_R * 2.4; | |
| 161 | for (const [dx, dz] of [[0, 0], [.55, 0], [-.55, 0], [0, .55], [0, -.55], [.3, .3], [-.3, -.3]]) sphere(c[0] + dx, c[1] + dx * .4, top + dz, .14, SPARK, 'solid'); | |
| 162 | } | |
| 163 | // the aimed apple has a ring round it at the water | |
| 164 | if (P.aimed === i) { | |
| 165 | for (let k = 0; k < 14; k++) { | |
| 166 | const t = Math.PI * 2 * k / 14; | |
| 167 | sphere(c[0] + Math.cos(t) * 1.45, c[1] + Math.sin(t) * 1.45, .22, .16, RING, 'solid'); | |
| 168 | } | |
| 169 | } | |
| 170 | } | |
| 171 | function draw() { | |
| 172 | pix.set(bakedPix); | |
| 173 | zb.set(bakedZ); | |
| 174 | pendingShadows.length = 0; | |
| 175 | // ripples come and go on the water (ambient: still under reduced motion) | |
| 176 | if (!STILL) { | |
| 177 | for (let k = 0; k < 7; k++) if ((Math.floor(G.time * 2) + k) % 3 === 0) { | |
| 178 | const t = k * 2.4 + 1, r = 1.6 + (k % 3) * 1.3; | |
| 179 | sphere(Math.cos(t) * r, Math.sin(t) * r, .05, .12, [190, 224, 244], 'solid'); | |
| 180 | } | |
| 181 | } | |
| 182 | P.apples.forEach((a, i) => { if (!a.caught) drawApple(i); }); | |
| 183 | // caught apples sit on the lawn in a row | |
| 184 | let n = 0; | |
| 185 | P.apples.forEach((a, i) => { | |
| 186 | if (!a.caught) return; | |
| 187 | const b = basketSpot(n++); | |
| 188 | sphere(b[0], b[1], APPLE_R * .8, APPLE_R * .8, FRUIT[i], 'gloss'); | |
| 189 | castShadow(b[0], b[1], APPLE_R * .8, APPLE_R * .8, 1, .2); | |
| 190 | }); | |
| 191 | flushShadows(); | |
| 192 | applyShadows(bakedZ); | |
| 193 | shadeEdges(); | |
| 194 | ctx.putImageData(image, 0, 0); | |
| 195 | } | |
| 196 | ||
| 197 | /* ---------- game: every rule is the rules script's ---------- */ | |
| 198 | const WHY = { | |
| 199 | 'round-over': 'The round is over. Press R to play again.', | |
| 200 | 'nothing-aimed': 'Aim at an apple first: tap one or press an arrow key.', | |
| 201 | 'unknown-apple': 'There is no such apple.', | |
| 202 | 'already-caught': 'That apple is already caught.', | |
| 203 | }; | |
| 204 | const ticksWord = n => `${n} tick${n === 1 ? '' : 's'}`; | |
| 205 | function tick() { | |
| 206 | const r = M.step(P); | |
| 207 | if (!r.ok) return; | |
| 208 | G.prevTick = P.tick; G.tickAt = G.time; G.dirty = true; | |
| 209 | P = r.state; | |
| 210 | if (P.status !== 'playing') finish(); | |
| 211 | } | |
| 212 | function finish() { | |
| 213 | G.overAt = G.time; | |
| 214 | say(`${P.line}. Tap or press R to play again.`); | |
| 215 | sound(P.status === 'won' ? 880 : 110, .4, P.status === 'won' ? 'triangle' : 'sawtooth', .15); | |
| 216 | } | |
| 217 | function aimAt(i) { | |
| 218 | ensureAudio(); | |
| 219 | const r = M.aim(P, i); | |
| 220 | if (!r.ok) { say(WHY[r.why] || `Cannot aim: ${r.why}.`); return false; } | |
| 221 | P = r.state; G.dirty = true; | |
| 222 | say(`You aim at the ${NAMES[i]} apple. Dunk when it is at the top of its bob.`); | |
| 223 | sound(500 + i * 40, .05, 'square'); | |
| 224 | return true; | |
| 225 | } | |
| 226 | function dunk() { | |
| 227 | ensureAudio(); | |
| 228 | const r = M.dunk(P); | |
| 229 | if (!r.ok) { say(WHY[r.why] || `Cannot dunk: ${r.why}.`); return; } | |
| 230 | P = r.state; G.dirty = true; | |
| 231 | const l = P.last, name = NAMES[l.apple]; | |
| 232 | if (l.hit) { | |
| 233 | say(`You dunk the ${name} apple ${l.offset === 0 ? 'at the top of its bob' : 'one tick from the top'}: ${l.offset === 0 ? M.EXACT_POINTS : M.WINDOW_POINTS} points. ${P.line}.`); | |
| 234 | sound(l.offset === 0 ? 760 : 620, .2, 'triangle'); | |
| 235 | } else { | |
| 236 | say(`Splash: the ${name} apple was ${ticksWord(l.offset)} from the top. You lose a breath. ${P.line}.`); | |
| 237 | sound(140, .25, 'sawtooth'); | |
| 238 | } | |
| 239 | if (P.status !== 'playing') finish(); | |
| 240 | } | |
| 241 | // the next apple still in the water in a direction, wrapping round | |
| 242 | function cycle(dir) { | |
| 243 | const n = M.APPLES; | |
| 244 | let i = P.aimed === null ? (dir > 0 ? -1 : 0) : P.aimed; | |
| 245 | for (let k = 0; k < n; k++) { | |
| 246 | i = (i + dir + n) % n; | |
| 247 | if (!P.apples[i].caught) return aimAt(i); | |
| 248 | } | |
| 249 | say(WHY['round-over']); | |
| 250 | } | |
| 251 | function update(dt) { | |
| 252 | G.time += dt; | |
| 253 | if (P.status === 'playing' && G.time >= G.nextAt) { G.nextAt += TICK_S; tick(); } | |
| 254 | if (G.time - G.tickAt < GLIDE_S + .05) G.dirty = true; | |
| 255 | const wind = STILL ? 0 : Math.floor(G.time * 2); | |
| 256 | if (wind !== G.wind) { G.wind = wind; G.dirty = true; } | |
| 257 | renderHud(); | |
| 258 | } | |
| 259 | function restart() { | |
| 260 | P = fresh(); | |
| 261 | G.prevTick = 0; G.tickAt = -9; G.nextAt = G.time + LEAD_S; G.dirty = true; | |
| 262 | say(`${P.line}. Aim with the arrows or a tap, then dunk.`); | |
| 263 | } | |
| 264 | ||
| 265 | /* ---------- input: tap an apple to aim, tap it again to dunk; arrows aim, Space dunks ---------- */ | |
| 266 | function appleAt(px, py) { | |
| 267 | let best = -1, top = PICK_PX; | |
| 268 | for (let i = 0; i < M.APPLES; i++) { | |
| 269 | const [sx, sy] = screenOf(i), d = Math.hypot(px - sx, py - sy); | |
| 270 | if (d < top) { top = d; best = i; } | |
| 271 | } | |
| 272 | return best; | |
| 273 | } | |
| 274 | canvas.addEventListener('pointerdown', e => { | |
| 275 | canvas.focus({ preventScroll: true }); | |
| 276 | ensureAudio(); | |
| 277 | if (P.status !== 'playing') { if (G.time - G.overAt > .6) restart(); return; } | |
| 278 | const r = canvas.getBoundingClientRect(); | |
| 279 | const i = appleAt((e.clientX - r.left) / r.width * W, (e.clientY - r.top) / r.height * H); | |
| 280 | if (i < 0) { say('Tap an apple to aim at it.'); return; } | |
| 281 | if (P.apples[i].caught) { say(WHY['already-caught']); return; } | |
| 282 | if (P.aimed === i) dunk(); else aimAt(i); | |
| 283 | }); | |
| 284 | canvas.addEventListener('contextmenu', e => e.preventDefault()); | |
| 285 | canvas.addEventListener('keydown', e => { | |
| 286 | if (e.ctrlKey || e.metaKey || e.altKey) return; | |
| 287 | const k = e.key; | |
| 288 | if (k === 'r' || k === 'R') { if (!e.repeat) restart(); } | |
| 289 | else if (k === 'ArrowLeft' || k === 'ArrowUp' || k === 'a' || k === 'A' || k === 'w' || k === 'W') { if (!e.repeat) { if (P.status === 'playing') cycle(-1); else say(WHY['round-over']); } } | |
| 290 | else if (k === 'ArrowRight' || k === 'ArrowDown' || k === 'd' || k === 'D' || k === 's' || k === 'S') { if (!e.repeat) { if (P.status === 'playing') cycle(1); else say(WHY['round-over']); } } | |
| 291 | else if (k === ' ' || k === 'Enter') { if (!e.repeat) dunk(); } | |
| 292 | else if (k >= '1' && k <= String(M.APPLES)) { if (!e.repeat) aimAt(Number(k) - 1); } | |
| 293 | else return; | |
| 294 | e.preventDefault(); | |
| 295 | }); | |
| 296 | ||
| 297 | /* ---------- sound ---------- */ | |
| 298 | let audio = null, muted = false; | |
| 299 | function ensureAudio() { | |
| 300 | if (audio) { if (audio.state === 'suspended') audio.resume(); return; } | |
| 301 | const AC = window.AudioContext || window.webkitAudioContext; | |
| 302 | if (AC) audio = new AC(); | |
| 303 | } | |
| 304 | function sound(freq, life, type, delay = 0) { | |
| 305 | if (!audio || muted) return; | |
| 306 | const now = audio.currentTime + delay, gain = audio.createGain(), osc = audio.createOscillator(); | |
| 307 | osc.type = type; | |
| 308 | osc.frequency.setValueAtTime(freq, now); | |
| 309 | gain.gain.setValueAtTime(.0001, now); | |
| 310 | gain.gain.exponentialRampToValueAtTime(.06, now + .01); | |
| 311 | gain.gain.exponentialRampToValueAtTime(.0005, now + life); | |
| 312 | osc.connect(gain).connect(audio.destination); | |
| 313 | osc.start(now); osc.stop(now + life + .02); | |
| 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 | renderHud(); | |
| 327 | say(`${P.line}. The tub ticks on every ${TICK_S} s: aim with the arrows or a tap, dunk with Space or a second tap.`); | |
| 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 | if (G.dirty) { G.dirty = false; draw(); } | |
| 334 | requestAnimationFrame(frame); | |
| 335 | } | |
| 336 | requestAnimationFrame(frame); | |
| 337 | </script> | |
| 338 | </body> | |
| 339 | </html> |
apple-bobbing/page.test.mjs added +81
| @@ -0,0 +1,81 @@ | ||
| 1 | import { test, expect } from 'bun:test'; | |
| 2 | import { boot } from '../playtest.mjs'; | |
| 3 | import M from './tub.js'; | |
| 4 | ||
| 5 | const state = g => JSON.parse(g.read('JSON.stringify(P)')); | |
| 6 | // a tub with fixed apples: apple 0 is at its top on even ticks of a 2-tick-free period of 8, the rest are far off | |
| 7 | const fixed = g => g.read(`P = { ...P, apples: [0, 1, 2, 3, 4].map(i => ({ period: 8, phase: i === 0 ? 0 : 4, caught: false })) }`); | |
| 8 | ||
| 9 | test('the tub ticks on by itself, and the HUD reads the rules state', () => { | |
| 10 | const g = boot('apple-bobbing'); | |
| 11 | expect(state(g).tick).toBe(0); | |
| 12 | g.run(1.5 + .5 * 4); | |
| 13 | const s = state(g); | |
| 14 | expect(s.tick).toBeGreaterThanOrEqual(3); | |
| 15 | expect(g.el('ticks').textContent).toBe(String(s.ticks - s.tick)); | |
| 16 | expect(g.el('breaths').textContent).toBe(String(s.breaths)); | |
| 17 | expect(g.el('caught').textContent).toBe('0'); | |
| 18 | expect(g.el('score').textContent).toBe('0'); | |
| 19 | }); | |
| 20 | ||
| 21 | test('arrows and a tap aim, a second tap or Space dunks, each announced; a refusal says why', () => { | |
| 22 | const g = boot('apple-bobbing'); | |
| 23 | g.keyDown(' '); | |
| 24 | expect(g.el('status').textContent).toContain('Aim at an apple first'); | |
| 25 | g.keyDown('ArrowRight'); | |
| 26 | expect(g.read('P.aimed')).toBe(0); | |
| 27 | expect(g.el('status').textContent).toContain('You aim at the red apple'); | |
| 28 | g.keyDown('ArrowRight'); | |
| 29 | expect(g.read('P.aimed')).toBe(1); | |
| 30 | const [x, y] = g.read('screenOf(3)'); | |
| 31 | g.tap(x, y); | |
| 32 | expect(g.read('P.aimed')).toBe(3); | |
| 33 | expect(g.el('status').textContent).toContain('pink apple'); | |
| 34 | fixed(g); g.read('P = { ...P, aimed: 3 }'); | |
| 35 | g.tap(x, y); // dunk: apple 3 is at the bottom of its bob | |
| 36 | expect(g.read('P.breaths')).toBe(2); | |
| 37 | expect(g.el('status').textContent).toContain('Splash'); | |
| 38 | expect(g.el('breaths').textContent).toBe('2'); | |
| 39 | }); | |
| 40 | ||
| 41 | test('a dunk at the top scores and is announced; the caught apple cannot be aimed again', () => { | |
| 42 | const g = boot('apple-bobbing'); | |
| 43 | fixed(g); | |
| 44 | g.keyDown('1'); | |
| 45 | g.keyDown(' '); | |
| 46 | expect(state(g).caught).toBe(1); | |
| 47 | expect(state(g).score).toBe(M.EXACT_POINTS); | |
| 48 | expect(g.el('status').textContent).toContain('at the top of its bob: 30 points'); | |
| 49 | g.keyDown('1'); | |
| 50 | expect(g.el('status').textContent).toContain('already caught'); | |
| 51 | g.keyDown('ArrowRight'); | |
| 52 | expect(g.read('P.aimed')).toBe(1); | |
| 53 | }); | |
| 54 | ||
| 55 | test('the aimed apple and the apple at its top are drawn differently from the rest', () => { | |
| 56 | const g = boot('apple-bobbing'); | |
| 57 | fixed(g); | |
| 58 | g.run(.1); | |
| 59 | const plain = g.picture(); | |
| 60 | g.keyDown('2'); g.run(.1); | |
| 61 | const ringed = g.picture(); | |
| 62 | expect(ringed).not.toBe(plain); | |
| 63 | g.read('P = { ...P, apples: P.apples.map((a, i) => i === 0 ? { ...a, phase: 4 } : a) }; G.dirty = true'); g.run(.1); | |
| 64 | expect(g.picture()).not.toBe(ringed); // apple 0 is no longer at its top: no sparkle | |
| 65 | }); | |
| 66 | ||
| 67 | test('the round ends with the line, a tap plays again, and R starts a fresh seed', () => { | |
| 68 | const g = boot('apple-bobbing'); | |
| 69 | g.read('P = { ...P, status: "out-of-breath", breaths: 0, line: "Out of breath: score 0, 0 apples caught of 5, 0 breaths left" }'); | |
| 70 | g.read('finish()'); | |
| 71 | expect(g.el('status').textContent).toContain('Out of breath'); | |
| 72 | g.keyDown('ArrowRight'); | |
| 73 | expect(g.el('status').textContent).toContain('The round is over'); | |
| 74 | g.run(1); | |
| 75 | g.tap(100, 100); | |
| 76 | expect(state(g).status).toBe('playing'); | |
| 77 | const seed = state(g).seed; | |
| 78 | g.press('r', .02); | |
| 79 | expect(state(g).seed).not.toBe(seed); | |
| 80 | expect(state(g).tick).toBe(0); | |
| 81 | }); |
index.html +1
| @@ -140,6 +140,7 @@ footer a::after { content: none; } | ||
| 140 | 140 | <li data-kind="Sport" data-input="Tap" style="--accent:#b5412f"><a href="village-darts/">Village Darts</a><span>Count down from 301 and finish on a double before the village rival does</span><span>Game forged by the crew</span><span class="tags">Sport · Tap</span></li> |
| 141 | 141 | <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> |
| 142 | 142 | <li data-kind="Arcade" data-input="Keys" style="--accent:#c0392b"><a href="bumper-cars/">Bumper Cars</a><span>Steer your car round the fairground floor and shove the rivals into the wall without a head-on bump</span><span>Game forged by the crew</span><span class="tags">Arcade · Keys</span></li> |
| 143 | <li data-kind="Timing" data-input="Tap" style="--accent:#c0392b"><a href="apple-bobbing/">Apple Bobbing</a><span>Dunk for each bobbing apple at the top of its bob before your breaths run out</span><span>Game forged by the crew</span><span class="tags">Timing · Tap</span></li> | |
| 143 | 144 | <li data-kind="Puzzle" data-input="Keys" style="--accent:#2a6f97"><a href="tide-crossing/">Tide Crossing</a><span>Step across the stones to the island before the tide covers them</span><span>Game forged by the crew</span><span class="tags">Puzzle · Keys</span></li> |
| 144 | 145 | </ul> |
| 145 | 146 | <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/apple-bobbing.png added
Binary file not shown.
og/home.png
Binary file not shown.
rounds.mjs +11
| @@ -15,6 +15,7 @@ import Sweep from './chimney-sweep/roofs.js'; | ||
| 15 | 15 | import Field from './fence-posts/field.js'; |
| 16 | 16 | import Carrot from './carrot-pull/bed.js'; |
| 17 | 17 | import Cars from './bumper-cars/floor.js'; |
| 18 | import Bobbing from './apple-bobbing/tub.js'; | |
| 18 | 19 | import Tide from './tide-crossing/causeway.js'; |
| 19 | 20 | |
| 20 | 21 | const MID = [180, 140]; |
| @@ -531,6 +532,16 @@ export const ROUNDS = { | ||
| 531 | 532 | const best = bestTurn(s); |
| 532 | 533 | if (best !== 'straight') g.press(best === 'left' ? 'ArrowLeft' : 'ArrowRight', .02); |
| 533 | 534 | } }, |
| 535 | // aim the apple whose next top comes soonest by the rules' own `offset`, wait for that tick, then dunk | |
| 536 | 'apple-bobbing': { over: /play again/, outcome: /You win: every apple is caught; score [1-9]\d*, 5 apples caught/, limit: 40, beat: g => { | |
| 537 | const s = JSON.parse(g.read('JSON.stringify(P)')); | |
| 538 | if (s.status !== 'playing') { g.run(.3); return; } | |
| 539 | const wait = a => { let t = 0; while (Bobbing.offset(a, s.tick + t) !== 0) t++; return t; }; | |
| 540 | let pick = -1, soonest = Infinity; | |
| 541 | s.apples.forEach((a, i) => { if (!a.caught && wait(a) < soonest) { soonest = wait(a); pick = i; } }); | |
| 542 | if (s.aimed !== pick) { g.press(String(pick + 1), .02); return; } | |
| 543 | if (soonest === 0) g.press(' ', .02); else g.run(.04); | |
| 544 | } }, | |
| 534 | 545 | // wait for the tick whose marker is nearest the rival's own stated target for this score, then throw |
| 535 | 546 | 'village-darts': { over: /play again/, outcome: /(You win|The rival wins) the leg in \d+ darts?, finishing on/, limit: 140, beat: g => { |
| 536 | 547 | if (g.read('P.status') !== 'playing' || g.read('P.turn') !== 'you' || g.read('!!G.flight')) { g.run(.2); return; } |
serve.test.mjs +7
| @@ -776,6 +776,13 @@ test('Bumper Cars and its rules script are served without a track', async () => | ||
| 776 | 776 | expect((await fetch(base + '/bumper-cars/floor.js')).status).toBe(200); |
| 777 | 777 | }); |
| 778 | 778 | |
| 779 | test('Apple Bobbing and its rules script are served without a track', async () => { | |
| 780 | const page = await fetch(base + '/apple-bobbing/'); | |
| 781 | expect(page.status).toBe(200); | |
| 782 | expect(page.headers.get('content-type')).toBe('text/html; charset=utf-8'); | |
| 783 | expect((await fetch(base + '/apple-bobbing/tub.js')).status).toBe(200); | |
| 784 | }); | |
| 785 | ||
| 779 | 786 | test('Pumpkin Patch and its rules script are served without a track', async () => { |
| 780 | 787 | const page = await fetch(base + '/pumpkin-patch/'); |
| 781 | 788 | expect(page.status).toBe(200); |
shell.js +1
| @@ -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: 'bumper-cars', title: 'Bumper Cars', blurb: 'Steer your car round the fairground floor and shove the rivals into the wall without a head-on bump', kind: 'Arcade', input: 'Keys', accent: '#c0392b', music: false, forged: true }, |
| 79 | { id: 'apple-bobbing', title: 'Apple Bobbing', blurb: 'Dunk for each bobbing apple at the top of its bob before your breaths run out', kind: 'Timing', input: 'Tap', accent: '#c0392b', music: false, forged: true }, | |
| 79 | 80 | { 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 }, |
| 80 | 81 | { id: 'village-darts', title: 'Village Darts', blurb: 'Count down from 301 and finish on a double before the village rival does', kind: 'Sport', input: 'Tap', accent: '#b5412f', music: false, forged: true }, |
| 81 | 82 | { 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 }, |
new above
offline