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

opened this

Check · Review · Land · Live — Its check is red. Next: builder, its check is red.

number 2 in the queue: no estimate: nothing landed in the last hour

Ready to land

vesper/pixel-plays:vesper/367-add-beach-huts-the-playable-page-and-its → main 4 commits fork

  • Ready to merge — no conflicts with main. 4 commits will land on main.

    Check faileddeploy 5130f4c

    Website spec: 10 findings, 2 the edge owns (report only)
    • / theme-color needs a light and a dark variant (media=prefers-color-scheme) — theme-color
    • / no color-scheme meta — color-scheme
    • / the first focusable element is not a skip link (an in-page #target link) — skip-link
    • / external script shell.js is not defer, async or module — scripts
    • / external script ramp.js is not defer, async or module — scripts
    • / external script town.js is not defer, async or module — scripts
    • / external script plaza.js is not defer, async or module — scripts
    • / a 29768-byte body was sent without br or gzip though Accept-Encoding: br, gzip — https://specification.website/ (the edge owns this)
    • / no Content-Security-Policy — csp
    • / no Permissions-Policy — permissions-policy
    • / no Cross-Origin-Opener-Policy — coop (the edge owns this)
    • / no ETag on the HTML — caching
    You need write access to merge.

Review

Sign in to review this change.
10 files changed +539 −3
README.md +5 −2

Diff not shown — a line 4,141 characters wide (limit 2,000). +5 −2

View file
beach-huts/index.html added +416
@@ -0,0 +1,416 @@
1 <!doctype html>
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 { --ink: #1c1b19; --muted: #6b675f; --paper: #fff; --accent: #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(--paper); color: var(--ink); }
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(--accent); 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(--accent); }
34 .row b { font-size: 16px; font-weight: 600; min-width: 1ch; font-variant-numeric: tabular-nums; }
35 .row span { color: var(--muted); }
36 #line { color: var(--muted); min-height: 2.7em; max-width: 60ch; }
37 button { pointer-events: auto; font: inherit; color: var(--muted); 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(--ink); }
39 button:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
40 noscript p { position: fixed; inset: 40% 16px auto; margin: 0; text-align: center; color: var(--muted); }
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 @@
22 "app": {
33 "run": "bun serve.mjs",
44 "health": "/",
5 "test": "bun test"
5 "test": "env BUN_JSC_numberOfGCMarkers=1 BUN_JSC_useConcurrentJIT=0 BUN_JSC_useConcurrentGC=0 bun test"
66 }
77 }
index.html +1
@@ -141,6 +141,7 @@ footer a::after { content: none; }
141141 <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>
142142 <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>
143143 <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>
144145 <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>
145146 <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>
146147 </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';
1717 import Cars from './bumper-cars/floor.js';
1818 import Bobbing from './apple-bobbing/tub.js';
1919 import Cake from './cake-stall/order.js';
20 import Huts from './beach-huts/row.js';
2021 import Tide from './tide-crossing/causeway.js';
2122
2223 const MID = [180, 140];
@@ -549,6 +550,28 @@ export const ROUNDS = {
549550 if (s.status !== 'playing' || s.shown > 0) { g.run(.2); return; }
550551 g.press(String(Cake.LAYERS.indexOf(s.order[s.placed.length]) + 1), .02);
551552 } },
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 } },
552575 // wait for the tick whose marker is nearest the rival's own stated target for this score, then throw
553576 'village-darts': { over: /play again/, outcome: /(You win|The rival wins) the leg in \d+ darts?, finishing on/, limit: 140, beat: g => {
554577 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
630653 export function playRound(id, seed = 1) {
631654 const { over, beat } = ROUNDS[id];
632655 const g = boot(id, { seed });
656 g.stopDrawing(); // the round reads only text, and a frame's picture is most of its cost
633657 let ended = null;
634658 g.watch(() => { if (!ended) { const t = g.text(); if (over.test(t)) ended = t; } });
635659 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 () => {
790790 expect((await fetch(base + '/cake-stall/order.js')).status).toBe(200);
791791 });
792792
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
793800 test('Pumpkin Patch and its rules script are served without a track', async () => {
794801 const page = await fetch(base + '/pumpkin-patch/');
795802 expect(page.status).toBe(200);
shell.js +1
@@ -77,6 +77,7 @@ window.PixelPlays = (() => {
7777 { 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 },
7878 { 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 },
7979 { 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 },
8081 { 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 },
8182 { 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 },
8283 { 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 },

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs