/* global React */ const { useEffect, useRef, useState, useCallback } = React; // ─────────── INTERACTIVE GOAL: kickable ball with physics ─────────── function GoalPlay() { const sectionRef = useRef(null); const stageRef = useRef(null); const ballRef = useRef(null); const aimSvgRef = useRef(null); const cleatRef = useRef(null); const stateRef = useRef({ // ball physics (in stage-relative px) x: 0, y: 0, vx: 0, vy: 0, r: 28, // drag state dragging: false, dragStartX: 0, dragStartY: 0, dragX: 0, dragY: 0, // mouse (for cleat rotation + click-kick) mouseStageX: 0, mouseStageY: 0, mouseInStage: false, lastMouseX: 0, lastMouseY: 0, mouseAngle: 0, // resize-tracked stageW: 900, stageH: 460, goalRect: { x: 0, y: 0, w: 0, h: 0 }, kickoff: { x: 0, y: 0 }, scoring: false, // touch device isTouch: false, }); const [showGoal, setShowGoal] = useState(false); const [confetti, setConfetti] = useState([]); const [shake, setShake] = useState(false); const isTouch = typeof window !== 'undefined' && ('ontouchstart' in window || navigator.maxTouchPoints > 0); const layout = useCallback(() => { const stage = stageRef.current; if (!stage) return; const { width, height } = stage.getBoundingClientRect(); stateRef.current.stageW = width; stateRef.current.stageH = height; const mobile = width < 600; const goalW = Math.min(width * (mobile ? 0.275 : 0.55), mobile ? 240 : 480); const goalH = Math.min(height * (mobile ? 0.275 : 0.55), mobile ? 120 : 240); const goalX = (width - goalW) / 2; const goalY = height * 0.08; stateRef.current.goalRect = { x: goalX, y: goalY, w: goalW, h: goalH }; const spawns = [ { x: width / 2, y: height - 70 }, // centre { x: width * 0.12, y: height - 70 }, // left corner kick { x: width * 0.88, y: height - 70 }, // right corner kick ]; stateRef.current.spawnPositions = spawns; stateRef.current.kickoff = spawns[0]; if (stateRef.current.x === 0 && stateRef.current.y === 0) { stateRef.current.x = stateRef.current.kickoff.x; stateRef.current.y = stateRef.current.kickoff.y; } }, []); useEffect(() => { layout(); // re-run a couple times to catch font-load/reflow on wide screens const t1 = setTimeout(layout, 50); const t2 = setTimeout(layout, 250); const t3 = setTimeout(layout, 800); const onResize = () => layout(); window.addEventListener('resize', onResize); return () => { clearTimeout(t1); clearTimeout(t2); clearTimeout(t3); window.removeEventListener('resize', onResize); }; }, [layout]); // Score celebration const triggerGoal = useCallback(() => { if (stateRef.current.scoring) return; stateRef.current.scoring = true; setShake(true); setShowGoal(true); const colors = ['#FEE658', '#DF0024', '#FFEEAB', '#FFFFFF', '#F6D300']; const pieces = []; for (let i = 0; i < 70; i++) { pieces.push({ id: i + Math.random(), x: 50 + (Math.random() - 0.5) * 16, y: 38 + (Math.random() - 0.5) * 8, vx: (Math.random() - 0.5) * 700, vy: -350 - Math.random() * 450, rot: Math.random() * 360, color: colors[i % colors.length], delay: Math.random() * 0.12, }); } setConfetti(pieces); setTimeout(() => setShake(false), 500); setTimeout(() => setShowGoal(false), 1400); setTimeout(() => { // respawn at a random spawn position const s = stateRef.current; const spawns = s.spawnPositions || [s.kickoff]; const spawn = spawns[Math.floor(Math.random() * spawns.length)]; s.x = spawn.x; s.y = spawn.y; s.vx = 0; s.vy = 0; s.scoring = false; setConfetti([]); }, 2200); }, []); // Physics loop useEffect(() => { let raf; let lastT = performance.now(); const tick = (t) => { const dt = Math.min(0.05, (t - lastT) / 1000); lastT = t; const s = stateRef.current; // friction const friction = 0.985; s.vx *= Math.pow(friction, dt * 60); s.vy *= Math.pow(friction, dt * 60); // step if (!s.dragging && !s.scoring) { s.x += s.vx * dt; s.y += s.vy * dt; // wall collisions (inside stage) const r = s.r; if (s.x - r < 0) { s.x = r; s.vx = -s.vx * 0.7; } if (s.x + r > s.stageW) { s.x = s.stageW - r; s.vx = -s.vx * 0.7; } if (s.y - r < 0) { s.y = r; s.vy = -s.vy * 0.7; } if (s.y + r > s.stageH) { s.y = s.stageH - r; s.vy = -s.vy * 0.7; } // stop tiny motion if (Math.abs(s.vx) < 2) s.vx = 0; if (Math.abs(s.vy) < 2) s.vy = 0; // Goal frame collisions — top + sides closed, bottom open const g = s.goalRect; const ballAtGoalX = s.x - r < g.x + g.w && s.x + r > g.x; const ballAtGoalY = s.y - r < g.y + g.h && s.y + r > g.y; // Crossbar: bounce if approaching from above (vy > 0 = moving down in screen) if (ballAtGoalX && s.y < g.y && s.y + r > g.y && s.vy > 0) { s.y = g.y - r; s.vy *= -0.65; } // Left post: bounce if approaching from left (vx > 0 = moving right) if (ballAtGoalY && s.y > g.y && s.x < g.x && s.x + r > g.x && s.vx > 0) { s.x = g.x - r; s.vx *= -0.65; } // Right post: bounce if approaching from right (vx < 0 = moving left) if (ballAtGoalY && s.y > g.y && s.x > g.x + g.w && s.x - r < g.x + g.w && s.vx < 0) { s.x = g.x + g.w + r; s.vx *= -0.65; } // Score: ball entered through open bottom (vy < 0 = moving upward toward goal) if (s.x > g.x && s.x < g.x + g.w && s.y > g.y && s.y < g.y + g.h && s.vy < 0) { triggerGoal(); } } // render ball const ball = ballRef.current; if (ball) { const speed = Math.hypot(s.vx, s.vy); const rot = (t / 4) % 360; ball.style.transform = `translate(${s.x - s.r}px, ${s.y - s.r}px) rotate(${rot}deg)`; } // render cleat cursor (uses viewport/fixed coords) const cleat = cleatRef.current; if (cleat && !s.isTouch && s.viewportX != null) { cleat.style.left = s.viewportX + 'px'; cleat.style.top = s.viewportY + 'px'; cleat.style.transform = `translate(-50%,-50%) rotate(${s.mouseAngle + 90}deg)`; } // render aim line while dragging const svg = aimSvgRef.current; if (svg) { if (s.dragging) { // slingshot: aim opposite of drag direction const dx = s.dragStartX - s.dragX; const dy = s.dragStartY - s.dragY; const len = Math.min(160, Math.hypot(dx, dy)); const ang = Math.atan2(dy, dx); const ex = s.x + Math.cos(ang) * len; const ey = s.y + Math.sin(ang) * len; svg.innerHTML = ` `; } else { svg.innerHTML = ''; } } raf = requestAnimationFrame(tick); }; raf = requestAnimationFrame(tick); return () => cancelAnimationFrame(raf); }, [triggerGoal]); // Pointer handlers (works for mouse + touch via Pointer Events) useEffect(() => { const stage = stageRef.current; const section = sectionRef.current; if (!stage || !section) return; const s = stateRef.current; const stagePoint = (e) => { const r = stage.getBoundingClientRect(); return { x: e.clientX - r.left, y: e.clientY - r.top }; }; const onMove = (e) => { const r = stage.getBoundingClientRect(); const sectR = section.getBoundingClientRect(); const inSection = e.clientX >= sectR.left && e.clientX <= sectR.right && e.clientY >= sectR.top && e.clientY <= sectR.bottom; const inStage = e.clientX >= r.left && e.clientX <= r.right && e.clientY >= r.top && e.clientY <= r.bottom; s.mouseInStage = inStage; const sx = e.clientX - r.left; const sy = e.clientY - r.top; // mouse velocity (px/sec) for kick power const now = performance.now(); const dt = Math.max(0.008, (now - (s.lastMoveT || now)) / 1000); const dx = sx - s.lastMouseX; const dy = sy - s.lastMouseY; s.mouseVx = dx / dt; s.mouseVy = dy / dt; s.lastMoveT = now; if (Math.hypot(dx, dy) > 1.5) { s.mouseAngle = Math.atan2(dy, dx) * 180 / Math.PI; } s.lastMouseX = sx; s.lastMouseY = sy; s.mouseStageX = sx; s.mouseStageY = sy; // cleat uses viewport coords (fixed position) s.viewportX = e.clientX; s.viewportY = e.clientY; if (cleatRef.current) { cleatRef.current.classList.toggle('visible', inSection && !s.isTouch); } if (s.dragging) { s.dragX = sx; s.dragY = sy; } else if (inStage && !s.isTouch && !s.scoring) { // collision-based kick: if mouse overlaps ball, transfer mouse velocity const dxb = sx - s.x, dyb = sy - s.y; const dist = Math.hypot(dxb, dyb); if (dist < s.r + 18) { const speed = Math.hypot(s.mouseVx, s.mouseVy); if (speed > 60) { // direction: from mouse toward ball center (push ball away) const ang = Math.atan2(s.y - sy, s.x - sx); const power = Math.min(1400, 180 + speed * 0.85); s.vx = Math.cos(ang) * power; s.vy = Math.sin(ang) * power; } } } }; const onPointerDown = (e) => { const { x, y } = stagePoint(e); const dist = Math.hypot(x - s.x, y - s.y); if (e.pointerType === 'touch' || e.pointerType === 'pen') s.isTouch = true; // mobile only: slingshot drag if (s.isTouch && dist < s.r + 30) { s.dragging = true; s.dragStartX = s.x; s.dragStartY = s.y; s.dragX = x; s.dragY = y; s.vx = 0; s.vy = 0; e.target.setPointerCapture?.(e.pointerId); } }; const onPointerUp = (e) => { if (s.dragging) { const dx = s.dragStartX - s.dragX; const dy = s.dragStartY - s.dragY; const len = Math.min(160, Math.hypot(dx, dy)); const ang = Math.atan2(dy, dx); const power = (len / 160) * 1100 + 80; s.vx = Math.cos(ang) * power; s.vy = Math.sin(ang) * power; s.dragging = false; } }; stage.addEventListener('pointerdown', onPointerDown); window.addEventListener('pointermove', onMove); window.addEventListener('pointerup', onPointerUp); window.addEventListener('pointercancel', onPointerUp); return () => { stage.removeEventListener('pointerdown', onPointerDown); window.removeEventListener('pointermove', onMove); window.removeEventListener('pointerup', onPointerUp); window.removeEventListener('pointercancel', onPointerUp); }; }, []); return (
⚽ Make your goal

