Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
373 changes: 373 additions & 0 deletions app/botao-11/LabV2.tsx

Large diffs are not rendered by default.

40 changes: 40 additions & 0 deletions app/botao-11/README.md
Original file line number Diff line number Diff line change
@@ -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.

<!-- preview deployment trigger: 2026-08-18 -->
63 changes: 63 additions & 0 deletions app/botao-11/botao11.module.css
Original file line number Diff line number Diff line change
@@ -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; } }
216 changes: 216 additions & 0 deletions app/botao-11/cpu.ts
Original file line number Diff line number Diff line change
@@ -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<Side, number>) {
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;
}
Loading