diff --git a/app/botao-11/LabV2.tsx b/app/botao-11/LabV2.tsx new file mode 100644 index 00000000..ccb006ab --- /dev/null +++ b/app/botao-11/LabV2.tsx @@ -0,0 +1,373 @@ +"use client"; + +import Link from "next/link"; +import { useCallback, useEffect, useMemo, useRef, useState, type CSSProperties, type PointerEvent as ReactPointerEvent, type WheelEvent as ReactWheelEvent } from "react"; +import { CLUBS } from "../game-data"; +import { chooseCpuShot } from "./cpu"; +import { FIELD, MAX_PULL, beginShot, ballOf, createMatch, resumeAfterGoal, shotSpeedFor, stepMatch } from "./engine"; +import { FORMATIONS_11 } from "./formations"; +import { renderMatch, type AimState, type Camera } from "./render-v2"; +import type { Body, MatchState, TeamPreset } from "./types"; +import styles from "./lab-v2.module.css"; + +type PanState = { pointerId: number; lastX: number; lastY: number } | null; + +const DEFAULT_SEED = 2026081811; +const MATCH_SECONDS = 240; + +function toPreset(club: (typeof CLUBS)[number]): TeamPreset { + return { id: club.id, name: club.name, abbr: club.abbr, primary: club.primary, secondary: club.secondary, strength: club.strength }; +} + +function initialClubId(preferred: string, fallbackIndex: number) { + return CLUBS.find((club) => club.id === preferred)?.id ?? CLUBS[fallbackIndex]?.id ?? CLUBS[0].id; +} + +function formatClock(seconds: number) { + const safe = Math.max(0, Math.ceil(seconds)); + return `${Math.floor(safe / 60)}:${String(safe % 60).padStart(2, "0")}`; +} + +function newCamera(): Camera { + return { x: FIELD.width / 2, y: FIELD.height / 2, zoom: 1, targetX: FIELD.width / 2, targetY: FIELD.height / 2, targetZoom: 1 }; +} + +export default function LabV2() { + const canvasRef = useRef(null); + const wrapperRef = useRef(null); + const matchRef = useRef(null); + const cameraRef = useRef(newCamera()); + const aimRef = useRef(null); + const panRef = useRef(null); + const hoverBodyRef = useRef(null); + const cpuThinkingRef = useRef(false); + const cpuThinkDueRef = useRef(null); + const goalStartedRef = useRef(null); + + const [userClubId, setUserClubId] = useState(() => initialClubId("flamengo", 0)); + const [cpuClubId, setCpuClubId] = useState(() => initialClubId("real-madrid", 1)); + const [userFormationId, setUserFormationId] = useState("433"); + const [cpuFormationId, setCpuFormationId] = useState("442"); + const [seed, setSeed] = useState(DEFAULT_SEED); + const [, setUiVersion] = useState(0); + const [followBall, setFollowBall] = useState(false); + const [showRoles, setShowRoles] = useState(true); + const [showMinimap, setShowMinimap] = useState(true); + const [cpuThinkMs, setCpuThinkMs] = useState(null); + + const clubs = useMemo(() => [...CLUBS].sort((a, b) => b.strength - a.strength || a.name.localeCompare(b.name, "pt-BR")), []); + const userClub = CLUBS.find((club) => club.id === userClubId) ?? CLUBS[0]; + const cpuClub = CLUBS.find((club) => club.id === cpuClubId) ?? CLUBS[1] ?? CLUBS[0]; + + if (!matchRef.current) { + matchRef.current = createMatch({ seed: DEFAULT_SEED, userTeam: toPreset(userClub), cpuTeam: toPreset(cpuClub), userFormationId: "433", cpuFormationId: "442", matchSeconds: MATCH_SECONDS }); + } + + const lockManualCamera = useCallback(() => { + const camera = cameraRef.current; + camera.targetX = camera.x; + camera.targetY = camera.y; + camera.targetZoom = camera.zoom; + setFollowBall(false); + }, []); + + const resetMatch = useCallback((nextSeed = seed) => { + matchRef.current = createMatch({ + seed: nextSeed, + userTeam: toPreset(CLUBS.find((club) => club.id === userClubId) ?? CLUBS[0]), + cpuTeam: toPreset(CLUBS.find((club) => club.id === cpuClubId) ?? CLUBS[1] ?? CLUBS[0]), + userFormationId, + cpuFormationId, + matchSeconds: MATCH_SECONDS, + }); + aimRef.current = null; + panRef.current = null; + hoverBodyRef.current = null; + cpuThinkingRef.current = false; + cpuThinkDueRef.current = null; + goalStartedRef.current = null; + cameraRef.current = newCamera(); + setFollowBall(false); + setCpuThinkMs(null); + setUiVersion((value) => value + 1); + }, [cpuClubId, cpuFormationId, seed, userClubId, userFormationId]); + + const focusBall = useCallback(() => { + const state = matchRef.current; + if (!state) return; + const ball = ballOf(state); + const camera = cameraRef.current; + camera.targetX = ball.x; + camera.targetY = ball.y; + camera.targetZoom = camera.zoom; + setFollowBall(true); + }, []); + + const fitWholeField = useCallback(() => { + const canvas = canvasRef.current; + const wrapper = wrapperRef.current; + if (!canvas || !wrapper) return; + const dpr = Math.min(window.devicePixelRatio || 1, 2); + const width = Math.max(640, Math.floor(wrapper.clientWidth * dpr)); + const height = Math.max(420, Math.floor(wrapper.clientHeight * dpr)); + const fit = Math.min(width / FIELD.width, height / FIELD.height) * 0.93; + const camera = cameraRef.current; + camera.x = FIELD.width / 2; + camera.y = FIELD.height / 2; + camera.zoom = fit; + camera.targetX = camera.x; + camera.targetY = camera.y; + camera.targetZoom = fit; + setFollowBall(false); + }, []); + + useEffect(() => { + const onKeyDown = (event: KeyboardEvent) => { + if (event.target instanceof HTMLInputElement || event.target instanceof HTMLSelectElement || event.target instanceof HTMLTextAreaElement) return; + const key = event.key.toLowerCase(); + if (key === "f") focusBall(); + if (key === "c") fitWholeField(); + if (key === "r") { const next = seed + 1; setSeed(next); resetMatch(next); } + if (event.key === " ") { setFollowBall((value) => !value); event.preventDefault(); } + }; + window.addEventListener("keydown", onKeyDown); + return () => window.removeEventListener("keydown", onKeyDown); + }, [fitWholeField, focusBall, resetMatch, seed]); + + const screenToWorld = useCallback((screenX: number, screenY: number) => { + const canvas = canvasRef.current; + if (!canvas) return { x: 0, y: 0 }; + const rect = canvas.getBoundingClientRect(); + const sx = (screenX - rect.left) * canvas.width / rect.width; + const sy = (screenY - rect.top) * canvas.height / rect.height; + const camera = cameraRef.current; + return { x: (sx - canvas.width / 2) / camera.zoom + camera.x, y: (sy - canvas.height / 2) / camera.zoom + camera.y }; + }, []); + + const pickUserDisc = useCallback((x: number, y: number) => { + const state = matchRef.current; + if (!state || state.phase !== "aim" || state.turn !== "user") return null; + let best: Body | null = null; + let bestDistance = Number.POSITIVE_INFINITY; + for (const body of state.bodies) { + if (body.kind !== "disc" || body.side !== "user") continue; + const distance = Math.hypot(body.x - x, body.y - y); + if (distance <= body.radius * 1.9 && distance < bestDistance) { best = body; bestDistance = distance; } + } + return best; + }, []); + + const onPointerDown = useCallback((event: ReactPointerEvent) => { + const canvas = canvasRef.current; + if (!canvas) return; + canvas.setPointerCapture(event.pointerId); + const world = screenToWorld(event.clientX, event.clientY); + const disc = event.button === 0 ? pickUserDisc(world.x, world.y) : null; + if (disc) { + aimRef.current = { bodyId: disc.id, pointerX: world.x, pointerY: world.y }; + return; + } + panRef.current = { pointerId: event.pointerId, lastX: event.clientX, lastY: event.clientY }; + lockManualCamera(); + }, [lockManualCamera, pickUserDisc, screenToWorld]); + + const onPointerMove = useCallback((event: ReactPointerEvent) => { + const world = screenToWorld(event.clientX, event.clientY); + const aim = aimRef.current; + if (aim) { + aim.pointerX = world.x; + aim.pointerY = world.y; + return; + } + const pan = panRef.current; + if (pan && pan.pointerId === event.pointerId) { + const camera = cameraRef.current; + camera.x -= (event.clientX - pan.lastX) / camera.zoom; + camera.y -= (event.clientY - pan.lastY) / camera.zoom; + camera.targetX = camera.x; + camera.targetY = camera.y; + camera.targetZoom = camera.zoom; + pan.lastX = event.clientX; + pan.lastY = event.clientY; + if (followBall) setFollowBall(false); + return; + } + hoverBodyRef.current = pickUserDisc(world.x, world.y)?.id ?? null; + }, [followBall, pickUserDisc, screenToWorld]); + + const fireAim = useCallback(() => { + const state = matchRef.current; + const aim = aimRef.current; + if (!state || !aim) return; + const disc = state.bodies.find((body) => body.id === aim.bodyId); + aimRef.current = null; + if (!disc) return; + const dx = disc.x - aim.pointerX; + const dy = disc.y - aim.pointerY; + const pull = Math.min(MAX_PULL, Math.hypot(dx, dy)); + if (pull < 8) return; + const length = Math.hypot(dx, dy) || 1; + const speed = shotSpeedFor(disc.power) * Math.min(1, pull / MAX_PULL); + if (beginShot(state, { bodyId: disc.id, vx: dx / length * speed, vy: dy / length * speed })) setUiVersion((value) => value + 1); + }, []); + + const onPointerUp = useCallback((event: ReactPointerEvent) => { + if (aimRef.current) fireAim(); + if (panRef.current?.pointerId === event.pointerId) panRef.current = null; + }, [fireAim]); + + const onWheel = useCallback((event: ReactWheelEvent) => { + event.preventDefault(); + const canvas = canvasRef.current; + if (!canvas) return; + const before = screenToWorld(event.clientX, event.clientY); + const camera = cameraRef.current; + const rect = canvas.getBoundingClientRect(); + const fit = Math.min(canvas.width / FIELD.width, canvas.height / FIELD.height); + const minZoom = fit * 0.72; + const maxZoom = fit * 3.15; + camera.zoom = Math.max(minZoom, Math.min(maxZoom, camera.zoom * Math.exp(-event.deltaY * 0.0012))); + camera.targetZoom = camera.zoom; + const sx = (event.clientX - rect.left) * canvas.width / rect.width; + const sy = (event.clientY - rect.top) * canvas.height / rect.height; + camera.x = before.x - (sx - canvas.width / 2) / camera.zoom; + camera.y = before.y - (sy - canvas.height / 2) / camera.zoom; + camera.targetX = camera.x; + camera.targetY = camera.y; + setFollowBall(false); + }, [screenToWorld]); + + useEffect(() => { + let frame = 0; + let previous = performance.now(); + let lastUiSync = 0; + + const draw = (now: number) => { + const canvas = canvasRef.current; + const wrapper = wrapperRef.current; + const state = matchRef.current; + if (!canvas || !wrapper || !state) { frame = requestAnimationFrame(draw); return; } + + const dpr = Math.min(window.devicePixelRatio || 1, 2); + const width = Math.max(640, Math.floor(wrapper.clientWidth * dpr)); + const height = Math.max(420, Math.floor(wrapper.clientHeight * dpr)); + if (canvas.width !== width || canvas.height !== height) { canvas.width = width; canvas.height = height; } + + const frameDt = Math.min(0.04, Math.max(0, (now - previous) / 1000)); + previous = now; + let remaining = frameDt; + while (remaining > 0) { + const slice = Math.min(remaining, 1 / 120); + stepMatch(state, slice); + remaining -= slice; + } + + const camera = cameraRef.current; + const fit = Math.min(canvas.width / FIELD.width, canvas.height / FIELD.height); + if (camera.zoom === 1 && camera.targetZoom === 1) { + camera.zoom = fit * 1.37; + camera.targetZoom = camera.zoom; + } + + if (followBall) { + const ball = ballOf(state); + const activeAim = aimRef.current; + const activeDisc = activeAim ? state.bodies.find((body) => body.id === activeAim.bodyId) : null; + if (activeDisc) { + camera.targetX = activeDisc.x * 0.58 + ball.x * 0.42; + camera.targetY = activeDisc.y * 0.58 + ball.y * 0.42; + } else { + camera.targetX = ball.x; + camera.targetY = ball.y; + } + camera.targetZoom = camera.zoom; + } + + const follow = 1 - Math.exp(-6.2 * Math.max(0.001, frameDt)); + camera.x += (camera.targetX - camera.x) * follow; + camera.y += (camera.targetY - camera.y) * follow; + camera.zoom += (camera.targetZoom - camera.zoom) * follow; + + const halfW = canvas.width / (2 * camera.zoom); + const halfH = canvas.height / (2 * camera.zoom); + const margin = 105; + const clampAxis = (value: number, half: number, worldSize: number) => { + const low = -margin; + const high = worldSize + margin; + if (half * 2 >= high - low) return worldSize / 2; + return Math.max(low + half, Math.min(high - half, value)); + }; + camera.x = clampAxis(camera.x, halfW, FIELD.width); + camera.y = clampAxis(camera.y, halfH, FIELD.height); + camera.targetX = clampAxis(camera.targetX, halfW, FIELD.width); + camera.targetY = clampAxis(camera.targetY, halfH, FIELD.height); + + if (state.phase === "goal") { + if (goalStartedRef.current === null) goalStartedRef.current = now; + if (now - goalStartedRef.current >= 950) { + resumeAfterGoal(state); + goalStartedRef.current = null; + setUiVersion((value) => value + 1); + } + } else goalStartedRef.current = null; + + if (state.phase === "aim" && state.turn === "cpu") { + if (cpuThinkDueRef.current === null) cpuThinkDueRef.current = now + 240; + if (!cpuThinkingRef.current && now >= cpuThinkDueRef.current) { + cpuThinkingRef.current = true; + const started = performance.now(); + const shot = chooseCpuShot(state, "cpu"); + setCpuThinkMs(performance.now() - started); + if (shot) beginShot(state, shot); + cpuThinkingRef.current = false; + cpuThinkDueRef.current = null; + setUiVersion((value) => value + 1); + } + } else { + cpuThinkDueRef.current = null; + cpuThinkingRef.current = false; + } + + renderMatch(canvas, state, camera, aimRef.current, hoverBodyRef.current, showRoles, showMinimap); + if (now - lastUiSync > 160) { lastUiSync = now; setUiVersion((value) => value + 1); } + frame = requestAnimationFrame(draw); + }; + + frame = requestAnimationFrame(draw); + return () => cancelAnimationFrame(frame); + }, [followBall, showMinimap, showRoles]); + + const state = matchRef.current; + const formation = FORMATIONS_11.find((item) => item.id === userFormationId) ?? FORMATIONS_11[0]; + const cpuFormation = FORMATIONS_11.find((item) => item.id === cpuFormationId) ?? FORMATIONS_11[0]; + const phaseLabel = state.phase === "finished" ? "FIM DE JOGO" : state.phase === "goal" ? "GOL" : state.turn === "cpu" ? cpuThinkingRef.current ? "CPU PENSANDO" : "VEZ DA CPU" : state.phase === "resolving" ? "BOLA ROLANDO" : "SUA VEZ"; + + return ( +
+
+
← 5×5
LABORATÓRIO V2FUTBOBO 11×11
+
+ {userClub.abbr}{state.score.user}{formatClock(state.clock)}{state.score.cpu}{cpuClub.abbr} +
+
{phaseLabel}{state.turns} turnos · {followBall ? "câmera seguindo" : "câmera manual"}
+
+ +
+ + +
+ event.preventDefault()} onPointerDown={onPointerDown} onPointerMove={onPointerMove} onPointerUp={onPointerUp} onPointerCancel={onPointerUp} onWheel={onWheel} /> +
{followBall ? "● SEGUINDO BOLA" : "CÂMERA MANUAL"}{followBall ? "pan ou zoom trava aqui" : "F para voltar a seguir"}
+
ARRASTA A PEÇA PARA TRÁS E SOLTApan/zoom = câmera fica parada · F = seguir bola · C = campo inteiro · R = reiniciar
+ {state.phase === "goal" &&
GOOOOOOOL{state.score.user} × {state.score.cpu}
} + {state.phase === "finished" &&
FIM DO EXPERIMENTO{userClub.abbr} {state.score.user} × {state.score.cpu} {cpuClub.abbr}

{state.turns} turnos · CPU: {cpuThinkMs?.toFixed(0) ?? "—"} ms no último cálculo

} +
+
+
+ ); +} diff --git a/app/botao-11/README.md b/app/botao-11/README.md new file mode 100644 index 00000000..6743bc89 --- /dev/null +++ b/app/botao-11/README.md @@ -0,0 +1,40 @@ +# Laboratório 11×11 + +Protótipo desktop isolado do modo 5×5. A rota `/botao-11` existe para responder +uma pergunta antes de integrar qualquer coisa à carreira: **futebol de botão com +11 peças por lado, campo grande e câmera continua divertido e legível?** + +## O que o protótipo já testa + +- 11 discos por lado, incluindo goleiro, com 4-3-3, 4-4-2, 3-5-2 e 4-2-3-1. +- Física de discos/bola/tabelas/traves inspirada no motor atual do Futbobo. +- Um toque alternado por turno, mantendo a identidade do 5×5. +- CPU sem teleporte: gera candidatos e faz rollouts no mesmo motor antes de agir. +- Campo 1160×720 com câmera suave, pan manual e zoom. +- Minimapa com retângulo da câmera. +- Clubes reais já existentes em `game-data.ts`, sem adicionar nenhum dado novo. +- Telemetria simples do custo do último turno da CPU. + +## Controles + +- Arraste uma peça sua para trás e solte: toque. +- Arraste o fundo do campo: move a câmera. +- Roda do mouse: zoom ancorado no cursor. +- `F`: centraliza a câmera na bola. +- `Espaço`: liga/desliga câmera automática. +- `R`: nova partida/seed. + +## Deliberadamente fora do protótipo + +- mobile/touch refinado; +- carreira, temporada, substituições e fadiga; +- laterais/escanteios (a mesa continua com tabelas como o 5×5); +- pênaltis e prorrogação; +- replay e áudio; +- integração com o jogador da carreira. + +Se a sensação de jogo passar no teste, o passo seguinte é extrair câmera e +`ruleset` para um contrato comum e decidir o que pode voltar para `app/botao` +sem mudar o comportamento clássico. + + diff --git a/app/botao-11/botao11.module.css b/app/botao-11/botao11.module.css new file mode 100644 index 00000000..8239e8be --- /dev/null +++ b/app/botao-11/botao11.module.css @@ -0,0 +1,63 @@ +.page { + min-height: 100vh; + height: 100vh; + overflow: hidden; + background: #070b08; + color: #f4f7f4; + font-family: var(--font-sans, Arial, Helvetica, sans-serif); + display: grid; + grid-template-rows: 72px minmax(0, 1fr); +} + +.topbar { display: grid; grid-template-columns: 1fr auto 1fr; align-items: center; gap: 24px; padding: 0 22px; border-bottom: 1px solid rgba(255,255,255,.09); background: rgba(8,13,10,.97); z-index: 5; } +.brand { display: flex; align-items: center; gap: 16px; min-width: 0; } +.brand > a { color: #9ab1a1; text-decoration: none; font-size: 12px; font-weight: 800; letter-spacing: .08em; border: 1px solid rgba(255,255,255,.12); border-radius: 9px; padding: 8px 10px; } +.brand div { display: grid; gap: 2px; } +.brand span, .eyebrow { font: 800 9px/1 ui-monospace, SFMono-Regular, Menlo, monospace; letter-spacing: .18em; color: #79b78b; } +.brand strong { font-size: 15px; letter-spacing: .04em; } + +.scoreboard { height: 48px; padding: 0 12px; min-width: 330px; display: grid; grid-template-columns: 66px 42px 76px 42px 66px; align-items: center; justify-items: center; background: #101713; border: 1px solid rgba(255,255,255,.1); border-radius: 13px; box-shadow: 0 14px 36px rgba(0,0,0,.25); } +.scoreboard span { position: relative; font: 900 12px/1 ui-monospace, SFMono-Regular, Menlo, monospace; } +.scoreboard span::before { content: ""; width: 7px; height: 7px; display: inline-block; border-radius: 999px; background: var(--team); margin-right: 6px; box-shadow: 0 0 12px var(--team); } +.scoreboard strong { font-size: 24px; font-variant-numeric: tabular-nums; } +.scoreboard em { font: 900 15px/1 ui-monospace, SFMono-Regular, Menlo, monospace; color: #cbd8ce; font-style: normal; padding: 0 12px; border-left: 1px solid rgba(255,255,255,.1); border-right: 1px solid rgba(255,255,255,.1); } +.status { justify-self: end; display: grid; grid-template-columns: 9px auto; gap: 2px 8px; align-items: center; text-align: left; } +.status i { width: 8px; height: 8px; border-radius: 99px; background: #65e684; box-shadow: 0 0 14px #65e684; grid-row: span 2; } +.status[data-turn="cpu"] i { background: #ffb45e; box-shadow: 0 0 14px #ffb45e; } +.status span { font-size: 11px; font-weight: 900; letter-spacing: .08em; } +.status small { color: #75867a; font: 700 9px/1 ui-monospace, monospace; } +.workspace { min-height: 0; display: grid; grid-template-columns: 260px minmax(0, 1fr); } +.sidebar { min-height: 0; overflow-y: auto; padding: 16px; background: #0c120e; border-right: 1px solid rgba(255,255,255,.08); display: grid; align-content: start; gap: 16px; } +.sidebar section { display: grid; gap: 10px; padding-bottom: 16px; border-bottom: 1px solid rgba(255,255,255,.07); } +.sidebar select { width: 100%; color: #eef5ef; background: #151e18; border: 1px solid rgba(255,255,255,.1); border-radius: 9px; padding: 9px 10px; font-size: 11px; outline: none; } +.formationGrid { display: grid; grid-template-columns: repeat(2, 1fr); gap: 6px; } +.formationGrid button, .testPanel button, .endOverlay button { border: 1px solid rgba(255,255,255,.1); background: #121a15; color: #dce6df; border-radius: 9px; padding: 9px 10px; cursor: pointer; transition: border-color .16s ease, background .16s ease, transform .16s ease; } +.formationGrid button:hover, .testPanel button:hover, .endOverlay button:hover { transform: translateY(-1px); border-color: rgba(255,255,255,.28); } +.formationGrid button { display: grid; gap: 3px; text-align: left; } +.formationGrid button strong { font: 900 12px/1 ui-monospace, monospace; } +.formationGrid button small { color: #718278; font-size: 9px; } +.formationGrid button.active { border-color: #63d77e; background: #14271a; color: #8ff0a6; } +.testPanel button { width: 100%; font-weight: 800; font-size: 10px; text-align: left; } +.testPanel button.primary { background: #e8f7eb; color: #0b140e; border-color: #e8f7eb; } +.toggleRow { display: grid; gap: 7px; color: #9aaba0; font-size: 10px; } +.toggleRow label { display: flex; align-items: center; gap: 7px; cursor: pointer; } +.toggleRow input { accent-color: #63d77e; } +.telemetry dl { margin: 0; display: grid; gap: 7px; } +.telemetry dl div { display: flex; justify-content: space-between; gap: 12px; font-size: 9px; } +.telemetry dt { color: #748279; } +.telemetry dd { margin: 0; color: #d9e5dc; font: 700 9px/1 ui-monospace, monospace; } +.canvasShell { position: relative; min-width: 0; min-height: 0; overflow: hidden; background: #050805; } +.canvas { display: block; width: 100%; height: 100%; cursor: crosshair; touch-action: none; user-select: none; } +.help { position: absolute; left: 18px; bottom: 18px; display: grid; gap: 4px; padding: 10px 12px; border: 1px solid rgba(255,255,255,.1); border-radius: 10px; background: rgba(6,11,8,.75); backdrop-filter: blur(9px); pointer-events: none; } +.help strong { font: 900 10px/1 ui-monospace, monospace; letter-spacing: .08em; } +.help span { color: #90a096; font-size: 9px; } +.goalBanner { position: absolute; inset: 0; display: grid; place-content: center; justify-items: center; gap: 8px; pointer-events: none; background: radial-gradient(circle, rgba(35,117,66,.25), transparent 44%); animation: goalIn .35s cubic-bezier(.2,.9,.2,1) both; } +.goalBanner span { font: 1000 clamp(46px, 7vw, 110px)/.85 ui-sans-serif, sans-serif; letter-spacing: -.07em; text-shadow: 0 10px 60px rgba(0,0,0,.6); } +.goalBanner strong { font: 900 20px/1 ui-monospace, monospace; padding: 9px 15px; background: rgba(4,9,6,.8); border-radius: 999px; } +.endOverlay { position: absolute; inset: 0; display: grid; place-content: center; justify-items: center; gap: 13px; background: rgba(4,8,5,.76); backdrop-filter: blur(8px); } +.endOverlay span { color: #83a18c; font: 900 10px/1 ui-monospace, monospace; letter-spacing: .18em; } +.endOverlay strong { font-size: clamp(32px, 4vw, 62px); letter-spacing: -.05em; } +.endOverlay p { margin: 0; color: #9aad9f; font-size: 12px; } +.endOverlay button { background: #edf7ef; color: #0b130d; font-weight: 900; padding: 12px 18px; } +@keyframes goalIn { from { opacity: 0; transform: scale(.86); } to { opacity: 1; transform: scale(1); } } +@media (max-width: 1050px) { .workspace { grid-template-columns: 220px minmax(0, 1fr); } .topbar { grid-template-columns: 1fr auto; } .status { display: none; } .scoreboard { justify-self: end; } } diff --git a/app/botao-11/cpu.ts b/app/botao-11/cpu.ts new file mode 100644 index 00000000..00fd4cc0 --- /dev/null +++ b/app/botao-11/cpu.ts @@ -0,0 +1,216 @@ +import { + FIELD, + GOAL_BOTTOM, + GOAL_TOP, + attackGoalX, + ballOf, + beginShot, + cloneMatch, + discsOf, + minShotSpeed, + otherSide, + ownGoalX, + shotSpeedFor, + speedForDistance, + stepMatch, +} from "./engine"; +import { createRng, hashSeed, type Rng } from "./rng"; +import type { Body, MatchState, Shot, Side } from "./types"; + +type Point = { x: number; y: number }; + +type Candidate = Shot & { kind: "attack" | "pass" | "clear" | "shape" }; + +function shotTowards(disc: Body, point: Point, ratio: number, kind: Candidate["kind"]): Candidate { + const dx = point.x - disc.x; + const dy = point.y - disc.y; + const length = Math.hypot(dx, dy) || 1; + const speed = shotSpeedFor(disc.power) * ratio; + return { bodyId: disc.id, vx: dx / length * speed, vy: dy / length * speed, kind }; +} + +function goalTargets(side: Side): Point[] { + const x = attackGoalX(side); + const mid = (GOAL_TOP + GOAL_BOTTOM) / 2; + return [ + { x, y: mid }, + { x, y: GOAL_TOP + 34 }, + { x, y: GOAL_BOTTOM - 34 }, + ]; +} + +function contactPoint(ball: Body, disc: Body, target: Point): Point { + const dx = target.x - ball.x; + const dy = target.y - ball.y; + const length = Math.hypot(dx, dy) || 1; + return { + x: ball.x - dx / length * (ball.radius + disc.radius) * 0.94, + y: ball.y - dy / length * (ball.radius + disc.radius) * 0.94, + }; +} + +function lineCanReachBall(disc: Body, ball: Body, point: Point) { + const dx = point.x - disc.x; + const dy = point.y - disc.y; + const length = Math.hypot(dx, dy); + if (length < 1) return false; + const nx = dx / length; + const ny = dy / length; + const toBallX = ball.x - disc.x; + const toBallY = ball.y - disc.y; + const projection = toBallX * nx + toBallY * ny; + if (projection <= 0) return false; + const perpendicular = Math.abs(toBallX * ny - toBallY * nx); + return perpendicular <= disc.radius + ball.radius - 1; +} + +function buildCandidates(state: MatchState, side: Side, rng: Rng): Candidate[] { + const ball = ballOf(state); + const discs = discsOf(state, side); + const byDistance = discs + .map((disc) => ({ disc, distance: Math.hypot(disc.x - ball.x, disc.y - ball.y) })) + .sort((a, b) => a.distance - b.distance); + const candidates: Candidate[] = []; + + // 1) As peças mais próximas tentam finalizar ou progredir a bola. + for (const { disc } of byDistance.slice(0, 7)) { + const attackTargets = goalTargets(side); + const forwardMates = discs + .filter((mate) => mate.id !== disc.id) + .sort((a, b) => side === "user" ? b.x - a.x : a.x - b.x) + .slice(0, 4) + .map((mate) => ({ x: mate.x, y: mate.y })); + const targets = [...attackTargets, ...forwardMates]; + targets.forEach((target, index) => { + const contact = contactPoint(ball, disc, target); + if (!lineCanReachBall(disc, ball, contact)) return; + const kind: Candidate["kind"] = index < attackTargets.length ? "attack" : "pass"; + candidates.push(shotTowards(disc, contact, 1, kind)); + candidates.push(shotTowards(disc, contact, kind === "attack" ? 0.72 : 0.56, kind)); + }); + + if (lineCanReachBall(disc, ball, ball)) { + candidates.push(shotTowards(disc, ball, 0.9, "clear")); + } + } + + // 2) Peças distantes fecham linhas ou avançam a estrutura. + const ownX = ownGoalX(side); + const attackX = attackGoalX(side); + for (const { disc, distance } of byDistance.slice(4)) { + if (disc.role === "GK" && distance > 280) continue; + const towardOwnGoal = { + x: ball.x * 0.62 + ownX * 0.38, + y: ball.y * 0.72 + FIELD.height / 2 * 0.28, + }; + const forward = { + x: disc.x + (attackX > ownX ? 1 : -1) * 112, + y: disc.y + rng.range(-76, 76), + }; + [towardOwnGoal, forward].forEach((target) => { + const dx = target.x - disc.x; + const dy = target.y - disc.y; + const travel = Math.hypot(dx, dy); + if (travel < 24) return; + const speed = Math.max(minShotSpeed(disc.power) * 1.05, Math.min(shotSpeedFor(disc.power) * 0.5, speedForDistance(travel))); + candidates.push({ bodyId: disc.id, vx: dx / travel * speed, vy: dy / travel * speed, kind: "shape" }); + }); + } + + if (!candidates.length && byDistance[0]) { + candidates.push(shotTowards(byDistance[0].disc, ball, 0.75, "clear")); + } + + // Mantém variedade sem transformar cada turno em um benchmark de física. + const attack = candidates.filter((candidate) => candidate.kind === "attack").slice(0, 12); + const others = candidates.filter((candidate) => candidate.kind !== "attack"); + const picked = [...attack]; + while (picked.length < 30 && others.length) { + const index = Math.floor(rng.next() * others.length); + picked.push(others.splice(index, 1)[0]); + } + return picked; +} + +function evaluate(state: MatchState, side: Side, before: Record) { + const opponent = otherSide(side); + const ball = ballOf(state); + const attackX = attackGoalX(side); + const ownX = ownGoalX(side); + let score = 0; + + score += (state.score[side] - before[side]) * 100_000; + score -= (state.score[opponent] - before[opponent]) * 120_000; + + const goalY = (GOAL_TOP + GOAL_BOTTOM) / 2; + const attackDistance = Math.hypot(ball.x - attackX, (ball.y - goalY) * 0.65); + const ownDistance = Math.hypot(ball.x - ownX, (ball.y - goalY) * 0.65); + score -= attackDistance * 2.2; + if (ownDistance < 360) score -= (360 - ownDistance) * 4.8; + + const mine = discsOf(state, side); + const theirs = discsOf(state, opponent); + const nearestMine = Math.min(...mine.map((disc) => Math.hypot(disc.x - ball.x, disc.y - ball.y))); + const nearestTheirs = Math.min(...theirs.map((disc) => Math.hypot(disc.x - ball.x, disc.y - ball.y))); + score += (nearestTheirs - nearestMine) * 1.4; + + // Presença entre bola e gol próprio, mas sem premiar ônibus completo. + const covering = mine.filter((disc) => side === "user" ? disc.x < ball.x : disc.x > ball.x).length; + score += Math.min(covering, 5) * 34; + + // Espalhamento: evita onze peças amontoadas no mesmo ponto. + let spacing = 0; + for (let i = 0; i < mine.length; i += 1) { + let nearest = Number.POSITIVE_INFINITY; + for (let j = 0; j < mine.length; j += 1) { + if (i === j) continue; + nearest = Math.min(nearest, Math.hypot(mine[i].x - mine[j].x, mine[i].y - mine[j].y)); + } + spacing += Math.min(nearest, 165); + } + score += spacing * 0.08; + return score; +} + +function rollout(state: MatchState, candidate: Candidate, side: Side) { + const clone = cloneMatch(state); + const before = { ...clone.score }; + if (!beginShot(clone, candidate)) return Number.NEGATIVE_INFINITY; + let elapsed = 0; + while (clone.phase === "resolving" && elapsed < 1.7) { + stepMatch(clone, 1 / 45); + elapsed += 1 / 45; + } + return evaluate(clone, side, before); +} + +function jitter(candidate: Candidate, disc: Body, strength: number, rng: Rng): Shot { + const speed = Math.hypot(candidate.vx, candidate.vy); + const angle = Math.atan2(candidate.vy, candidate.vx); + const skill = Math.max(0, Math.min(1, (strength - 58) / 34)); + const control = disc.control / 100; + const error = (1 - skill * 0.62 - control * 0.25); + const nextAngle = angle + rng.range(-0.055, 0.055) * Math.max(0.15, error); + const nextSpeed = speed * (1 + rng.range(-0.09, 0.09) * Math.max(0.2, error)); + return { bodyId: candidate.bodyId, vx: Math.cos(nextAngle) * nextSpeed, vy: Math.sin(nextAngle) * nextSpeed }; +} + +export function chooseCpuShot(state: MatchState, side: Side = "cpu") { + const rng = createRng(hashSeed(state.setup.seed, "cpu11", state.turns, state.score.user, state.score.cpu)); + const candidates = buildCandidates(state, side, rng); + let best: Candidate | null = null; + let bestScore = Number.NEGATIVE_INFINITY; + const strength = side === "cpu" ? state.setup.cpuTeam.strength : state.setup.userTeam.strength; + + for (const candidate of candidates) { + const noise = rng.range(-1, 1) * (92 - strength) * 5.5; + const score = rollout(state, candidate, side) + noise; + if (score > bestScore) { + best = candidate; + bestScore = score; + } + } + if (!best) return null; + const disc = state.bodies.find((body) => body.id === best?.bodyId); + return disc ? jitter(best, disc, strength, rng) : best; +} diff --git a/app/botao-11/engine.ts b/app/botao-11/engine.ts new file mode 100644 index 00000000..96241c31 --- /dev/null +++ b/app/botao-11/engine.ts @@ -0,0 +1,494 @@ +import { formationById } from "./formations"; +import { createRng, hashSeed } from "./rng"; +import type { Body, MatchSetup, MatchState, Shot, Side } from "./types"; + +export const FIELD = { + width: 1480, + height: 860, + inset: 42, + goalWidth: 216, + goalDepth: 42, + discRadius: 15, + keeperRadius: 17, + ballRadius: 8, + postRadius: 5, + centerRadius: 104, + areaDepth: 218, + areaWidth: 420, +}; + +export const GOAL_TOP = (FIELD.height - FIELD.goalWidth) / 2; +export const GOAL_BOTTOM = GOAL_TOP + FIELD.goalWidth; +export const MAX_PULL = 172; + +const DISC_FRICTION = 1.48; +const BALL_FRICTION = 1.82; +const STOP_SPEED = 15; +const MAX_SPEED = 1120; +const MAX_RESOLVE_SECONDS = 4.6; +const RESTITUTION_DISC_DISC = 0.84; +const RESTITUTION_DISC_BALL = 0.89; +const RESTITUTION_WALL = 0.52; +const RESTITUTION_POST = 0.67; +const MIN_SHOT_RATIO = 0.075; + +export function otherSide(side: Side): Side { + return side === "user" ? "cpu" : "user"; +} + +export function attackGoalX(side: Side) { + return side === "user" ? FIELD.width : 0; +} + +export function ownGoalX(side: Side) { + return side === "user" ? 0 : FIELD.width; +} + +export function ballOf(state: MatchState) { + const ball = state.bodies.find((body) => body.kind === "ball"); + if (!ball) throw new Error("botao11: partida sem bola"); + return ball; +} + +export function discsOf(state: MatchState, side: Side) { + return state.bodies.filter((body) => body.kind === "disc" && body.side === side); +} + +export function movingBodies(state: MatchState) { + return state.bodies.filter((body) => body.kind === "disc" || body.kind === "ball"); +} + +function clamp(value: number, min: number, max: number) { + return Math.max(min, Math.min(max, value)); +} + +function teamPower(strength: number) { + return clamp(53 + (strength - 60) * 0.9, 42, 94); +} + +function teamControl(strength: number) { + return clamp(49 + (strength - 60) * 0.95, 40, 95); +} + +export function shotSpeedFor(power: number) { + return 555 + clamp(power, 0, 100) * 2.72; +} + +export function minShotSpeed(power: number) { + return shotSpeedFor(power) * MIN_SHOT_RATIO; +} + +export function speedForDistance(distance: number) { + return Math.max(0, distance) * DISC_FRICTION; +} + +export function distanceForSpeed(speed: number) { + return Math.max(0, speed) / DISC_FRICTION; +} + +function createDisc(args: { + id: string; + side: Side; + number: number; + role: Body["role"]; + power: number; + control: number; +}): Body { + const keeper = args.role === "GK"; + return { + id: args.id, + kind: "disc", + side: args.side, + x: 0, + y: 0, + vx: 0, + vy: 0, + radius: keeper ? FIELD.keeperRadius : FIELD.discRadius, + mass: keeper ? 1.16 : 1, + friction: DISC_FRICTION, + number: args.number, + role: args.role, + power: keeper ? args.power * 0.94 : args.power, + control: args.control, + }; +} + +function createBall(): Body { + return { + id: "ball", + kind: "ball", + side: null, + x: FIELD.width / 2, + y: FIELD.height / 2, + vx: 0, + vy: 0, + radius: FIELD.ballRadius, + mass: 0.38, + friction: BALL_FRICTION, + number: 0, + power: 0, + control: 0, + }; +} + +function createPost(id: string, x: number, y: number): Body { + return { + id, + kind: "post", + side: null, + x, + y, + vx: 0, + vy: 0, + radius: FIELD.postRadius, + mass: Number.POSITIVE_INFINITY, + friction: 0, + number: 0, + power: 0, + control: 0, + }; +} + +const NUMBERS = [1, 2, 4, 5, 3, 6, 8, 10, 7, 9, 11]; + +function placeSide(state: MatchState, side: Side) { + const formation = formationById(side === "user" ? state.setup.userFormationId : state.setup.cpuFormationId); + const discs = discsOf(state, side); + const rng = createRng(hashSeed(state.setup.seed, side, state.turns, state.score.user, state.score.cpu)); + discs.forEach((disc, index) => { + const slot = formation.slots[index]; + const usableHalf = FIELD.width / 2 - FIELD.inset * 1.8; + const depth = FIELD.inset * 1.3 + slot.depth * usableHalf; + disc.x = side === "user" ? depth : FIELD.width - depth; + disc.y = FIELD.inset + slot.lane * (FIELD.height - FIELD.inset * 2); + disc.x += rng.range(-5.5, 5.5); + disc.y += rng.range(-5.5, 5.5); + disc.vx = 0; + disc.vy = 0; + }); +} + +function separateBodies(state: MatchState) { + const dynamic = movingBodies(state); + for (let iteration = 0; iteration < 16; iteration += 1) { + let moved = false; + for (let i = 0; i < dynamic.length; i += 1) { + for (let j = i + 1; j < dynamic.length; j += 1) { + const a = dynamic[i]; + const b = dynamic[j]; + const min = a.radius + b.radius + 1.5; + let dx = b.x - a.x; + let dy = b.y - a.y; + let dist = Math.hypot(dx, dy); + if (dist >= min) continue; + if (dist < 0.001) { + dx = 1; + dy = 0; + dist = 1; + } + const overlap = min - dist; + const nx = dx / dist; + const ny = dy / dist; + const aShare = a.kind === "ball" ? 0.7 : 0.5; + const bShare = b.kind === "ball" ? 0.7 : 0.5; + a.x -= nx * overlap * aShare; + a.y -= ny * overlap * aShare; + b.x += nx * overlap * bShare; + b.y += ny * overlap * bShare; + moved = true; + } + } + if (!moved) break; + } +} + +export function createMatch(setup: MatchSetup): MatchState { + const userPower = teamPower(setup.userTeam.strength); + const userControl = teamControl(setup.userTeam.strength); + const cpuPower = teamPower(setup.cpuTeam.strength); + const cpuControl = teamControl(setup.cpuTeam.strength); + const userFormation = formationById(setup.userFormationId); + const cpuFormation = formationById(setup.cpuFormationId); + const bodies: Body[] = [createBall()]; + + userFormation.slots.forEach((slot, index) => { + bodies.push(createDisc({ + id: `user-${index}`, + side: "user", + number: NUMBERS[index], + role: slot.role, + power: userPower, + control: userControl, + })); + }); + cpuFormation.slots.forEach((slot, index) => { + bodies.push(createDisc({ + id: `cpu-${index}`, + side: "cpu", + number: NUMBERS[index], + role: slot.role, + power: cpuPower, + control: cpuControl, + })); + }); + bodies.push( + createPost("post-left-top", 0, GOAL_TOP), + createPost("post-left-bottom", 0, GOAL_BOTTOM), + createPost("post-right-top", FIELD.width, GOAL_TOP), + createPost("post-right-bottom", FIELD.width, GOAL_BOTTOM), + ); + + const state: MatchState = { + setup, + phase: "aim", + turn: "user", + bodies, + score: { user: 0, cpu: 0 }, + clock: setup.matchSeconds, + turns: 0, + resolveElapsed: 0, + events: [], + lastTouch: null, + version: 0, + }; + placeSide(state, "user"); + placeSide(state, "cpu"); + separateBodies(state); + return state; +} + +export function cloneMatch(state: MatchState): MatchState { + return { + ...state, + setup: { ...state.setup, userTeam: { ...state.setup.userTeam }, cpuTeam: { ...state.setup.cpuTeam } }, + bodies: state.bodies.map((body) => ({ ...body })), + score: { ...state.score }, + events: [], + lastTouch: state.lastTouch ? { ...state.lastTouch } : null, + }; +} + +export function resetKickoff(state: MatchState, kickoffSide: Side) { + placeSide(state, "user"); + placeSide(state, "cpu"); + const ball = ballOf(state); + ball.x = FIELD.width / 2; + ball.y = FIELD.height / 2; + ball.vx = 0; + ball.vy = 0; + separateBodies(state); + state.turn = kickoffSide; + state.lastTouch = null; + state.phase = "aim"; + state.resolveElapsed = 0; + state.version += 1; +} + +export function resumeAfterGoal(state: MatchState) { + if (state.phase !== "goal") return; + const lastGoal = [...state.events].reverse().find((event) => event.type === "goal"); + const kickoff = lastGoal && lastGoal.type === "goal" ? otherSide(lastGoal.side) : "user"; + resetKickoff(state, kickoff); +} + +export function beginShot(state: MatchState, shot: Shot) { + if (state.phase !== "aim") return false; + const body = state.bodies.find((candidate) => candidate.id === shot.bodyId); + if (!body || body.kind !== "disc" || body.side !== state.turn) return false; + const rawSpeed = Math.hypot(shot.vx, shot.vy); + const max = shotSpeedFor(body.power); + if (rawSpeed < minShotSpeed(body.power)) return false; + const scale = rawSpeed > max ? max / rawSpeed : 1; + body.vx = shot.vx * scale; + body.vy = shot.vy * scale; + state.phase = "resolving"; + state.resolveElapsed = 0; + state.turns += 1; + state.lastTouch = null; + state.version += 1; + return true; +} + +function resolveCollision(a: Body, b: Body) { + let dx = b.x - a.x; + let dy = b.y - a.y; + let distance = Math.hypot(dx, dy); + const minimum = a.radius + b.radius; + if (distance >= minimum) return false; + if (distance < 0.0001) { + dx = 0.6; + dy = 0.8; + distance = 1; + } + const nx = dx / distance; + const ny = dy / distance; + const overlap = minimum - distance; + const invA = Number.isFinite(a.mass) ? 1 / a.mass : 0; + const invB = Number.isFinite(b.mass) ? 1 / b.mass : 0; + const invSum = invA + invB || 1; + a.x -= nx * overlap * (invA / invSum); + a.y -= ny * overlap * (invA / invSum); + b.x += nx * overlap * (invB / invSum); + b.y += ny * overlap * (invB / invSum); + + const relative = (b.vx - a.vx) * nx + (b.vy - a.vy) * ny; + if (relative >= 0) return true; + const restitution = a.kind === "post" || b.kind === "post" + ? RESTITUTION_POST + : a.kind === "ball" || b.kind === "ball" + ? RESTITUTION_DISC_BALL + : RESTITUTION_DISC_DISC; + const impulse = -(1 + restitution) * relative / invSum; + a.vx -= impulse * nx * invA; + a.vy -= impulse * ny * invA; + b.vx += impulse * nx * invB; + b.vy += impulse * ny * invB; + return true; +} + +function checkGoal(state: MatchState) { + const ball = ballOf(state); + const withinMouth = ball.y > GOAL_TOP + FIELD.postRadius && ball.y < GOAL_BOTTOM - FIELD.postRadius; + if (!withinMouth) return false; + let scoring: Side | null = null; + if (ball.x - ball.radius <= 0) scoring = "cpu"; + if (ball.x + ball.radius >= FIELD.width) scoring = "user"; + if (!scoring) return false; + state.score[scoring] += 1; + const scorer = state.lastTouch?.side === scoring + ? state.bodies.find((body) => body.id === state.lastTouch?.bodyId)?.role ?? "?" + : "gol contra"; + state.events.push({ type: "goal", side: scoring, scorer }); + movingBodies(state).forEach((body) => { + body.vx = 0; + body.vy = 0; + }); + state.phase = "goal"; + state.version += 1; + return true; +} + +function walls(body: Body) { + if (body.kind === "post") return; + if (body.y - body.radius < 0) { + body.y = body.radius; + body.vy = Math.abs(body.vy) * RESTITUTION_WALL; + } else if (body.y + body.radius > FIELD.height) { + body.y = FIELD.height - body.radius; + body.vy = -Math.abs(body.vy) * RESTITUTION_WALL; + } + + const inMouth = body.y > GOAL_TOP + FIELD.postRadius && body.y < GOAL_BOTTOM - FIELD.postRadius; + if (!inMouth) { + if (body.x - body.radius < 0) { + body.x = body.radius; + body.vx = Math.abs(body.vx) * RESTITUTION_WALL; + } else if (body.x + body.radius > FIELD.width) { + body.x = FIELD.width - body.radius; + body.vx = -Math.abs(body.vx) * RESTITUTION_WALL; + } + } else if (body.kind === "disc") { + // Peças não entram dentro da rede; só a bola cruza a linha de gol. + if (body.x - body.radius < 0) { + body.x = body.radius; + body.vx = Math.abs(body.vx) * RESTITUTION_WALL; + } else if (body.x + body.radius > FIELD.width) { + body.x = FIELD.width - body.radius; + body.vx = -Math.abs(body.vx) * RESTITUTION_WALL; + } + } +} + +function friction(body: Body, dt: number) { + if (body.kind === "post") return; + const factor = Math.exp(-body.friction * dt); + body.vx *= factor; + body.vy *= factor; + let speed = Math.hypot(body.vx, body.vy); + // A bola perde o restinho de embalo mais rápido para o jogador não ficar + // contemplando 2 segundos de rolagem inútil depois que o lance acabou. + if (body.kind === "ball" && speed < 105) { + const lowSpeedBrake = Math.exp(-2.25 * dt); + body.vx *= lowSpeedBrake; + body.vy *= lowSpeedBrake; + speed = Math.hypot(body.vx, body.vy); + } + if (speed < STOP_SPEED) { + body.vx = 0; + body.vy = 0; + } else if (speed > MAX_SPEED) { + const ratio = MAX_SPEED / speed; + body.vx *= ratio; + body.vy *= ratio; + } +} + +function detectBallTouch(state: MatchState, a: Body, b: Body) { + const disc = a.kind === "disc" && b.kind === "ball" ? a : b.kind === "disc" && a.kind === "ball" ? b : null; + if (disc?.side) state.lastTouch = { side: disc.side, bodyId: disc.id }; +} + +function integrate(state: MatchState, dt: number) { + const movable = movingBodies(state); + movable.forEach((body) => { + body.x += body.vx * dt; + body.y += body.vy * dt; + walls(body); + }); + + for (let pass = 0; pass < 2; pass += 1) { + for (let i = 0; i < state.bodies.length; i += 1) { + const a = state.bodies[i]; + if (a.kind === "post" && pass > 0) continue; + for (let j = i + 1; j < state.bodies.length; j += 1) { + const b = state.bodies[j]; + if (a.kind === "post" && b.kind === "post") continue; + const hit = resolveCollision(a, b); + if (hit) detectBallTouch(state, a, b); + } + } + } + + movable.forEach((body) => friction(body, dt)); +} + +export function stepMatch(state: MatchState, dt: number) { + const safeDt = clamp(dt, 0, 0.032); + if (state.phase === "finished") return; + + if (state.phase === "aim" || state.phase === "resolving") { + state.clock = Math.max(0, state.clock - safeDt); + if (state.clock <= 0 && state.phase !== "resolving") { + state.phase = "finished"; + state.events.push({ type: "match-end" }); + state.version += 1; + return; + } + } + + if (state.phase !== "resolving") return; + state.resolveElapsed += safeDt; + integrate(state, safeDt); + if (checkGoal(state)) return; + + const moving = movingBodies(state); + const stillMoving = moving.some((body) => body.vx !== 0 || body.vy !== 0); + const peakSpeed = moving.reduce((max, body) => Math.max(max, Math.hypot(body.vx, body.vy)), 0); + const quietTail = state.resolveElapsed >= 2.15 && peakSpeed < 30; + if (!stillMoving || quietTail || state.resolveElapsed >= MAX_RESOLVE_SECONDS) { + movingBodies(state).forEach((body) => { + body.vx = 0; + body.vy = 0; + }); + if (state.clock <= 0) { + state.phase = "finished"; + state.events.push({ type: "match-end" }); + } else { + state.turn = otherSide(state.turn); + state.phase = "aim"; + state.events.push({ type: "settled" }, { type: "turn", side: state.turn }); + } + state.resolveElapsed = 0; + state.version += 1; + } +} diff --git a/app/botao-11/formations.ts b/app/botao-11/formations.ts new file mode 100644 index 00000000..4d24d44f --- /dev/null +++ b/app/botao-11/formations.ts @@ -0,0 +1,80 @@ +import type { Formation } from "./types"; + +export const FORMATIONS_11: Formation[] = [ + { + id: "433", + name: "Equilíbrio", + shape: "4-3-3", + slots: [ + { role: "GK", lane: 0.50, depth: 0.035 }, + { role: "RB", lane: 0.84, depth: 0.25 }, + { role: "CB", lane: 0.61, depth: 0.20 }, + { role: "CB", lane: 0.39, depth: 0.20 }, + { role: "LB", lane: 0.16, depth: 0.25 }, + { role: "CM", lane: 0.72, depth: 0.49 }, + { role: "DM", lane: 0.50, depth: 0.43 }, + { role: "CM", lane: 0.28, depth: 0.49 }, + { role: "RW", lane: 0.83, depth: 0.78 }, + { role: "ST", lane: 0.50, depth: 0.86 }, + { role: "LW", lane: 0.17, depth: 0.78 }, + ], + }, + { + id: "442", + name: "Clássica", + shape: "4-4-2", + slots: [ + { role: "GK", lane: 0.50, depth: 0.035 }, + { role: "RB", lane: 0.84, depth: 0.24 }, + { role: "CB", lane: 0.61, depth: 0.19 }, + { role: "CB", lane: 0.39, depth: 0.19 }, + { role: "LB", lane: 0.16, depth: 0.24 }, + { role: "RW", lane: 0.84, depth: 0.52 }, + { role: "CM", lane: 0.61, depth: 0.48 }, + { role: "CM", lane: 0.39, depth: 0.48 }, + { role: "LW", lane: 0.16, depth: 0.52 }, + { role: "ST", lane: 0.62, depth: 0.82 }, + { role: "ST", lane: 0.38, depth: 0.82 }, + ], + }, + { + id: "352", + name: "Pressão", + shape: "3-5-2", + slots: [ + { role: "GK", lane: 0.50, depth: 0.035 }, + { role: "CB", lane: 0.72, depth: 0.20 }, + { role: "CB", lane: 0.50, depth: 0.16 }, + { role: "CB", lane: 0.28, depth: 0.20 }, + { role: "RW", lane: 0.90, depth: 0.50 }, + { role: "CM", lane: 0.68, depth: 0.48 }, + { role: "DM", lane: 0.50, depth: 0.40 }, + { role: "CM", lane: 0.32, depth: 0.48 }, + { role: "LW", lane: 0.10, depth: 0.50 }, + { role: "ST", lane: 0.61, depth: 0.82 }, + { role: "ST", lane: 0.39, depth: 0.82 }, + ], + }, + { + id: "4231", + name: "Controle", + shape: "4-2-3-1", + slots: [ + { role: "GK", lane: 0.50, depth: 0.035 }, + { role: "RB", lane: 0.84, depth: 0.24 }, + { role: "CB", lane: 0.61, depth: 0.19 }, + { role: "CB", lane: 0.39, depth: 0.19 }, + { role: "LB", lane: 0.16, depth: 0.24 }, + { role: "DM", lane: 0.63, depth: 0.43 }, + { role: "DM", lane: 0.37, depth: 0.43 }, + { role: "RW", lane: 0.82, depth: 0.66 }, + { role: "AM", lane: 0.50, depth: 0.64 }, + { role: "LW", lane: 0.18, depth: 0.66 }, + { role: "ST", lane: 0.50, depth: 0.86 }, + ], + }, +]; + +export function formationById(id: string) { + return FORMATIONS_11.find((formation) => formation.id === id) ?? FORMATIONS_11[0]; +} diff --git a/app/botao-11/lab-v2.module.css b/app/botao-11/lab-v2.module.css new file mode 100644 index 00000000..6ef0c125 --- /dev/null +++ b/app/botao-11/lab-v2.module.css @@ -0,0 +1,7 @@ +.page{height:100vh;min-height:100vh;overflow:hidden;background:#070b08;color:#f4f7f4;font-family:var(--font-sans,Arial,Helvetica,sans-serif);display:grid;grid-template-rows:68px minmax(0,1fr)} +.topbar{display:grid;grid-template-columns:1fr auto 1fr;align-items:center;gap:20px;padding:0 20px;border-bottom:1px solid rgba(255,255,255,.09);background:rgba(8,13,10,.98);z-index:5} +.brand{display:flex;align-items:center;gap:14px;min-width:0}.brand>a{color:#9ab1a1;text-decoration:none;font-size:11px;font-weight:900;letter-spacing:.08em;border:1px solid rgba(255,255,255,.12);border-radius:9px;padding:8px 10px}.brand div{display:grid;gap:2px}.brand span,.eyebrow{font:800 9px/1 ui-monospace,SFMono-Regular,Menlo,monospace;letter-spacing:.18em;color:#79b78b}.brand strong{font-size:15px;letter-spacing:.04em} +.scoreboard{height:46px;padding:0 12px;min-width:330px;display:grid;grid-template-columns:66px 42px 76px 42px 66px;align-items:center;justify-items:center;background:#101713;border:1px solid rgba(255,255,255,.1);border-radius:13px;box-shadow:0 14px 36px rgba(0,0,0,.25)}.scoreboard span{font:900 12px/1 ui-monospace,monospace}.scoreboard span:before{content:"";width:7px;height:7px;display:inline-block;border-radius:99px;background:var(--team);margin-right:6px;box-shadow:0 0 12px var(--team)}.scoreboard strong{font-size:23px;font-variant-numeric:tabular-nums}.scoreboard em{font:900 14px/1 ui-monospace,monospace;color:#cbd8ce;font-style:normal;padding:0 12px;border-left:1px solid rgba(255,255,255,.1);border-right:1px solid rgba(255,255,255,.1)} +.status{justify-self:end;display:grid;grid-template-columns:9px auto;gap:2px 8px;align-items:center}.status i{width:8px;height:8px;border-radius:99px;background:#65e684;box-shadow:0 0 14px #65e684;grid-row:span 2}.status[data-turn="cpu"] i{background:#ffb45e;box-shadow:0 0 14px #ffb45e}.status span{font-size:11px;font-weight:900;letter-spacing:.08em}.status small{color:#75867a;font:700 9px/1 ui-monospace,monospace} +.workspace{min-height:0;display:grid;grid-template-columns:252px minmax(0,1fr)}.sidebar{min-height:0;overflow-y:auto;padding:15px;background:#0c120e;border-right:1px solid rgba(255,255,255,.08);display:grid;align-content:start;gap:15px}.sidebar section{display:grid;gap:9px;padding-bottom:15px;border-bottom:1px solid rgba(255,255,255,.07)}.sidebar select{width:100%;color:#eef5ef;background:#151e18;border:1px solid rgba(255,255,255,.1);border-radius:9px;padding:9px 10px;font-size:11px;outline:none}.formationGrid{display:grid;grid-template-columns:repeat(2,1fr);gap:6px}.formationGrid button,.controls button,.endOverlay button{border:1px solid rgba(255,255,255,.1);background:#121a15;color:#dce6df;border-radius:9px;padding:9px 10px;cursor:pointer;transition:.15s ease}.formationGrid button:hover,.controls button:hover,.endOverlay button:hover{transform:translateY(-1px);border-color:rgba(255,255,255,.28)}.formationGrid button{display:grid;gap:3px;text-align:left}.formationGrid button strong{font:900 12px/1 ui-monospace,monospace}.formationGrid button small{color:#718278;font-size:9px}.formationGrid button.active{border-color:#63d77e;background:#14271a;color:#8ff0a6}.controls button{width:100%;font-weight:800;font-size:10px;text-align:left}.controls button.primary{background:#e8f7eb;color:#0b140e;border-color:#e8f7eb}.toggles{display:grid;gap:7px;color:#9aaba0;font-size:10px}.toggles label{display:flex;align-items:center;gap:7px;cursor:pointer}.toggles input{accent-color:#63d77e}.telemetry dl{margin:0;display:grid;gap:7px}.telemetry dl div{display:flex;justify-content:space-between;gap:12px;font-size:9px}.telemetry dt{color:#748279}.telemetry dd{margin:0;color:#d9e5dc;font:700 9px/1 ui-monospace,monospace} +.canvasShell{position:relative;min-width:0;min-height:0;overflow:hidden;background:#050805}.canvas{display:block;width:100%;height:100%;cursor:crosshair;touch-action:none;user-select:none}.cameraBadge{position:absolute;left:18px;top:18px;display:grid;gap:3px;padding:9px 11px;border:1px solid rgba(255,255,255,.11);border-radius:10px;background:rgba(5,10,7,.78);backdrop-filter:blur(9px);pointer-events:none;box-shadow:0 10px 28px rgba(0,0,0,.24)}.cameraBadge strong{font:900 9px/1 ui-monospace,monospace;letter-spacing:.08em;color:#dce8df}.cameraBadge span{font-size:8px;color:#83968a}.cameraBadge[data-follow="on"]{border-color:rgba(99,215,126,.4)}.cameraBadge[data-follow="on"] strong{color:#8ff0a6}.help{position:absolute;left:18px;bottom:18px;display:grid;gap:4px;padding:10px 12px;border:1px solid rgba(255,255,255,.1);border-radius:10px;background:rgba(6,11,8,.76);backdrop-filter:blur(9px);pointer-events:none}.help strong{font:900 10px/1 ui-monospace,monospace;letter-spacing:.08em}.help span{color:#90a096;font-size:9px}.goalBanner{position:absolute;inset:0;display:grid;place-content:center;justify-items:center;gap:8px;pointer-events:none;background:radial-gradient(circle,rgba(35,117,66,.25),transparent 44%);animation:goalIn .3s cubic-bezier(.2,.9,.2,1) both}.goalBanner span{font:1000 clamp(46px,7vw,110px)/.85 ui-sans-serif,sans-serif;letter-spacing:-.07em;text-shadow:0 10px 60px rgba(0,0,0,.6)}.goalBanner strong{font:900 20px/1 ui-monospace,monospace;padding:9px 15px;background:rgba(4,9,6,.8);border-radius:999px}.endOverlay{position:absolute;inset:0;display:grid;place-content:center;justify-items:center;gap:13px;background:rgba(4,8,5,.76);backdrop-filter:blur(8px)}.endOverlay span{color:#83a18c;font:900 10px/1 ui-monospace,monospace;letter-spacing:.18em}.endOverlay strong{font-size:clamp(32px,4vw,62px);letter-spacing:-.05em}.endOverlay p{margin:0;color:#9aad9f;font-size:12px}.endOverlay button{background:#edf7ef;color:#0b130d;font-weight:900;padding:12px 18px}@keyframes goalIn{from{opacity:0;transform:scale(.86)}to{opacity:1;transform:scale(1)}}@media(max-width:1050px){.workspace{grid-template-columns:220px minmax(0,1fr)}.topbar{grid-template-columns:1fr auto}.status{display:none}.scoreboard{justify-self:end}} diff --git a/app/botao-11/page.tsx b/app/botao-11/page.tsx new file mode 100644 index 00000000..3eba2ed9 --- /dev/null +++ b/app/botao-11/page.tsx @@ -0,0 +1 @@ +export { default } from "./LabV2"; diff --git a/app/botao-11/render-v2.ts b/app/botao-11/render-v2.ts new file mode 100644 index 00000000..ff1fb330 --- /dev/null +++ b/app/botao-11/render-v2.ts @@ -0,0 +1,352 @@ +import { FIELD, GOAL_BOTTOM, GOAL_TOP, MAX_PULL, ballOf, distanceForSpeed, shotSpeedFor } from "./engine"; +import type { Body, MatchState, Side } from "./types"; + +export type Camera = { + x: number; + y: number; + zoom: number; + targetX: number; + targetY: number; + targetZoom: number; +}; + +export type AimState = { bodyId: string; pointerX: number; pointerY: number } | null; + +function contrast(hex: string) { + const clean = hex.replace("#", "").padEnd(6, "0").slice(0, 6); + const r = Number.parseInt(clean.slice(0, 2), 16); + const g = Number.parseInt(clean.slice(2, 4), 16); + const b = Number.parseInt(clean.slice(4, 6), 16); + return (r * 299 + g * 587 + b * 114) / 1000 > 150 ? "#101713" : "#ffffff"; +} + +export function renderMatch( + canvas: HTMLCanvasElement, + state: MatchState, + camera: Camera, + aim: AimState, + hoverBodyId: string | null, + showRoles: boolean, + showMinimap: boolean, +) { + const ctx = canvas.getContext("2d"); + if (!ctx) return; + const width = canvas.width; + const height = canvas.height; + const sx = (x: number) => (x - camera.x) * camera.zoom + width / 2; + const sy = (y: number) => (y - camera.y) * camera.zoom + height / 2; + const sr = (v: number) => v * camera.zoom; + + ctx.clearRect(0, 0, width, height); + const bg = ctx.createRadialGradient(width / 2, height / 2, 0, width / 2, height / 2, Math.max(width, height) * 0.8); + bg.addColorStop(0, "#101a16"); + bg.addColorStop(1, "#030604"); + ctx.fillStyle = bg; + ctx.fillRect(0, 0, width, height); + + ctx.save(); + ctx.shadowColor = "rgba(0,0,0,.75)"; + ctx.shadowBlur = sr(42); + ctx.fillStyle = "#173f28"; + ctx.fillRect(sx(0), sy(0), sr(FIELD.width), sr(FIELD.height)); + ctx.restore(); + + ctx.fillStyle = "#247443"; + ctx.fillRect(sx(0), sy(0), sr(FIELD.width), sr(FIELD.height)); + const stripeWidth = FIELD.width / 12; + for (let i = 0; i < 12; i += 1) { + if (i % 2 === 0) continue; + ctx.fillStyle = "rgba(255,255,255,.026)"; + ctx.fillRect(sx(i * stripeWidth), sy(0), sr(stripeWidth), sr(FIELD.height)); + } + + ctx.strokeStyle = "rgba(241,255,244,.86)"; + ctx.lineWidth = Math.max(1.5, sr(2.35)); + ctx.beginPath(); + ctx.rect(sx(0), sy(0), sr(FIELD.width), sr(FIELD.height)); + ctx.moveTo(sx(FIELD.width / 2), sy(0)); + ctx.lineTo(sx(FIELD.width / 2), sy(FIELD.height)); + ctx.stroke(); + + ctx.beginPath(); + ctx.arc(sx(FIELD.width / 2), sy(FIELD.height / 2), sr(FIELD.centerRadius), 0, Math.PI * 2); + ctx.stroke(); + ctx.fillStyle = "rgba(255,255,255,.92)"; + ctx.beginPath(); + ctx.arc(sx(FIELD.width / 2), sy(FIELD.height / 2), sr(4), 0, Math.PI * 2); + ctx.fill(); + + const areaY = (FIELD.height - FIELD.areaWidth) / 2; + ctx.strokeRect(sx(0), sy(areaY), sr(FIELD.areaDepth), sr(FIELD.areaWidth)); + ctx.strokeRect(sx(FIELD.width - FIELD.areaDepth), sy(areaY), sr(FIELD.areaDepth), sr(FIELD.areaWidth)); + const smallAreaDepth = 82; + const smallAreaPad = 52; + ctx.strokeRect(sx(0), sy(GOAL_TOP - smallAreaPad), sr(smallAreaDepth), sr(FIELD.goalWidth + smallAreaPad * 2)); + ctx.strokeRect(sx(FIELD.width - smallAreaDepth), sy(GOAL_TOP - smallAreaPad), sr(smallAreaDepth), sr(FIELD.goalWidth + smallAreaPad * 2)); + ctx.fillStyle = "rgba(255,255,255,.9)"; + for (const px of [FIELD.areaDepth * 0.68, FIELD.width - FIELD.areaDepth * 0.68]) { + ctx.beginPath(); + ctx.arc(sx(px), sy(FIELD.height / 2), sr(3.5), 0, Math.PI * 2); + ctx.fill(); + } + + const drawNet = (left: boolean) => { + const x0 = left ? -FIELD.goalDepth : FIELD.width; + ctx.fillStyle = "rgba(230,242,232,.055)"; + ctx.fillRect(sx(x0), sy(GOAL_TOP), sr(FIELD.goalDepth), sr(FIELD.goalWidth)); + ctx.strokeStyle = "rgba(235,248,237,.22)"; + ctx.lineWidth = Math.max(1, sr(1)); + for (let i = 0; i <= 6; i += 1) { + const yy = GOAL_TOP + FIELD.goalWidth * i / 6; + ctx.beginPath(); + ctx.moveTo(sx(left ? -FIELD.goalDepth : FIELD.width), sy(yy)); + ctx.lineTo(sx(left ? 0 : FIELD.width + FIELD.goalDepth), sy(yy)); + ctx.stroke(); + } + }; + drawNet(true); + drawNet(false); + + ctx.fillStyle = "rgba(255,255,255,.16)"; + ctx.font = `${Math.max(10, sr(12))}px ui-monospace, monospace`; + ctx.textAlign = "center"; + ctx.textBaseline = "alphabetic"; + ctx.fillText("VOCÊ ATACA →", sx(FIELD.width / 2), sy(FIELD.height - 22)); + + if (aim) { + const disc = state.bodies.find((body) => body.id === aim.bodyId); + if (disc) { + const dx = disc.x - aim.pointerX; + const dy = disc.y - aim.pointerY; + const rawPull = Math.hypot(dx, dy); + const pull = Math.min(MAX_PULL, rawPull); + if (pull > 2) { + const nx = dx / rawPull; + const ny = dy / rawPull; + const speed = shotSpeedFor(disc.power) * Math.min(1, pull / MAX_PULL); + const travel = distanceForSpeed(speed); + const endX = disc.x + nx * travel; + const endY = disc.y + ny * travel; + ctx.strokeStyle = "rgba(255,255,255,.72)"; + ctx.lineWidth = Math.max(2, sr(2.25)); + ctx.setLineDash([sr(12), sr(9)]); + ctx.beginPath(); + ctx.moveTo(sx(disc.x), sy(disc.y)); + ctx.lineTo(sx(endX), sy(endY)); + ctx.stroke(); + ctx.setLineDash([]); + ctx.strokeStyle = "rgba(255,255,255,.3)"; + ctx.beginPath(); + ctx.arc(sx(endX), sy(endY), sr(disc.radius), 0, Math.PI * 2); + ctx.stroke(); + + ctx.strokeStyle = pull >= MAX_PULL * 0.98 ? "#ffd45e" : "rgba(255,255,255,.84)"; + ctx.lineWidth = Math.max(3, sr(4)); + ctx.beginPath(); + ctx.moveTo(sx(disc.x), sy(disc.y)); + ctx.lineTo(sx(aim.pointerX), sy(aim.pointerY)); + ctx.stroke(); + } + } + } + + const ball = ballOf(state); + const nearestUserId = state.phase === "aim" && state.turn === "user" + ? state.bodies + .filter((body) => body.kind === "disc" && body.side === "user") + .reduce<{ id: string; distance: number } | null>((best, body) => { + const distance = Math.hypot(body.x - ball.x, body.y - ball.y); + return !best || distance < best.distance ? { id: body.id, distance } : best; + }, null)?.id ?? null + : null; + + const teamFor = (side: Side) => side === "user" ? state.setup.userTeam : state.setup.cpuTeam; + for (const body of state.bodies) { + if (body.kind === "post") continue; + if (body.kind === "ball") { + const speed = Math.hypot(body.vx, body.vy); + if (speed > 45) { + const trail = Math.min(62, speed * 0.07); + const len = speed || 1; + ctx.strokeStyle = `rgba(255,255,255,${Math.min(0.38, speed / 1800)})`; + ctx.lineWidth = Math.max(2, sr(3)); + ctx.beginPath(); + ctx.moveTo(sx(body.x), sy(body.y)); + ctx.lineTo(sx(body.x - body.vx / len * trail), sy(body.y - body.vy / len * trail)); + ctx.stroke(); + } + ctx.save(); + ctx.shadowColor = "rgba(0,0,0,.6)"; + ctx.shadowBlur = sr(10); + ctx.shadowOffsetY = sr(3); + ctx.fillStyle = "#f8fbf7"; + ctx.beginPath(); + ctx.arc(sx(body.x), sy(body.y), sr(body.radius), 0, Math.PI * 2); + ctx.fill(); + ctx.restore(); + ctx.fillStyle = "#18211c"; + ctx.beginPath(); + ctx.arc(sx(body.x + body.radius * 0.12), sy(body.y - body.radius * 0.1), sr(body.radius * 0.34), 0, Math.PI * 2); + ctx.fill(); + continue; + } + + const team = teamFor(body.side as Side); + const selected = aim?.bodyId === body.id; + const hovered = hoverBodyId === body.id; + const suggested = nearestUserId === body.id && !selected; + if (suggested) { + ctx.strokeStyle = "rgba(255,212,94,.5)"; + ctx.lineWidth = Math.max(1.5, sr(2)); + ctx.setLineDash([sr(5), sr(5)]); + ctx.beginPath(); + ctx.arc(sx(body.x), sy(body.y), sr(body.radius + 9), 0, Math.PI * 2); + ctx.stroke(); + ctx.setLineDash([]); + } + + ctx.save(); + ctx.shadowColor = "rgba(0,0,0,.46)"; + ctx.shadowBlur = sr(selected ? 18 : 9); + ctx.shadowOffsetY = sr(4); + ctx.fillStyle = team.primary; + ctx.beginPath(); + ctx.arc(sx(body.x), sy(body.y), sr(body.radius), 0, Math.PI * 2); + ctx.fill(); + ctx.restore(); + + ctx.strokeStyle = selected ? "#ffffff" : hovered ? "#ffd45e" : team.secondary; + ctx.lineWidth = Math.max(2, sr(selected || hovered ? 4 : 2.8)); + ctx.beginPath(); + ctx.arc(sx(body.x), sy(body.y), sr(body.radius - 1.5), 0, Math.PI * 2); + ctx.stroke(); + + if (body.role === "GK") { + ctx.strokeStyle = "rgba(255,255,255,.56)"; + ctx.lineWidth = Math.max(1, sr(1.4)); + ctx.beginPath(); + ctx.arc(sx(body.x), sy(body.y), sr(body.radius + 4), 0, Math.PI * 2); + ctx.stroke(); + } + + ctx.fillStyle = contrast(team.primary); + ctx.font = `800 ${Math.max(9, sr(9.5))}px ui-monospace, monospace`; + ctx.textAlign = "center"; + ctx.textBaseline = "middle"; + ctx.fillText(String(body.number), sx(body.x), sy(body.y)); + + if (showRoles && camera.zoom > 0.62) { + const label = body.role ?? ""; + const w = sr(31); + const h = sr(13); + ctx.fillStyle = "rgba(4,10,7,.76)"; + ctx.fillRect(sx(body.x) - w / 2, sy(body.y + body.radius + 8) - h / 2, w, h); + ctx.fillStyle = "#ffffff"; + ctx.font = `700 ${Math.max(7, sr(7.5))}px ui-monospace, monospace`; + ctx.fillText(label, sx(body.x), sy(body.y + body.radius + 8)); + } + } + + ctx.fillStyle = "#f4f7f3"; + for (const body of state.bodies) { + if (body.kind !== "post") continue; + ctx.beginPath(); + ctx.arc(sx(body.x), sy(body.y), sr(body.radius), 0, Math.PI * 2); + ctx.fill(); + } + + drawOffscreenBallIndicator(ctx, ball, width, height, camera); + if (showMinimap) drawMinimap(ctx, state, width, height, camera); +} + +function drawOffscreenBallIndicator(ctx: CanvasRenderingContext2D, ball: Body, width: number, height: number, camera: Camera) { + const bx = (ball.x - camera.x) * camera.zoom + width / 2; + const by = (ball.y - camera.y) * camera.zoom + height / 2; + const pad = 52; + if (bx >= pad && bx <= width - pad && by >= pad && by <= height - pad) return; + const cx = width / 2; + const cy = height / 2; + const dx = bx - cx; + const dy = by - cy; + const scaleX = Math.abs(dx) > 0.001 ? (width / 2 - pad) / Math.abs(dx) : Number.POSITIVE_INFINITY; + const scaleY = Math.abs(dy) > 0.001 ? (height / 2 - pad) / Math.abs(dy) : Number.POSITIVE_INFINITY; + const scale = Math.min(scaleX, scaleY); + const x = cx + dx * scale; + const y = cy + dy * scale; + const angle = Math.atan2(dy, dx); + ctx.save(); + ctx.translate(x, y); + ctx.rotate(angle); + ctx.fillStyle = "rgba(255,212,94,.96)"; + ctx.beginPath(); + ctx.moveTo(15, 0); + ctx.lineTo(-8, -8); + ctx.lineTo(-5, 0); + ctx.lineTo(-8, 8); + ctx.closePath(); + ctx.fill(); + ctx.rotate(-angle); + ctx.fillStyle = "#ffd45e"; + ctx.font = "900 10px ui-monospace, monospace"; + ctx.textAlign = "center"; + ctx.textBaseline = "bottom"; + ctx.fillText("BOLA", 0, -13); + ctx.restore(); +} + +function drawMinimap(ctx: CanvasRenderingContext2D, state: MatchState, width: number, height: number, camera: Camera) { + const mapW = Math.min(350, width * 0.215); + const mapH = mapW * FIELD.height / FIELD.width; + const x = width - mapW - 22; + const y = 22; + ctx.save(); + ctx.fillStyle = "rgba(4,9,6,.84)"; + ctx.strokeStyle = "rgba(255,255,255,.18)"; + ctx.lineWidth = 2; + roundedRect(ctx, x - 10, y - 10, mapW + 20, mapH + 20, 16); + ctx.fill(); + ctx.stroke(); + ctx.fillStyle = "#245f39"; + ctx.fillRect(x, y, mapW, mapH); + ctx.strokeStyle = "rgba(255,255,255,.62)"; + ctx.lineWidth = 1; + ctx.strokeRect(x, y, mapW, mapH); + ctx.beginPath(); + ctx.moveTo(x + mapW / 2, y); + ctx.lineTo(x + mapW / 2, y + mapH); + ctx.stroke(); + + const px = (worldX: number) => x + worldX / FIELD.width * mapW; + const py = (worldY: number) => y + worldY / FIELD.height * mapH; + state.bodies.forEach((body) => { + if (body.kind === "post") return; + const team = body.side === "user" ? state.setup.userTeam : state.setup.cpuTeam; + ctx.fillStyle = body.kind === "ball" ? "#ffffff" : team.primary; + ctx.beginPath(); + ctx.arc(px(body.x), py(body.y), body.kind === "ball" ? 3 : 4, 0, Math.PI * 2); + ctx.fill(); + }); + + const viewW = width / camera.zoom; + const viewH = height / camera.zoom; + ctx.strokeStyle = "rgba(255,212,94,.88)"; + ctx.lineWidth = 2; + ctx.strokeRect( + px(camera.x - viewW / 2), + py(camera.y - viewH / 2), + viewW / FIELD.width * mapW, + viewH / FIELD.height * mapH, + ); + ctx.restore(); +} + +function roundedRect(ctx: CanvasRenderingContext2D, x: number, y: number, width: number, height: number, radius: number) { + const r = Math.min(radius, width / 2, height / 2); + ctx.beginPath(); + ctx.moveTo(x + r, y); + ctx.arcTo(x + width, y, x + width, y + height, r); + ctx.arcTo(x + width, y + height, x, y + height, r); + ctx.arcTo(x, y + height, x, y, r); + ctx.arcTo(x, y, x + width, y, r); + ctx.closePath(); +} diff --git a/app/botao-11/rng.ts b/app/botao-11/rng.ts new file mode 100644 index 00000000..0ba58a15 --- /dev/null +++ b/app/botao-11/rng.ts @@ -0,0 +1,35 @@ +export type Rng = { + next: () => number; + range: (min: number, max: number) => number; + int: (min: number, max: number) => number; + chance: (probability: number) => boolean; + pick: (items: readonly T[]) => T; +}; + +export function createRng(seed: number): Rng { + let state = (Math.floor(Math.abs(seed)) || 1) >>> 0; + const next = () => { + state = (state + 0x6d2b79f5) >>> 0; + let t = state; + t = Math.imul(t ^ (t >>> 15), t | 1); + t ^= t + Math.imul(t ^ (t >>> 7), t | 61); + return ((t ^ (t >>> 14)) >>> 0) / 4294967296; + }; + return { + next, + range: (min, max) => min + next() * (max - min), + int: (min, max) => min + Math.floor(next() * (max - min + 1)), + chance: (probability) => next() < probability, + pick: (items) => items[Math.floor(next() * items.length)], + }; +} + +export function hashSeed(...parts: Array): number { + let hash = 2166136261; + const text = parts.join("|"); + for (let i = 0; i < text.length; i += 1) { + hash ^= text.charCodeAt(i); + hash = Math.imul(hash, 16777619); + } + return hash >>> 0; +} diff --git a/app/botao-11/types.ts b/app/botao-11/types.ts new file mode 100644 index 00000000..0b4265e7 --- /dev/null +++ b/app/botao-11/types.ts @@ -0,0 +1,82 @@ +export type Side = "user" | "cpu"; +export type Phase = "aim" | "resolving" | "goal" | "finished"; + +export type Vec2 = { x: number; y: number }; + +export type TeamPreset = { + id: string; + name: string; + abbr: string; + primary: string; + secondary: string; + strength: number; +}; + +export type DiscRole = "GK" | "RB" | "CB" | "LB" | "DM" | "CM" | "AM" | "RW" | "LW" | "ST"; + +export type FormationSlot = { + role: DiscRole; + lane: number; + depth: number; +}; + +export type Formation = { + id: string; + name: string; + shape: string; + slots: FormationSlot[]; +}; + +export type BodyKind = "disc" | "ball" | "post"; + +export type Body = { + id: string; + kind: BodyKind; + side: Side | null; + x: number; + y: number; + vx: number; + vy: number; + radius: number; + mass: number; + friction: number; + number: number; + role?: DiscRole; + power: number; + control: number; +}; + +export type MatchEvent = + | { type: "goal"; side: Side; scorer: string } + | { type: "turn"; side: Side } + | { type: "settled" } + | { type: "match-end" }; + +export type MatchSetup = { + seed: number; + userTeam: TeamPreset; + cpuTeam: TeamPreset; + userFormationId: string; + cpuFormationId: string; + matchSeconds: number; +}; + +export type MatchState = { + setup: MatchSetup; + phase: Phase; + turn: Side; + bodies: Body[]; + score: Record; + clock: number; + turns: number; + resolveElapsed: number; + events: MatchEvent[]; + lastTouch: { side: Side; bodyId: string } | null; + version: number; +}; + +export type Shot = { + bodyId: string; + vx: number; + vy: number; +};