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

Merged opened this

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

Merged

quill/pixel-plays:quill/310-add-welly-toss-the-playable-page-and-its → main 1 commit fork

  • Merged. quill/310-add-welly-toss-the-playable-page-and-its was merged into main as f495867.

Review

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

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

View file
index.html +1
@@ -133,6 +133,7 @@ footer a::after { content: none; }
133133 <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>
134134 <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>
135135 <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>
136137 </ul>
137138 <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>
138139 </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 = {
490490 }
491491 g.press(' ', .02);
492492 } },
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 } },
493501 'window-washer': { over: /play again/, outcome: /[1-9]\d* of 500/, beat: g => g.drag([60, 100], [300, 100], .6) },
494502 };
495503
serve.test.mjs +7
@@ -733,3 +733,10 @@ test('Chimney Sweep and its rules script are served without a track', async () =
733733 expect(page.headers.get('content-type')).toBe('text/html; charset=utf-8');
734734 expect((await fetch(base + '/chimney-sweep/roofs.js')).status).toBe(200);
735735 });
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 = (() => {
7373 { 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 },
7474 { 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 },
7575 { 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 },
7677 ];
7778 const MUSIC_VOLUME = .4;
7879 const CREDITS = [
welly-toss/index.html added +318
@@ -0,0 +1,318 @@
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>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 { --ink: #1c1b19; --muted: #6b675f; --paper: #fff; --accent: #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(--paper); color: var(--ink); }
26 body { font: 13px/1.35 ui-monospace, "Cascadia Mono", Consolas, Menlo, monospace; overflow: hidden; }
27 #stage { position: fixed; inset: 0; display: grid; place-items: center; }
28 canvas { display: block; image-rendering: pixelated; touch-action: none; cursor: pointer; outline: none; }
29 canvas:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; }
30 .hud { position: fixed; left: max(16px, env(safe-area-inset-left)); right: max(16px, env(safe-area-inset-right)); display: flex; justify-content: space-between; gap: 16px; pointer-events: none; }
31 #scores { position: fixed; top: max(14px, env(safe-area-inset-top)); left: max(16px, env(safe-area-inset-left)); display: grid; gap: 2px; pointer-events: none; }
32 #bottom { bottom: max(14px, env(safe-area-inset-bottom)); align-items: flex-end; }
33 .row { display: flex; align-items: center; gap: 8px; color: var(--accent); }
34 .row b { font-size: 16px; font-weight: 600; min-width: 1ch; font-variant-numeric: tabular-nums; }
35 .row span { color: var(--muted); }
36 #throws { display: grid; gap: 1px; margin: 4px 0 0; padding: 0; list-style: none; color: var(--muted); font-variant-numeric: tabular-nums; }
37 #throws li.foul { color: #a8352c; }
38 #throws li.now { color: var(--ink); }
39 #line { color: var(--muted); min-height: 1.35em; }
40 button { pointer-events: auto; font: inherit; color: var(--muted); background: none; border: 0; padding: 10px; margin: -10px; cursor: pointer; }
41 button:hover, button:focus-visible { color: var(--ink); }
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 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs