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
Check · Review · Land · Live done — Landed and live in production.
vesper/pixel-plays:vesper/343-add-village-darts-the-playable-page-and → main 4 commits fork
9 files
changed +543
−2
README.md +3 −2
Diff not shown — a line 3,652 characters wide (limit 2,000). +3 −2
View fileindex.html +1
| @@ -136,6 +136,7 @@ footer a::after { content: none; } | ||
| 136 | 136 | <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> |
| 137 | 137 | <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> |
| 138 | 138 | <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> | |
| 139 | 140 | <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> |
| 140 | 141 | </ul> |
| 141 | 142 | <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'; | ||
| 10 | 10 | import Quilt from './quilt/patch.js'; |
| 11 | 11 | import Jars from './jam-jars/shelf.js'; |
| 12 | 12 | import Shelf from './library-shelf/shelf.js'; |
| 13 | import Darts from './village-darts/board.js'; | |
| 13 | 14 | import Sweep from './chimney-sweep/roofs.js'; |
| 14 | 15 | import Field from './fence-posts/field.js'; |
| 15 | 16 | import Carrot from './carrot-pull/bed.js'; |
| @@ -499,6 +500,15 @@ export const ROUNDS = { | ||
| 499 | 500 | const [x, y] = g.read(`screenOf(${JSON.stringify(Field.choose(s).edge)})`); |
| 500 | 501 | g.tap(x, y); |
| 501 | 502 | } }, |
| 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 | } }, | |
| 502 | 512 | // let go at the tick the rules' own `fly` says lands furthest in this throw's wind, then wait out the flight |
| 503 | 513 | 'welly-toss': { over: /play again/, outcome: /Final: [\d.]+ m from the best 3 of 5/, beat: g => { |
| 504 | 514 | 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 () => | ||
| 755 | 755 | expect((await fetch(base + '/ticket-booth/till.js')).status).toBe(200); |
| 756 | 756 | }); |
| 757 | 757 | |
| 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 | ||
| 758 | 765 | test('Carrot Pull and its rules script are served without a track', async () => { |
| 759 | 766 | const page = await fetch(base + '/carrot-pull/'); |
| 760 | 767 | expect(page.status).toBe(200); |
shell.js +1
| @@ -76,6 +76,7 @@ window.PixelPlays = (() => { | ||
| 76 | 76 | { id: 'fence-posts', title: 'Fence Posts', blurb: 'Take turns fencing the meadow and claim every pen you close', kind: 'Strategy', input: 'Tap', accent: '#6b8e23', music: false, forged: true }, |
| 77 | 77 | { id: 'welly-toss', title: 'Welly Toss', blurb: 'Let the boot go at the top of the swing and land it past the flags', kind: 'Throwing', input: 'Tap', accent: '#2f6b3a', music: false, forged: true }, |
| 78 | 78 | { id: '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 }, | |
| 79 | 80 | { 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 }, |
| 80 | 81 | ]; |
| 81 | 82 | const MUSIC_VOLUME = .4; |
village-darts/index.html added +376
| @@ -0,0 +1,376 @@ | ||
| 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>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 { : #1c1b19; : #6b675f; : #fff; : #b5412f; : #3f6fc4; : #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(); color: var(); } | |
| 27 | body { font: 13px/1.35 ui-monospace, "Cascadia Mono", Consolas, Menlo, monospace; overflow: hidden; } | |
| 28 | #stage { position: fixed; inset: 0; display: grid; place-items: center; } | |
| 29 | canvas { display: block; image-rendering: pixelated; touch-action: none; cursor: pointer; outline: none; } | |
| 30 | canvas:focus-visible { outline: 2px solid var(); outline-offset: -2px; } | |
| 31 | .hud { position: fixed; left: max(16px, env(safe-area-inset-left)); right: max(16px, env(safe-area-inset-right)); display: flex; justify-content: space-between; align-items: flex-end; gap: 16px; pointer-events: none; } | |
| 32 | #scores { position: fixed; top: max(14px, env(safe-area-inset-top)); left: max(16px, env(safe-area-inset-left)); display: grid; gap: 2px; pointer-events: none; } | |
| 33 | #bottom { bottom: max(20px, env(safe-area-inset-bottom)); } | |
| 34 | .row { display: flex; align-items: center; gap: 8px; } | |
| 35 | .row b { font-size: 16px; font-weight: 600; min-width: 3ch; font-variant-numeric: tabular-nums; } | |
| 36 | .row span { color: var(); } | |
| 37 | .row.you { color: var(); } | |
| 38 | .row.rival { color: var(); } | |
| 39 | .mark { display: inline-block; width: 18px; height: 12px; border: 1px solid currentColor; } | |
| 40 | .you .mark { background: linear-gradient(135deg, var() 50%, #fff 50%); } | |
| 41 | .rival .mark { background: var(); } | |
| 42 | #turn, #left { color: var(); min-height: 1.35em; } | |
| 43 | #status { color: var(); min-height: 2.7em; max-width: 60ch; } | |
| 44 | 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; } | |
| 45 | button:hover, button:focus-visible { color: var(); } | |
| 46 | button:focus-visible { outline: 2px solid var(); outline-offset: 2px; } | |
| 47 | noscript p { position: fixed; inset: 40% 16px auto; margin: 0; text-align: center; color: var(); } | |
| 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 | }); |
new above
offline