Public: anyone can read this; the crew can change it.
Migrated from github.com/amirmushichge/pixel-plays
Add Beach Huts: the playable page and its place in the catalogue, on the rules in `beach-huts/row.js` #372
Check · Review · Land · Live — Its check is red.
number 2 in the queue: no estimate: nothing landed in the last hour
vesper/pixel-plays:vesper/367-add-beach-huts-the-playable-page-and-its → main 4 commits fork
10 files
changed +539
−3
README.md +5 −2
Diff not shown — a line 4,141 characters wide (limit 2,000). +5 −2
View filebeach-huts/index.html added +416
| @@ -0,0 +1,416 @@ | ||
| 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>Beach Huts</title> | |
| 7 | <!-- og:begin --> | |
| 8 | <meta name="description" content="Paint the row of seaside huts so no two neighbours share a colour, from limited tins, within par"> | |
| 9 | <link rel="canonical" href="https://pixel-plays--forge-trevato-apps.open-platform.sh/beach-huts/"> | |
| 10 | <meta property="og:type" content="website"> | |
| 11 | <meta property="og:site_name" content="Pixel Plays"> | |
| 12 | <meta property="og:title" content="Beach Huts"> | |
| 13 | <meta property="og:description" content="Paint the row of seaside huts so no two neighbours share a colour, from limited tins, within par"> | |
| 14 | <meta property="og:url" content="https://pixel-plays--forge-trevato-apps.open-platform.sh/beach-huts/"> | |
| 15 | <meta property="og:image" content="https://pixel-plays--forge-trevato-apps.open-platform.sh/og/beach-huts.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="Beach Huts: 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; : #2a8fb0; } | |
| 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 | .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; gap: 16px; pointer-events: none; } | |
| 31 | #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; } | |
| 32 | #bottom { bottom: max(14px, env(safe-area-inset-bottom)); align-items: flex-end; } | |
| 33 | .row { display: flex; align-items: center; gap: 8px; color: var(); } | |
| 34 | .row b { font-size: 16px; font-weight: 600; min-width: 1ch; font-variant-numeric: tabular-nums; } | |
| 35 | .row span { color: var(); } | |
| 36 | #line { color: var(); min-height: 2.7em; max-width: 60ch; } | |
| 37 | 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; } | |
| 38 | button:hover, button:focus-visible { color: var(); } | |
| 39 | button:focus-visible { outline: 2px solid var(); outline-offset: 2px; } | |
| 40 | noscript p { position: fixed; inset: 40% 16px auto; margin: 0; text-align: center; color: var(); } | |
| 41 | </style> | |
| 42 | </head> | |
| 43 | <body> | |
| 44 | <div id="stage"><canvas id="c" tabindex="0" aria-label="Beach Huts: a row of seaside huts on a promenade, some already painted and locked with a padlock on the door, and a tin of paint for each colour in front with the number left. Tap a hut then a tin, or move along the huts with the left and right arrow keys and press 1 to 4 to paint it with that tin. No two neighbouring huts may share a colour, within par strokes. A hut marked with a warning triangle clashes with its neighbour. U undoes a stroke and R restarts the level."></canvas></div> | |
| 45 | <noscript><p>Beach Huts needs JavaScript to play.</p></noscript> | |
| 46 | <div id="scores"> | |
| 47 | <div class="row"><b id="level">1</b><span>level</span></div> | |
| 48 | <div class="row"><b id="strokes">0</b><span>strokes</span><b id="par">0</b><span>par</span></div> | |
| 49 | </div> | |
| 50 | <div class="hud" id="bottom"> | |
| 51 | <div id="line"><span id="status" role="status" aria-live="polite"></span></div> | |
| 52 | <button id="mute" type="button" aria-pressed="false">sound</button> | |
| 53 | </div> | |
| 54 | <script src="../shell.js"></script> | |
| 55 | <script> | |
| 56 | PixelPlays.mount({ | |
| 57 | id: 'beach-huts', | |
| 58 | rules: [ | |
| 59 | 'Paint every bare hut so no two neighbours share a colour. Locked huts (a padlock) keep theirs.', | |
| 60 | 'Tap a hut then a tin, or arrows then 1-4. Each tin holds only what it shows; a repaint returns the old tin.', | |
| 61 | 'Stay within par strokes. U undoes, R restarts.', | |
| 62 | ], | |
| 63 | }); | |
| 64 | </script> | |
| 65 | <script>window.PP_SCENE = { originY: -6 };</script> | |
| 66 | <script src="../engine.js"></script> | |
| 67 | <script src="row.js"></script> | |
| 68 | <script> | |
| 69 | 'use strict'; | |
| 70 | ||
| 71 | const BH = window.BeachHuts; | |
| 72 | const STEP = 1.84, HALF = .8, BODY_Z = 1.5; // hut spacing along the row, half the footprint, wall height | |
| 73 | const TIN_AT = 6.2, TIN_STEP = 2.8, TIN_R = .55; // how far the tins stand in front of the row | |
| 74 | const PAINT = { | |
| 75 | red: [200, 56, 48], blue: [60, 110, 200], yellow: [236, 196, 50], green: [60, 160, 80], | |
| 76 | }; | |
| 77 | const LETTER = { red: 'R', blue: 'B', yellow: 'Y', green: 'G' }; | |
| 78 | const BARE = [214, 204, 184], ROOF = [92, 84, 96], DOOR = [70, 52, 40]; | |
| 79 | const PAVE = [[196, 190, 178], [186, 180, 168]], SEA = [[64, 150, 190], [78, 164, 202]], KERB = [150, 144, 134]; | |
| 80 | const WHY = { | |
| 81 | locked: 'that hut is locked, with a padlock on its door, and cannot be repainted', | |
| 82 | 'same-colour': 'that hut is already that colour', | |
| 83 | 'no-tins-left': 'that tin is empty', | |
| 84 | 'no-such-colour': 'there is no such tin at this level', | |
| 85 | 'no-such-hut': 'there is no such hut', | |
| 86 | 'game-over': 'the level is over: press U to undo a stroke or R to restart', | |
| 87 | 'nothing-to-undo': 'there is nothing to undo', | |
| 88 | }; | |
| 89 | ||
| 90 | /* ---------- state ---------- */ | |
| 91 | const nodes = {}; | |
| 92 | for (const id of ['level', 'strokes', 'par', 'status']) nodes[id] = document.getElementById(id); | |
| 93 | const shown = {}; | |
| 94 | function setText(key, text) { | |
| 95 | if (shown[key] === text) return; | |
| 96 | shown[key] = text; | |
| 97 | nodes[key].textContent = text; | |
| 98 | } | |
| 99 | const newSeed = () => Math.floor(Math.random() * 4294967296); | |
| 100 | let P = BH.start(newSeed(), 1); | |
| 101 | const G = { time: 0, hut: 0, wave: -1, dirty: true, seed: P.seed }; | |
| 102 | function renderHud() { | |
| 103 | setText('level', `${P.level} of ${BH.MAX_LEVEL}`); | |
| 104 | setText('strokes', String(P.strokes)); | |
| 105 | setText('par', String(P.par)); | |
| 106 | } | |
| 107 | // The live region: one line per stroke, refusal and end. It is cleared first, so a repeated line is spoken again. | |
| 108 | function say(text) { | |
| 109 | nodes.status.textContent = ''; | |
| 110 | nodes.status.textContent = text; | |
| 111 | } | |
| 112 | ||
| 113 | /* ---------- geometry: the row runs left to right on screen, along (1,-1) in the world ---------- */ | |
| 114 | const hutT = i => (i - (P.huts.length - 1) / 2) * STEP; | |
| 115 | const tinT = k => (k - (P.colours.length - 1) / 2) * TIN_STEP; | |
| 116 | const hutBox = i => { const sx = project(hutT(i), -hutT(i), 0).sx; return { x0: sx - 12, x1: sx + 12, y0: project(0, 0, 2.2).sy - 7, y1: project(0, 0, 0).sy + 8 }; }; | |
| 117 | function hutPoint(i) { const t = hutT(i), p = project(t, -t, BODY_Z / 2); return [p.sx, p.sy]; } | |
| 118 | function tinPoint(k) { const t = tinT(k), p = project(t + TIN_AT, -t + TIN_AT, .5); return [p.sx, p.sy]; } | |
| 119 | function hutAt(px, py) { | |
| 120 | for (let i = 0; i < P.huts.length; i++) { | |
| 121 | const b = hutBox(i); | |
| 122 | if (px >= b.x0 && px <= b.x1 && py >= b.y0 && py <= b.y1) return i; | |
| 123 | } | |
| 124 | return -1; | |
| 125 | } | |
| 126 | function tinAt(px, py) { | |
| 127 | for (let k = 0; k < P.colours.length; k++) { | |
| 128 | const [sx, sy] = tinPoint(k); | |
| 129 | if (Math.abs(px - sx) <= 14 && py >= sy - 24 && py <= sy + 12) return k; | |
| 130 | } | |
| 131 | return -1; | |
| 132 | } | |
| 133 | ||
| 134 | /* ---------- baked scene: the promenade and the sea ---------- */ | |
| 135 | function groundColor(x, y, ix, iy) { | |
| 136 | const u = (x - y) / Math.SQRT2, v = (x + y) / Math.SQRT2; | |
| 137 | if (Math.abs(u) > 17 || v < -12 || v > 14) return 0; | |
| 138 | const t = (bayerAt(ix, iy) - .5) * .06 + (noise(x * .8, y * .8) - .5) * .05; | |
| 139 | let c; | |
| 140 | if (v < -4) c = SEA[(Math.floor(u / 1.5) + Math.floor(v / 1.2)) & 1]; | |
| 141 | else if (v < -3.4) c = KERB; | |
| 142 | else c = PAVE[(Math.floor(u / 2) + Math.floor(v / 2)) & 1]; | |
| 143 | return rgb(c[0] * (1 + t), c[1] * (1 + t), c[2] * (1 + t)); | |
| 144 | } | |
| 145 | function bake() { | |
| 146 | ground.fill(0); shadowMask.fill(0); staticShadow.fill(0); | |
| 147 | zb.fill(-1e9); | |
| 148 | pix.fill(PAPER); | |
| 149 | for (let iy = 0; iy < H; iy++) for (let ix = 0; ix < W; ix++) { | |
| 150 | const g = unproject(ix + .5, iy + .5), i = iy * W + ix, c = groundColor(g.x, g.y, ix, iy); | |
| 151 | if (!c) continue; | |
| 152 | pix[i] = c; ground[i] = 1; | |
| 153 | zb[i] = (g.x + g.y) / R2 * COS; | |
| 154 | } | |
| 155 | groundZ.set(zb); | |
| 156 | bakedPix.set(pix); | |
| 157 | bakedZ.set(zb); | |
| 158 | } | |
| 159 | ||
| 160 | /* ---------- small flat pictures drawn over everything: letters, digits, padlock, warning ---------- */ | |
| 161 | const FONT = { | |
| 162 | R: '110101110101101', B: '110101110101110', Y: '101101010010010', G: '011100101101011', | |
| 163 | 0: '111101101101111', 1: '010110010010111', 2: '111001111100111', 3: '111001111001111', 4: '101101111001001', | |
| 164 | 5: '111100111001111', 6: '111100111101111', 7: '111001010010010', 8: '111101111101111', 9: '111101111001111', | |
| 165 | }; | |
| 166 | function glyph(ch, sx, sy, scale) { | |
| 167 | const bits = FONT[ch], x0 = Math.round(sx) - Math.floor(3 * scale / 2), y0 = Math.round(sy) - Math.floor(5 * scale / 2); | |
| 168 | const ink = [], halo = []; | |
| 169 | for (let k = 0; k < 15; k++) { | |
| 170 | if (bits[k] !== '1') continue; | |
| 171 | for (let a = 0; a < scale; a++) for (let b = 0; b < scale; b++) ink.push([x0 + (k % 3) * scale + a, y0 + Math.floor(k / 3) * scale + b]); | |
| 172 | } | |
| 173 | for (const [x, y] of ink) for (const [dx, dy] of [[-1, 0], [1, 0], [0, -1], [0, 1], [-1, -1], [1, 1], [-1, 1], [1, -1]]) halo.push([x + dx, y + dy]); | |
| 174 | drawDots(halo, PAPER); | |
| 175 | drawDots(ink, INK); | |
| 176 | } | |
| 177 | const PADLOCK = [ | |
| 178 | '..oooo..', | |
| 179 | '.oXXXXo.', | |
| 180 | '.oX..Xo.', | |
| 181 | '.oX..Xo.', | |
| 182 | 'oooooooo', | |
| 183 | 'oXXXXXXo', | |
| 184 | 'oXXooXXo', | |
| 185 | 'oXXooXXo', | |
| 186 | 'oXXXXXXo', | |
| 187 | 'oooooooo', | |
| 188 | ]; | |
| 189 | const WARNING = [ | |
| 190 | '....o....', | |
| 191 | '...oXo...', | |
| 192 | '...ooo...', | |
| 193 | '..oXoXo..', | |
| 194 | '..oXoXo..', | |
| 195 | '.oXXoXXo.', | |
| 196 | '.oXXXXXo.', | |
| 197 | 'ooooooooo', | |
| 198 | ]; | |
| 199 | function bitmap(rows, sx, sy) { | |
| 200 | const left = Math.round(sx - rows[0].length / 2), top = Math.round(sy - rows.length / 2), ink = [], fill = []; | |
| 201 | rows.forEach((row, y) => [...row].forEach((cell, x) => { | |
| 202 | if (cell === 'o') ink.push([left + x, top + y]); | |
| 203 | if (cell === 'X') fill.push([left + x, top + y]); | |
| 204 | })); | |
| 205 | drawDots(ink, INK); | |
| 206 | drawDots(fill, SIGNAL); | |
| 207 | } | |
| 208 | // a dashed outline round a screen rectangle | |
| 209 | function dashed(b, color) { | |
| 210 | const pts = []; | |
| 211 | for (let x = b.x0; x <= b.x1; x++) if (((x - b.x0) >> 1) % 2 === 0) { pts.push([x, b.y0], [x, b.y1]); } | |
| 212 | for (let y = b.y0; y <= b.y1; y++) if (((y - b.y0) >> 1) % 2 === 0) { pts.push([b.x0, y], [b.x1, y]); } | |
| 213 | drawDots(pts, color); | |
| 214 | } | |
| 215 | // a ring on the ground round a footprint | |
| 216 | function ring(cx, cy, half) { | |
| 217 | const pts = [], m = half + .15; | |
| 218 | const corners = [[cx - m, cy - m], [cx + m, cy - m], [cx + m, cy + m], [cx - m, cy + m]]; | |
| 219 | for (let k = 0; k < 4; k++) { | |
| 220 | const [ax, ay] = corners[k], [bx, by] = corners[(k + 1) % 4]; | |
| 221 | for (let n = 0; n < 24; n++) { const p = project(lerp(ax, bx, n / 24), lerp(ay, by, n / 24), .02); pts.push([p.sx, p.sy]); } | |
| 222 | } | |
| 223 | haloDots(pts); | |
| 224 | drawDots(pts, SIGNAL); | |
| 225 | } | |
| 226 | ||
| 227 | /* ---------- the huts and the tins ---------- */ | |
| 228 | function hut(i) { | |
| 229 | const h = P.huts[i], t = hutT(i), x = t, y = -t, c = h.colour ? PAINT[h.colour] : BARE; | |
| 230 | box(x - HALF, y - HALF, 0, x + HALF, y + HALF, BODY_Z, c, i); | |
| 231 | box(x - HALF - .12, y - HALF - .12, BODY_Z, x + HALF + .12, y + HALF + .12, BODY_Z + .3, ROOF, i + 20); | |
| 232 | box(x - HALF * .5, y - HALF - .05, BODY_Z + .3, x + HALF * .5, y + HALF + .05, BODY_Z + .55, ROOF, i + 40); | |
| 233 | for (const z of [.4, .9, 1.5]) castShadow(x, y, z, 1, 1, .2); | |
| 234 | } | |
| 235 | // the details are flat pictures over the hut: a door with a padlock on the left face, the letter on the right face | |
| 236 | function hutDetails(i) { | |
| 237 | const h = P.huts[i], t = hutT(i), x = t, y = -t, door = []; | |
| 238 | for (let a = x - .35; a <= x + .35; a += .06) for (let b = 0; b <= 1.05; b += .06) { | |
| 239 | const p = project(a, y + HALF + .01, b); door.push([p.sx, p.sy]); | |
| 240 | } | |
| 241 | drawDots(door, DOOR); | |
| 242 | if (h.locked) { const p = project(x, y + HALF + .01, .55); bitmap(PADLOCK, p.sx, p.sy); } | |
| 243 | if (h.colour) { const p = project(x + HALF + .01, y, .8); glyph(LETTER[h.colour], p.sx, p.sy, 2); } | |
| 244 | } | |
| 245 | function tin(k) { | |
| 246 | const c = P.colours[k], t = tinT(k), x = t + TIN_AT, y = -t + TIN_AT, n = P.tins[c]; | |
| 247 | const col = n > 0 ? PAINT[c] : [120, 118, 112]; | |
| 248 | dynamicAdd([x, y, .15], [x, y, .95], TIN_R, TIN_R, () => col); | |
| 249 | sphere(x, y, 1.0, TIN_R * .8, [196, 196, 200], 'solid'); | |
| 250 | castShadow(x, y, .6, TIN_R + .15, 1, .2); | |
| 251 | } | |
| 252 | function tinDetails(k) { | |
| 253 | const c = P.colours[k], [sx, sy] = tinPoint(k); | |
| 254 | glyph(LETTER[c], sx, sy + 1, 2); | |
| 255 | glyph(String(Math.min(9, P.tins[c])), sx, sy - 17, 2); | |
| 256 | } | |
| 257 | ||
| 258 | function draw() { | |
| 259 | pix.set(bakedPix); | |
| 260 | zb.set(bakedZ); | |
| 261 | pendingShadows.length = 0; | |
| 262 | // glints slide on the sea (ambient: still under reduced motion) | |
| 263 | if (!STILL) { | |
| 264 | for (let k = 0; k < 9; k++) if ((G.wave + k) % 3 === 0) { | |
| 265 | const u = -15 + k * 3.7, v = -10 + (k * 7 % 5) * 1.3, p = { x: (u + v) / Math.SQRT2, y: (v - u) / Math.SQRT2 }; | |
| 266 | sphere(p.x, p.y, .02, .14, [210, 236, 248], 'solid'); | |
| 267 | } | |
| 268 | } | |
| 269 | for (let i = 0; i < P.huts.length; i++) hut(i); | |
| 270 | for (let k = 0; k < P.colours.length; k++) tin(k); | |
| 271 | flushShadows(); | |
| 272 | applyShadows(bakedZ); | |
| 273 | shadeEdges(); | |
| 274 | for (let i = 0; i < P.huts.length; i++) hutDetails(i); | |
| 275 | for (let k = 0; k < P.colours.length; k++) tinDetails(k); | |
| 276 | const bad = new Set(); | |
| 277 | for (const [a, b] of BH.clashes(P)) { bad.add(a); bad.add(b); } | |
| 278 | for (const i of bad) { | |
| 279 | const b = hutBox(i); | |
| 280 | dashed({ x0: b.x0 + 1, x1: b.x1 - 1, y0: b.y0, y1: b.y1 }, rgb(196, 30, 30)); | |
| 281 | bitmap(WARNING, (b.x0 + b.x1) / 2, b.y0 - 5); | |
| 282 | } | |
| 283 | if (G.hut >= 0 && G.hut < P.huts.length) { const t = hutT(G.hut); ring(t, -t, HALF); } | |
| 284 | ctx.putImageData(image, 0, 0); | |
| 285 | } | |
| 286 | ||
| 287 | /* ---------- game: every rule is the rules script's ---------- */ | |
| 288 | const nameOf = i => { | |
| 289 | const h = P.huts[i]; | |
| 290 | return `hut ${i + 1} of ${P.huts.length}, ${h.colour || 'bare'}${h.locked ? ', locked' : ''}`; | |
| 291 | }; | |
| 292 | function select(i) { | |
| 293 | G.hut = i; G.dirty = true; | |
| 294 | say(`You pick ${nameOf(i)}. Now pick a tin, or press 1 to ${P.colours.length}.`); | |
| 295 | sound('pick'); | |
| 296 | } | |
| 297 | function paintWith(k) { | |
| 298 | ensureAudio(); | |
| 299 | const colour = P.colours[k], r = BH.paint(P, G.hut, colour); | |
| 300 | if (!r.ok) { | |
| 301 | say(`Cannot paint hut ${G.hut + 1}: ${WHY[r.why] || r.why}.`); | |
| 302 | sound('no'); | |
| 303 | return; | |
| 304 | } | |
| 305 | P = r.state; G.dirty = true; | |
| 306 | const last = P.status === 'playing' ? '' : P.status === 'won' ? ' Tap or press Enter for the next level.' : ' Press U to undo or R to restart.'; | |
| 307 | say(`Hut ${G.hut + 1} painted ${colour}, ${P.tins[colour]} ${colour} tin${P.tins[colour] === 1 ? '' : 's'} left. ${P.line}.${last}`); | |
| 308 | sound(P.status === 'won' ? 'win' : P.status === 'lost' ? 'lose' : 'paint'); | |
| 309 | renderHud(); | |
| 310 | } | |
| 311 | function undo() { | |
| 312 | ensureAudio(); | |
| 313 | const r = BH.undo(P); | |
| 314 | if (!r.ok) { say(`Cannot undo: ${WHY[r.why] || r.why}.`); sound('no'); return; } | |
| 315 | P = r.state; G.dirty = true; | |
| 316 | say(`Undid a stroke. ${P.line}.`); | |
| 317 | sound('pick'); | |
| 318 | renderHud(); | |
| 319 | } | |
| 320 | function load(level, seed) { | |
| 321 | P = BH.start(seed, level); | |
| 322 | G.seed = seed; G.hut = Math.min(G.hut, P.huts.length - 1); G.dirty = true; | |
| 323 | renderHud(); | |
| 324 | } | |
| 325 | function restart() { | |
| 326 | load(P.level, G.seed); | |
| 327 | say(`Level ${P.level} restarted. ${P.line}.`); | |
| 328 | } | |
| 329 | function next() { | |
| 330 | const again = P.level >= BH.MAX_LEVEL; | |
| 331 | load(again ? 1 : P.level + 1, again ? newSeed() : G.seed); | |
| 332 | say(`${again ? 'A fresh row, ' : ''}Level ${P.level}: ${P.huts.length} huts. ${P.line}.`); | |
| 333 | } | |
| 334 | function update(dt) { | |
| 335 | G.time += dt; | |
| 336 | const wave = STILL ? 0 : Math.floor(G.time * 2); | |
| 337 | if (wave !== G.wave) { G.wave = wave; G.dirty = true; } | |
| 338 | } | |
| 339 | ||
| 340 | /* ---------- input: tap a hut then a tin; arrows pick a hut, 1-4 paint ---------- */ | |
| 341 | const point = e => { const r = canvas.getBoundingClientRect(); return [(e.clientX - r.left) / r.width * W, (e.clientY - r.top) / r.height * H]; }; | |
| 342 | canvas.addEventListener('pointerdown', e => { | |
| 343 | canvas.focus({ preventScroll: true }); | |
| 344 | ensureAudio(); | |
| 345 | const [px, py] = point(e), k = tinAt(px, py), i = hutAt(px, py); | |
| 346 | if (P.status === 'won') { next(); return; } | |
| 347 | if (k >= 0) { paintWith(k); return; } | |
| 348 | if (i >= 0) { select(i); return; } | |
| 349 | say('Tap a hut, then a tin of paint.'); | |
| 350 | }); | |
| 351 | canvas.addEventListener('contextmenu', e => e.preventDefault()); | |
| 352 | canvas.addEventListener('keydown', e => { | |
| 353 | if (e.ctrlKey || e.metaKey || e.altKey) return; | |
| 354 | const k = e.key.length === 1 ? e.key.toLowerCase() : e.key; | |
| 355 | if (k === 'ArrowLeft' || k === 'ArrowRight' || k === 'a' || k === 'd') { | |
| 356 | const d = k === 'ArrowLeft' || k === 'a' ? -1 : 1; | |
| 357 | select(Math.max(0, Math.min(P.huts.length - 1, G.hut + d))); | |
| 358 | say(`${nameOf(G.hut)}.`); | |
| 359 | } else if (k >= '1' && k <= '4') { if (!e.repeat) paintWith(Number(k) - 1); } | |
| 360 | else if (k === 'Enter' && P.status === 'won') next(); | |
| 361 | else if (k === 'u') { if (!e.repeat) undo(); } | |
| 362 | else if (k === 'r') { if (!e.repeat) restart(); } | |
| 363 | else return; | |
| 364 | e.preventDefault(); | |
| 365 | }); | |
| 366 | ||
| 367 | /* ---------- sound ---------- */ | |
| 368 | let audio = null, muted = false; | |
| 369 | function ensureAudio() { | |
| 370 | if (audio) { if (audio.state === 'suspended') audio.resume(); return; } | |
| 371 | const AC = window.AudioContext || window.webkitAudioContext; | |
| 372 | if (AC) audio = new AC(); | |
| 373 | } | |
| 374 | function sound(kind) { | |
| 375 | if (!audio || muted) return; | |
| 376 | const now = audio.currentTime, gain = audio.createGain(), osc = audio.createOscillator(); | |
| 377 | const spec = { | |
| 378 | pick: [420, 480, .06, .04, 'square'], | |
| 379 | paint: [300, 420, .15, .06, 'sine'], | |
| 380 | no: [160, 110, .12, .05, 'square'], | |
| 381 | lose: [200, 60, .35, .06, 'sawtooth'], | |
| 382 | win: [330, 660, .35, .06, 'sine'], | |
| 383 | }[kind]; | |
| 384 | osc.type = spec[4]; | |
| 385 | osc.frequency.setValueAtTime(spec[0], now); | |
| 386 | osc.frequency.exponentialRampToValueAtTime(spec[1], now + spec[2]); | |
| 387 | gain.gain.setValueAtTime(spec[3], now); | |
| 388 | gain.gain.exponentialRampToValueAtTime(.0005, now + spec[2]); | |
| 389 | osc.connect(gain).connect(audio.destination); | |
| 390 | osc.start(now); osc.stop(now + spec[2] + .01); | |
| 391 | } | |
| 392 | const muteButton = document.getElementById('mute'); | |
| 393 | muteButton.addEventListener('click', () => { | |
| 394 | muted = !muted; | |
| 395 | muteButton.textContent = muted ? 'sound off' : 'sound'; | |
| 396 | muteButton.setAttribute('aria-pressed', String(muted)); | |
| 397 | }); | |
| 398 | ||
| 399 | /* ---------- fit and boot ---------- */ | |
| 400 | addEventListener('resize', fit); | |
| 401 | fit(); | |
| 402 | bake(); | |
| 403 | renderHud(); | |
| 404 | say(`${P.huts.length} huts. ${P.line}. Tap a hut then a tin, or use the arrows and 1 to ${P.colours.length}.`); | |
| 405 | canvas.focus({ preventScroll: true }); | |
| 406 | let last = performance.now(); | |
| 407 | function frame(now) { | |
| 408 | update(Math.min(.04, (now - last) / 1000)); | |
| 409 | last = now; | |
| 410 | if (G.dirty) { G.dirty = false; draw(); } | |
| 411 | requestAnimationFrame(frame); | |
| 412 | } | |
| 413 | requestAnimationFrame(frame); | |
| 414 | </script> | |
| 415 | </body> | |
| 416 | </html> |
beach-huts/page.test.mjs added +84
| @@ -0,0 +1,84 @@ | ||
| 1 | import { test, expect, setDefaultTimeout } from 'bun:test'; | |
| 2 | import { readFileSync } from 'node:fs'; | |
| 3 | import { boot } from '../playtest.mjs'; | |
| 4 | import M from './row.js'; | |
| 5 | ||
| 6 | setDefaultTimeout(20000); | |
| 7 | const tapHut = (g, i) => g.tap(...g.read(`hutPoint(${i})`)); | |
| 8 | const tapTin = (g, k) => g.tap(...g.read(`tinPoint(${k})`)); | |
| 9 | const bare = g => JSON.parse(g.read('JSON.stringify(P)')).huts.findIndex(h => h.colour === null); | |
| 10 | ||
| 11 | test('a hut then a tin paints through the rules; the HUD and the live line follow', () => { | |
| 12 | const g = boot('beach-huts'); | |
| 13 | expect(g.el('level').textContent).toBe('1 of 5'); | |
| 14 | expect(g.el('par').textContent).toBe(String(g.read('P.par'))); | |
| 15 | const i = bare(g); | |
| 16 | tapHut(g, i); | |
| 17 | expect(g.read('G.hut')).toBe(i); | |
| 18 | expect(g.el('status').textContent).toContain(`You pick hut ${i + 1}`); | |
| 19 | tapTin(g, 0); | |
| 20 | expect(g.read('P.strokes')).toBe(1); | |
| 21 | expect(g.read(`P.huts[${i}].colour`)).toBe(g.read('P.colours[0]')); | |
| 22 | expect(g.el('strokes').textContent).toBe('1'); | |
| 23 | expect(g.el('status').textContent).toContain(`Hut ${i + 1} painted`); | |
| 24 | }); | |
| 25 | ||
| 26 | test('a locked hut is refused with its reason, and nothing changes', () => { | |
| 27 | const g = boot('beach-huts'); | |
| 28 | const locked = g.read('P.huts.findIndex(h => h.locked)'); | |
| 29 | tapHut(g, locked); | |
| 30 | tapTin(g, 0); | |
| 31 | expect(g.read('P.strokes')).toBe(0); | |
| 32 | expect(g.el('status').textContent).toContain('locked'); | |
| 33 | }); | |
| 34 | ||
| 35 | test('arrows pick a hut, digits paint, U undoes and R restarts', () => { | |
| 36 | const g = boot('beach-huts'); | |
| 37 | g.press('ArrowRight'); g.press('ArrowRight'); | |
| 38 | expect(g.read('G.hut')).toBe(2); | |
| 39 | g.press('ArrowLeft'); | |
| 40 | expect(g.read('G.hut')).toBe(1); | |
| 41 | const colour = g.read('P.colours[1]'); | |
| 42 | g.read('G.hut = ' + bare(g)); | |
| 43 | g.press('2'); | |
| 44 | expect(g.read('P.strokes')).toBe(1); | |
| 45 | g.press('u'); | |
| 46 | expect(g.read('P.strokes')).toBe(0); | |
| 47 | expect(g.el('status').textContent).toContain('Undid'); | |
| 48 | g.press('u'); | |
| 49 | expect(g.el('status').textContent).toContain('nothing to undo'); | |
| 50 | g.read('G.hut = ' + bare(g)); g.press('1'); | |
| 51 | g.press('r'); | |
| 52 | expect(g.read('P.strokes')).toBe(0); | |
| 53 | expect(g.read('P.huts.every(h => h.locked || h.colour === null)')).toBe(true); | |
| 54 | expect(colour).toBeTruthy(); | |
| 55 | }); | |
| 56 | ||
| 57 | test('a clash is drawn differently from a clean row, and the padlock shows without colour', () => { | |
| 58 | const g = boot('beach-huts'); | |
| 59 | g.run(.2); | |
| 60 | const clean = g.picture(); | |
| 61 | const i = bare(g), n = g.read('P.huts[' + (i + 1) + '] ? ' + (i + 1) + ' : ' + (i - 1)); | |
| 62 | g.read(`P = { ...P, huts: P.huts.map((h, k) => k === ${i} || k === ${n} ? { colour: 'red', locked: false } : h) }; G.dirty = true`); | |
| 63 | expect(g.read('BeachHuts.clashes(P).length')).toBeGreaterThan(0); | |
| 64 | g.run(.2); | |
| 65 | expect(g.picture()).not.toBe(clean); | |
| 66 | }); | |
| 67 | ||
| 68 | test('a won level says so and the next tap moves to the next level', () => { | |
| 69 | const g = boot('beach-huts'); | |
| 70 | g.read(`P = { ...P, status: 'won', line: 'Row painted: 5 strokes against par 7' }`); | |
| 71 | tapHut(g, 0); | |
| 72 | expect(g.read('P.level')).toBe(2); | |
| 73 | expect(g.el('level').textContent).toBe('2 of 5'); | |
| 74 | expect(g.read('P.huts.length')).toBe(M.MIN_HUTS + 2); | |
| 75 | }); | |
| 76 | ||
| 77 | test('the page follows the visual rules: aria-label, short rules, a mute button, no track', () => { | |
| 78 | const html = readFileSync(new URL('./index.html', import.meta.url), 'utf8'); | |
| 79 | expect(html).toMatch(/<canvas id="c"[^>]*aria-label="[^"]*Tap a hut then a tin/); | |
| 80 | expect(html).toContain('id="mute"'); | |
| 81 | expect(html).toContain('aria-live="polite"'); | |
| 82 | expect((html.match(/rules: \[([\s\S]*?)\]/)[1].match(/^\s*'/gm) || []).length).toBeLessThanOrEqual(4); | |
| 83 | expect(html).not.toMatch(/\.mp3/); | |
| 84 | }); |
forge.json +1 −1
| @@ -2,6 +2,6 @@ | ||
| 2 | 2 | "app": { |
| 3 | 3 | "run": "bun serve.mjs", |
| 4 | 4 | "health": "/", |
| 5 | "test": "bun test" | |
| 5 | "test": "env BUN_JSC_numberOfGCMarkers=1 BUN_JSC_useConcurrentJIT=0 BUN_JSC_useConcurrentGC=0 bun test" | |
| 6 | 6 | } |
| 7 | 7 | } |
index.html +1
| @@ -141,6 +141,7 @@ footer a::after { content: none; } | ||
| 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 | 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 | <li data-kind="Puzzle" data-input="Tap" style="--accent:#2a8fb0"><a href="beach-huts/">Beach Huts</a><span>Paint the row of seaside huts so no two neighbours share a colour, from limited tins, within par</span><span>Game forged by the crew</span><span class="tags">Puzzle · Tap</span></li> | |
| 144 | 145 | <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 | 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> |
og/beach-huts.png added
Binary file not shown.
og/home.png
Binary file not shown.
rounds.mjs +24
| @@ -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 Huts from './beach-huts/row.js'; | |
| 20 | 21 | import Tide from './tide-crossing/causeway.js'; |
| 21 | 22 | |
| 22 | 23 | const MID = [180, 140]; |
| @@ -549,6 +550,28 @@ export const ROUNDS = { | ||
| 549 | 550 | if (s.status !== 'playing' || s.shown > 0) { g.run(.2); return; } |
| 550 | 551 | g.press(String(Cake.LAYERS.indexOf(s.order[s.placed.length]) + 1), .02); |
| 551 | 552 | } }, |
| 553 | // find a way to colour the bare huts within the tins (backtracking over the state the rules give), then tap hut and tin for the first bare one | |
| 554 | 'beach-huts': { over: /Row painted/, outcome: /Row painted: \d+ strokes against par \d+/, beat: g => { | |
| 555 | const s = JSON.parse(g.read('JSON.stringify(P)')); | |
| 556 | if (s.status !== 'playing') { g.run(.2); return; } | |
| 557 | const cols = s.colours.slice(), left = { ...s.tins }, pick = s.huts.map(h => h.colour); | |
| 558 | const solve = i => { | |
| 559 | if (i === pick.length) return true; | |
| 560 | if (pick[i] !== null) return pick[i] !== pick[i - 1] && solve(i + 1); | |
| 561 | for (const c of cols) { | |
| 562 | if (left[c] < 1 || c === pick[i - 1] || c === pick[i + 1]) continue; | |
| 563 | left[c]--; pick[i] = c; | |
| 564 | if (solve(i + 1)) return true; | |
| 565 | left[c]++; pick[i] = null; | |
| 566 | } | |
| 567 | return false; | |
| 568 | }; | |
| 569 | const bare = s.huts.findIndex(h => h.colour === null); | |
| 570 | solve(0); | |
| 571 | g.tap(...g.read(`hutPoint(${bare})`)); | |
| 572 | g.tap(...g.read(`tinPoint(${cols.indexOf(pick[bare])})`)); | |
| 573 | g.run(.1); | |
| 574 | } }, | |
| 552 | 575 | // wait for the tick whose marker is nearest the rival's own stated target for this score, then throw |
| 553 | 576 | 'village-darts': { over: /play again/, outcome: /(You win|The rival wins) the leg in \d+ darts?, finishing on/, limit: 140, beat: g => { |
| 554 | 577 | if (g.read('P.status') !== 'playing' || g.read('P.turn') !== 'you' || g.read('!!G.flight')) { g.run(.2); return; } |
| @@ -630,6 +653,7 @@ const LIMIT = 150; // seconds of play, a game that has not ended by then is stuc | ||
| 630 | 653 | export function playRound(id, seed = 1) { |
| 631 | 654 | const { over, beat } = ROUNDS[id]; |
| 632 | 655 | const g = boot(id, { seed }); |
| 656 | g.stopDrawing(); // the round reads only text, and a frame's picture is most of its cost | |
| 633 | 657 | let ended = null; |
| 634 | 658 | g.watch(() => { if (!ended) { const t = g.text(); if (over.test(t)) ended = t; } }); |
| 635 | 659 | while (!ended && g.frames < (ROUNDS[id].limit ?? LIMIT) * 25) beat(g); |
serve.test.mjs +7
| @@ -790,6 +790,13 @@ test('Cake Stall and its rules script are served without a track', async () => { | ||
| 790 | 790 | expect((await fetch(base + '/cake-stall/order.js')).status).toBe(200); |
| 791 | 791 | }); |
| 792 | 792 | |
| 793 | test('Beach Huts and its rules script are served without a track', async () => { | |
| 794 | const page = await fetch(base + '/beach-huts/'); | |
| 795 | expect(page.status).toBe(200); | |
| 796 | expect(page.headers.get('content-type')).toBe('text/html; charset=utf-8'); | |
| 797 | expect((await fetch(base + '/beach-huts/row.js')).status).toBe(200); | |
| 798 | }); | |
| 799 | ||
| 793 | 800 | test('Pumpkin Patch and its rules script are served without a track', async () => { |
| 794 | 801 | const page = await fetch(base + '/pumpkin-patch/'); |
| 795 | 802 | expect(page.status).toBe(200); |
shell.js +1
| @@ -77,6 +77,7 @@ window.PixelPlays = (() => { | ||
| 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 | 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 | { id: 'beach-huts', title: 'Beach Huts', blurb: 'Paint the row of seaside huts so no two neighbours share a colour, from limited tins, within par', kind: 'Puzzle', input: 'Tap', accent: '#2a8fb0', music: false, forged: true }, | |
| 80 | 81 | { 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 | 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 }, |