Public: anyone can read this; the crew can change it.
Migrated from github.com/amirmushichge/pixel-plays
Add Welly Toss: the playable page and its place in the catalogue, on the rules in `welly-toss/throw.js` #334
Check · Review · Land · Live done — Landed and live in production.
quill/pixel-plays:quill/310-add-welly-toss-the-playable-page-and-its → main 1 commit fork
9 files
changed +386
−2
README.md +3 −2
Diff not shown — a line 3,211 characters wide (limit 2,000). +3 −2
View fileindex.html +1
| @@ -133,6 +133,7 @@ footer a::after { content: none; } | ||
| 133 | 133 | <li data-kind="Timing" data-input="Tap" style="--accent:#d0573c"><a href="skipping-rope/">Skipping Rope</a><span>Jump the turning rope as it quickens and friends join the line</span><span>Game forged by the crew</span><span class="tags">Timing · Tap</span></li> |
| 134 | 134 | <li data-kind="Memory" data-input="Tap" style="--accent:#c98aa0"><a href="tea-party/">Tea Party</a><span>Remember every guest's order and pour it right as the table grows</span><span>Game forged by the crew</span><span class="tags">Memory · Tap</span></li> |
| 135 | 135 | <li data-kind="Climbing" data-input="Tap" style="--accent:#4a4a4a"><a href="chimney-sweep/">Chimney Sweep</a><span>Climb the flues with your brush and duck the rising smoke</span><span>Game forged by the crew</span><span class="tags">Climbing · Tap</span></li> |
| 136 | <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> | |
| 136 | 137 | </ul> |
| 137 | 138 | <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> |
| 138 | 139 | </main> |
og/home.png
Binary file not shown.
og/welly-toss.png added
Binary file not shown.
rounds.mjs +8
| @@ -490,6 +490,14 @@ export const ROUNDS = { | ||
| 490 | 490 | } |
| 491 | 491 | g.press(' ', .02); |
| 492 | 492 | } }, |
| 493 | // let go at the tick the rules' own `fly` says lands furthest in this throw's wind, then wait out the flight | |
| 494 | 'welly-toss': { over: /play again/, outcome: /Final: [\d.]+ m from the best 3 of 5/, beat: g => { | |
| 495 | if (g.read('P.status') !== 'swinging') { g.run(.3); return; } | |
| 496 | const want = g.read(`(() => { let b = 0, top = -1; for (let t = 0; t <= WellyToss.PERIOD_TICKS / 2; t++) { const r = WellyToss.fly(t, P.winds[P.throws.length]); if (!r.foul && r.distance > top) { top = r.distance; b = t; } } return b; })()`); | |
| 497 | for (let i = 0; i < 80 && g.read('P.tick') !== want; i++) g.run(.02); | |
| 498 | g.tap(...MID); | |
| 499 | g.run(.2); | |
| 500 | } }, | |
| 493 | 501 | 'window-washer': { over: /play again/, outcome: /[1-9]\d* of 500/, beat: g => g.drag([60, 100], [300, 100], .6) }, |
| 494 | 502 | }; |
| 495 | 503 |
serve.test.mjs +7
| @@ -733,3 +733,10 @@ test('Chimney Sweep and its rules script are served without a track', async () = | ||
| 733 | 733 | expect(page.headers.get('content-type')).toBe('text/html; charset=utf-8'); |
| 734 | 734 | expect((await fetch(base + '/chimney-sweep/roofs.js')).status).toBe(200); |
| 735 | 735 | }); |
| 736 | ||
| 737 | test('Welly Toss and its rules script are served without a track', async () => { | |
| 738 | const page = await fetch(base + '/welly-toss/'); | |
| 739 | expect(page.status).toBe(200); | |
| 740 | expect(page.headers.get('content-type')).toBe('text/html; charset=utf-8'); | |
| 741 | expect((await fetch(base + '/welly-toss/throw.js')).status).toBe(200); | |
| 742 | }); |
shell.js +1
| @@ -73,6 +73,7 @@ window.PixelPlays = (() => { | ||
| 73 | 73 | { id: 'tea-party', title: 'Tea Party', blurb: 'Remember every guest\'s order and pour it right as the table grows', kind: 'Memory', input: 'Tap', accent: '#c98aa0', music: false, forged: true }, |
| 74 | 74 | { id: 'chestnut-roast', title: 'Chestnut Roast', blurb: 'Catch the roasted chestnuts in your paper cone and let the burnt ones drop', kind: 'Catching', input: 'Drag', accent: '#8b4513', music: false, forged: true }, |
| 75 | 75 | { id: 'chimney-sweep', title: 'Chimney Sweep', blurb: 'Climb the flues with your brush and duck the rising smoke', kind: 'Climbing', input: 'Tap', accent: '#4a4a4a', music: false, forged: true }, |
| 76 | { 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 }, | |
| 76 | 77 | ]; |
| 77 | 78 | const MUSIC_VOLUME = .4; |
| 78 | 79 | const CREDITS = [ |
welly-toss/index.html added +318
| @@ -0,0 +1,318 @@ | ||
| 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>Welly Toss</title> | |
| 7 | <!-- og:begin --> | |
| 8 | <meta name="description" content="Let the boot go at the top of the swing and land it past the flags"> | |
| 9 | <link rel="canonical" href="https://pixel-plays--forge-trevato-apps.open-platform.sh/welly-toss/"> | |
| 10 | <meta property="og:type" content="website"> | |
| 11 | <meta property="og:site_name" content="Pixel Plays"> | |
| 12 | <meta property="og:title" content="Welly Toss"> | |
| 13 | <meta property="og:description" content="Let the boot go at the top of the swing and land it past the flags"> | |
| 14 | <meta property="og:url" content="https://pixel-plays--forge-trevato-apps.open-platform.sh/welly-toss/"> | |
| 15 | <meta property="og:image" content="https://pixel-plays--forge-trevato-apps.open-platform.sh/og/welly-toss.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="Welly Toss: 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; : #2f6b3a; } | |
| 24 | * { box-sizing: border-box; } | |
| 25 | html, body { -webkit-user-select: none; user-select: none; -webkit-touch-callout: none; -webkit-tap-highlight-color: transparent; height: 100%; margin: 0; background: var(); color: var(); } | |
| 26 | body { font: 13px/1.35 ui-monospace, "Cascadia Mono", Consolas, Menlo, monospace; overflow: hidden; } | |
| 27 | #stage { position: fixed; inset: 0; display: grid; place-items: center; } | |
| 28 | canvas { display: block; image-rendering: pixelated; touch-action: none; cursor: pointer; outline: none; } | |
| 29 | canvas:focus-visible { outline: 2px solid var(); outline-offset: -2px; } | |
| 30 | .hud { position: fixed; left: max(16px, env(safe-area-inset-left)); right: max(16px, env(safe-area-inset-right)); display: flex; justify-content: space-between; gap: 16px; pointer-events: none; } | |
| 31 | #scores { position: fixed; top: max(14px, env(safe-area-inset-top)); left: max(16px, env(safe-area-inset-left)); display: grid; gap: 2px; pointer-events: none; } | |
| 32 | #bottom { bottom: max(14px, env(safe-area-inset-bottom)); align-items: flex-end; } | |
| 33 | .row { display: flex; align-items: center; gap: 8px; color: var(); } | |
| 34 | .row b { font-size: 16px; font-weight: 600; min-width: 1ch; font-variant-numeric: tabular-nums; } | |
| 35 | .row span { color: var(); } | |
| 36 | #throws { display: grid; gap: 1px; margin: 4px 0 0; padding: 0; list-style: none; color: var(); font-variant-numeric: tabular-nums; } | |
| 37 | #throws li.foul { color: #a8352c; } | |
| 38 | #throws li.now { color: var(); } | |
| 39 | #line { color: var(); min-height: 1.35em; } | |
| 40 | button { pointer-events: auto; font: inherit; color: var(); background: none; border: 0; padding: 10px; margin: -10px; cursor: pointer; } | |
| 41 | button:hover, button:focus-visible { color: var(); } | |
| 42 | </style> | |
| 43 | </head> | |
| 44 | <body> | |
| 45 | <div id="stage"><canvas id="c" tabindex="0" aria-label="A grass field with marker flags every ten metres, white side lines and a windsock showing this throw's crosswind. A boot swings back and forth on your arm: tap, click or press Space to let go at the top of the forward swing, which lands it furthest. Five throws, the best three distances count; a boot that drifts over a side line, or is let go on the backswing, is a foul."></canvas></div> | |
| 46 | <div id="scores"> | |
| 47 | <div class="row"><b id="number">1</b><span id="of">of 5 throws</span></div> | |
| 48 | <div class="row"><b id="total">0</b><span>m, best three</span></div> | |
| 49 | <div class="row"><b id="best">0</b><span>m, best total</span></div> | |
| 50 | <div class="row"><span id="wind">wind 0 m/s</span></div> | |
| 51 | <ol id="throws" aria-label="Throws so far"><li id="t0"></li><li id="t1"></li><li id="t2"></li><li id="t3"></li><li id="t4"></li></ol> | |
| 52 | </div> | |
| 53 | <div class="hud" id="bottom"> | |
| 54 | <div id="line"><span id="status" role="status" aria-live="polite"></span></div> | |
| 55 | <button id="mute" type="button" aria-pressed="false">sound on</button> | |
| 56 | </div> | |
| 57 | <script src="../shell.js"></script> | |
| 58 | <script> | |
| 59 | PixelPlays.mount({ | |
| 60 | id: 'welly-toss', | |
| 61 | rules: [ | |
| 62 | 'Tap, click or press Space to let go of the boot.', | |
| 63 | 'Release at the top of the forward swing to land it furthest.', | |
| 64 | 'Five throws, the best three count. Over a side line is a foul.', | |
| 65 | ], | |
| 66 | }); | |
| 67 | </script> | |
| 68 | <script>window.PP_SCENE = { originY: 4 };</script> | |
| 69 | <script src="../engine.js"></script> | |
| 70 | <script src="throw.js"></script> | |
| 71 | <script> | |
| 72 | 'use strict'; | |
| 73 | ||
| 74 | const M = WellyToss; | |
| 75 | const BEST_KEY = 'pixel-plays-welly-toss-best'; | |
| 76 | const K = .62, KL = 1.1, KO = .9; // screen units per metre along the field, across it, and for the height and size of things | |
| 77 | const MID = 21; // the field is centred on this distance | |
| 78 | const world = (d, lat, z) => [(d - MID) * K, lat * KL, z * KO]; | |
| 79 | const GREEN_A = [92, 134, 62], GREEN_B = [78, 120, 58], CHALK = [244, 244, 236], DIRT = [148, 112, 78]; | |
| 80 | const POLE = [236, 232, 222], PENNANT = [214, 86, 52], GOLD = [246, 196, 64], SOCK_A = [236, 126, 40], SOCK_B = [244, 240, 232]; | |
| 81 | const BOOT = [47, 107, 58], SOLE = [40, 36, 34], COAT = [58, 84, 140], SKIN = [222, 170, 130], FOUL = [196, 52, 44]; | |
| 82 | const PIVOT_Z = 1.9, ARM = 1.5; // metres: the shoulder and the length of the swinging arm | |
| 83 | ||
| 84 | /* ---------- state ---------- */ | |
| 85 | const best0 = (() => { try { return Number(localStorage.getItem(BEST_KEY)) || 0; } catch (e) { return 0; } })(); | |
| 86 | let P = M.start(Date.now() & 0xffff); | |
| 87 | const G = { time: 0, acc: 0, best: best0, status: '', total: 1, seen: 0, saved: false, record: false, flash: null }; | |
| 88 | const nodes = {}; | |
| 89 | for (const id of ['number', 'of', 'total', 'best', 'wind', 'status']) nodes[id] = document.getElementById(id); | |
| 90 | const slots = [0, 1, 2, 3, 4].map(i => document.getElementById('t' + i)); | |
| 91 | const shown = t => t.foul ? `foul · ${t.why}` : `${t.distance.toFixed(2)} m`; | |
| 92 | function windText(w) { | |
| 93 | if (w === 0) return 'wind: still air'; | |
| 94 | return `wind ${Math.abs(w).toFixed(1)} m/s toward the ${w > 0 ? 'near' : 'far'} line`; | |
| 95 | } | |
| 96 | function renderHud() { | |
| 97 | const n = Math.min(P.throws.length + 1, M.THROWS); | |
| 98 | nodes.number.textContent = String(n); | |
| 99 | nodes.of.textContent = `of ${M.THROWS} throws`; | |
| 100 | nodes.total.textContent = P.score.toFixed(2); | |
| 101 | nodes.best.textContent = Math.max(G.best, P.score).toFixed(2); // the saved best total, beaten live | |
| 102 | nodes.wind.textContent = windText(P.winds[Math.min(P.throws.length, M.THROWS - 1)]); | |
| 103 | slots.forEach((el, i) => { | |
| 104 | const t = P.throws[i]; | |
| 105 | el.textContent = t ? `${i + 1}. ${shown(t)}` : `${i + 1}. ${i === P.throws.length && P.status !== 'done' ? '·' : '–'}`; | |
| 106 | el.className = t && t.foul ? 'foul' : i === P.throws.length ? 'now' : ''; | |
| 107 | }); | |
| 108 | } | |
| 109 | function setStatus(text) { | |
| 110 | if (text === G.status) return; | |
| 111 | G.status = text; | |
| 112 | nodes.status.textContent = text; // the live region: one line per release, landing and the end | |
| 113 | } | |
| 114 | ||
| 115 | /* ---------- baked scene: the field ---------- */ | |
| 116 | function groundColor(ix, iy) { | |
| 117 | const g = unproject(ix + .5, iy + .5), d = g.x / K + MID, lat = g.y / KL; | |
| 118 | const edge = Math.pow(Math.pow(Math.abs(d - 20.5) / 29, 8) + Math.pow(Math.abs(lat) / 6.3, 8), .125); | |
| 119 | const rough = edge + (noise(d * .08, lat * .3) - .5) * .05; | |
| 120 | if (rough > 1.03) return 0; | |
| 121 | if (rough > .93 && hash(ix, iy) < (rough - .93) / .1) return 0; | |
| 122 | const t = .94 + hash(ix, iy) * .1; | |
| 123 | const chalk = Math.abs(Math.abs(lat) - M.SIDE) < .16 && d > -3 && d < 46 | |
| 124 | || Math.abs(lat) < M.SIDE && (M.FLAGS.some(f => Math.abs(d - f) < .2) || Math.abs(d) < .2); | |
| 125 | if (chalk) return rgb(CHALK[0] * t, CHALK[1] * t, CHALK[2] * t); | |
| 126 | if (d < 2.4 && Math.abs(lat) < 1.6) return rgb(DIRT[0] * t, DIRT[1] * t, DIRT[2] * t); // the worn patch at the throwing line | |
| 127 | const c = (Math.floor((d + 5) / 5) & 1) ? GREEN_A : GREEN_B; // mown stripes | |
| 128 | return rgb(c[0] * t, c[1] * t, c[2] * t); | |
| 129 | } | |
| 130 | function bake() { | |
| 131 | ground.fill(0); shadowMask.fill(0); staticShadow.fill(0); kinds.fill(0); | |
| 132 | zb.fill(-1e9); | |
| 133 | pix.fill(rgb(255, 255, 255)); | |
| 134 | for (let iy = 0; iy < H; iy++) for (let ix = 0; ix < W; ix++) { | |
| 135 | const g = unproject(ix + .5, iy + .5), i = iy * W + ix, c = groundColor(ix, iy); | |
| 136 | if (!c) continue; | |
| 137 | pix[i] = c; ground[i] = 1; | |
| 138 | zb[i] = (g.x + g.y) / R2 * COS; | |
| 139 | } | |
| 140 | groundZ.set(zb); | |
| 141 | bakeOcclusion(); | |
| 142 | staticShadow.set(shadowMask); | |
| 143 | applyShadows(groundZ); | |
| 144 | bakedPix.set(pix); | |
| 145 | bakedZ.set(zb); | |
| 146 | } | |
| 147 | ||
| 148 | /* ---------- figures ---------- */ | |
| 149 | const flick = () => STILL ? 0 : Math.floor(G.time * 8); | |
| 150 | function flags() { | |
| 151 | const reached = M.furthestFlag(P.throws) ?? 0; | |
| 152 | for (const m of M.FLAGS) { | |
| 153 | const [x, y] = world(m, -M.SIDE - .9, 0), top = 4.4 * KO; | |
| 154 | dynamicAdd([x, y, 0], [x, y, top], .07, .07, () => POLE); | |
| 155 | const col = m <= reached ? GOLD : PENNANT, wave = (flick() + m) % 2 ? .08 : 0; | |
| 156 | for (let k = 0; k < 4; k++) sphere(x + k * .2 * KL * .5, y + k * .3 + wave * k, top - .1 - k * .06, .13 - k * .02, col, 'solid'); | |
| 157 | } | |
| 158 | } | |
| 159 | function windsock() { | |
| 160 | const w = P.winds[Math.min(P.throws.length, M.THROWS - 1)], s = Math.abs(w) / M.WIND_MAX, dir = w < 0 ? -1 : 1; | |
| 161 | const [x, y] = world(6, M.SIDE + 1.4, 0), top = 4.2 * KO; | |
| 162 | dynamicAdd([x, y, 0], [x, y, top], .08, .08, () => POLE); | |
| 163 | for (let k = 1; k <= 5; k++) { | |
| 164 | const flutter = STILL ? 0 : ((flick() + k) % 3 - 1) * .04 * s; | |
| 165 | const out = k * .34 * (.25 + .75 * s), drop = k * .3 * (1 - s) * (1 - s) * 1.2; | |
| 166 | sphere(x, y + dir * out + flutter, top - drop, .3 - k * .035, k & 1 ? SOCK_A : SOCK_B, 'solid'); | |
| 167 | } | |
| 168 | castShadow(x, y, top, .3, 1, 0); | |
| 169 | } | |
| 170 | function thrower(angle) { | |
| 171 | const [px, py, pz] = world(0, 0, PIVOT_Z), [fx, fy] = world(-.2, 0, 0); | |
| 172 | dynamicAdd([fx, fy, 0], [fx, fy, 1.5 * KO], .26, .22, () => COAT); | |
| 173 | sphere(fx, fy, 1.95 * KO, .2, SKIN, 'solid'); | |
| 174 | const a = angle * Math.PI / 180, tip = [px + Math.sin(a) * ARM * KO, py, pz - Math.cos(a) * ARM * KO]; | |
| 175 | dynamicAdd([px, py, pz], tip, .07, .07, () => SKIN); | |
| 176 | return tip; | |
| 177 | } | |
| 178 | function boot(x, y, z, spin) { | |
| 179 | const sx = Math.sin(spin), cz = Math.cos(spin); | |
| 180 | dynamicAdd([x, y, z], [x - sx * .5 * KO, y, z + cz * .5 * KO], .17, .2, () => BOOT); | |
| 181 | sphere(x + cz * .1 * KO, y, z - sx * .1 * KO, .22, SOLE, 'solid'); | |
| 182 | sphere(x + cz * .28 * KO, y, z - sx * .28 * KO, .2, BOOT, 'solid'); | |
| 183 | } | |
| 184 | function marks() { | |
| 185 | P.throws.forEach(t => { | |
| 186 | const [x, y] = world(t.why === 'backswing' ? -2.4 : t.distance, t.drift, 0); | |
| 187 | sphere(x, y, .06, .22, t.foul ? FOUL : CHALK, 'solid'); | |
| 188 | sphere(x, y, .14, .12, t.foul ? CHALK : BOOT, 'solid'); | |
| 189 | }); | |
| 190 | } | |
| 191 | function phaseTick() { return P.tick + Math.min(1, G.acc / M.TICK_S); } // the swing, between rule ticks | |
| 192 | function draw() { | |
| 193 | pix.set(bakedPix); | |
| 194 | zb.set(bakedZ); | |
| 195 | flags(); | |
| 196 | windsock(); | |
| 197 | marks(); | |
| 198 | if (P.status === 'flying' && P.flight) { | |
| 199 | const r = P.flight.result, total = G.total, f = Math.min(1, Math.max(0, 1 - (P.flight.left - Math.min(1, G.acc / M.TICK_S)) / total)); | |
| 200 | const rel = M.fly(r.tick, 0), a0 = rel.angle * Math.PI / 180, t = r.time * f; | |
| 201 | const [px, py, pz] = world(0, 0, PIVOT_Z), s0 = [px + Math.sin(a0) * ARM * KO, py, pz - Math.cos(a0) * ARM * KO]; | |
| 202 | thrower(rel.angle); | |
| 203 | const along = r.time ? t / r.time : 1; | |
| 204 | const [x, y] = world(r.distance * along, r.drift * along, 0); | |
| 205 | const z = r.time ? (r.speed * Math.sin(r.angle * Math.PI / 180) * t - M.G * t * t / 2) * KO : 0; | |
| 206 | const back = r.why === 'backswing' ? -1 : 0; | |
| 207 | boot(x + s0[0] * (1 - f) + back * f * 2.6 * KO, y, Math.max(.12, z) + s0[2] * (1 - f), f * Math.max(1, r.time) * 7); | |
| 208 | castShadow(x, y, Math.max(0, z), .3, 1, 0); | |
| 209 | } else if (P.status === 'swinging') { | |
| 210 | const tip = thrower(M.fly(phaseTick(), 0).angle); | |
| 211 | boot(tip[0], tip[1], tip[2], 0); | |
| 212 | castShadow(tip[0], tip[1], tip[2], .3, 1, 0); | |
| 213 | } else thrower(0); | |
| 214 | flushShadows(); | |
| 215 | applyShadows(groundZ); | |
| 216 | shadeEdges(); | |
| 217 | ctx.putImageData(image, 0, 0); | |
| 218 | } | |
| 219 | ||
| 220 | /* ---------- game ---------- */ | |
| 221 | function announce(t, n) { | |
| 222 | if (t.foul) { setStatus(`Throw ${n}: foul, ${t.why}`); sound('foul'); return; } | |
| 223 | const f = t.flag === null ? 'short of the first flag' : `past the ${t.flag} m flag`; | |
| 224 | setStatus(`Throw ${n}: ${t.distance.toFixed(2)} m, ${f}`); | |
| 225 | sound('land'); | |
| 226 | } | |
| 227 | function act() { | |
| 228 | while (G.seen < P.throws.length) announce(P.throws[G.seen], ++G.seen); | |
| 229 | renderHud(); | |
| 230 | if (P.status === 'done' && !G.saved) { | |
| 231 | G.saved = true; | |
| 232 | G.record = P.score > G.best; | |
| 233 | if (G.record) { G.best = P.score; try { localStorage.setItem(BEST_KEY, String(P.score)); } catch (e) { /* private window: keep it for this visit */ } } | |
| 234 | setStatus(`${P.line}${G.record ? ' · new best' : ''} · tap or press space to play again`); | |
| 235 | } | |
| 236 | } | |
| 237 | function restart() { | |
| 238 | if (P.status !== 'done') return false; | |
| 239 | P = M.start((Date.now() + G.seen * 977) & 0xffff); | |
| 240 | G.seen = 0; G.saved = false; G.record = false; G.acc = 0; | |
| 241 | setStatus('tap or press space at the top of the forward swing'); | |
| 242 | renderHud(); | |
| 243 | return true; | |
| 244 | } | |
| 245 | function letGo() { | |
| 246 | ensureAudio(); | |
| 247 | if (restart()) return; | |
| 248 | const r = M.release(P); | |
| 249 | if (!r.ok) return; | |
| 250 | P = r.state; | |
| 251 | G.total = P.flight.left; | |
| 252 | setStatus(r.throw.foul && r.throw.why === 'backswing' ? 'Let go on the backswing' : `Let go at ${r.throw.angle.toFixed(0)} degrees, ${r.throw.speed.toFixed(1)} m/s`); | |
| 253 | sound('throw'); | |
| 254 | renderHud(); | |
| 255 | } | |
| 256 | function update(dt) { | |
| 257 | G.time += dt; | |
| 258 | G.acc += dt; | |
| 259 | while (G.acc >= M.TICK_S) { G.acc -= M.TICK_S; P = M.step(P); } | |
| 260 | act(); | |
| 261 | } | |
| 262 | ||
| 263 | /* ---------- input ---------- */ | |
| 264 | canvas.addEventListener('pointerdown', e => { canvas.focus({ preventScroll: true }); letGo(); }); | |
| 265 | canvas.addEventListener('keydown', e => { | |
| 266 | if (e.key !== ' ' && e.key !== 'Enter') return; | |
| 267 | e.preventDefault(); | |
| 268 | if (!e.repeat) letGo(); | |
| 269 | }); | |
| 270 | ||
| 271 | /* ---------- sound ---------- */ | |
| 272 | let audio = null, muted = false; | |
| 273 | function ensureAudio() { | |
| 274 | if (audio) { if (audio.state === 'suspended') audio.resume(); return; } | |
| 275 | const AC = window.AudioContext || window.webkitAudioContext; | |
| 276 | if (AC) audio = new AC(); | |
| 277 | } | |
| 278 | function sound(kind) { | |
| 279 | if (!audio || muted) return; | |
| 280 | const now = audio.currentTime, gain = audio.createGain(), osc = audio.createOscillator(); | |
| 281 | const spec = { | |
| 282 | throw: [200, 520, .18, .05, 'sine'], | |
| 283 | land: [180, 90, .14, .07, 'sine'], | |
| 284 | foul: [200, 70, .25, .05, 'square'], | |
| 285 | }[kind]; | |
| 286 | osc.type = spec[4]; | |
| 287 | osc.frequency.setValueAtTime(spec[0], now); | |
| 288 | osc.frequency.exponentialRampToValueAtTime(spec[1], now + spec[2]); | |
| 289 | gain.gain.setValueAtTime(spec[3], now); | |
| 290 | gain.gain.exponentialRampToValueAtTime(.0005, now + spec[2]); | |
| 291 | osc.connect(gain).connect(audio.destination); | |
| 292 | osc.start(now); osc.stop(now + spec[2] + .01); | |
| 293 | } | |
| 294 | const muteButton = document.getElementById('mute'); | |
| 295 | muteButton.addEventListener('click', () => { | |
| 296 | muted = !muted; | |
| 297 | muteButton.textContent = muted ? 'sound off' : 'sound on'; | |
| 298 | muteButton.setAttribute('aria-pressed', String(muted)); | |
| 299 | }); | |
| 300 | ||
| 301 | /* ---------- fit and boot ---------- */ | |
| 302 | addEventListener('resize', fit); | |
| 303 | fit(); | |
| 304 | bake(); | |
| 305 | setStatus('tap or press space at the top of the forward swing'); | |
| 306 | renderHud(); | |
| 307 | canvas.focus({ preventScroll: true }); | |
| 308 | let last = performance.now(); | |
| 309 | function frame(now) { | |
| 310 | update(Math.min(.04, (now - last) / 1000)); | |
| 311 | last = now; | |
| 312 | draw(); | |
| 313 | requestAnimationFrame(frame); | |
| 314 | } | |
| 315 | requestAnimationFrame(frame); | |
| 316 | </script> | |
| 317 | </body> | |
| 318 | </html> |
welly-toss/page.test.mjs added +48
| @@ -0,0 +1,48 @@ | ||
| 1 | import { test, expect, setDefaultTimeout } from 'bun:test'; | |
| 2 | import { boot } from '../playtest.mjs'; | |
| 3 | ||
| 4 | setDefaultTimeout(20000); | |
| 5 | const MID = [180, 140]; | |
| 6 | // wait for the swing to reach a forward tick that flies for a while | |
| 7 | const swingTo = g => { for (let i = 0; i < 200 && !(g.read('P.tick') >= 18 && g.read('P.tick') <= 20); i++) g.run(.02); }; | |
| 8 | ||
| 9 | test('a tap lets go through the rules, and the landing is announced and listed', () => { | |
| 10 | const g = boot('welly-toss'); | |
| 11 | swingTo(g); | |
| 12 | expect(g.read('P.status')).toBe('swinging'); | |
| 13 | g.tap(...MID); | |
| 14 | expect(g.read('P.status')).toBe('flying'); | |
| 15 | g.run(5); | |
| 16 | expect(g.read('P.throws.length')).toBe(1); | |
| 17 | expect(g.el('status').textContent).toContain('Throw 1'); | |
| 18 | expect(g.el('t0').textContent).toContain('1.'); | |
| 19 | expect(g.el('number').textContent).toBe('2'); | |
| 20 | }); | |
| 21 | ||
| 22 | test('space lets go too, but not while the boot is still in the air', () => { | |
| 23 | const g = boot('welly-toss'); | |
| 24 | swingTo(g); | |
| 25 | g.press(' ', .02); | |
| 26 | expect(g.read('P.status')).toBe('flying'); | |
| 27 | const before = g.read('P.flight.left'); | |
| 28 | g.press(' ', .02); | |
| 29 | expect(g.read('P.flight ? P.flight.left <= ' + before + ' : true')).toBe(true); | |
| 30 | }); | |
| 31 | ||
| 32 | test('five throws end the contest with the rules line and a tap plays again', () => { | |
| 33 | const g = boot('welly-toss'); | |
| 34 | for (let i = 0; i < 5; i++) { swingTo(g); g.tap(...MID); g.run(5); } | |
| 35 | expect(g.read('P.status')).toBe('done'); | |
| 36 | expect(g.el('status').textContent).toContain('Final:'); | |
| 37 | expect(g.el('status').textContent).toContain('play again'); | |
| 38 | g.tap(...MID); | |
| 39 | expect(g.read('P.status')).toBe('swinging'); | |
| 40 | expect(g.read('P.throws.length')).toBe(0); | |
| 41 | }); | |
| 42 | ||
| 43 | test('the HUD shows this throw\'s wind and the best-three total from the rules', () => { | |
| 44 | const g = boot('welly-toss'); | |
| 45 | expect(g.el('wind').textContent).toContain('wind'); | |
| 46 | swingTo(g); g.tap(...MID); g.run(5); | |
| 47 | expect(g.el('total').textContent).toBe(g.read('P.score.toFixed(2)')); | |
| 48 | }); |
new above
offline