№ 015
Hidden Pitch
A file upload with a penalty hidden inside — pull back the ball, score, and the upload starts.
- Version
- 1.0.0
- Download
- 661 KB · 18 files
- Added
- Oct 26, 2026
- Built with
- HTML, CSS, JS
- Needs
- Google Fonts
- Tags
- file upload, game, football
Usage
Copy hidden-pitch.css and hidden-pitch.js (and assets/, if there is one) into your project, then:
new HiddenPitch(element, {
upload: HiddenPitch.xhr('/api/upload'), // or any (file, onProgress, signal) => Promise
assets: 'assets/', // folder with the kit images
accept: ['.pdf', 'image/*'], // optional
maxSize: 25 * 1024 * 1024, // optional, bytes
onComplete(file, response) {},
})
An ordinary file upload with a penalty hidden inside. Choose a file and the card unfolds into a tiny
pitch; the file folds into a football sitting in an elastic launcher. Pull it back and let go:
score and the upload starts while the referee checks VAR, then GOOOAL! once the transfer really
finishes. Miss and it's a red card — the file is safe, take another shot, or skip the game entirely.
The shot never decides the upload: it is only marked complete when the transfer succeeds.
Keyboard: focus the ball, arrows aim and set power, Enter shoots. The round button shoots too.Source
/* Hidden Pitch — a file upload with a penalty inside. Laid out on a 760-unit grid (--u), so the whole
object scales with the width of its container. */
.hp {
--hp-ivory: #f5f1e9;
--hp-tray: #ece5d8;
--hp-edge: #e1d9c9;
--hp-green: #557b57;
--hp-forest: #293d30;
--hp-sage: #dce5d7;
--hp-ochre: #d4a85f;
--hp-ochre-dk: #a77b3a;
--hp-red: #b9483f;
--hp-ink: #24302a;
--hp-muted: #7a7a70;
--hp-ui: "Inter", "Geist", system-ui, sans-serif;
--hp-sport: "Barlow Condensed", "Inter", sans-serif;
container-type: inline-size;
position: relative;
width: 100%;
color: var(--hp-ink);
font-family: var(--hp-ui);
-webkit-tap-highlight-color: transparent;
}
.hp *,
.hp *::before,
.hp *::after {
box-sizing: border-box;
}
.hp-card {
--u: calc(100cqw / 760);
--h: 300;
--open: 0;
position: relative;
width: 100%;
height: calc(var(--u) * var(--h));
border-radius: calc(var(--u) * 28);
background: var(--hp-ivory);
overflow: hidden;
box-shadow:
inset 0 calc(var(--u) * 2) 0 #fffdf8,
inset 0 calc(var(--u) * -12) 0 var(--hp-edge), /* the slab's front edge */
0 calc(var(--u) * 2) calc(var(--u) * 3) rgba(70, 58, 40, 0.12),
0 calc(var(--u) * 26) calc(var(--u) * 50) rgba(70, 58, 40, 0.16);
user-select: none;
}
/* ── stadium: unfolds out of the card, hinged at its bottom edge ── */
.hp-stadium {
position: absolute;
left: 0;
top: 0;
width: calc(var(--u) * 760);
height: calc(var(--u) * 900);
transform-origin: 50% 100%;
transform: perspective(calc(var(--u) * 1600)) rotateX(calc((1 - var(--open)) * 82deg));
opacity: min(1, calc(var(--open) * 3));
}
.hp-stadium::before {
content: "";
position: absolute;
left: calc(var(--u) * 26);
top: calc(var(--u) * 26);
width: calc(var(--u) * 708);
height: calc(var(--u) * 866);
border-radius: calc(var(--u) * 22);
background: var(--hp-tray);
box-shadow: inset 0 calc(var(--u) * 3) calc(var(--u) * 6) rgba(70, 58, 40, 0.12);
}
.hp-stadium img,
.hp-frame,
.hp-net {
position: absolute;
display: block;
pointer-events: none;
}
.hp-crowd-top { left: calc(var(--u) * 120); top: calc(var(--u) * 18); width: calc(var(--u) * 520); }
.hp-crowd-left { left: calc(var(--u) * 16); top: calc(var(--u) * 108); height: calc(var(--u) * 772); }
.hp-crowd-right { right: calc(var(--u) * 16); top: calc(var(--u) * 108); height: calc(var(--u) * 772); }
.hp-pitch {
position: absolute;
left: calc(var(--u) * 98);
top: calc(var(--u) * 120);
width: calc(var(--u) * 564);
height: calc(var(--u) * 760);
border-radius: calc(var(--u) * 4);
overflow: hidden;
background:
repeating-linear-gradient(to bottom, rgba(255, 255, 255, 0.035) 0 calc(var(--u) * 54), rgba(0, 0, 0, 0.035) 0 calc(var(--u) * 108)),
url("assets/pitch.jpg") center / calc(var(--u) * 380) auto,
var(--hp-green);
box-shadow: inset 0 calc(var(--u) * 4) calc(var(--u) * 10) rgba(0, 0, 0, 0.3);
}
.hp-lines {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
fill: none;
stroke: rgba(255, 255, 255, 0.82);
stroke-width: 3;
}
.hp-lines .hp-spot { fill: rgba(255, 255, 255, 0.82); stroke: none; }
.hp-net,
.hp-frame {
left: calc(var(--u) * 272);
top: calc(var(--u) * 72);
width: calc(var(--u) * 216);
transform-origin: 50% 100%;
}
.hp-frame { opacity: calc(var(--open) * var(--open) * var(--open)); }
.hp-ref {
/* small beside the ball and goal; feet stay at (600, 481) */
left: calc(var(--u) * 569);
top: calc(var(--u) * 353);
width: calc(var(--u) * 62);
filter: drop-shadow(calc(var(--u) * -10) calc(var(--u) * 6) calc(var(--u) * 6) rgba(0, 0, 0, 0.28));
}
/* the VAR monitor rises at the touchline beside the referee (same place and scale as in the review image) */
.hp-monitor {
--m: 0;
left: calc(var(--u) * 622);
top: calc(var(--u) * 330);
width: calc(var(--u) * 62);
transform-origin: 50% 100%;
transform: translateY(calc(var(--u) * 14 * (1 - var(--m)))) scale(calc(0.6 + 0.4 * var(--m)));
opacity: var(--m);
filter: drop-shadow(calc(var(--u) * -8) calc(var(--u) * 6) calc(var(--u) * 6) rgba(0, 0, 0, 0.3));
}
/* the referee turned to watch the monitor, with the monitor itself */
.hp-ref-review {
left: calc(var(--u) * 574);
top: calc(var(--u) * 330);
width: calc(var(--u) * 110);
filter: drop-shadow(calc(var(--u) * -10) calc(var(--u) * 6) calc(var(--u) * 6) rgba(0, 0, 0, 0.28));
}
/* the amber light on top of the monitor: blinks while the review runs */
.hp-beacon {
position: absolute;
left: calc(var(--u) * 644);
top: calc(var(--u) * 331);
width: calc(var(--u) * 16);
height: calc(var(--u) * 16);
border-radius: 50%;
background: radial-gradient(circle, rgba(255, 214, 120, 0.95) 0 22%, rgba(255, 160, 40, 0.55) 45%, rgba(255, 140, 20, 0) 70%);
opacity: 0;
pointer-events: none;
}
/* ── the launcher, ball and elastic ── */
.hp-overlay {
position: absolute;
left: 0;
top: 0;
width: calc(var(--u) * 760);
height: calc(var(--u) * 1090);
overflow: visible;
pointer-events: none;
opacity: var(--open);
}
.hp-band-edge,
.hp-band,
.hp-band-hi {
fill: none;
stroke-linecap: round;
}
.hp-band-edge { stroke: #a2752f; }
.hp-band { stroke: #dcae58; }
.hp-band-hi { stroke: #f4d48c; opacity: 0.8; }
.hp-aim circle { fill: #fff; }
.hp-post {
position: absolute;
top: calc(var(--u) * 768);
width: calc(var(--u) * 40);
opacity: var(--open);
filter: drop-shadow(0 calc(var(--u) * 5) calc(var(--u) * 4) rgba(0, 0, 0, 0.35));
pointer-events: none;
}
.hp-post-l { left: calc(var(--u) * 158); }
.hp-post-r { left: calc(var(--u) * 562); }
/* the leather pouch, behind the ball; turns with the pull around the ball's seat */
.hp-pouch {
position: absolute;
left: 0;
top: 0;
width: calc(var(--u) * 96);
transform-origin: 50% 45%;
opacity: var(--open);
filter: drop-shadow(0 calc(var(--u) * 4) calc(var(--u) * 3) rgba(0, 0, 0, 0.3));
pointer-events: none;
}
.hp-ball,
.hp-ball-shadow,
.hp-sheet {
position: absolute;
left: 0;
top: 0;
width: calc(var(--u) * 42); /* 2 × the ball radius in hidden-pitch.js */
height: calc(var(--u) * 42);
}
.hp-ball {
padding: 0;
border: 0;
border-radius: 50%;
background: none;
cursor: grab;
touch-action: none;
}
.hp-ball img { display: block; width: 100%; height: 100%; pointer-events: none; }
.hp-ball:focus-visible { outline: calc(var(--u) * 3) solid var(--hp-ochre); outline-offset: calc(var(--u) * 4); }
[data-state="aiming"] .hp-ball { cursor: grabbing; }
.hp-ball-shadow {
height: calc(var(--u) * 17);
border-radius: 50%;
background: radial-gradient(closest-side, rgba(0, 0, 0, 0.42), rgba(0, 0, 0, 0));
}
/* the file, folding itself into a ball */
.hp-sheet {
--ballness: 0;
width: calc(var(--u) * 46);
height: calc(var(--u) * 54);
margin: calc(var(--u) * -27) 0 0 calc(var(--u) * -23);
border: calc(var(--u) * 3) solid var(--hp-forest);
background: var(--hp-ivory);
}
.hp-sheet::after {
content: "";
position: absolute;
inset: calc(var(--u) * -4);
border-radius: 50%;
background: url("assets/football.webp") center / cover;
opacity: calc(var(--ballness) * var(--ballness));
}
/* ── VAR / GOAL / RED CARD ── */
.hp-banner {
--in: 0;
position: absolute;
left: calc(var(--u) * 150);
right: calc(var(--u) * 150);
top: calc(var(--u) * 528); /* below the referee and the VAR monitor, which are part of the joke */
display: grid;
justify-items: center;
gap: calc(var(--u) * 8);
padding: calc(var(--u) * 26) calc(var(--u) * 20) calc(var(--u) * 24);
border-radius: calc(var(--u) * 18);
background: rgba(245, 241, 233, 0.96);
box-shadow: 0 calc(var(--u) * 14) calc(var(--u) * 30) rgba(20, 30, 22, 0.28);
text-align: center;
opacity: 0;
transform: scale(0.92);
pointer-events: none;
}
.hp-banner.is-on {
opacity: var(--in);
transform: scale(calc(0.92 + 0.08 * var(--in)));
}
.hp-banner b {
font: 600 calc(var(--u) * 78) / 0.9 var(--hp-sport);
letter-spacing: 0.02em;
color: var(--hp-forest);
}
.hp-banner[data-kind="var"] b { font-size: calc(var(--u) * 64); letter-spacing: 0.06em; }
.hp-banner[data-kind="red"] b { color: var(--hp-red); }
.hp-banner[data-kind="upload"] b { font-size: calc(var(--u) * 56); }
.hp-banner span { font: 500 calc(var(--u) * 19) / 1.3 var(--hp-ui); color: var(--hp-ink); }
.hp-banner em {
font: 600 calc(var(--u) * 12) / 1.3 var(--hp-ui);
font-style: normal;
letter-spacing: 0.18em;
text-transform: uppercase;
color: var(--hp-muted);
}
.hp-banner em:empty { display: none; }
/* the VAR review: a dark pill over the penalty area */
.hp-var {
--in: 0;
position: absolute;
left: calc(var(--u) * 150);
top: calc(var(--u) * 228);
display: flex;
align-items: center;
gap: calc(var(--u) * 16);
padding: calc(var(--u) * 14) calc(var(--u) * 22) calc(var(--u) * 14) calc(var(--u) * 16);
border-radius: calc(var(--u) * 12);
background: rgba(28, 41, 33, 0.95);
box-shadow: 0 calc(var(--u) * 10) calc(var(--u) * 24) rgba(10, 20, 14, 0.35);
color: var(--hp-ivory);
opacity: var(--in);
transform: translateY(calc(var(--u) * -8 * (1 - var(--in))));
pointer-events: none;
}
.hp-var-badge {
padding: calc(var(--u) * 4) calc(var(--u) * 8);
border: calc(var(--u) * 2.5) solid var(--hp-ivory);
border-radius: calc(var(--u) * 4);
font: 600 calc(var(--u) * 26) / 1 var(--hp-sport);
letter-spacing: 0.04em;
}
.hp-var-rule {
width: calc(var(--u) * 1.5);
height: calc(var(--u) * 44);
background: rgba(245, 241, 233, 0.35);
}
.hp-var-text { display: grid; gap: calc(var(--u) * 4); }
.hp-var-text b { font: 600 calc(var(--u) * 26) / 1 var(--hp-sport); letter-spacing: 0.06em; }
.hp-var-text span { font: 500 calc(var(--u) * 16) / 1.2 var(--hp-ui); opacity: 0.9; }
.hp-spin {
position: relative;
width: calc(var(--u) * 30);
height: calc(var(--u) * 30);
margin-left: calc(var(--u) * 18);
}
.hp-spin i {
position: absolute;
left: 50%;
top: 50%;
width: calc(var(--u) * 5.5);
height: calc(var(--u) * 5.5);
margin: calc(var(--u) * -2.75);
border-radius: 50%;
background: var(--hp-ivory);
}
.hp-spin i:nth-child(1) { transform: rotate(0deg) translateY(calc(var(--u) * -12)); }
.hp-spin i:nth-child(2) { transform: rotate(45deg) translateY(calc(var(--u) * -12)); }
.hp-spin i:nth-child(3) { transform: rotate(90deg) translateY(calc(var(--u) * -12)); }
.hp-spin i:nth-child(4) { transform: rotate(135deg) translateY(calc(var(--u) * -12)); }
.hp-spin i:nth-child(5) { transform: rotate(180deg) translateY(calc(var(--u) * -12)); }
.hp-spin i:nth-child(6) { transform: rotate(225deg) translateY(calc(var(--u) * -12)); }
.hp-spin i:nth-child(7) { transform: rotate(270deg) translateY(calc(var(--u) * -12)); }
.hp-spin i:nth-child(8) { transform: rotate(315deg) translateY(calc(var(--u) * -12)); }
/* ── the plain upload field (before a file) ── */
.hp-drop {
position: absolute;
left: calc(var(--u) * 26);
right: calc(var(--u) * 26);
top: calc(var(--u) * 26);
height: calc(var(--u) * 236);
display: grid;
place-content: center;
justify-items: center;
gap: calc(var(--u) * 10);
border: calc(var(--u) * 2) dashed #c9c1b1;
border-radius: calc(var(--u) * 20);
cursor: pointer;
opacity: calc(1 - var(--open) * 3);
}
.has-file .hp-drop { pointer-events: none; }
[data-state="over"] .hp-drop { border-color: var(--hp-forest); border-style: solid; background: var(--hp-sage); }
[data-state="invalid"] .hp-drop { border-color: var(--hp-red); }
.hp-drop-icon {
width: calc(var(--u) * 34);
height: calc(var(--u) * 34);
fill: none;
stroke: var(--hp-forest);
stroke-width: 1.8;
stroke-linecap: round;
stroke-linejoin: round;
}
.hp-drop-title {
margin: 0;
font: 600 calc(var(--u) * 34) / 1 var(--hp-sport);
letter-spacing: 0.02em;
}
.hp-drop-note {
margin: 0;
font: 500 calc(var(--u) * 15) / 1.4 var(--hp-ui);
color: var(--hp-muted);
}
.hp-drop-error {
margin: 0;
min-height: 1.3em;
font: 600 calc(var(--u) * 14) / 1.3 var(--hp-ui);
color: var(--hp-red);
}
.hp-link {
padding: calc(var(--u) * 6) calc(var(--u) * 2);
border: 0;
background: none;
font: 600 calc(var(--u) * 15) / 1.4 var(--hp-ui);
color: var(--hp-forest);
text-decoration: underline;
text-underline-offset: 0.25em;
cursor: pointer;
}
.hp-link:disabled { opacity: 0.35; cursor: default; }
.hp-link:focus-visible,
.hp-go:focus-visible { outline: calc(var(--u) * 3) solid var(--hp-ochre); outline-offset: calc(var(--u) * 3); }
/* ── file bar ── */
.hp-bar {
position: absolute;
left: calc(var(--u) * 26);
right: calc(var(--u) * 26);
bottom: calc(var(--u) * 60);
height: calc(var(--u) * 124);
display: flex;
align-items: center;
gap: calc(var(--u) * 24);
padding: 0 calc(var(--u) * 28) 0 calc(var(--u) * 34);
border: calc(var(--u) * 1.5) solid var(--hp-edge);
border-radius: calc(var(--u) * 22);
background: #f1ece2;
opacity: var(--open);
}
.hp-extra {
position: absolute;
left: 0;
right: 0;
bottom: calc(var(--u) * 18);
margin: 0;
display: flex;
justify-content: center;
gap: calc(var(--u) * 30);
opacity: var(--open);
}
.hp-extra .hp-link { font-size: calc(var(--u) * 13); color: var(--hp-muted); }
.hp:not(.has-file) .hp-bar,
.hp:not(.has-file) .hp-extra { visibility: hidden; }
.hp-file-icon {
width: calc(var(--u) * 40);
flex: none;
fill: none;
stroke: var(--hp-forest);
stroke-width: 1.6;
stroke-linejoin: round;
}
.hp-file {
flex: 1;
min-width: 0;
display: grid;
gap: calc(var(--u) * 10);
}
.hp-name {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font: 500 calc(var(--u) * 28) / 1.1 var(--hp-ui);
letter-spacing: -0.01em;
}
.hp-status {
display: flex;
align-items: center;
gap: calc(var(--u) * 10);
font: 600 calc(var(--u) * 12.5) / 1.2 var(--hp-ui);
letter-spacing: 0.24em;
text-transform: uppercase;
color: #5b6158;
white-space: nowrap;
overflow: hidden;
}
.hp-status i {
flex: none;
width: calc(var(--u) * 11);
height: calc(var(--u) * 11);
border-radius: 50%;
background: #6f9a4f;
}
[data-state="missed"] .hp-status i,
[data-state="failed"] .hp-status i { background: var(--hp-red); }
[data-state="var"] .hp-status i,
[data-state="uploading"] .hp-status i { background: var(--hp-ochre); }
.hp-bar-rule {
width: calc(var(--u) * 1.5);
height: calc(var(--u) * 70);
background: var(--hp-edge);
flex: none;
}
.hp-go {
flex: none;
width: calc(var(--u) * 84);
height: calc(var(--u) * 84);
min-width: 44px;
min-height: 44px;
display: grid;
place-items: center;
border: 0;
border-radius: 50%;
background: var(--hp-forest);
box-shadow: 0 calc(var(--u) * 6) calc(var(--u) * 12) rgba(41, 61, 48, 0.3);
cursor: pointer;
}
.hp-go svg {
width: calc(var(--u) * 34);
fill: none;
stroke: var(--hp-ivory);
stroke-width: 2;
stroke-linecap: round;
stroke-linejoin: round;
}
.hp-go:disabled { opacity: 0.5; cursor: default; }
.hp-progress {
position: absolute;
left: calc(var(--u) * 34);
right: calc(var(--u) * 28);
bottom: calc(var(--u) * 12);
height: calc(var(--u) * 3);
border-radius: calc(var(--u) * 2);
overflow: hidden;
}
.hp-progress i {
display: block;
height: 100%;
background: var(--hp-forest);
transform-origin: 0 50%;
transform: scaleX(0);
}
/* sound on / off, top right of the card */
.hp-mute {
position: absolute;
right: calc(var(--u) * 40);
top: calc(var(--u) * 40);
z-index: 3;
width: max(44px, calc(var(--u) * 52));
height: max(44px, calc(var(--u) * 52));
display: grid;
place-items: center;
border: 0;
border-radius: 50%;
background: rgba(245, 241, 233, 0.92);
box-shadow: 0 calc(var(--u) * 4) calc(var(--u) * 10) rgba(41, 61, 48, 0.2);
cursor: pointer;
opacity: var(--open);
}
.hp:not(.has-file) .hp-mute { visibility: hidden; }
.hp-mute svg {
width: calc(var(--u) * 26);
fill: none;
stroke: var(--hp-forest);
stroke-width: 1.8;
stroke-linecap: round;
stroke-linejoin: round;
}
.hp-mute .hp-mute-body { fill: var(--hp-forest); }
.hp-mute .hp-mute-off,
.hp-mute.is-muted .hp-mute-on { display: none; }
.hp-mute.is-muted .hp-mute-off { display: inline; }
.hp-mute:focus-visible { outline: calc(var(--u) * 3) solid var(--hp-ochre); outline-offset: calc(var(--u) * 3); }
.hp-input {
position: absolute;
width: 1px;
height: 1px;
opacity: 0;
pointer-events: none;
}
.hp-live {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip-path: inset(50%);
} /* Hidden Pitch — usage:
* new HiddenPitch(element, {
* upload: HiddenPitch.xhr('/api/upload'), // or any (file, onProgress, signal) => Promise
* assets: 'assets/', // folder with the kit images
* accept: ['.pdf', 'image/*'], // optional
* maxSize: 25 * 1024 * 1024, // optional, bytes
* onComplete(file, response) {},
* })
*
* An ordinary file upload with a penalty hidden inside. Choose a file and the card unfolds into a tiny
* pitch; the file folds into a football sitting in an elastic launcher. Pull it back and let go:
* score and the upload starts while the referee checks VAR, then GOOOAL! once the transfer really
* finishes. Miss and it's a red card — the file is safe, take another shot, or skip the game entirely.
* The shot never decides the upload: it is only marked complete when the transfer succeeds.
* Keyboard: focus the ball, arrows aim and set power, Enter shoots. The round button shoots too.
*/
(function () {
// Geometry in units of a 760-unit-wide component (see hidden-pitch.css).
const W = 760, H_OPEN = 1090, H_CLOSED = 300;
const BALL = { x: 380, y: 772 }; // ball resting in the launcher
const R = 21; // ball radius
// launcher: where each band leaves its post (upper strand at the top, lower strand at the pin)
const POSTS = [{ top: { x: 197, y: 792 }, pin: { x: 184, y: 812 } }, { top: { x: 563, y: 792 }, pin: { x: 576, y: 812 } }];
const POUCH = { w: 96, h: 47, cx: 0.5, cy: 0.45, tip: 45, tipY: -7 }; // the leather pouch and its band tips
const MAX_PULL = 120;
const GOAL = { line: 134, left: 278, right: 482, depth: 26 };
const PITCH = { left: 98, right: 662, top: 120, stands: 74 };
// choreography (s)
const T = { unfold: 0.5, fold: 0.35, folded: 0.9, net: 0.6, var: 0.45, varMin: 2.2, turn: 0.35, decide: 0.75, red: 0.15, redHold: 1.7, back: 0.25, close: 0.42 };
const reducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)');
const clamp = (v, lo, hi) => Math.max(lo, Math.min(hi, v));
const lerp = (a, b, t) => a + (b - a) * t;
const unit = (t, from, dur) => clamp((t - from) / dur, 0, 1);
const easeOut = (t) => 1 - (1 - t) ** 3;
const easeInOut = (t) => (t < 0.5 ? 4 * t ** 3 : 1 - (-2 * t + 2) ** 3 / 2);
const esc = (s) => String(s).replace(/[&<>"]/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c]));
const sizeText = (b) => (b < 1024 ? `${b} B` : b < 1048576 ? `${Math.round(b / 1024)} KB` : `${(b / 1048576).toFixed(1)} MB`);
const xhr = (url, { field = 'file', headers = {}, method = 'POST' } = {}) => (file, onProgress, signal) =>
new Promise((resolve, reject) => {
const req = new XMLHttpRequest();
req.open(method, url);
Object.entries(headers).forEach(([k, v]) => req.setRequestHeader(k, v));
req.upload.onprogress = (e) => onProgress(e.lengthComputable ? e.loaded / e.total : null);
req.onload = () => (req.status >= 200 && req.status < 300 ? resolve(req.response) : reject(new Error(`Server error ${req.status}`)));
req.onerror = () => reject(new Error('Network error'));
req.onabort = () => reject(Object.assign(new Error('Cancelled'), { name: 'AbortError' }));
signal.addEventListener('abort', () => req.abort());
const body = new FormData();
body.append(field, file);
req.send(body);
});
// Where a shot from the launcher ends up, and how it gets there.
const plan = (dir, power) => {
const range = 160 + 700 * power;
const at = (t) => ({ x: BALL.x + dir.x * t, y: BALL.y + dir.y * t });
const tSide = dir.x > 0 ? (PITCH.right - BALL.x) / dir.x : dir.x < 0 ? (PITCH.left - BALL.x) / dir.x : Infinity;
const tGoal = dir.y < 0 ? (GOAL.line - BALL.y) / dir.y : Infinity;
if (tSide < Math.min(tGoal, range)) return { outcome: 'stands', to: at(tSide + 18), dist: tSide };
if (tGoal <= range) {
const x = at(tGoal).x;
const nearPost = [GOAL.left, GOAL.right].find((p) => Math.abs(x - p) < R * 0.75);
if (nearPost !== undefined) {
const contact = at(tGoal - 4);
return { outcome: 'post', to: contact, bounce: { x: contact.x + Math.sign(x - nearPost || 1) * 70, y: contact.y + 150 }, dist: tGoal };
}
if (x > GOAL.left && x < GOAL.right) {
const end = at(tGoal + GOAL.depth / Math.abs(dir.y));
return { outcome: 'goal', to: { x: end.x, y: Math.max(end.y, GOAL.line - GOAL.depth) }, cross: tGoal / (tGoal + GOAL.depth), dist: tGoal };
}
const tStand = (PITCH.stands - BALL.y) / dir.y;
return { outcome: 'stands', to: at(tStand), dist: tStand };
}
return { outcome: 'short', to: at(range), dist: range };
};
const MISS_LINE = {
stands: "That one's going into the stands.",
post: 'Off the post! So close.',
short: 'Not quite enough power.',
};
/* ── sound: a match-day loop, the crowd, the referee's whistle, VAR, and the ball itself ──
* Everything is generated with Web Audio. Methods take a context time `t`, so an OfflineAudioContext
* can render the same sounds (the reel bounces its audio this way).
*/
const hz = (m) => 440 * 2 ** ((m - 69) / 12);
const MUSIC = { bpm: 112, chords: [[62, 66, 69], [57, 61, 64], [59, 62, 66], [55, 59, 62]] }; // D A Bm G
class Sound {
constructor(ctx = new (window.AudioContext || window.webkitAudioContext)()) {
this.ctx = ctx;
this.master = ctx.createGain();
const comp = ctx.createDynamicsCompressor();
this.master.connect(comp).connect(ctx.destination);
this.musicBus = ctx.createGain();
this.musicBus.gain.value = 0;
this.musicBus.connect(this.master);
this.noise = ctx.createBuffer(1, ctx.sampleRate * 2, ctx.sampleRate);
const d = this.noise.getChannelData(0);
for (let i = 0; i < d.length; i++) d[i] = Math.random() * 2 - 1;
this.timer = null;
this.crowdOn = false;
}
get now() {
return this.ctx.currentTime;
}
resume() {
if (this.ctx.state === 'suspended') this.ctx.resume().catch(() => {});
}
setMuted(m) {
this.master.gain.setTargetAtTime(m ? 0 : 1, this.now, 0.03);
}
src(type, freq, q = 1, loop = false) {
const s = this.ctx.createBufferSource(), f = this.ctx.createBiquadFilter();
s.buffer = this.noise;
s.loop = loop;
f.type = type;
f.frequency.value = freq;
f.Q.value = q;
s.connect(f);
return { s, f };
}
hit(t, type, freq, vol, decay, q = 1) {
const { s, f } = this.src(type, freq, q), g = this.ctx.createGain();
g.gain.setValueAtTime(vol, t);
g.gain.exponentialRampToValueAtTime(0.0001, t + decay);
f.connect(g).connect(this.master);
s.start(t, Math.random());
s.stop(t + decay + 0.05);
}
tone(t, type, f0, f1, vol, dur, dest = this.master) {
const o = this.ctx.createOscillator(), g = this.ctx.createGain();
o.type = type;
o.frequency.setValueAtTime(f0, t);
if (f1 !== f0) o.frequency.exponentialRampToValueAtTime(f1, t + dur);
g.gain.setValueAtTime(0.0001, t);
g.gain.exponentialRampToValueAtTime(vol, t + 0.008);
g.gain.exponentialRampToValueAtTime(0.0001, t + dur);
o.connect(g).connect(dest);
o.start(t);
o.stop(t + dur + 0.05);
}
/* the crowd: a low murmur, chatter that comes and goes, and a roar for big moments */
crowd(t = this.now) {
if (this.crowdOn) return;
this.crowdOn = true;
const { ctx } = this;
const mur = this.src('bandpass', 420, 0.6, true);
this.murmur = ctx.createGain();
this.murmur.gain.setValueAtTime(0, t);
this.murmur.gain.linearRampToValueAtTime(0.09, t + 1.2);
mur.f.connect(this.murmur).connect(this.master);
const chat = this.src('bandpass', 1300, 1.8, true), cg = ctx.createGain(), lfo = ctx.createOscillator(), depth = ctx.createGain();
cg.gain.value = 0.035;
lfo.frequency.value = 0.6;
depth.gain.value = 0.025;
lfo.connect(depth).connect(cg.gain);
chat.f.connect(cg).connect(this.master);
const roar = this.src('bandpass', 950, 0.5, true);
this.roarGain = ctx.createGain();
this.roarGain.gain.value = 0;
roar.f.connect(this.roarGain).connect(this.master);
[mur.s, chat.s, roar.s].forEach((s) => s.start(t, Math.random()));
lfo.start(t);
this.crowdNodes = [mur.s, chat.s, roar.s, lfo];
}
// anticipation: the murmur rises as the ball is pulled back
tension(p, t = this.now) {
this.murmur?.gain.setTargetAtTime(0.09 + 0.12 * p, t, 0.15);
}
roar(t, peak, hold) {
const g = this.roarGain?.gain;
if (!g) return;
g.cancelScheduledValues(t);
g.setValueAtTime(g.value, t);
g.linearRampToValueAtTime(peak, t + 0.18);
g.setTargetAtTime(0, t + 0.18 + hold, 0.9);
this.tension(0, t);
}
// the "ooooh" of a miss: a falling, vowel-ish swell
groan(t) {
[[700, 330], [1150, 620]].forEach(([a, b], i) => {
const { s, f } = this.src('bandpass', a, 4, true), g = this.ctx.createGain();
f.frequency.setValueAtTime(a, t);
f.frequency.exponentialRampToValueAtTime(b, t + 1.1);
g.gain.setValueAtTime(0.0001, t);
g.gain.linearRampToValueAtTime(i ? 0.16 : 0.32, t + 0.25);
g.gain.exponentialRampToValueAtTime(0.0001, t + 1.5);
f.connect(g).connect(this.master);
s.start(t, Math.random());
s.stop(t + 1.6);
});
this.tension(0, t);
}
/* the referee's whistle: two close tones and the pea's fast trill */
whistle(t, dur = 0.4, vol = 0.16) {
const { ctx } = this;
const g = ctx.createGain(), trill = ctx.createOscillator(), depth = ctx.createGain();
trill.frequency.value = 29;
depth.gain.value = 140;
trill.connect(depth);
g.gain.setValueAtTime(0.0001, t);
g.gain.exponentialRampToValueAtTime(vol, t + 0.02);
g.gain.setValueAtTime(vol, t + dur - 0.06);
g.gain.exponentialRampToValueAtTime(0.0001, t + dur);
g.connect(this.master);
[2850, 3050].forEach((f) => {
const o = ctx.createOscillator();
o.frequency.value = f;
depth.connect(o.frequency);
o.connect(g);
o.start(t);
o.stop(t + dur + 0.05);
});
const breath = this.src('bandpass', 3000, 2);
const bg = ctx.createGain();
bg.gain.setValueAtTime(vol * 0.25, t);
bg.gain.exponentialRampToValueAtTime(0.0001, t + dur);
breath.f.connect(bg).connect(this.master);
breath.s.start(t);
breath.s.stop(t + dur + 0.05);
trill.start(t);
trill.stop(t + dur + 0.05);
}
/* VAR: soft digital scanning while the footage is checked */
varStart(t = this.now) {
this.varStop(t);
this.musicBus.gain.setTargetAtTime(0.03, t, 0.2); // the music steps back for the review
this.tone(t, 'sine', 520, 1040, 0.08, 0.35); // the monitor powering up
this.varStep = 0;
this.varNext = t + 0.3;
this.varTimer = setInterval(() => this.varSchedule(), 40);
}
varSchedule(until = this.now + 0.15) {
while (this.varNext < until) {
const hi = this.varStep % 4 === 3;
this.tone(this.varNext, 'square', hi ? 1320 : 880, hi ? 1320 : 880, 0.05, 0.06);
this.hit(this.varNext, 'highpass', 6000, 0.03, 0.02);
this.varStep++;
this.varNext += 0.22;
}
}
varStop(t = this.now) {
clearInterval(this.varTimer);
this.varTimer = null;
this.musicBus.gain.setTargetAtTime(this.musicLevel || 0, t, 0.3);
}
// the decision: a rising two-note chime
verdict(t) {
this.tone(t, 'sine', 784, 784, 0.12, 0.35);
this.tone(t + 0.14, 'sine', 1175, 1175, 0.12, 0.6);
}
/* the ball and the launcher */
kick(t) {
this.tone(t, 'sine', 150, 55, 0.5, 0.14);
this.hit(t, 'bandpass', 1600, 0.35, 0.05, 1.2);
}
twang(t) {
this.tone(t, 'triangle', 210, 120, 0.14, 0.22);
this.tone(t, 'sine', 420, 260, 0.05, 0.16);
}
stretch(p, t = this.now) {
if (p - (this.lastStretch || 0) < 0.12) return; // a soft creak every so often as the band stretches
this.lastStretch = p;
this.tone(t, 'sawtooth', 160 + p * 260, 140 + p * 240, 0.012, 0.08);
}
swish(t) {
this.hit(t, 'highpass', 2500, 0.22, 0.35, 0.7);
this.hit(t, 'bandpass', 700, 0.12, 0.2, 0.8);
}
clang(t) {
[523, 1347, 2210, 3020].forEach((f, i) => this.tone(t, 'sine', f, f * 0.995, 0.11 / (i + 1), 0.9 - i * 0.15));
this.hit(t, 'bandpass', 3200, 0.15, 0.03);
}
whoosh(t, dur = 0.5) {
const { s, f } = this.src('bandpass', 400, 1.2), g = this.ctx.createGain();
f.frequency.setValueAtTime(400, t);
f.frequency.exponentialRampToValueAtTime(1800, t + dur);
g.gain.setValueAtTime(0.0001, t);
g.gain.linearRampToValueAtTime(0.12, t + dur * 0.4);
g.gain.exponentialRampToValueAtTime(0.0001, t + dur);
f.connect(g).connect(this.master);
s.start(t, Math.random());
s.stop(t + dur + 0.05);
}
/* match-day music: bright offbeat chords, bass, and light drums */
music(t = this.now, level = 0.11) {
this.musicLevel = level;
this.musicBus.gain.cancelScheduledValues(t);
this.musicBus.gain.setValueAtTime(this.musicBus.gain.value, t);
this.musicBus.gain.linearRampToValueAtTime(level, t + 1);
if (this.timer || this.offline) return; // offline (bouncing), the caller schedules the steps
this.step = 0;
this.next = t + 0.05;
this.timer = setInterval(() => this.schedule(), 25);
}
musicStop(t = this.now) {
this.musicLevel = 0;
this.musicBus.gain.setTargetAtTime(0, t, 0.4);
clearInterval(this.timer);
this.timer = null;
}
get stepDur() {
return 60 / MUSIC.bpm / 4;
}
schedule(until = this.now + 0.12) {
while (this.next < until) {
this.playStep(this.step, this.next);
this.step++;
this.next += this.stepDur;
}
}
playStep(step, t) {
const s = step % 16, chord = MUSIC.chords[Math.floor(step / 16) % 4];
const bus = this.musicBus;
if (s % 4 === 0) this.tone(t, 'sine', 120, 45, 0.55, 0.16, bus); // kick
if (s === 4 || s === 12) { // clap
const { s: n, f } = this.src('bandpass', 1500, 0.9), g = this.ctx.createGain();
g.gain.setValueAtTime(0.28, t);
g.gain.exponentialRampToValueAtTime(0.0001, t + 0.12);
f.connect(g).connect(bus);
n.start(t, Math.random());
n.stop(t + 0.15);
}
if (s % 2 === 1) { // hat
const { s: n, f } = this.src('highpass', 8000, 0.7), g = this.ctx.createGain();
g.gain.setValueAtTime(0.06, t);
g.gain.exponentialRampToValueAtTime(0.0001, t + 0.04);
f.connect(g).connect(bus);
n.start(t, Math.random());
n.stop(t + 0.06);
}
if (s % 2 === 0) this.tone(t, 'triangle', hz(chord[0] - 24), hz(chord[0] - 24), 0.22, this.stepDur * 1.6, bus); // bass
if (s % 4 === 2) chord.forEach((m) => this.tone(t, 'sawtooth', hz(m), hz(m), 0.035, 0.16, bus)); // offbeat stabs
if (s === 0 || s === 10) chord.forEach((m) => this.tone(t, 'square', hz(m + 12), hz(m + 12), 0.012, 0.3, bus)); // sparkle
}
stop() {
this.musicStop();
this.varStop();
this.crowdNodes?.forEach((n) => n.stop());
this.crowdNodes = null;
this.crowdOn = false;
}
}
class HiddenPitch {
constructor(root, { upload, assets = 'assets/', accept = [], maxSize = Infinity, sound = true, onComplete, onError } = {}) {
this.root = root;
this.upload = upload;
this.assets = assets.replace(/\/?$/, '/');
this.accept = accept;
this.maxSize = maxSize;
this.onComplete = onComplete;
this.onError = onError;
this.soundOn = sound;
try {
this.muted = localStorage.getItem('hp-muted') === '1';
} catch {
this.muted = false;
}
this.build();
this.reset();
this.render(this.clock());
}
// Everything shown comes from this state and a timestamp.
reset() {
this.state = 'empty'; // empty | over | invalid | opening | ready | aiming | flight | var | complete | missed | uploading | failed | closing
this.since = 0;
this.file = null;
this.aim = { angle: 0, power: 0.72 }; // keyboard / button aim
this.pull = null; // live drag: { x, y } offset of the ball from rest
this.release = null; // { at, from } — bands snapping back
this.shot = null;
this.progress = 0;
this.uploaded = false;
this.uploadedAt = 0;
this.goalAt = 0;
this.run = 0;
this.error = '';
}
clock() {
return performance.now() / 1000;
}
img(name) {
return `${this.assets}${name}`;
}
build() {
this.root.classList.add('hp');
const accept = this.accept.join(',');
this.root.innerHTML = `
<div class="hp-card">
<div class="hp-stadium" aria-hidden="true">
<img class="hp-crowd-top" src="${this.img('crowd-top.webp')}" alt="">
<img class="hp-crowd-left" src="${this.img('crowd-left.webp')}" alt="">
<img class="hp-crowd-right" src="${this.img('crowd-right.webp')}" alt="">
<div class="hp-pitch">
<svg class="hp-lines" viewBox="0 0 564 760" preserveAspectRatio="none">
<rect x="12" y="14" width="540" height="734" />
<rect x="112" y="14" width="340" height="176" />
<rect x="197" y="14" width="170" height="66" />
<circle cx="282" cy="150" r="3.5" class="hp-spot" />
<line x1="12" y1="548" x2="552" y2="548" />
<path d="M190 548 A92 92 0 0 0 374 548" />
</svg>
</div>
<img class="hp-net" src="${this.img('goal-net.webp')}" alt="">
<img class="hp-monitor" src="${this.img('var-monitor.webp')}" alt="">
<img class="hp-ref hp-ref-neutral" src="${this.img('referee-neutral.webp')}" alt="">
<img class="hp-ref hp-ref-var" src="${this.img('referee-var.webp')}" alt="">
<img class="hp-ref hp-ref-red" src="${this.img('referee-red-card.webp')}" alt="">
<img class="hp-ref-review" src="${this.img('var-review.webp')}" alt="">
<i class="hp-beacon"></i>
</div>
<svg class="hp-overlay" viewBox="0 0 ${W} ${H_OPEN}" aria-hidden="true">
<g class="hp-aim"></g>
<g class="hp-bands"></g>
</svg>
<img class="hp-post hp-post-l" src="${this.img('post-left.webp')}" alt="" aria-hidden="true">
<img class="hp-post hp-post-r" src="${this.img('post-right.webp')}" alt="" aria-hidden="true">
<img class="hp-pouch" src="${this.img('pouch.webp')}" alt="" aria-hidden="true">
<div class="hp-ball-shadow" aria-hidden="true"></div>
<button class="hp-ball" type="button" aria-label="Football. Arrow keys aim and set power, Enter shoots."><img src="${this.img('football.webp')}" alt="" draggable="false"></button>
<img class="hp-frame" src="${this.img('goal-frame.webp')}" alt="" aria-hidden="true">
<div class="hp-sheet" aria-hidden="true"></div>
<div class="hp-banner" aria-hidden="true"><b></b><span></span><em></em></div>
<div class="hp-var" aria-hidden="true">
<span class="hp-var-badge">VAR</span><i class="hp-var-rule"></i>
<span class="hp-var-text"><b>VAR CHECK</b><span>Checking the goal…</span></span>
<span class="hp-spin">${'<i></i>'.repeat(8)}</span>
</div>
<div class="hp-drop">
<svg class="hp-drop-icon" viewBox="0 0 24 24" aria-hidden="true"><path d="M12 16V4m0 0-5 5m5-5 5 5M4 15v4a1 1 0 0 0 1 1h14a1 1 0 0 0 1-1v-4" /></svg>
<p class="hp-drop-title">Drop a file to take the penalty</p>
<p class="hp-drop-note"><button type="button" class="hp-link" data-act="browse">Browse files</button><span class="hp-drop-rules"></span></p>
<p class="hp-drop-error" role="alert"></p>
</div>
<div class="hp-bar">
<svg class="hp-file-icon" viewBox="0 0 24 30" aria-hidden="true"><path d="M3 2h12l6 6v19a1 1 0 0 1-1 1H3a1 1 0 0 1-1-1V3a1 1 0 0 1 1-1Zm12 0v6h6" /></svg>
<div class="hp-file">
<span class="hp-name"></span>
<span class="hp-status"><i></i><span></span></span>
</div>
<span class="hp-bar-rule"></span>
<button class="hp-go" type="button"><svg viewBox="0 0 24 24" aria-hidden="true"><path class="hp-go-icon" d="M12 19V5m0 0-6 6m6-6 6 6" /></svg></button>
<span class="hp-progress"><i></i></span>
</div>
<p class="hp-extra">
<button type="button" class="hp-link" data-act="skip">Skip the shot — upload directly</button>
<button type="button" class="hp-link" data-act="replace">Replace file</button>
</p>
<button class="hp-mute" type="button" aria-pressed="false" aria-label="Mute sound">
<svg viewBox="0 0 24 24" aria-hidden="true"><path class="hp-mute-body" d="M4 9h4l5-4v14l-5-4H4z" /><path class="hp-mute-on" d="M16.5 8.5a5 5 0 0 1 0 7M19 6a8.5 8.5 0 0 1 0 12" /><path class="hp-mute-off" d="m16 9 6 6m0-6-6 6" /></svg>
</button>
<input class="hp-input" type="file" ${accept ? `accept="${esc(accept)}"` : ''} tabindex="-1" aria-hidden="true">
<p class="hp-live" aria-live="polite"></p>
</div>`;
const q = (s) => this.root.querySelector(s);
this.card = q('.hp-card');
this.stadium = q('.hp-stadium');
this.ballEl = q('.hp-ball');
this.shadowEl = q('.hp-ball-shadow');
this.bands = q('.hp-bands');
this.pouchEl = q('.hp-pouch');
this.varEl = q('.hp-var');
this.spin = [...this.root.querySelectorAll('.hp-spin i')];
this.beacon = q('.hp-beacon');
this.aimEl = q('.hp-aim');
this.net = q('.hp-net');
this.sheet = q('.hp-sheet');
this.monitor = q('.hp-monitor');
this.refs = { neutral: q('.hp-ref-neutral'), var: q('.hp-ref-var'), red: q('.hp-ref-red'), review: q('.hp-ref-review') };
this.banner = q('.hp-banner');
this.drop = q('.hp-drop');
this.dropError = q('.hp-drop-error');
this.nameEl = q('.hp-name');
this.statusEl = q('.hp-status span');
this.go = q('.hp-go');
this.goIcon = q('.hp-go-icon');
this.progressEl = q('.hp-progress i');
this.skipBtn = q('[data-act="skip"]');
this.input = q('.hp-input');
this.live = q('.hp-live');
q('.hp-drop-rules').textContent = this.rules();
this.input.addEventListener('change', () => {
if (this.input.files[0]) this.take(this.input.files[0]);
this.input.value = '';
});
this.root.addEventListener('click', (e) => {
const act = e.target.closest('[data-act]')?.dataset.act;
if (act === 'browse' || act === 'replace') this.input.click();
if (act === 'skip') this.skip();
});
this.drop.addEventListener('click', (e) => {
if (!e.target.closest('button')) this.input.click();
});
this.go.addEventListener('click', () => this.goAction());
this.muteBtn = q('.hp-mute');
this.muteBtn.addEventListener('click', () => {
this.sound();
this.setMuted(!this.muted);
});
this.setMuted(this.muted);
// drag and drop onto the card
let depth = 0;
this.card.addEventListener('dragenter', (e) => {
if (!e.dataTransfer?.types.includes('Files') || !this.canTake()) return;
e.preventDefault();
if (depth++ === 0 && this.state !== 'ready') { this.before = this.state; this.set('over'); }
});
this.card.addEventListener('dragover', (e) => {
if (!e.dataTransfer?.types.includes('Files')) return;
e.preventDefault();
});
this.card.addEventListener('dragleave', () => {
if (--depth === 0 && this.state === 'over') this.set(this.before || 'empty');
});
this.card.addEventListener('drop', (e) => {
e.preventDefault();
depth = 0;
const file = e.dataTransfer.files[0];
if (file && this.canTake()) this.take(file);
else if (this.state === 'over') this.set(this.before || 'empty');
});
// the shot: press the ball, pull back against the elastic, let go
const unitsAt = (e) => {
const r = this.card.getBoundingClientRect();
const k = W / r.width;
return { x: (e.clientX - r.left) * k, y: (e.clientY - r.top) * k };
};
this.ballEl.addEventListener('pointerdown', (e) => {
if (this.state !== 'ready' || e.button !== 0) return;
e.preventDefault();
this.ballEl.setPointerCapture(e.pointerId);
const p = unitsAt(e);
this.grab = { x: p.x - BALL.x, y: p.y - BALL.y };
this.keyAim = false;
this.sound();
this.startAim({ x: 0, y: 0 });
});
this.ballEl.addEventListener('pointermove', (e) => {
if (this.state !== 'aiming' || !this.ballEl.hasPointerCapture(e.pointerId)) return;
const p = unitsAt(e);
this.moveAim({ x: p.x - BALL.x - this.grab.x, y: p.y - BALL.y - this.grab.y });
});
const letGo = () => this.state === 'aiming' && this.shoot();
this.ballEl.addEventListener('pointerup', letGo);
this.ballEl.addEventListener('pointercancel', () => this.state === 'aiming' && this.cancelAim());
this.ballEl.addEventListener('keydown', (e) => {
if (this.state !== 'ready' && this.state !== 'aiming') return;
const k = { ArrowLeft: [-3, 0], ArrowRight: [3, 0], ArrowUp: [0, 0.05], ArrowDown: [0, -0.05] }[e.key];
if (k) {
e.preventDefault();
this.aim.angle = clamp(this.aim.angle + k[0], -40, 40);
this.aim.power = clamp(this.aim.power + k[1], 0.15, 1);
this.keyAim = true;
this.sound();
this.showAim();
} else if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
this.shootAim();
} else if (e.key === 'Escape' && this.state === 'aiming') this.cancelAim();
});
}
rules() {
const parts = [];
if (this.accept.length) parts.push(this.accept.map((a) => a.replace(/^\./, '').replace('/*', '').toUpperCase()).join(', '));
if (Number.isFinite(this.maxSize)) parts.push(`up to ${sizeText(this.maxSize)}`);
return parts.length ? ` · ${parts.join(' · ')}` : '';
}
canTake() {
return ['empty', 'over', 'invalid', 'ready', 'missed', 'complete', 'failed'].includes(this.state);
}
set(state, now = this.clock()) {
const prev = this.state;
this.state = state;
this.since = now;
if (prev !== state) this.cue(prev, state);
this.loop();
}
/* ── sound cues ── */
// created on the first user gesture (browsers only allow audio after one)
sound() {
if (!this.soundOn) return null;
if (!this.audio) {
try {
this.audio = new Sound();
this.audio.setMuted(this.muted);
} catch {
this.soundOn = false;
return null;
}
}
this.audio.resume();
return this.audio;
}
cue(prev, state) {
const a = this.audio;
if (!a) return;
const t = a.now;
if (state === 'opening') {
a.whoosh(t, 0.5);
a.whoosh(t + T.fold, 0.4);
a.crowd(t);
a.music(t);
}
if (state === 'ready' && prev === 'opening') a.whistle(t + 0.1, 0.22, 0.12); // ball placed: a short peep
if (state === 'flight') {
const s = this.shot;
a.twang(t);
a.kick(t + 0.01);
if (s.outcome === 'goal') a.swish(t + s.dur * s.cross);
if (s.outcome === 'post') a.clang(t + s.dur * 0.75);
if (s.outcome === 'stands') a.roar(t + s.dur, 0.08, 0.3);
}
if (state === 'var') {
a.roar(t, 0.22, 0.4); // the crowd thinks it's in…
a.varStart(t + 0.35); // …then everyone waits for the review
a.tension(0.6, t + 1);
}
if (state === 'complete') {
a.varStop(t);
if (prev === 'var') {
a.verdict(t); // the decision is in…
a.whistle(t + T.decide - 0.1, 0.9, 0.16); // …the referee gives it
a.roar(t + T.decide - 0.05, 0.42, 1.4);
} else a.verdict(t);
}
if (state === 'missed') {
a.groan(t);
a.whistle(t + T.red, 0.18, 0.15);
a.whistle(t + T.red + 0.26, 0.42, 0.15);
}
if (state === 'failed') {
a.varStop(t);
a.tone(t, 'sine', 392, 330, 0.08, 0.25);
a.tone(t + 0.22, 'sine', 330, 262, 0.08, 0.4);
}
if (state === 'empty') a.stop();
}
setMuted(m) {
this.muted = m;
try {
localStorage.setItem('hp-muted', m ? '1' : '0');
} catch {}
this.audio?.setMuted(m);
this.muteBtn.setAttribute('aria-pressed', String(m));
this.muteBtn.setAttribute('aria-label', m ? 'Unmute sound' : 'Mute sound');
this.muteBtn.classList.toggle('is-muted', m);
}
say(text) {
this.live.textContent = text;
}
/* ── the file ── */
take(file, now = this.clock()) {
this.sound();
const problem = this.check(file);
if (problem) {
this.error = problem;
if (!this.file) this.set('invalid', now);
else this.set(this.state === 'over' ? 'ready' : this.state, now);
this.dropError.textContent = problem;
this.say(problem);
return;
}
this.abort?.abort();
this.run++;
const wasOpen = !!this.file;
this.file = file;
this.uploaded = false;
this.progress = 0;
this.shot = null;
this.pull = null;
this.dropError.textContent = '';
this.nameEl.textContent = file.name;
this.nameEl.title = file.name;
this.set(wasOpen ? 'ready' : 'opening', now);
this.say(`${file.name} ready. Pull the ball back and release to shoot, or skip the shot to upload directly.`);
}
check(file) {
if (file.size > this.maxSize) return `That file is over ${sizeText(this.maxSize)}.`;
if (!this.accept.length) return null;
const name = file.name.toLowerCase(), type = (file.type || '').toLowerCase();
const ok = this.accept.some((a) => {
a = a.trim().toLowerCase();
if (a.startsWith('.')) return name.endsWith(a);
if (a.endsWith('/*')) return type.startsWith(a.slice(0, -1));
return type === a;
});
return ok ? null : `Only ${this.rules().replace(/^ · /, '')} files can be uploaded.`;
}
/* ── aiming and shooting ── */
startAim(pull, now = this.clock()) {
this.pull = pull;
this.set('aiming', now);
}
// stretch with growing resistance up to a firm maximum
moveAim(raw) {
const len = Math.hypot(raw.x, raw.y);
const k = len ? (MAX_PULL * Math.tanh(len / MAX_PULL)) / len : 0;
this.pull = { x: raw.x * k, y: raw.y * k };
const p = Math.hypot(this.pull.x, this.pull.y) / MAX_PULL;
this.audio?.tension(p);
this.audio?.stretch(p);
this.loop();
}
cancelAim(now = this.clock()) {
this.audio?.tension(0);
if (this.audio) this.audio.lastStretch = 0;
this.release = { at: now, from: this.pull };
this.pull = null;
this.set('ready', now);
}
// keyboard aim, shown as a pull so the elastic and the aiming line read the same way
showAim(now = this.clock()) {
const a = (this.aim.angle * Math.PI) / 180;
this.pull = { x: -Math.sin(a) * MAX_PULL * this.aim.power, y: Math.cos(a) * MAX_PULL * this.aim.power };
this.audio?.tension(this.aim.power);
if (this.state !== 'aiming') this.set('aiming', now);
else this.loop();
}
shootAim(now = this.clock()) {
if (this.state === 'ready') {
const a = (this.aim.angle * Math.PI) / 180;
this.pull = { x: -Math.sin(a) * MAX_PULL * this.aim.power, y: Math.cos(a) * MAX_PULL * this.aim.power };
}
this.shoot(now);
}
shoot(now = this.clock()) {
const p = this.pull;
const len = p ? Math.hypot(p.x, p.y) : 0;
if (len < MAX_PULL * 0.08) {
this.cancelAim(now);
return;
}
const dir = { x: -p.x / len, y: -p.y / len };
const power = len / MAX_PULL;
const s = plan(dir, power);
const dist = Math.hypot(s.to.x - BALL.x, s.to.y - BALL.y);
this.shot = { ...s, at: now, dur: reducedMotion.matches ? 0 : 0.35 + 0.3 * Math.min(1, dist / 700), height: 0.2 + 0.35 * power };
this.release = { at: now, from: p };
this.pull = null;
if (this.audio) this.audio.lastStretch = 0;
this.set('flight', now);
this.say(s.outcome === 'goal' ? 'Goal! Checking VAR while your file uploads.' : `Missed. ${MISS_LINE[s.outcome]} Your file is safe — take another shot.`);
}
// the round button: shoot, retry a failed upload, or pick another file once done
goAction() {
this.sound();
if (this.state === 'ready') this.shootAim();
else if (this.state === 'failed') this.startUpload();
else if (this.state === 'complete') this.input.click();
}
skip(now = this.clock()) {
this.sound();
if (!['ready', 'missed', 'aiming'].includes(this.state)) return;
this.pull = null;
this.skipped = true;
this.set('uploading', now);
this.startUpload(now);
}
/* ── the real upload ── */
startUpload(now = this.clock()) {
const file = this.file;
const run = ++this.run;
this.progress = 0;
this.uploaded = false;
this.error = '';
if (this.state === 'failed') this.set(this.skipped ? 'uploading' : 'var', now);
this.abort = new AbortController();
if (!this.upload) {
this.fail(new Error('No uploader set'), run);
return;
}
Promise.resolve()
.then(() => this.upload(file, (p) => this.report(run, p), this.abort.signal))
.then((response) => this.finish(run, response))
.catch((err) => this.fail(err, run));
}
report(run, p) {
if (run !== this.run) return;
this.progress = p === null || p === undefined ? null : clamp(p, 0, 1);
this.loop();
}
finish(run, response, now = this.clock()) {
if (run !== this.run) return;
this.progress = 1;
this.uploaded = true;
this.uploadedAt = now;
this.response = response;
this.loop();
}
fail(err, run) {
if (run !== this.run || err?.name === 'AbortError') return;
this.error = err?.message || 'Upload failed';
this.set('failed');
this.say(`Upload failed: ${this.error}. Your goal still stands — retry the upload.`);
this.onError?.(this.file, err);
}
/* ── per frame ── */
ballAt(now) {
const s = this.shot;
if (!s || this.state === 'ready' || this.state === 'aiming') {
const p = this.pull || this.releasePull(now);
return { x: BALL.x + p.x, y: BALL.y + p.y, z: 0, spin: 0, on: true };
}
const t = now - s.at;
if (s.outcome === 'post') {
const t1 = s.dur * 0.75;
if (t < t1) {
const k = 1 - (1 - t / t1) ** 2;
return { x: lerp(BALL.x, s.to.x, k), y: lerp(BALL.y, s.to.y, k), z: Math.sin(Math.PI * k) * s.height, spin: k * 540, on: true };
}
const k = easeOut(unit(t, t1, 0.4));
return { x: lerp(s.to.x, s.bounce.x, k), y: lerp(s.to.y, s.bounce.y, k), z: Math.sin(Math.PI * k) * 0.08, spin: 540 + k * 260, on: true };
}
const k = s.dur ? 1 - (1 - clamp(t / s.dur, 0, 1)) ** 2 : 1;
const into = s.outcome === 'stands' ? unit(t, s.dur, 0.18) : 0; // lost among the crowd
return {
x: lerp(BALL.x, s.to.x, k), y: lerp(BALL.y, s.to.y, k),
z: Math.sin(Math.PI * Math.min(1, k)) * (s.outcome === 'short' ? s.height * 0.5 : s.height),
spin: k * (300 + s.dist), on: into < 1, fade: into,
};
}
// after a release the elastic snaps back with one small overshoot
releasePull(now) {
const r = this.release;
if (!r) return { x: 0, y: 0 };
const t = (now - r.at) / 0.16;
if (t >= 1.6) return { x: 0, y: 0 };
const k = t < 1 ? 1 - easeOut(t) : -0.06 * Math.sin(Math.PI * (t - 1) / 0.6);
return { x: r.from.x * k, y: r.from.y * k };
}
tick(now) {
const t = now - this.since;
const s = this.shot;
if (this.state === 'opening' && t >= (reducedMotion.matches ? 0 : T.folded)) this.set('ready', now);
if (this.state === 'flight' && now - s.at >= s.dur + (s.outcome === 'post' ? 0.4 : 0)) {
if (s.outcome === 'goal') {
this.goalAt = now;
this.skipped = false;
this.set('var', now);
this.startUpload(now);
} else this.set('missed', now);
}
if (this.state === 'var' && this.uploaded && t >= T.var + T.varMin) this.complete(now);
if (this.state === 'uploading' && this.uploaded) this.complete(now);
if (this.state === 'missed' && t >= T.red + T.redHold) {
this.shot = null;
this.release = null;
this.set('ready', now);
}
}
complete(now) {
this.fromVar = this.state === 'var'; // after a review, the referee signals the decision first
this.set('complete', now);
this.say(`${this.file.name} uploaded.`);
this.onComplete?.(this.file, this.response);
}
render(now) {
const s = this.state, t = now - this.since, rm = reducedMotion.matches;
this.root.dataset.state = s;
// the card unfolds into the stadium
const open = !this.file ? 0 : s === 'opening' ? (rm ? 1 : easeOut(unit(t, 0, T.unfold))) : 1;
this.card.style.setProperty('--h', lerp(H_CLOSED, H_OPEN, open).toFixed(1));
this.card.style.setProperty('--open', open.toFixed(3));
this.root.classList.toggle('has-file', !!this.file);
// the file folds into the ball
const fold = s === 'opening' && !rm ? unit(t, T.fold, T.folded - T.fold) : this.file ? 1 : 0;
this.sheet.style.visibility = fold > 0 && fold < 1 ? '' : 'hidden';
if (fold > 0 && fold < 1) {
const k = easeInOut(fold);
const x = lerp(84, BALL.x, k), y = lerp(970, BALL.y, k);
this.sheet.style.transform = `translate(calc(var(--u) * ${x.toFixed(1)}), calc(var(--u) * ${y.toFixed(1)})) rotate(${(k * 200).toFixed(1)}deg) scale(${lerp(1, 0.8, k).toFixed(3)})`;
this.sheet.style.borderRadius = `${(k * 50).toFixed(1)}%`;
this.sheet.style.setProperty('--ballness', k.toFixed(3));
}
// ball, its shadow and the elastic
const b = this.ballAt(now);
const showBall = this.file && fold >= 1 && b.on && s !== 'complete' || s === 'complete' && this.shot;
const press = s === 'aiming' ? 0.965 : 1;
const lift = 1 + b.z * 0.7;
this.ballEl.style.visibility = showBall ? '' : 'hidden';
this.ballEl.style.transform = `translate(calc(var(--u) * ${(b.x - R).toFixed(2)}), calc(var(--u) * ${(b.y - R).toFixed(2)})) scale(${(press * lift * (1 - (b.fade || 0) * 0.5)).toFixed(3)}) rotate(${b.spin.toFixed(1)}deg)`;
this.ballEl.style.opacity = String(1 - (b.fade || 0));
this.shadowEl.style.visibility = showBall ? '' : 'hidden';
this.shadowEl.style.transform = `translate(calc(var(--u) * ${(b.x - R + b.z * 26).toFixed(2)}), calc(var(--u) * ${(b.y - R * 0.3 + b.z * 30).toFixed(2)})) scale(${(1 - b.z * 0.4).toFixed(3)})`;
this.shadowEl.style.opacity = String((1 - b.z * 0.9) * (1 - (b.fade || 0)));
this.drawBands(s === 'ready' || s === 'aiming' || s === 'opening' ? this.pull || this.releasePull(now) : this.releasePull(now));
this.drawAim(s === 'aiming' ? this.pull : null);
// the net takes the ball, with one small secondary movement
const netT = s === 'flight' || !this.goalAt || !this.shot || this.shot.outcome !== 'goal' ? -1 : now - this.goalAt;
const flightNet = s === 'flight' && this.shot?.outcome === 'goal' ? unit(now - this.shot.at, this.shot.dur * this.shot.cross, this.shot.dur * (1 - this.shot.cross)) : 0;
const wob = netT >= 0 && netT < T.net && !rm ? Math.sin((netT / T.net) * Math.PI * 2) * (1 - netT / T.net) : 0;
const bulge = flightNet ? flightNet : netT >= 0 ? Math.max(0, 1 - netT / 0.25) * 0.4 + wob * 0.6 : 0;
this.net.style.transform = `translateY(calc(var(--u) * ${(-bulge * 7).toFixed(2)})) scale(${(1 + bulge * 0.025).toFixed(4)}, ${(1 - bulge * 0.05).toFixed(4)})`;
// referee and VAR monitor: the monitor rises, the referee turns to watch it, then turns back and
// signals the decision before the goal is given
const deciding = s === 'complete' && this.fromVar && t < T.decide;
const pose = s === 'var' ? (t < T.turn && !rm ? 'neutral' : 'review')
: deciding ? 'var'
: s === 'missed' && t >= T.red ? 'red' : 'neutral';
for (const [k, el] of Object.entries(this.refs)) el.style.visibility = k === pose ? '' : 'hidden';
const red = this.refs.red;
red.style.transform = pose === 'red' && !rm ? `translateY(calc(var(--u) * ${(-6 * (1 - easeOut(unit(t, T.red, 0.2)))).toFixed(2)}))` : '';
// the standalone monitor shows while it rises and during the decision; while the referee watches,
// it is part of the review image
const mon = s === 'var' ? (pose === 'review' ? 0 : easeOut(unit(t, 0, T.turn)))
: s === 'complete' && this.fromVar ? (deciding ? 1 : 1 - easeOut(unit(t, T.decide, 0.3))) : 0;
this.monitor.style.setProperty('--m', mon.toFixed(3));
const lit = (pose === 'review' || deciding) && (rm || Math.floor(now / 0.4) % 2 === 0);
this.beacon.style.opacity = pose === 'review' || deciding ? (lit ? '1' : '0.15') : '0'; // the amber light blinks during the review
// the VAR review: a dark pill with a spinner while the upload runs
const varIn = s === 'var' ? (rm ? 1 : easeOut(unit(t, T.var - 0.25, 0.3))) : 0;
this.varEl.style.setProperty('--in', varIn.toFixed(3));
const head = Math.floor(now * 12) % 8;
this.spin.forEach((d, i) => (d.style.opacity = String(0.25 + 0.75 * (((i - head + 8) % 8) / 7))));
// banner
const banner = s === 'complete' && !this.skipped ? ['GOOOAL!', 'Upload complete. Beautiful finish.', '']
: s === 'complete' ? ['UPLOAD COMPLETE', 'No penalty needed.', '']
: s === 'missed' && t >= T.red ? ['RED CARD!', MISS_LINE[this.shot?.outcome] || '', 'Take another shot — your file is safe.']
: null;
this.banner.classList.toggle('is-on', !!banner);
this.banner.dataset.kind = banner ? banner[0].split(' ')[0].toLowerCase().replace(/[^a-z]/g, '') : '';
if (banner) {
const [b1, b2, b3] = this.banner.children;
b1.textContent = banner[0];
b2.textContent = banner[1];
b3.textContent = banner[2];
const from = s === 'missed' ? T.red : s === 'complete' && this.fromVar ? T.decide - 0.1 : 0;
const intro = rm ? 1 : easeOut(unit(t, from, 0.3));
this.banner.style.setProperty('--in', intro.toFixed(3));
}
// file bar
const pct = this.progress === null ? '' : ` · ${Math.round(this.progress * 100)}%`;
const status = {
opening: 'Folding into a football…',
ready: 'Ready to shoot',
aiming: this.keyAim
? `Aim ${this.aim.angle ? `${Math.abs(this.aim.angle)}° ${this.aim.angle < 0 ? 'left' : 'right'}` : 'straight'} · power ${Math.round(this.aim.power * 100)}% · Enter to shoot`
: `Pull back and release · power ${Math.round((Math.hypot(this.pull?.x || 0, this.pull?.y || 0) / MAX_PULL) * 100)}%`,
flight: 'Shot!',
var: `Review in progress${pct}`,
uploading: `Uploading${pct}`,
complete: `Uploaded · ${this.file ? sizeText(this.file.size) : ''}`,
missed: 'File safe · take another shot',
failed: `Upload failed · ${this.error}`,
}[s] || '';
this.statusEl.textContent = status;
const uploading = s === 'var' || s === 'uploading';
this.progressEl.style.transform = `scaleX(${uploading || s === 'complete' ? (this.progress ?? 0.15).toFixed(3) : 0})`;
const goMode = s === 'failed' ? 'retry' : s === 'complete' ? 'again' : 'shoot';
if (this.goMode !== goMode) {
this.goMode = goMode;
this.goIcon.setAttribute('d', { shoot: 'M12 19V5m0 0-6 6m6-6 6 6', retry: 'M4 12a8 8 0 1 0 2.4-5.7M4 4v4h4', again: 'M12 5v14M5 12h14' }[goMode]);
this.go.setAttribute('aria-label', { shoot: 'Shoot (straight at the goal)', retry: 'Retry upload', again: 'Upload another file' }[goMode]);
}
this.go.disabled = !['ready', 'failed', 'complete'].includes(s);
this.skipBtn.disabled = !['ready', 'missed', 'aiming'].includes(s);
}
// The pouch follows the pull and turns with it; each side has two rubber strands, from the post's
// top and from its pin to the pouch's tip. They thin as they stretch.
drawBands(pull) {
const len = Math.hypot(pull.x, pull.y);
const tension = len / MAX_PULL;
const rot = len > 2 ? Math.atan2(pull.y, pull.x) - Math.PI / 2 : 0;
const ax = BALL.x + pull.x, ay = BALL.y + pull.y + 5; // where the ball sits in the pouch
const c = Math.cos(rot), sn = Math.sin(rot);
const tip = (side, dy) => ({ x: ax + c * side * POUCH.tip - sn * (POUCH.tipY + dy), y: ay + sn * side * POUCH.tip + c * (POUCH.tipY + dy) });
this.pouchEl.style.transform = `translate(calc(var(--u) * ${(ax - POUCH.w * POUCH.cx).toFixed(2)}), calc(var(--u) * ${(ay - POUCH.h * POUCH.cy).toFixed(2)})) rotate(${((rot * 180) / Math.PI).toFixed(2)}deg)`;
const w = 5.4 - tension * 2;
const strand = (p, q) => {
const d = `M${p.x} ${p.y}L${q.x.toFixed(1)} ${q.y.toFixed(1)}`;
return `<path d="${d}" class="hp-band-edge" stroke-width="${(w + 1.6).toFixed(2)}" /><path d="${d}" class="hp-band" stroke-width="${w.toFixed(2)}" /><path d="${d}" class="hp-band-hi" stroke-width="${(w * 0.3).toFixed(2)}" transform="translate(0 -${(w * 0.22).toFixed(2)})" />`;
};
this.bands.innerHTML = [-1, 1].map((side, i) => strand(POSTS[i].top, tip(side, -3)) + strand(POSTS[i].pin, tip(side, 3))).join('');
}
drawAim(pull) {
if (!pull) {
this.aimEl.innerHTML = '';
return;
}
const len = Math.hypot(pull.x, pull.y);
if (len < 4) {
this.aimEl.innerHTML = '';
return;
}
const dir = { x: -pull.x / len, y: -pull.y / len };
const range = (160 + 700 * (len / MAX_PULL)) * 0.55;
let dots = '';
for (let d = 46; d < range; d += 22) {
dots += `<circle cx="${(BALL.x + dir.x * d).toFixed(1)}" cy="${(BALL.y + dir.y * d).toFixed(1)}" r="${(4.2 - (d / range) * 2).toFixed(2)}" opacity="${(1 - (d / range) * 0.7).toFixed(2)}" />`;
}
this.aimEl.innerHTML = dots;
}
/* ── animation: runs while anything moves ── */
loop() {
if (this.running) return;
this.running = true;
const frame = () => {
const now = this.clock();
this.tick(now);
this.render(now);
const t = now - this.since;
const still = (this.state === 'empty' || this.state === 'invalid' || this.state === 'over' || this.state === 'failed'
|| (this.state === 'ready' && (!this.release || now - this.release.at > 0.3))
|| (this.state === 'complete' && t > 1)) && this.state !== 'aiming';
if (still) {
this.running = false;
return;
}
requestAnimationFrame(frame);
};
requestAnimationFrame(frame);
}
}
HiddenPitch.xhr = xhr;
HiddenPitch.Sound = Sound;
HiddenPitch.plan = plan;
HiddenPitch.T = T;
window.HiddenPitch = HiddenPitch;
})(); <!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Hidden Pitch</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Barlow+Condensed:wght@600&family=Cormorant+Garamond:wght@600&family=Inter:wght@500;600&display=swap" rel="stylesheet">
<link rel="stylesheet" href="hidden-pitch.css">
<style>
* { box-sizing: border-box; }
html, body { min-height: 100%; margin: 0; }
body {
display: grid;
place-items: center;
padding: 32px 16px;
/* warm ivory */
background: #efe9df;
font-family: "Inter", system-ui, sans-serif;
color: #293d30;
}
.page {
display: flex;
flex-direction: column;
align-items: center;
width: 100%;
max-width: 520px;
gap: 26px;
}
.eyebrow {
margin: 0;
font-size: 11px;
font-weight: 600;
letter-spacing: 0.4em;
margin-right: -0.4em;
}
.title {
margin: -14px 0 0;
font: 600 40px/1 "Cormorant Garamond", Georgia, serif;
letter-spacing: 0.04em;
}
#pitch { width: 100%; }
/* demo only: how the simulated upload behaves */
.sim { display: flex; gap: 8px; border: 0; padding: 0; margin: 0; font-size: 12px; font-weight: 600; letter-spacing: 0.12em; }
.sim label { display: flex; gap: 6px; align-items: center; padding: 8px 12px; border-radius: 999px; background: #e3dccf; cursor: pointer; }
.sim input { accent-color: #293d30; }
</style>
</head>
<body>
<main class="page">
<p class="eyebrow">INTERACTION STUDY / 001</p>
<h1 class="title">THE HIDDEN PITCH</h1>
<div id="pitch"></div>
<fieldset class="sim">
<label><input type="radio" name="sim" value="normal" checked> NORMAL UPLOAD</label>
<label><input type="radio" name="sim" value="fail"> UPLOAD FAILS</label>
</fieldset>
</main>
<script src="hidden-pitch.js"></script>
<script>
// A stand-in for a real upload, so the demo runs without a server.
// In a real app: upload: HiddenPitch.xhr('/api/upload')
const simulate = (file, onProgress, signal) => new Promise((resolve, reject) => {
const fail = document.querySelector('input[name="sim"]:checked').value === 'fail';
let p = 0;
const timer = setInterval(() => {
p = Math.min(1, p + 0.012 + Math.random() * 0.016); // about 6 seconds, so the VAR review can be enjoyed
if (fail && p > 0.55) {
clearInterval(timer);
reject(new Error('Network error'));
return;
}
onProgress(p);
if (p >= 1) {
clearInterval(timer);
resolve({ ok: true });
}
}, 120);
signal.addEventListener('abort', () => {
clearInterval(timer);
reject(Object.assign(new Error('Cancelled'), { name: 'AbortError' }));
});
});
new HiddenPitch(document.getElementById('pitch'), {
upload: simulate,
maxSize: 25 * 1024 * 1024,
onComplete: (file) => console.log('Uploaded', file.name),
});
</script>
</body>
</html> All 18 files in the zip
-
assets/crowd-left.webp -
assets/crowd-right.webp -
assets/crowd-top.webp -
assets/football.webp -
assets/goal-frame.webp -
assets/goal-net.webp -
assets/pitch.jpg -
assets/post-left.webp -
assets/post-right.webp -
assets/pouch.webp -
assets/referee-neutral.webp -
assets/referee-red-card.webp -
assets/referee-var.webp -
assets/var-monitor.webp -
assets/var-review.webp -
hidden-pitch.css -
hidden-pitch.js -
index.html