Public: anyone can read this; the crew can change it.

Migrated from github.com/amirmushichge/pixel-plays

Add Village Darts: the playable page and its place in the catalogue, on the rules in `village-darts/board.js` #347

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

vesper/pixel-plays:vesper/343-add-village-darts-the-playable-page-and → main 4 commits fork

  • Merged. vesper/343-add-village-darts-the-playable-page-and was merged into main as 2688fb5.

Review

Sign in to review this change.
9 files changed +543 −2
README.md +3 −2

Diff not shown — a line 3,652 characters wide (limit 2,000). +3 −2

View file
index.html +1
@@ -136,6 +136,7 @@ footer a::after { content: none; }
136136 <li data-kind="Strategy" data-input="Tap" style="--accent:#6b8e23"><a href="fence-posts/">Fence Posts</a><span>Take turns fencing the meadow and claim every pen you close</span><span>Game forged by the crew</span><span class="tags">Strategy · Tap</span></li>
137137 <li data-kind="Throwing" data-input="Tap" style="--accent:#2f6b3a"><a href="welly-toss/">Welly Toss</a><span>Let the boot go at the top of the swing and land it past the flags</span><span>Game forged by the crew</span><span class="tags">Throwing · Tap</span></li>
138138 <li data-kind="Puzzle" data-input="Tap" style="--accent:#2b7a8c"><a href="ticket-booth/">Ticket Booth</a><span>Hand each pier visitor exact change from a limited till</span><span>Game forged by the crew</span><span class="tags">Puzzle · Tap</span></li>
139 <li data-kind="Sport" data-input="Tap" style="--accent:#b5412f"><a href="village-darts/">Village Darts</a><span>Count down from 301 and finish on a double before the village rival does</span><span>Game forged by the crew</span><span class="tags">Sport · Tap</span></li>
139140 <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>
140141 </ul>
141142 <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/home.png
Binary file not shown.
og/village-darts.png added
Binary file not shown.
rounds.mjs +10
@@ -10,6 +10,7 @@ import Wheel from './ferris-wheel/wheel.js';
1010 import Quilt from './quilt/patch.js';
1111 import Jars from './jam-jars/shelf.js';
1212 import Shelf from './library-shelf/shelf.js';
13 import Darts from './village-darts/board.js';
1314 import Sweep from './chimney-sweep/roofs.js';
1415 import Field from './fence-posts/field.js';
1516 import Carrot from './carrot-pull/bed.js';
@@ -499,6 +500,15 @@ export const ROUNDS = {
499500 const [x, y] = g.read(`screenOf(${JSON.stringify(Field.choose(s).edge)})`);
500501 g.tap(x, y);
501502 } },
503 // wait for the tick whose marker is nearest the rival's own stated target for this score, then throw
504 'village-darts': { over: /play again/, outcome: /(You win|The rival wins) the leg in \d+ darts?, finishing on/, limit: 140, beat: g => {
505 if (g.read('P.status') !== 'playing' || g.read('P.turn') !== 'you' || g.read('!!G.flight')) { g.run(.2); return; }
506 const t = Darts.rivalTarget(g.read('P.scores.you')), now = g.read('P.tick');
507 let best = now, bd = 1e9;
508 for (let k = now + 1; k <= now + 50; k++) { const a = Darts.aimAt(k), d = Math.hypot(a.x - t.x, a.y - t.y); if (d < bd) { bd = d; best = k; } }
509 while (g.read('P.tick') < best) g.step(1);
510 g.keyDown(' '); g.run(.05);
511 } },
502512 // let go at the tick the rules' own `fly` says lands furthest in this throw's wind, then wait out the flight
503513 'welly-toss': { over: /play again/, outcome: /Final: [\d.]+ m from the best 3 of 5/, beat: g => {
504514 if (g.read('P.status') !== 'swinging') { g.run(.3); return; }
serve.test.mjs +7
@@ -755,6 +755,13 @@ test('Ticket Booth and its rules script are served without a track', async () =>
755755 expect((await fetch(base + '/ticket-booth/till.js')).status).toBe(200);
756756 });
757757
758 test('Village Darts and its rules script are served without a track', async () => {
759 const page = await fetch(base + '/village-darts/');
760 expect(page.status).toBe(200);
761 expect(page.headers.get('content-type')).toBe('text/html; charset=utf-8');
762 expect((await fetch(base + '/village-darts/board.js')).status).toBe(200);
763 });
764
758765 test('Carrot Pull and its rules script are served without a track', async () => {
759766 const page = await fetch(base + '/carrot-pull/');
760767 expect(page.status).toBe(200);
shell.js +1
@@ -76,6 +76,7 @@ window.PixelPlays = (() => {
7676 { id: 'fence-posts', title: 'Fence Posts', blurb: 'Take turns fencing the meadow and claim every pen you close', kind: 'Strategy', input: 'Tap', accent: '#6b8e23', music: false, forged: true },
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: 'ticket-booth', title: 'Ticket Booth', blurb: 'Hand each pier visitor exact change from a limited till', kind: 'Puzzle', input: 'Tap', accent: '#2b7a8c', music: false, forged: true },
79 { id: '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 },
7980 { 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 },
8081 ];
8182 const MUSIC_VOLUME = .4;
village-darts/index.html added +376
@@ -0,0 +1,376 @@
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>Village Darts</title>
7 <!-- og:begin -->
8 <meta name="description" content="Count down from 301 and finish on a double before the village rival does">
9 <link rel="canonical" href="https://pixel-plays--forge-trevato-apps.open-platform.sh/village-darts/">
10 <meta property="og:type" content="website">
11 <meta property="og:site_name" content="Pixel Plays">
12 <meta property="og:title" content="Village Darts">
13 <meta property="og:description" content="Count down from 301 and finish on a double before the village rival does">
14 <meta property="og:url" content="https://pixel-plays--forge-trevato-apps.open-platform.sh/village-darts/">
15 <meta property="og:image" content="https://pixel-plays--forge-trevato-apps.open-platform.sh/og/village-darts.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="Village Darts: 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: #b5412f; --you: #3f6fc4; --rival: #c4562a; }
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(--paper); color: var(--ink); }
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(--accent); 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: 3ch; font-variant-numeric: tabular-nums; }
36 .row span { color: var(--muted); }
37 .row.you { color: var(--you); }
38 .row.rival { color: var(--rival); }
39 .mark { display: inline-block; width: 18px; height: 12px; border: 1px solid currentColor; }
40 .you .mark { background: linear-gradient(135deg, var(--you) 50%, #fff 50%); }
41 .rival .mark { background: var(--rival); }
42 #turn, #left { color: var(--muted); min-height: 1.35em; }
43 #status { color: var(--muted); min-height: 2.7em; max-width: 60ch; }
44 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; }
45 button:hover, button:focus-visible { color: var(--ink); }
46 button:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
47 noscript p { position: fixed; inset: 40% 16px auto; margin: 0; text-align: center; color: var(--muted); }
48 </style>
49 </head>
50 <body>
51 <div id="stage"><canvas id="c" tabindex="0" aria-label="Village Darts: a village hall dartboard. The aim marker wobbles across the board. Tap, or press Space or Enter, to throw your dart from where the marker is. Count down from 301 and finish exactly on zero with a double; the rival throws its visit after each of yours. Press R to restart."></canvas></div>
52 <noscript><p>Village Darts needs JavaScript to play.</p></noscript>
53 <div id="scores">
54 <div class="row you"><i class="mark"></i><b id="yscore">301</b><span>you</span></div>
55 <div class="row rival"><i class="mark"></i><b id="rscore">301</b><span>rival</span></div>
56 <div id="turn"></div>
57 <div id="left"></div>
58 </div>
59 <div class="hud" id="bottom">
60 <div id="status" role="status" aria-live="polite"></div>
61 <button id="mute" type="button" aria-pressed="false">sound on</button>
62 </div>
63 <script src="../shell.js"></script>
64 <script>
65 PixelPlays.mount({
66 id: 'village-darts',
67 rules: [
68 'Tap, or press Space or Enter, to throw your dart from where the wobbling marker is; R restarts.',
69 'Three darts a visit, then the rival throws its three. The treble ring triples, the double ring doubles, the bull is 50.',
70 'Count down from 301 and finish exactly on zero with a double; a bust sends the score back to where the visit began.',
71 'Your darts are blue and fly in an arc; the rival\'s are red and fly straight.',
72 ],
73 });
74 </script>
75 <script>window.PP_SCENE = { originY: 0, shadowX: 1.3, shadowY: -.12 };</script>
76 <script src="../engine.js"></script>
77 <script src="board.js"></script>
78 <script>
79 'use strict';
80
81 const M = window.VillageDarts;
82 const CX = 180, CY = 128, KX = .62, KY = KX * COS; // board centre on the canvas, and pixels per mm (the 30-degree view squashes y)
83 const R_NUM = 186, R_EDGE = 198; // numeral ring and black surround, in mm
84 const TICK = 1 / 24; // seconds per wobble step
85 const FLIGHT = { you: .38, rival: .3 }; // seconds in the air
86 const RIVAL_FIRST = 1.3, RIVAL_GAP = .55, HOLD = 1.1; // pauses so the rival's visit can be followed, and darts stay on the board
87 const FROM = { you: [74, 276], rival: [300, 276] };
88 const BLACK = [34, 32, 30], CREAM = [236, 222, 184], RED = [186, 46, 40], GREEN = [38, 122, 72], WIRE = [190, 192, 196];
89 const TEAM = { you: { flight: [70, 112, 200], light: [150, 176, 232] }, rival: { flight: [200, 84, 44], light: [240, 150, 110] } };
90 const BRASS = rgb(204, 168, 70), STEEL = rgb(150, 154, 160), DARK = rgb(40, 36, 34);
91
92 /* ---------- geometry: board millimetres to canvas pixels ---------- */
93 const sx = x => CX + x * KX, sy = y => CY - y * KY;
94 const mmOf = (px, py) => [(px - CX) / KX, (CY - py) / KY];
95
96 /* ---------- state ---------- */
97 const nodes = {};
98 for (const k of ['yscore', 'rscore', 'turn', 'left', 'status']) nodes[k] = document.getElementById(k);
99 const shown = {};
100 function setText(key, text) {
101 if (shown[key] === text) return;
102 shown[key] = text;
103 nodes[key].textContent = text;
104 }
105 let P = M.start(Math.floor(Math.random() * 4294967296));
106 // stuck: darts on the board this visit; flight: the dart in the air; acc: time owed to the wobble; hold: when the board clears
107 const G = { time: 0, stuck: [], flight: null, acc: 0, hold: 0, rivalAt: 0, overAt: 0, dirty: true, frame: -1 };
108
109 function renderHud() {
110 setText('yscore', String(P.scores.you));
111 setText('rscore', String(P.scores.rival));
112 setText('turn', P.status === 'done' ? 'The leg is over' : P.turn === 'you' ? 'Your visit' : "The rival's visit");
113 setText('left', P.status === 'done' ? '' : `${M.DARTS_PER_VISIT - P.visit.length} dart${M.DARTS_PER_VISIT - P.visit.length === 1 ? '' : 's'} left`);
114 }
115 function say(text) { setText('status', text); }
116
117 /* ---------- reading a dart: its words ---------- */
118 function words(d) {
119 if (d.ring === 'miss') return 'a miss, no score';
120 if (d.ring === 'bull') return 'the bull for 50';
121 if (d.ring === 'outer bull') return 'the outer bull for 25';
122 return `${d.ring} ${d.segment} for ${d.points}`;
123 }
124 // the rules hand back only the state after a rival dart, so its landing is read off a probe: the same stream thrown
125 // at the stated target for 301 (which cannot bust), minus that target, plus the real one
126 function rivalThrow(s) {
127 const t = M.rivalTarget(s.scores.rival), base = M.rivalTarget(M.START_SCORE);
128 const probe = M.rivalDart({ ...s, scores: { ...s.scores, rival: M.START_SCORE }, visit: [] }).visit[0];
129 const x = t.x + probe.x - base.x, y = t.y + probe.y - base.y, state = M.rivalDart(s);
130 return { state, dart: M.land(s, x, y).dart }; // the rules themselves say what it scored and whether it bust
131 }
132
133 /* ---------- the hall: wall, floor, board ---------- */
134 const NUM_FONT = ['111101101101111', '010110010010111', '111001111100111', '111001111001111', '101101111001001',
135 '111100111001111', '111100111101111', '111001001001001', '111101111101111', '111101111001111'];
136 function numeral(n, cx, cy) {
137 const s = String(n), w = s.length * 4 - 1;
138 let x0 = Math.round(cx - w / 2);
139 const y0 = Math.round(cy - 2);
140 for (const ch of s) {
141 const g = NUM_FONT[+ch];
142 for (let i = 0; i < 15; i++) if (g[i] === '1') pix[(y0 + (i / 3 | 0)) * W + x0 + i % 3] = rgb(...CREAM);
143 x0 += 4;
144 }
145 }
146 function boardColor(x, y) {
147 const d = M.score(x, y);
148 if (d.ring === 'bull') return RED;
149 if (d.ring === 'outer bull') return GREEN;
150 const odd = M.ORDER.indexOf(d.segment) % 2;
151 return d.ring === 'treble' || d.ring === 'double' ? (odd ? GREEN : RED) : (odd ? CREAM : BLACK);
152 }
153 const WIRES = [M.R_BULL, M.R_OUTER_BULL, M.R_TREBLE_IN, M.R_TREBLE_OUT, M.R_DOUBLE_IN, M.R_DOUBLE_OUT];
154 function bake() {
155 ground.fill(0); shadowMask.fill(0); zb.fill(-1e9);
156 for (let iy = 0; iy < H; iy++) for (let ix = 0; ix < W; ix++) {
157 const i = iy * W + ix;
158 let c;
159 if (iy >= 238) { // floor boards, with the oche line
160 const board = Math.floor((ix + (iy > 250 ? 20 : 0)) / 36), g = hash(board, 4);
161 c = [150 + g * 26, 108 + g * 18, 70 + g * 12];
162 if (iy === 238 || iy === 239) c = [92, 64, 44];
163 if (iy === 268 && ix > 30 && ix < 330) c = [236, 226, 196];
164 if ((ix + (iy > 250 ? 20 : 0)) % 36 === 0) c = [96, 68, 46];
165 } else if (iy >= 190) { // dado panelling
166 const g = hash(Math.floor(ix / 18), 9);
167 c = [96 + g * 12, 62 + g * 8, 54 + g * 8];
168 if (ix % 18 === 0) c = [70, 44, 40];
169 if (iy === 190 || iy === 191) c = [200, 170, 120];
170 } else { // plastered wall
171 const n = noise(ix * .08, iy * .08) * 14;
172 c = [196 + n, 178 + n, 138 + n];
173 }
174 const [bx, by] = mmOf(ix + .5, iy + .5), r = Math.hypot(bx, by);
175 const [sxm, sym] = mmOf(ix + .5 - 9, iy + .5), rs = Math.hypot(sxm, sym);
176 if (r > R_EDGE && rs <= R_EDGE) c = c.map(v => v * .58); // the board's hard shadow, screen-right
177 pix[iy * W + ix] = rgb(...c);
178 if (r > R_EDGE) continue;
179 let b = r > M.R_DOUBLE_OUT ? BLACK : boardColor(bx, by);
180 for (const w of WIRES) if (Math.abs(r - w) < 1.1 / KX * .8) b = WIRE;
181 if (r > M.R_DOUBLE_OUT && r < M.R_DOUBLE_OUT + 1.6 / KX * .8) b = WIRE;
182 // the segment wires: a thin line along each boundary
183 if (r > M.R_OUTER_BULL && r <= M.R_DOUBLE_OUT) {
184 const deg = (Math.atan2(bx, by) * 180 / Math.PI + 360 + 9) % 18;
185 if (Math.min(deg, 18 - deg) * Math.PI / 180 * r < .6 / KX * .8) b = WIRE;
186 }
187 pix[i] = rgb(...b);
188 }
189 M.ORDER.forEach((n, k) => {
190 const a = k * 18 * Math.PI / 180;
191 numeral(n, sx(R_NUM * Math.sin(a)), sy(R_NUM * Math.cos(a)));
192 });
193 bakedPix.set(pix);
194 }
195
196 /* ---------- darts ---------- */
197 const put = (x, y, c) => { x |= 0; y |= 0; if (x >= 0 && y >= 0 && x < W && y < H) pix[y * W + x] = c; };
198 function darken(x, y, k) {
199 x |= 0; y |= 0;
200 if (x < 0 || y < 0 || x >= W || y >= H) return;
201 const c = pix[y * W + x];
202 pix[y * W + x] = rgb((c & 255) * k, (c >> 8 & 255) * k, (c >> 16 & 255) * k);
203 }
204 // a dart with its tip at (x, y): a brass barrel toward the viewer (down the screen), then its flights.
205 // yours end in a triangle, the rival's in a square block, so they differ by shape as well as colour
206 function dartAt(by, x, y) {
207 const f = rgb(...TEAM[by].flight), l = rgb(...TEAM[by].light);
208 put(x, y, DARK);
209 for (let k = 1; k <= 5; k++) { put(x, y + k, k < 3 ? STEEL : BRASS); }
210 if (by === 'you') {
211 for (let k = 0; k < 5; k++) { const w = k; for (let j = -w; j <= w; j++) put(x + j, y + 5 + k, j === 0 ? l : f); }
212 } else {
213 for (let k = 0; k < 5; k++) for (let j = -2; j <= 2; j++) put(x + j, y + 6 + k, (j + k) % 2 ? l : f);
214 }
215 }
216 function landed(d) { return [sx(d.x) | 0, sy(d.y) | 0]; }
217 function drawDarts() {
218 for (const d of G.stuck) { const [x, y] = landed(d); for (let k = 1; k <= 5; k++) darken(x + k, y, .55); darken(x + 1, y + 3, .6); }
219 for (const d of G.stuck) { const [x, y] = landed(d); dartAt(d.by, x, y); }
220 }
221 // the dart in the air: yours lobs up in an arc, the rival's flies flat and straight
222 function flightAt(f) {
223 const t = clamp((G.time - f.t0) / f.dur, 0, 1), a = FROM[f.by], b = landed(f.dart);
224 const arc = f.by === 'you' ? Math.sin(Math.PI * t) * 90 : 0;
225 return [a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t - arc];
226 }
227 function drawFlight() {
228 const f = G.flight;
229 if (!f) return;
230 const [x, y] = flightAt(f);
231 dartAt(f.by, x, y);
232 // the rival's streak trails behind it, toward the side it was thrown from
233 const behind = Math.sign(FROM.rival[0] - landed(f.dart)[0]) || 1;
234 if (f.by === 'rival') for (let k = 1; k < 5; k++) put(x + behind * k * 3, y + k * 2, rgb(...TEAM.rival.light));
235 }
236 function drawMarker() {
237 if (P.status !== 'playing' || P.turn !== 'you') return;
238 const mx = sx(P.aim.x), my = sy(P.aim.y), pts = [];
239 for (let k = 0; k < 16; k++) { const a = k / 16 * Math.PI * 2; pts.push([Math.round(mx + Math.cos(a) * 6), Math.round(my + Math.sin(a) * 6)]); }
240 for (const [dx, dy] of [[-9, 0], [-8, 0], [8, 0], [9, 0], [0, -9], [0, -8], [0, 8], [0, 9], [0, 0]]) pts.push([Math.round(mx) + dx, Math.round(my) + dy]);
241 haloDots(pts);
242 drawDots(pts, SIGNAL);
243 }
244 // bunting along the top of the wall: it flutters a frame at a time, and holds still if motion is reduced
245 function drawBunting(t) {
246 const cols = [RED, CREAM, GREEN, [220, 170, 50]];
247 for (let k = 0; k < 14; k++) {
248 const x = 12 + k * 25, sag = Math.round(Math.sin(k / 13 * Math.PI) * 14), flick = STILL ? 0 : Math.round(Math.sin(t * 1.3 + k * 1.7));
249 const c = rgb(...cols[k % 4]);
250 for (let j = 0; j < 9; j++) for (let i = -(8 - j) >> 1; i <= (8 - j) >> 1; i++) put(x + i + (j > 4 ? flick : 0), 6 + sag + j, c);
251 put(x, 5 + sag, DARK);
252 }
253 }
254 function draw() {
255 pix.set(bakedPix);
256 drawBunting(STILL ? 0 : Math.floor(G.time * 8));
257 drawDarts();
258 drawFlight();
259 drawMarker();
260 ctx.putImageData(image, 0, 0);
261 }
262
263 /* ---------- game: every rule is the rules script's ---------- */
264 function visitEnded(before, after) { return after.status === 'done' || after.turn !== before.turn; }
265 function throwNow() {
266 ensureAudio();
267 G.dirty = true;
268 if (G.flight) return;
269 if (P.status === 'done' && P.end) { if (G.time - G.overAt > .6) restart(); return; }
270 const r = M.throwDart(P);
271 if (!r.ok) { say(`Cannot throw: ${r.why}.`); return; }
272 if (G.hold) { G.stuck = []; G.hold = 0; }
273 G.flight = { by: 'you', dart: r.dart, state: r.state, t0: G.time, dur: FLIGHT.you };
274 sound(520, .08, 'triangle');
275 }
276 function startRival() {
277 const r = rivalThrow(P);
278 G.flight = { by: 'rival', dart: r.dart, state: r.state, t0: G.time, dur: FLIGHT.rival };
279 sound(330, .08, 'square');
280 }
281 function land() {
282 const f = G.flight, before = P;
283 G.flight = null;
284 P = f.state;
285 G.stuck.push(f.dart);
286 G.dirty = true;
287 const who = f.by === 'you' ? 'You throw' : 'The rival throws';
288 const owner = f.by === 'you' ? 'your' : "the rival's";
289 const over = visitEnded(before, P);
290 let said = `${who} ${words(f.dart)}`;
291 if (f.dart.bust) said += `. Bust: ${owner} score goes back to ${P.scores[f.by]}`;
292 say(`${said}. ${P.line}${P.status === 'done' ? '. Tap or press Enter to play again, or R.' : '.'}`);
293 sound(f.dart.bust ? 120 : f.dart.points ? 300 + Math.min(f.dart.points, 60) * 6 : 160, .12, f.dart.bust ? 'sawtooth' : 'square');
294 if (P.status === 'done') {
295 G.overAt = G.time; G.hold = 0;
296 sound(P.end.winner === 'you' ? 880 : 110, .4, P.end.winner === 'you' ? 'triangle' : 'sawtooth', .15);
297 } else if (over) G.hold = G.time + HOLD;
298 G.rivalAt = G.time + (over ? RIVAL_FIRST : RIVAL_GAP);
299 }
300 function update(dt) {
301 G.time += dt;
302 if (G.flight && G.time >= G.flight.t0 + G.flight.dur) land();
303 if (G.hold && G.time >= G.hold) { G.stuck = []; G.hold = 0; G.dirty = true; }
304 if (P.status === 'playing' && P.turn === 'you' && !G.flight) {
305 G.acc += dt;
306 while (G.acc >= TICK) { G.acc -= TICK; P = M.step(P); G.dirty = true; }
307 } else if (P.status === 'playing' && P.turn === 'rival' && !G.flight && G.time >= G.rivalAt) {
308 if (G.hold) { G.stuck = []; G.hold = 0; }
309 startRival();
310 }
311 if (G.flight) G.dirty = true;
312 const beat = STILL ? 0 : Math.floor(G.time * 8);
313 if (beat !== G.frame) { G.frame = beat; G.dirty = true; }
314 renderHud();
315 }
316 function restart() {
317 P = M.start(Math.floor(Math.random() * 4294967296));
318 G.stuck = []; G.flight = null; G.acc = 0; G.hold = 0; G.dirty = true;
319 say(`${P.line}. Tap or press Space to throw.`);
320 }
321
322 /* ---------- input ---------- */
323 canvas.addEventListener('pointerdown', () => canvas.focus({ preventScroll: true }));
324 canvas.addEventListener('pointerup', () => throwNow());
325 canvas.addEventListener('contextmenu', e => e.preventDefault());
326 canvas.addEventListener('keydown', e => {
327 if (e.ctrlKey || e.metaKey || e.altKey) return;
328 if (e.key === ' ' || e.key === 'Enter') { if (!e.repeat) throwNow(); }
329 else if (e.key === 'r' || e.key === 'R') restart();
330 else return;
331 e.preventDefault();
332 });
333
334 /* ---------- sound ---------- */
335 let audio = null, muted = false;
336 function ensureAudio() {
337 if (audio) { if (audio.state === 'suspended') audio.resume(); return; }
338 const AC = window.AudioContext || window.webkitAudioContext;
339 if (AC) audio = new AC();
340 }
341 function sound(freq, life, type, delay = 0) {
342 if (!audio || muted) return;
343 const now = audio.currentTime + delay, gain = audio.createGain(), osc = audio.createOscillator();
344 osc.type = type;
345 osc.frequency.setValueAtTime(freq, now);
346 gain.gain.setValueAtTime(.0001, now);
347 gain.gain.exponentialRampToValueAtTime(.06, now + .01);
348 gain.gain.exponentialRampToValueAtTime(.0005, now + life);
349 osc.connect(gain).connect(audio.destination);
350 osc.start(now); osc.stop(now + life + .02);
351 }
352 const muteButton = document.getElementById('mute');
353 muteButton.addEventListener('click', () => {
354 muted = !muted;
355 muteButton.textContent = muted ? 'sound off' : 'sound on';
356 muteButton.setAttribute('aria-pressed', String(muted));
357 });
358
359 /* ---------- fit and boot ---------- */
360 addEventListener('resize', fit);
361 fit();
362 bake();
363 renderHud();
364 say(`${P.line}. Tap or press Space to throw.`);
365 canvas.focus({ preventScroll: true });
366 let last = performance.now();
367 function frame(now) {
368 update(Math.min(.04, (now - last) / 1000));
369 last = now;
370 if (G.dirty) { G.dirty = false; draw(); }
371 requestAnimationFrame(frame);
372 }
373 requestAnimationFrame(frame);
374 </script>
375 </body>
376 </html>
village-darts/page.test.mjs added +145
@@ -0,0 +1,145 @@
1 import { test, expect } from 'bun:test';
2 import { boot } from '../playtest.mjs';
3 import M from './board.js';
4
5 const toTick = (g, k) => { while (g.read('P.tick') < k) g.step(1); };
6
7 test('Space throws from where the marker is, the dart flies, then lands and the HUD and aria-live line read the rules state', () => {
8 const g = boot('village-darts');
9 expect(g.el('yscore').textContent).toBe('301');
10 expect(g.el('left').textContent).toBe('3 darts left');
11 toTick(g, 7);
12 const aim = g.read('P.aim'), want = M.throwDart(JSON.parse(g.read('JSON.stringify(P)'))).dart;
13 g.keyDown(' ');
14 expect(g.read('!!G.flight')).toBe(true);
15 expect(g.read('P.visit.length')).toBe(0); // still in the air
16 g.run(.6);
17 expect(g.read('G.stuck.length')).toBe(1);
18 expect(g.read('P.visit[0].label')).toBe(want.label);
19 expect(Math.abs(g.read('P.visit[0].x') - aim.x)).toBeLessThanOrEqual(M.SCATTER);
20 expect(g.el('left').textContent).toBe('2 darts left');
21 expect(g.el('yscore').textContent).toBe(String(g.read('P.scores.you')));
22 expect(g.el('status').textContent).toContain('You throw');
23 });
24
25 test('the marker moves one rules step per tick and Enter and a tap also throw', () => {
26 const g = boot('village-darts');
27 const t0 = g.read('P.tick');
28 g.run(1);
29 expect(g.read('P.tick')).toBeGreaterThan(t0 + 15);
30 g.press('Enter', .6);
31 expect(g.read('P.visit.length')).toBe(1);
32 g.tap(180, 130); g.run(.6);
33 expect(g.read('P.visit.length')).toBe(2);
34 });
35
36 test('three darts hand the board to the rival, who throws one dart at a time, and landed darts stay until the visit ends', () => {
37 const g = boot('village-darts');
38 for (let i = 0; i < 3; i++) { g.press(' ', .6); }
39 expect(g.read("P.turn")).toBe('rival');
40 expect(g.el('turn').textContent).toBe("The rival's visit");
41 expect(g.read('G.stuck.length')).toBe(3);
42 g.run(1.6);
43 // the rival's first dart is in the air or landed, one at a time
44 g.run(.5);
45 expect(g.read('G.stuck.length')).toBe(1);
46 expect(g.read('G.stuck[0].by')).toBe('rival');
47 expect(g.read('P.visit.length')).toBe(1);
48 expect(g.el('status').textContent).toContain('The rival throws');
49 g.run(3);
50 expect(g.read('P.turn')).toBe('you');
51 });
52
53 test('a throw during the rival visit is refused with the rules reason', () => {
54 const g = boot('village-darts');
55 for (let i = 0; i < 3; i++) g.press(' ', .6);
56 g.press(' ', .05);
57 expect(g.el('status').textContent).toContain('Cannot throw: the rival is throwing its visit');
58 });
59
60 test('a rival dart read off the probe lands where the rules put it: land() there gives the very state rivalDart gave', () => {
61 const g = boot('village-darts');
62 for (let seed = 1; seed < 400; seed++) {
63 let s = M.start(seed);
64 s = { ...s, turn: 'rival', scores: { ...s.scores, rival: [301, 120, 41, 40, 32, 50, 7][seed % 7] } };
65 const r = g.read(`rivalThrow(${JSON.stringify(s)})`);
66 const again = M.land(s, r.dart.x, r.dart.y).n;
67 const what = v => v.map(({ x, y, ...d }) => d); // the position is rounded twice, to a hundredth: what it scored must agree
68 for (const k of ['status', 'turn', 'scores', 'end']) expect(r.state[k]).toEqual(again[k]);
69 expect(what(r.state.visit)).toEqual(what(again.visit));
70 expect(r.dart.bust === true).toBe(again.visit.length === 0 && again.status === 'playing' && again.scores.rival === s.visitStart && r.dart.points > 0);
71 }
72 });
73
74 test('a dart that must bust says Bust and the score goes back', () => {
75 const g = boot('village-darts');
76 // on 2 any scoring dart busts, and the marker on treble 20 cannot miss the board
77 g.read(`P = { ...P, scores: { you: 2, rival: 301 }, visitStart: 2, aim: { x: 0, y: 103 } }`);
78 g.keyDown(' ');
79 g.run(.6);
80 expect(g.read('P.visit.length')).toBe(0);
81 expect(g.read('P.turn')).toBe('rival');
82 expect(g.read('P.scores.you')).toBe(2);
83 expect(g.read('G.stuck[0].bust')).toBe(true);
84 expect(g.el('status').textContent).toContain('Bust: your score goes back to 2');
85 expect(g.el('yscore').textContent).toBe('2');
86 });
87
88 test('the end line is announced, a tap plays again and R restarts', () => {
89 const g = boot('village-darts');
90 g.read(`P = { ...P, status: 'done', end: { winner: 'you', darts: 9, double: 'D16' }, line: 'You win the leg in 9 darts, finishing on D16' }; G.overAt = G.time`);
91 g.run(1);
92 g.press(' ', .6);
93 expect(g.read('P.status')).toBe('playing');
94 g.press(' ', .6);
95 expect(g.read('P.visit.length')).toBe(1);
96 g.press('r', .1);
97 expect(g.read('P.visit.length')).toBe(0);
98 expect(g.read('G.stuck.length')).toBe(0);
99 });
100
101 test('your dart flies in an arc above the straight line; the rival\'s flies on it', () => {
102 const g = boot('village-darts');
103 g.keyDown(' ');
104 g.run(.19);
105 const mine = g.read('flightAt(G.flight)'), land = g.read('landed(G.flight.dart)'), from = g.read('FROM.you');
106 expect(mine[1]).toBeLessThan((from[1] + land[1]) / 2 - 20);
107 g.run(.5);
108 g.read(`P = { ...P, turn: 'rival', visit: [] }; G.rivalAt = G.time`);
109 g.run(.2);
110 expect(g.read("G.flight.by")).toBe('rival');
111 const t = (g.read('G.time') - g.read('G.flight.t0')) / g.read('G.flight.dur');
112 const r = g.read('flightAt(G.flight)'), a = g.read('FROM.rival'), b = g.read('landed(G.flight.dart)');
113 expect(r[1]).toBeCloseTo(a[1] + (b[1] - a[1]) * t, 5);
114 });
115
116 test('the bunting flutters and, with reduced motion, holds still', () => {
117 const still = (reduced) => {
118 const g = boot('village-darts', { reduced });
119 g.read(`P = { ...P, status: 'done', end: { winner: 'you', darts: 9, double: 'D16' } }`); // no marker: only the hall moves
120 g.run(.3);
121 const seen = new Set();
122 for (let i = 0; i < 25; i++) { g.run(.1); seen.add(g.picture()); }
123 return seen.size;
124 };
125 expect(still(false)).toBeGreaterThan(1);
126 expect(still(true)).toBe(1);
127 });
128
129 test('mute toggles its label and aria-pressed', () => {
130 const g = boot('village-darts');
131 g.el('mute').fire('click');
132 expect(g.el('mute').textContent).toBe('sound off');
133 expect(g.el('mute').attrs['aria-pressed']).toBe('true');
134 });
135
136 test('the page source says how to play, holds the rules to four lines and names no .mp3', async () => {
137 const html = await Bun.file(new URL('./index.html', import.meta.url)).text();
138 expect(html).toMatch(/<canvas id="c"[^>]*aria-label="[^"]*Tap[^"]*Space or Enter[^"]*"/);
139 expect(html).toMatch(/min-height: 44px; min-width: 44px/);
140 expect(html).toMatch(/id="mute"[^>]*aria-pressed="false"/);
141 expect(html).toMatch(/role="status" aria-live="polite"/);
142 expect(html).not.toContain('.mp3');
143 const rules = html.match(/rules: \[([\s\S]*?)\n \],/)[1].split('\n').filter(l => l.trim());
144 expect(rules.length).toBeLessThanOrEqual(4);
145 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs