Public: anyone can read this; the crew can change it.
Migrated from github.com/amirmushichge/pixel-plays
Add Cheese Rolling: the playable page and its place in the catalogue, on the rules in `cheese-rolling/hill.js` #377
Check · Review · Land · Live — Its check is red.
first in the queue: no estimate: nothing landed in the last hour
quill/pixel-plays:quill/368-add-cheese-rolling-the-playable-page-and → main 2 commits fork 1 thread · 1 unresolved
10 files
changed +489
−4
README.md +3 −2
Diff not shown — a line 4,184 characters wide (limit 2,000). +3 −2
View filecheese-rolling/index.html added +362
| @@ -0,0 +1,362 @@ | ||
| 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>Cheese Rolling</title> | |
| 7 | <!-- og:begin --> | |
| 8 | <meta name="description" content="Chase the cheese down a steep hill, dodge the tussocks and catch it before the bottom"> | |
| 9 | <link rel="canonical" href="https://pixel-plays--forge-trevato-apps.open-platform.sh/cheese-rolling/"> | |
| 10 | <meta property="og:type" content="website"> | |
| 11 | <meta property="og:site_name" content="Pixel Plays"> | |
| 12 | <meta property="og:title" content="Cheese Rolling"> | |
| 13 | <meta property="og:description" content="Chase the cheese down a steep hill, dodge the tussocks and catch it before the bottom"> | |
| 14 | <meta property="og:url" content="https://pixel-plays--forge-trevato-apps.open-platform.sh/cheese-rolling/"> | |
| 15 | <meta property="og:image" content="https://pixel-plays--forge-trevato-apps.open-platform.sh/og/cheese-rolling.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="Cheese Rolling: 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; : #d9a21b; } | |
| 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="Cheese Rolling: a steep hill of grass lanes with tussocks, and a round cheese bouncing down it one row every beat. Press the left and right arrow keys to change lane and the down arrow to run down a row; do nothing and you stay put. On touch, tap beside the runner to move that way, or tap the runner to stay. Stand on the cheese's square as it rolls by before it reaches the bottom; a tussock trips you for a few beats. R starts a fresh hill."></canvas></div> | |
| 46 | <noscript><p>Cheese Rolling needs JavaScript to play.</p></noscript> | |
| 47 | <div id="scores"> | |
| 48 | <div class="row"><b id="level">1</b><span>level</span><b id="tick">0</b><span>tick</span></div> | |
| 49 | <div class="row"><b id="trips">0</b><span>trips</span><b id="rows">0</b><span>rows 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: 'cheese-rolling', | |
| 59 | rules: [ | |
| 60 | 'The cheese bounces down the hill one row a beat. Left, right and down arrows (or a tap beside the runner) pick your move; doing nothing stays put.', | |
| 61 | 'Stand on the cheese\'s square as it rolls by, before it passes the bottom. The dots show its line.', | |
| 62 | 'A tussock trips you: you miss the next few beats. R starts a fresh hill, N goes up a level after a win.', | |
| 63 | ], | |
| 64 | }); | |
| 65 | </script> | |
| 66 | <script>window.PP_SCENE = { originY: 4, shadowX: 1.3, shadowY: -.12 };</script> | |
| 67 | <script src="../engine.js"></script> | |
| 68 | <script src="hill.js"></script> | |
| 69 | <script> | |
| 70 | 'use strict'; | |
| 71 | ||
| 72 | const M = window.CheeseRolling; | |
| 73 | const TICK_S = .7; // the hill's beat: seconds per tick | |
| 74 | const LEAD_S = 2; // a moment to look before the first tick | |
| 75 | const GLIDE_S = .25; // the cheese and runner slide to their new square this long | |
| 76 | const WIN = 14, PITCH = 1.3, SLOPE = .3, PICK_PX = 24; | |
| 77 | const ACTION_KEYS = { ArrowLeft: 'left', ArrowRight: 'right', ArrowDown: 'down', a: 'left', A: 'left', d: 'right', D: 'right', s: 'down', S: 'down', ' ': 'stay' }; | |
| 78 | const GRASS_C = [[116, 160, 80], [104, 148, 72]], EDGE_C = [78, 118, 56], TOP_C = [226, 214, 170], FINISH_C = [[236, 236, 230], [196, 60, 50]]; | |
| 79 | const TUFT = [62, 104, 54], BLADE = [112, 162, 70], CHEESE = [238, 190, 58], HOLE = [176, 124, 30], TRAIL = [250, 236, 160]; | |
| 80 | const COAT = [58, 98, 172], SKIN = [238, 200, 160], STAR = [255, 226, 90]; | |
| 81 | ||
| 82 | /* ---------- state ---------- */ | |
| 83 | const nodes = {}; | |
| 84 | for (const k of ['level', 'tick', 'trips', 'rows', 'status']) nodes[k] = document.getElementById(k); | |
| 85 | const shown = {}; | |
| 86 | function setText(key, text) { | |
| 87 | if (shown[key] === text) return; | |
| 88 | shown[key] = text; | |
| 89 | nodes[key].textContent = text; | |
| 90 | } | |
| 91 | function say(text) { shown.status = null; setText('status', text); } | |
| 92 | const seedNow = () => Math.floor(Math.random() * 4294967296); | |
| 93 | let level = 0; | |
| 94 | let P = M.start(seedNow(), level); | |
| 95 | // time: the page clock, tickAt: when the last tick ran, nextAt: when the next one does, from: where the sprites slid from | |
| 96 | const G = { time: 0, tickAt: -9, nextAt: LEAD_S, overAt: 0, pending: 'stay', from: null, sway: -1, dirty: true }; | |
| 97 | G.from = { runner: P.runner, cheese: P.cheeseStart }; | |
| 98 | ||
| 99 | function renderHud() { | |
| 100 | setText('level', String(P.level + 1)); | |
| 101 | setText('tick', String(P.tick)); | |
| 102 | setText('trips', String(P.trips)); | |
| 103 | setText('rows', String(P.rowsLeft)); | |
| 104 | } | |
| 105 | ||
| 106 | /* ---------- geometry ---------- */ | |
| 107 | const windowStart = () => { | |
| 108 | const focus = P.tick > P.runner.row ? P.runner.row : Math.floor((P.runner.row + P.tick) / 2); | |
| 109 | return clamp(focus - (WIN >> 1), 0, Math.max(0, P.rows - WIN)); | |
| 110 | }; | |
| 111 | let LO = 0; | |
| 112 | const wx = c => (c - (P.lanes - 1) / 2) * PITCH; | |
| 113 | const wy = r => (r - LO - WIN / 2) * PITCH; | |
| 114 | const zAt = y => -y * SLOPE; | |
| 115 | const spot = (r, c) => { const y = wy(r); return [wx(c), y, zAt(y)]; }; | |
| 116 | function screenOf(r, c) { const s = spot(r, c), p = project(s[0], s[1], s[2]); return [p.sx, p.sy]; } | |
| 117 | ||
| 118 | /* ---------- the hill ---------- */ | |
| 119 | const put = (x, y, c) => { x |= 0; y |= 0; if (x >= 0 && y >= 0 && x < W && y < H) pix[y * W + x] = c; }; | |
| 120 | function darken(x, y, k) { | |
| 121 | x |= 0; y |= 0; | |
| 122 | if (x < 0 || y < 0 || x >= W || y >= H) return; | |
| 123 | const c = pix[y * W + x]; | |
| 124 | if (c === PAPER) return; | |
| 125 | pix[y * W + x] = rgb((c & 255) * k, (c >> 8 & 255) * k, (c >> 16 & 255) * k); | |
| 126 | } | |
| 127 | function tile(r, c) { | |
| 128 | const x0 = wx(c) - PITCH / 2, y0 = wy(r) - PITCH / 2, last = r === P.rows - 1; | |
| 129 | const base = r === 0 ? TOP_C : last ? FINISH_C[c & 1] : GRASS_C[c & 1]; | |
| 130 | const edge = rgb(...(last || r === 0 ? [140, 132, 110] : EDGE_C)); | |
| 131 | const n = 13, st = PITCH / n; | |
| 132 | for (let i = 0; i < n; i++) for (let j = 0; j < n; j++) { | |
| 133 | const x = x0 + (i + .5) * st, y = y0 + (j + .5) * st, p = project(x, y, zAt(y)); | |
| 134 | const ix = p.sx | 0, iy = p.sy | 0; | |
| 135 | if (ix < 0 || iy < 0 || ix >= W || iy >= H) continue; | |
| 136 | const k = iy * W + ix; | |
| 137 | if (p.d <= zb[k]) continue; | |
| 138 | zb[k] = p.d; | |
| 139 | if (i === 0 || j === 0) { pix[k] = edge; continue; } | |
| 140 | const t = 1 + (bayerAt(ix, iy) - .5) * .1 + (noise(x * 1.6, y * 1.6) - .5) * .22; | |
| 141 | pix[k] = rgb(base[0] * t, base[1] * t, base[2] * t); | |
| 142 | } | |
| 143 | } | |
| 144 | // a hard shadow on the hill, thrown screen-right of what stands on it | |
| 145 | function shadow(x, y, h, rx) { | |
| 146 | const p = project(x + SHADOW_X * h, y + SHADOW_Y * h, zAt(y)), ry = rx * SIN + 1; | |
| 147 | for (let iy = Math.floor(p.sy - ry); iy <= p.sy + ry; iy++) for (let ix = Math.floor(p.sx - rx); ix <= p.sx + rx; ix++) { | |
| 148 | const dx = (ix + .5 - p.sx) / rx, dy = (iy + .5 - p.sy) / ry; | |
| 149 | if (dx * dx + dy * dy <= 1) darken(ix, iy, .6); | |
| 150 | } | |
| 151 | } | |
| 152 | const vis = r => r >= LO && r < LO + WIN; | |
| 153 | ||
| 154 | /* ---------- things on the hill ---------- */ | |
| 155 | // a tussock is a dark dome with spiky blades: no other thing on the hill looks like it | |
| 156 | function drawTussock(t) { | |
| 157 | const [x, y, z] = spot(t.row, t.col); | |
| 158 | shadow(x, y, 1, 7); | |
| 159 | sphere(x, y, z + .3, .5, TUFT, 'rough'); | |
| 160 | const sway = STILL ? 0 : [-.07, 0, .07, 0][(G.sway + t.row + t.col) & 3]; | |
| 161 | for (const [dx, dy, h] of [[-.25, -.1, .95], [.05, .2, 1.15], [.3, -.15, .9], [-.05, -.3, 1]]) { | |
| 162 | capsule([x + dx, y + dy, z + .35], [x + dx * 1.5 + sway, y + dy * 1.5, z + h], .09, .03, () => BLADE, 'solid'); | |
| 163 | } | |
| 164 | } | |
| 165 | function drawTrail() { // the cheese's line down the hill: small pale dots | |
| 166 | for (let r = P.tick + 1; r < P.rows; r++) { | |
| 167 | if (!vis(r)) continue; | |
| 168 | const [x, y, z] = spot(r, P.path[r]); | |
| 169 | sphere(x, y, z + .08, .13, TRAIL, 'solid'); | |
| 170 | } | |
| 171 | } | |
| 172 | function glide() { return STILL ? 1 : clamp((G.time - G.tickAt) / GLIDE_S, 0, 1); } | |
| 173 | const mix = (a, b, k) => [lerp(a.row, b.row, k), lerp(a.col, b.col, k)]; | |
| 174 | const spotAt = (rc) => { const y = (rc[0] - LO - WIN / 2) * PITCH; return [wx(rc[1]), y, zAt(y)]; }; | |
| 175 | function drawCheese() { | |
| 176 | const k = ease(glide()), now = { row: Math.min(P.tick, P.rows), col: P.path[Math.min(P.tick, P.rows - 1)] }; | |
| 177 | const rc = mix(G.from.cheese, now, k); | |
| 178 | const [x, y, z0] = spotAt(rc); | |
| 179 | const hop = STILL ? 0 : Math.sin(Math.PI * k) * .8 * (P.status === 'playing' ? 1 : 0); | |
| 180 | const z = z0 + hop; | |
| 181 | shadow(x, y, hop + .6, 8); | |
| 182 | // a round wheel: wide and flat, a ring of rind with holes in the top face | |
| 183 | for (let a = 0; a < 10; a++) { | |
| 184 | const t = Math.PI * 2 * a / 10; | |
| 185 | sphere(x + Math.cos(t) * .5, y + Math.sin(t) * .5, z + .32, .24, CHEESE, 'solid'); | |
| 186 | sphere(x + Math.cos(t) * .5, y + Math.sin(t) * .5, z + .6, .22, CHEESE, 'solid'); | |
| 187 | } | |
| 188 | sphere(x, y, z + .5, .52, CHEESE, 'solid'); | |
| 189 | for (const [dx, dy] of [[-.2, .1], [.22, -.15], [.05, .3]]) sphere(x + dx, y + dy, z + .98, .08, HOLE, 'solid'); | |
| 190 | } | |
| 191 | // the runner stands tall; tripped, they lie flat with stars circling, so a trip is read from the shape and not colour alone | |
| 192 | function drawRunner() { | |
| 193 | const k = ease(glide()), rc = mix(G.from.runner, P.runner, k), [x, y, z] = spotAt(rc); | |
| 194 | if (P.tripped > 0) { | |
| 195 | shadow(x, y, .5, 9); | |
| 196 | capsule([x - .55, y, z + .35], [x + .35, y, z + .35], .34, .3, () => COAT, 'solid'); | |
| 197 | sphere(x + .75, y, z + .4, .3, SKIN, 'solid'); | |
| 198 | const beat = STILL ? 0 : Math.floor(G.time * 6); | |
| 199 | for (let i = 0; i < 3; i++) { | |
| 200 | const t = Math.PI * 2 * i / 3 + beat * .9; | |
| 201 | sphere(x + .75 + Math.cos(t) * .45, y + Math.sin(t) * .45, z + 1.15, .12, STAR, 'solid'); | |
| 202 | } | |
| 203 | return; | |
| 204 | } | |
| 205 | shadow(x, y, 1.9, 7); | |
| 206 | capsule([x, y, z + .2], [x, y, z + 1.05], .36, .3, () => COAT, 'solid'); | |
| 207 | sphere(x, y, z + 1.6, .3, SKIN, 'solid'); | |
| 208 | capsule([x - .3, y, z + .95], [x - .55, y, z + .5], .1, .08, () => SKIN, 'solid'); | |
| 209 | capsule([x + .3, y, z + .95], [x + .55, y, z + .5], .1, .08, () => SKIN, 'solid'); | |
| 210 | } | |
| 211 | function draw() { | |
| 212 | LO = windowStart(); | |
| 213 | pix.fill(PAPER); | |
| 214 | zb.fill(-1e9); | |
| 215 | for (let r = LO; r < Math.min(P.rows, LO + WIN); r++) for (let c = 0; c < P.lanes; c++) tile(r, c); | |
| 216 | drawTrail(); | |
| 217 | // far to near, so a nearer thing is never drawn under a farther one | |
| 218 | P.tussocks.filter(t => vis(t.row)).forEach(drawTussock); | |
| 219 | drawCheese(); | |
| 220 | drawRunner(); | |
| 221 | ctx.putImageData(image, 0, 0); | |
| 222 | } | |
| 223 | ||
| 224 | /* ---------- game: every rule is the rules script's ---------- */ | |
| 225 | const WHY = { | |
| 226 | 'off-hill': 'that is off the hill', | |
| 227 | 'unknown-action': 'that is not a move the runner knows', | |
| 228 | 'game-over': 'the run is over', | |
| 229 | }; | |
| 230 | const WORDS = { left: 'You step left', right: 'You step right', down: 'You run down a row', stay: 'You wait' }; | |
| 231 | function tick() { | |
| 232 | const wanted = G.pending; | |
| 233 | G.pending = 'stay'; | |
| 234 | let r = M.step(P, wanted), note = ''; | |
| 235 | if (!r.ok) { | |
| 236 | note = `Cannot move ${wanted}: ${WHY[r.why] ?? r.why}. `; | |
| 237 | r = M.step(P, 'stay'); | |
| 238 | } | |
| 239 | if (!r.ok) return; | |
| 240 | const before = P; | |
| 241 | G.from = { runner: before.runner, cheese: { row: Math.min(before.tick, before.rows), col: before.path[Math.min(before.tick, before.rows - 1)] } }; | |
| 242 | G.tickAt = G.time; G.dirty = true; | |
| 243 | P = r.state; | |
| 244 | const verb = before.tripped > 0 ? 'You are still tripped' : note ? 'You wait' : WORDS[wanted]; | |
| 245 | let line = `${note}${verb}. ${P.line}.`; | |
| 246 | if (P.trips > before.trips) { | |
| 247 | line = `${note}You trip on a tussock at row ${P.runner.row + 1} and miss the next ${P.tripped} ticks. ${P.line}.`; | |
| 248 | sound(150, .25, 'sawtooth'); | |
| 249 | } else sound(P.status === 'playing' ? 300 + P.tick * 12 : 300, .05, 'square'); | |
| 250 | if (P.status === 'won') line += ' Tap or press N for the next level, or R to play it again.'; | |
| 251 | else if (P.status === 'lost') line += ' Tap or press R to try again.'; | |
| 252 | say(line); | |
| 253 | if (P.status !== 'playing') { G.overAt = G.time; sound(P.status === 'won' ? 880 : 110, .4, P.status === 'won' ? 'triangle' : 'sawtooth'); } | |
| 254 | } | |
| 255 | function fresh(lv) { | |
| 256 | level = lv; | |
| 257 | P = M.start(seedNow(), level); | |
| 258 | G.from = { runner: P.runner, cheese: P.cheeseStart }; | |
| 259 | G.pending = 'stay'; G.tickAt = -9; G.nextAt = G.time + LEAD_S; G.dirty = true; | |
| 260 | } | |
| 261 | function restart() { | |
| 262 | fresh(level); | |
| 263 | say(`Level ${P.level + 1}. ${P.line}. Arrow keys to move; doing nothing waits.`); | |
| 264 | } | |
| 265 | function nextLevel() { | |
| 266 | if (P.status !== 'won') { say(`Catch the cheese first. ${P.line}.`); return; } | |
| 267 | fresh(Math.min(M.MAX_LEVEL, level + 1)); | |
| 268 | say(`Level ${P.level + 1}. ${P.line}. Arrow keys to move; doing nothing waits.`); | |
| 269 | } | |
| 270 | function choose(action) { | |
| 271 | ensureAudio(); | |
| 272 | if (P.status !== 'playing') { say(`${P.line}. ${P.status === 'won' ? 'Press N for the next level' : 'Press R to try again'}.`); return; } | |
| 273 | G.pending = action; | |
| 274 | say(`Next beat: ${action === 'stay' ? 'wait' : action}. ${P.line}.`); | |
| 275 | } | |
| 276 | function update(dt) { | |
| 277 | G.time += dt; | |
| 278 | if (P.status === 'playing' && G.time >= G.nextAt) { G.nextAt += TICK_S; tick(); } | |
| 279 | if (G.time - G.tickAt < GLIDE_S + .05) G.dirty = true; | |
| 280 | const sway = STILL ? 0 : Math.floor(G.time * 4) & 3; | |
| 281 | if (sway !== G.sway) { G.sway = sway; G.dirty = true; } | |
| 282 | if (!STILL && P.tripped > 0) G.dirty = true; | |
| 283 | renderHud(); | |
| 284 | } | |
| 285 | ||
| 286 | /* ---------- input: arrows pick the move for the next beat; a tap beside the runner does the same ---------- */ | |
| 287 | function moveAt(px, py) { | |
| 288 | const { row, col } = P.runner; | |
| 289 | let best = null, top = PICK_PX; | |
| 290 | for (const [a, [dr, dc]] of Object.entries(M.ACTIONS)) { | |
| 291 | const r = row + dr, c = col + dc; | |
| 292 | if (r >= P.rows || c < 0 || c >= P.lanes) continue; | |
| 293 | const [sx, sy] = screenOf(r, c), d = Math.hypot(px - sx, py - sy); | |
| 294 | if (d < top) { top = d; best = a; } | |
| 295 | } | |
| 296 | return best; | |
| 297 | } | |
| 298 | canvas.addEventListener('pointerdown', e => { | |
| 299 | canvas.focus({ preventScroll: true }); | |
| 300 | ensureAudio(); | |
| 301 | if (P.status !== 'playing') { | |
| 302 | if (G.time - G.overAt > .6) { if (P.status === 'won') nextLevel(); else restart(); } | |
| 303 | return; | |
| 304 | } | |
| 305 | const r = canvas.getBoundingClientRect(); | |
| 306 | const a = moveAt((e.clientX - r.left) / r.width * W, (e.clientY - r.top) / r.height * H); | |
| 307 | if (!a) { say('Tap beside the runner to move that way, or the runner to wait.'); return; } | |
| 308 | choose(a); | |
| 309 | }); | |
| 310 | canvas.addEventListener('contextmenu', e => e.preventDefault()); | |
| 311 | canvas.addEventListener('keydown', e => { | |
| 312 | if (e.ctrlKey || e.metaKey || e.altKey) return; | |
| 313 | const k = e.key; | |
| 314 | if (Object.hasOwn(ACTION_KEYS, k)) { if (!e.repeat) choose(ACTION_KEYS[k]); } | |
| 315 | else if (k === 'r' || k === 'R') { if (!e.repeat) restart(); } | |
| 316 | else if (k === 'n' || k === 'N') { if (!e.repeat) nextLevel(); } | |
| 317 | else return; | |
| 318 | e.preventDefault(); | |
| 319 | }); | |
| 320 | ||
| 321 | /* ---------- sound ---------- */ | |
| 322 | let audio = null, muted = false; | |
| 323 | function ensureAudio() { | |
| 324 | if (audio) { if (audio.state === 'suspended') audio.resume(); return; } | |
| 325 | const AC = window.AudioContext || window.webkitAudioContext; | |
| 326 | if (AC) audio = new AC(); | |
| 327 | } | |
| 328 | function sound(freq, life, type) { | |
| 329 | if (!audio || muted) return; | |
| 330 | const now = audio.currentTime, gain = audio.createGain(), osc = audio.createOscillator(); | |
| 331 | osc.type = type; | |
| 332 | osc.frequency.setValueAtTime(freq, now); | |
| 333 | gain.gain.setValueAtTime(.0001, now); | |
| 334 | gain.gain.exponentialRampToValueAtTime(.06, now + .01); | |
| 335 | gain.gain.exponentialRampToValueAtTime(.0005, now + life); | |
| 336 | osc.connect(gain).connect(audio.destination); | |
| 337 | osc.start(now); osc.stop(now + life + .02); | |
| 338 | } | |
| 339 | const muteButton = document.getElementById('mute'); | |
| 340 | muteButton.addEventListener('click', () => { | |
| 341 | muted = !muted; | |
| 342 | muteButton.textContent = muted ? 'sound off' : 'sound on'; | |
| 343 | muteButton.setAttribute('aria-pressed', String(muted)); | |
| 344 | }); | |
| 345 | ||
| 346 | /* ---------- fit and boot ---------- */ | |
| 347 | addEventListener('resize', fit); | |
| 348 | fit(); | |
| 349 | renderHud(); | |
| 350 | say(`${P.line}. The cheese rolls on every ${TICK_S} s: arrow keys or a tap beside the runner to move, otherwise you wait.`); | |
| 351 | canvas.focus({ preventScroll: true }); | |
| 352 | let last = performance.now(); | |
| 353 | function frame(now) { | |
| 354 | update(Math.min(.04, (now - last) / 1000)); | |
| 355 | last = now; | |
| 356 | if (G.dirty) { G.dirty = false; draw(); } | |
| 357 | requestAnimationFrame(frame); | |
| 358 | } | |
| 359 | requestAnimationFrame(frame); | |
| 360 | </script> | |
| 361 | </body> | |
| 362 | </html> |
cheese-rolling/page.test.mjs added +104
| @@ -0,0 +1,104 @@ | ||
| 1 | import { test, expect } from 'bun:test'; | |
| 2 | import { boot } from '../playtest.mjs'; | |
| 3 | import M from './hill.js'; | |
| 4 | ||
| 5 | const state = g => JSON.parse(g.read('JSON.stringify(P)')); | |
| 6 | // a hill with no tussocks and a straight cheese lane 2, the runner at row 4, lane 0 | |
| 7 | const bare = g => g.read(`P = { ...P, tussocks: [], path: P.path.map(() => 2), runner: { row: 4, col: 0 }, runnerStart: { row: 4, col: 0 } }; G.from = { runner: P.runner, cheese: P.cheeseStart }`); | |
| 8 | ||
| 9 | test('the hill beats on by itself and the HUD reads the rules state', () => { | |
| 10 | const g = boot('cheese-rolling'); | |
| 11 | expect(g.el('level').textContent).toBe('1'); | |
| 12 | g.run(2 + .7 * 3); | |
| 13 | const s = state(g); | |
| 14 | expect(s.tick).toBeGreaterThanOrEqual(3); | |
| 15 | expect(g.el('tick').textContent).toBe(String(s.tick)); | |
| 16 | expect(g.el('trips').textContent).toBe(String(s.trips)); | |
| 17 | expect(g.el('rows').textContent).toBe(String(s.rowsLeft)); | |
| 18 | }); | |
| 19 | ||
| 20 | test('arrow keys pick the move for the next beat, and doing nothing is stay', () => { | |
| 21 | const g = boot('cheese-rolling'); | |
| 22 | bare(g); | |
| 23 | g.press('ArrowRight'); | |
| 24 | expect(g.el('status').textContent).toContain('Next beat: right'); | |
| 25 | g.run(2); | |
| 26 | expect(state(g).runner.col).toBe(1); | |
| 27 | expect(g.el('status').textContent).toContain('You step right'); | |
| 28 | g.run(.8); | |
| 29 | expect(state(g).runner.col).toBe(1); // nothing pressed: stayed | |
| 30 | expect(g.el('status').textContent).toContain('You wait'); | |
| 31 | g.press('ArrowDown'); | |
| 32 | g.run(.8); | |
| 33 | expect(state(g).runner.row).toBe(5); | |
| 34 | }); | |
| 35 | ||
| 36 | test('a tap beside the runner picks that move, a tap on the runner waits', () => { | |
| 37 | const g = boot('cheese-rolling'); | |
| 38 | bare(g); | |
| 39 | g.read('G.dirty = true'); g.run(.1); | |
| 40 | const [x, y] = g.read('screenOf(P.runner.row, P.runner.col + 1)'); | |
| 41 | g.tap(x, y); | |
| 42 | expect(g.read('G.pending')).toBe('right'); | |
| 43 | const [rx, ry] = g.read('screenOf(P.runner.row, P.runner.col)'); | |
| 44 | g.tap(rx, ry); | |
| 45 | expect(g.read('G.pending')).toBe('stay'); | |
| 46 | }); | |
| 47 | ||
| 48 | test('a refused move is announced with its reason and the beat still passes', () => { | |
| 49 | const g = boot('cheese-rolling'); | |
| 50 | bare(g); | |
| 51 | g.press('ArrowLeft'); | |
| 52 | g.run(2); | |
| 53 | expect(state(g).tick).toBe(1); | |
| 54 | expect(state(g).runner.col).toBe(0); | |
| 55 | expect(g.el('status').textContent).toContain('Cannot move left: that is off the hill'); | |
| 56 | }); | |
| 57 | ||
| 58 | test('a tussock trips the runner, announced, shown on the runner and counted', () => { | |
| 59 | const g = boot('cheese-rolling'); | |
| 60 | bare(g); | |
| 61 | g.read('P = { ...P, tussocks: [{ row: 4, col: 1 }] }; G.dirty = true'); g.run(.1); | |
| 62 | const plain = g.picture(); | |
| 63 | g.press('ArrowRight'); | |
| 64 | g.run(2); | |
| 65 | expect(state(g).trips).toBe(1); | |
| 66 | expect(g.el('trips').textContent).toBe('1'); | |
| 67 | expect(g.el('status').textContent).toContain('You trip on a tussock'); | |
| 68 | expect(g.picture()).not.toBe(plain); | |
| 69 | g.press('ArrowRight'); | |
| 70 | g.run(.8); | |
| 71 | expect(state(g).runner.col).toBe(1); // tripped: the move is lost | |
| 72 | }); | |
| 73 | ||
| 74 | test('catching the cheese ends the run with the rules line; N goes up a level, R restarts', () => { | |
| 75 | const g = boot('cheese-rolling'); | |
| 76 | for (let i = 0; i < 8 && state(g).status === 'playing'; i++) { | |
| 77 | const a = M.solve(state(g))[0]; | |
| 78 | if (a !== 'stay') g.press({ left: 'ArrowLeft', right: 'ArrowRight', down: 'ArrowDown' }[a], .02); | |
| 79 | g.run(i ? .7 : 2); | |
| 80 | } | |
| 81 | expect(state(g).status).toBe('won'); | |
| 82 | expect(g.el('status').textContent).toContain('Caught the cheese on row'); | |
| 83 | g.keyDown('ArrowLeft'); | |
| 84 | expect(g.el('status').textContent).toContain('Press N for the next level'); | |
| 85 | g.press('n'); | |
| 86 | expect(state(g).level).toBe(1); | |
| 87 | expect(state(g).status).toBe('playing'); | |
| 88 | g.press('r'); | |
| 89 | expect(state(g).tick).toBe(0); | |
| 90 | }); | |
| 91 | ||
| 92 | test('missing the cheese loses with the rules line', () => { | |
| 93 | const g = boot('cheese-rolling'); | |
| 94 | g.read('P = { ...P, tussocks: [], path: P.path.map(() => 4), runner: { row: 4, col: 0 } }'); | |
| 95 | g.run(2 + .7 * (state(g).rows + 1)); | |
| 96 | expect(state(g).status).toBe('lost'); | |
| 97 | expect(g.el('status').textContent).toContain('Lost: the cheese rolled past the bottom'); | |
| 98 | }); | |
| 99 | ||
| 100 | test('reduced motion holds the sway and the hop still', () => { | |
| 101 | const a = boot('cheese-rolling', { reduced: true }), b = boot('cheese-rolling', { reduced: true }); | |
| 102 | a.run(1.5); b.run(1.5); | |
| 103 | expect(a.picture()).toBe(b.picture()); | |
| 104 | }); |
chestnut-roast/page.test.mjs +2 −2
| @@ -68,7 +68,7 @@ test('letting everything fall ends the round with the rules line, and a tap play | ||
| 68 | 68 | g.tap(180, 200); |
| 69 | 69 | expect(g.read('P.status')).toBe('playing'); |
| 70 | 70 | expect(g.read('P.score')).toBe(0); |
| 71 | }); | |
| 71 | }, 30000); | |
| 72 | 72 | |
| 73 | 73 | test('the page has a mute button, a how-to-play label and no mp3', async () => { |
| 74 | 74 | const html = await Bun.file(new URL('./index.html', import.meta.url)).text(); |
| @@ -131,7 +131,7 @@ test('a round above the saved best is announced as a new best and saved', () => | ||
| 131 | 131 | expect(g.el('status').textContent).toContain('new best'); |
| 132 | 132 | expect(g.store[BEST]).toBe(String(g.read('P.score'))); |
| 133 | 133 | expect(Number(g.store[BEST])).toBeGreaterThan(5); |
| 134 | }); | |
| 134 | }, 30000); | |
| 135 | 135 | |
| 136 | 136 | test('a round below the saved best is not announced as a new best and leaves the save alone', () => { |
| 137 | 137 | const low = boot('chestnut-roast', { stored: { [BEST]: '999' } }); |
index.html +1
| @@ -142,6 +142,7 @@ footer a::after { content: none; } | ||
| 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 | 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> |
| 144 | 144 | <li data-kind="Memory" data-input="Tap" style="--accent:#c75b8c"><a href="cake-stall/">Cake Stall</a><span>Memorise the customer's stack of cake layers, then stack them back in the same order</span><span>Game forged by the crew</span><span class="tags">Memory · Tap</span></li> |
| 145 | <li data-kind="Catching" data-input="Keys" style="--accent:#d9a21b"><a href="cheese-rolling/">Cheese Rolling</a><span>Chase the cheese down a steep hill, dodge the tussocks and catch it before the bottom</span><span>Game forged by the crew</span><span class="tags">Catching · Keys</span></li> | |
| 145 | 146 | <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> |
| 146 | 147 | </ul> |
| 147 | 148 | <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/cheese-rolling.png added
Binary file not shown.
og/home.png
Binary file not shown.
rounds.mjs +9
| @@ -17,6 +17,7 @@ import Carrot from './carrot-pull/bed.js'; | ||
| 17 | 17 | import Cars from './bumper-cars/floor.js'; |
| 18 | 18 | import Bobbing from './apple-bobbing/tub.js'; |
| 19 | 19 | import Cake from './cake-stall/order.js'; |
| 20 | import Hill from './cheese-rolling/hill.js'; | |
| 20 | 21 | import Tide from './tide-crossing/causeway.js'; |
| 21 | 22 | |
| 22 | 23 | const MID = [180, 140]; |
| @@ -543,6 +544,14 @@ export const ROUNDS = { | ||
| 543 | 544 | if (s.aimed !== pick) { g.press(String(pick + 1), .02); return; } |
| 544 | 545 | if (soonest === 0) g.press(' ', .02); else g.run(.04); |
| 545 | 546 | } }, |
| 547 | // each tick, play the first move of the rules' own shortest line to the cheese from the state as it stands | |
| 548 | 'cheese-rolling': { over: /for the next level|to try again/, outcome: /Caught the cheese on row \d+ at tick [1-9]\d*/, limit: 40, beat: g => { | |
| 549 | const s = JSON.parse(g.read('JSON.stringify(P)')); | |
| 550 | if (s.status !== 'playing' || decided.get(g) === s.tick) { g.run(.04); return; } | |
| 551 | decided.set(g, s.tick); | |
| 552 | const key = { left: 'ArrowLeft', right: 'ArrowRight', down: 'ArrowDown' }[Hill.solve(s)[0]]; | |
| 553 | if (key) g.press(key, .02); | |
| 554 | } }, | |
| 546 | 555 | // wait out the showing, then stack the order from the rules' own state, one layer a beat; eight orders win |
| 547 | 556 | 'cake-stall': { over: /tap or press R/i, outcome: /You win: all 8 orders served, score [1-9]\d*/, limit: 60, beat: g => { |
| 548 | 557 | const s = JSON.parse(g.read('JSON.stringify(P)')); |
serve.test.mjs +7
| @@ -803,3 +803,10 @@ test('Tide Crossing and its rules script are served without a track', async () = | ||
| 803 | 803 | expect(page.headers.get('content-type')).toBe('text/html; charset=utf-8'); |
| 804 | 804 | expect((await fetch(base + '/tide-crossing/causeway.js')).status).toBe(200); |
| 805 | 805 | }); |
| 806 | ||
| 807 | test('Cheese Rolling and its rules script are served without a track', async () => { | |
| 808 | const page = await fetch(base + '/cheese-rolling/'); | |
| 809 | expect(page.status).toBe(200); | |
| 810 | expect(page.headers.get('content-type')).toBe('text/html; charset=utf-8'); | |
| 811 | expect((await fetch(base + '/cheese-rolling/hill.js')).status).toBe(200); | |
| 812 | }); |
shell.js +1
| @@ -78,6 +78,7 @@ window.PixelPlays = (() => { | ||
| 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 | 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 }, |
| 80 | 80 | { id: 'cake-stall', title: 'Cake Stall', blurb: 'Memorise the customer\'s stack of cake layers, then stack them back in the same order', kind: 'Memory', input: 'Tap', accent: '#c75b8c', music: false, forged: true }, |
| 81 | { id: 'cheese-rolling', title: 'Cheese Rolling', blurb: 'Chase the cheese down a steep hill, dodge the tussocks and catch it before the bottom', kind: 'Catching', input: 'Keys', accent: '#d9a21b', music: false, forged: true }, | |
| 81 | 82 | { 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 }, |
| 82 | 83 | { 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 }, |
| 83 | 84 | { 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 }, |
This compares two reduced-motion boots to each other, so it passes even if reduced motion is ignored. Compare reduced against normal motion, and assert frames over time are static when reduced.