Take Your Shot

{isTouch ? 'Drag the ball back like a slingshot, then release to shoot. Score on the goal for the celebration.' : 'Move your cleat into the ball to kick it. The faster you swing, the harder you shoot. Score for confetti.'}

{isTouch ? 'Drag & flick the ball into the goal' : 'Swipe your cleat through the ball'}
{confetti.map(p => ( ))}
{!isTouch && ( )}
); } function CleatSVG() { return ( {/* sole */} {/* shoe body */} {/* yellow stripe */} {/* studs */} {/* tongue / laces */} ); } function GoalNet({ shake, stateRef }) { const [, force] = React.useState(0); React.useEffect(() => { let raf; const tick = () => { force(v => (v + 1) % 1000); raf = requestAnimationFrame(tick); }; raf = requestAnimationFrame(tick); return () => cancelAnimationFrame(raf); }, []); const s = stateRef.current; const g = s.goalRect; const sw = s.stageW; const sh = s.stageH; if (!g || !g.w || !sw) return null; const postW = 10; // Grass strip geometry — horizon sits at the base of the goal posts const grassTop = g.y + g.h - 30; const stripeCount = 12; const stripeW = sw / stripeCount; // Blade zigzag along the top edge of grass const bladeH = 14; const bladeCount = Math.ceil(sw / 10); let bladePath = `M 0 ${grassTop}`; for (let i = 0; i <= bladeCount; i++) { const bx = i * (sw / bladeCount); const tip = (i % 2 === 0) ? grassTop - bladeH : grassTop - bladeH * 0.45; bladePath += ` L ${bx} ${tip}`; } bladePath += ` L ${sw} ${grassTop} L ${sw} ${sh} L 0 ${sh} Z`; return ( {/* ── GRASS ── */} {/* alternating turf stripes */} {Array.from({ length: stripeCount }).map((_, i) => ( ))} {/* blade silhouette on top */} {/* lighter blade highlights */} {/* ── FIELD MARKINGS ── */} {(() => { const goalLine = g.y + g.h; // base of goal posts const fieldH = sh - goalLine - 8; // available field depth const yard = fieldH / 22; // px per yard (18-yd box fills ~82% of field) const cx = g.x + g.w / 2; // centre x const b6 = 6 * yard; const b18 = 18 * yard; const psY = goalLine + 12 * yard; // penalty spot (12 yds out) const arcR = 10 * yard; // penalty arc radius const arcDx = 8 * yard; // arc–box intersection offset const lw = Math.max(3, yard * 0.22); return ( {/* Goal line — full stage width */} {/* 6-yard box */} {/* 18-yard box */} {/* Penalty spot */} {/* Penalty arc (D) — curves away from goal, outside 18-yd box */} ); })()} {/* ── GOAL (interior perspective) ── */} {(() => { const p = postW; // Vanishing point: centre-back of goal opening const vpx = g.x + g.w / 2; const vpy = g.y + g.h * 0.52; const lp = (a, b, t) => a + t * (b - a); // Back-net: proportional depth (~9.5% of goal half-width) const netDepth = g.w * 0.095; const dt = netDepth / ((vpx - g.x) || 1); const bx1 = g.x + netDepth; const bx2 = g.x + g.w - netDepth; const by1 = lp(g.y, vpy, dt); const by2 = lp(g.y + g.h, vpy, dt); // Post inner face: proportional (~1.9% of goal width = ~6px on desktop) const innerDepth = g.w * 0.019; const it = innerDepth / ((vpx - g.x) || 1); const pilx = g.x + innerDepth; const pirx = g.x + g.w - innerDepth; const pity = lp(g.y - p, vpy, it); const piby = lp(g.y + g.h + p/2, vpy, it); // Crossbar inner-bottom corners (same fixed depth) const cblx = lp(g.x - p, vpx, it); const cbrx = lp(g.x + g.w + p, vpx, it); const cby = lp(g.y, vpy, it); const Q = (arr) => arr.map(([x, y]) => `${x},${y}`).join(' '); // Fewer grid divisions on small screens const cols = sw < 600 ? 8 : 16; const rows = sw < 600 ? 6 : 12; return ( {/* ── Net fill: full opening, 45% white ── */} {/* ── Net perspective grid ── */} {/* 4 corner edge lines */} {/* Top face: lines converging from crossbar → back-top edge */} {Array.from({length:cols-1}).map((_,i) => ( ))} {/* Left face: height lines from left post → back-left */} {Array.from({length:rows-1}).map((_,i) => ( ))} {/* Right face: height lines from right post → back-right */} {Array.from({length:rows-1}).map((_,i) => ( ))} {/* Depth rings: 3 rectangular rings between front and back */} {[1,2,3].map((j) => { const t = j / 4; const lx = lp(g.x, bx1, t); const rx = lp(g.x+g.w, bx2, t); const ty = lp(g.y, by1, t); const bot = lp(g.y+g.h, by2, t); return ( ); })} {/* Back face grid */} {Array.from({length:cols-1}).map((_,i) => ( ))} {Array.from({length:rows-1}).map((_,i) => ( ))} {/* Front frame: top + sides, no bottom */} {/* ── Crossbar inner-bottom face ── */} {/* ── Left post inner face ── */} {/* ── Right post inner face ── */} {/* ── Front posts (on top) ── */} ); })()} ); } function Confetti({ x, y, vx, vy, rot, color, delay }) { const ref = useRef(null); useEffect(() => { const el = ref.current; if (!el) return; const start = performance.now() + delay * 1000; let raf; const tick = (now) => { const t = Math.max(0, (now - start) / 1000); const px = x + (vx * t) / 14; const py = y + ((vy * t + 0.5 * 980 * t * t) / 14); const op = Math.max(0, 1 - t / 1.6); el.style.left = px + '%'; el.style.top = py + '%'; el.style.opacity = String(op); el.style.transform = `translate(-50%,-50%) rotate(${rot + t * 720}deg)`; if (t < 2) raf = requestAnimationFrame(tick); }; raf = requestAnimationFrame(tick); return () => cancelAnimationFrame(raf); }, []); return (
); } window.GoalPlay = GoalPlay;