From 83f792b0247fe89d87a5a568e4454d5bad52d900 Mon Sep 17 00:00:00 2001 From: erereck <131626550+erereck@users.noreply.github.com> Date: Tue, 18 Aug 2026 21:54:42 -0300 Subject: [PATCH 01/16] add 11v11 prototype types --- app/botao-11/types.ts | 82 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 82 insertions(+) create mode 100644 app/botao-11/types.ts 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; +}; From 8dd1a9c3e09b068dfad90b65b8b52ca7d334e754 Mon Sep 17 00:00:00 2001 From: erereck <131626550+erereck@users.noreply.github.com> Date: Tue, 18 Aug 2026 21:54:50 -0300 Subject: [PATCH 02/16] add 11v11 prototype rng --- app/botao-11/rng.ts | 35 +++++++++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) create mode 100644 app/botao-11/rng.ts 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; +} From c1e9553d66418fefc67474ba84cd4bc54495d086 Mon Sep 17 00:00:00 2001 From: erereck <131626550+erereck@users.noreply.github.com> Date: Tue, 18 Aug 2026 21:55:05 -0300 Subject: [PATCH 03/16] add 11v11 formations --- app/botao-11/formations.ts | 80 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 80 insertions(+) create mode 100644 app/botao-11/formations.ts diff --git a/app/botao-11/formations.ts b/app/botao-11/formations.ts new file mode 100644 index 00000000..adb48685 --- /dev/null +++ b/app/botao-11/formations.ts @@ -0,0 +1,80 @@ +import type { Formation } from "./types"; + +export const FORMATIONS: Formation[] = [ + { + id: "433", + name: "4-3-3", + shape: "4-3-3", + slots: [ + { role: "GK", lane: 0.5, depth: 0.055 }, + { role: "LB", lane: 0.12, depth: 0.25 }, + { role: "CB", lane: 0.37, depth: 0.22 }, + { role: "CB", lane: 0.63, depth: 0.22 }, + { role: "RB", lane: 0.88, depth: 0.25 }, + { role: "CM", lane: 0.24, depth: 0.48 }, + { role: "DM", lane: 0.5, depth: 0.42 }, + { role: "CM", lane: 0.76, depth: 0.48 }, + { role: "LW", lane: 0.14, depth: 0.73 }, + { role: "ST", lane: 0.5, depth: 0.78 }, + { role: "RW", lane: 0.86, depth: 0.73 }, + ], + }, + { + id: "442", + name: "4-4-2", + shape: "4-4-2", + slots: [ + { role: "GK", lane: 0.5, depth: 0.055 }, + { role: "LB", lane: 0.12, depth: 0.25 }, + { role: "CB", lane: 0.37, depth: 0.22 }, + { role: "CB", lane: 0.63, depth: 0.22 }, + { role: "RB", lane: 0.88, depth: 0.25 }, + { role: "LW", lane: 0.12, depth: 0.51 }, + { role: "CM", lane: 0.38, depth: 0.46 }, + { role: "CM", lane: 0.62, depth: 0.46 }, + { role: "RW", lane: 0.88, depth: 0.51 }, + { role: "ST", lane: 0.37, depth: 0.76 }, + { role: "ST", lane: 0.63, depth: 0.76 }, + ], + }, + { + id: "352", + name: "3-5-2", + shape: "3-5-2", + slots: [ + { role: "GK", lane: 0.5, depth: 0.055 }, + { role: "CB", lane: 0.22, depth: 0.23 }, + { role: "CB", lane: 0.5, depth: 0.19 }, + { role: "CB", lane: 0.78, depth: 0.23 }, + { role: "LW", lane: 0.08, depth: 0.48 }, + { role: "CM", lane: 0.31, depth: 0.47 }, + { role: "DM", lane: 0.5, depth: 0.4 }, + { role: "CM", lane: 0.69, depth: 0.47 }, + { role: "RW", lane: 0.92, depth: 0.48 }, + { role: "ST", lane: 0.37, depth: 0.76 }, + { role: "ST", lane: 0.63, depth: 0.76 }, + ], + }, + { + id: "4231", + name: "4-2-3-1", + shape: "4-2-3-1", + slots: [ + { role: "GK", lane: 0.5, depth: 0.055 }, + { role: "LB", lane: 0.12, depth: 0.25 }, + { role: "CB", lane: 0.37, depth: 0.22 }, + { role: "CB", lane: 0.63, depth: 0.22 }, + { role: "RB", lane: 0.88, depth: 0.25 }, + { role: "DM", lane: 0.35, depth: 0.42 }, + { role: "DM", lane: 0.65, depth: 0.42 }, + { role: "LW", lane: 0.16, depth: 0.65 }, + { role: "AM", lane: 0.5, depth: 0.61 }, + { role: "RW", lane: 0.84, depth: 0.65 }, + { role: "ST", lane: 0.5, depth: 0.8 }, + ], + }, +]; + +export function getFormation(id: string): Formation { + return FORMATIONS.find((formation) => formation.id === id) ?? FORMATIONS[0]; +} From c002369fbda15acd70d6fb7fd59dbb43644e3c21 Mon Sep 17 00:00:00 2001 From: erereck <131626550+erereck@users.noreply.github.com> Date: Tue, 18 Aug 2026 21:55:37 -0300 Subject: [PATCH 04/16] align 11v11 formations with prototype engine --- app/botao-11/formations.ts | 102 ++++++++++++++++++------------------- 1 file changed, 51 insertions(+), 51 deletions(-) diff --git a/app/botao-11/formations.ts b/app/botao-11/formations.ts index adb48685..4d24d44f 100644 --- a/app/botao-11/formations.ts +++ b/app/botao-11/formations.ts @@ -1,80 +1,80 @@ import type { Formation } from "./types"; -export const FORMATIONS: Formation[] = [ +export const FORMATIONS_11: Formation[] = [ { id: "433", - name: "4-3-3", + name: "Equilíbrio", shape: "4-3-3", slots: [ - { role: "GK", lane: 0.5, depth: 0.055 }, - { role: "LB", lane: 0.12, depth: 0.25 }, - { role: "CB", lane: 0.37, depth: 0.22 }, - { role: "CB", lane: 0.63, depth: 0.22 }, - { role: "RB", lane: 0.88, depth: 0.25 }, - { role: "CM", lane: 0.24, depth: 0.48 }, - { role: "DM", lane: 0.5, depth: 0.42 }, - { role: "CM", lane: 0.76, depth: 0.48 }, - { role: "LW", lane: 0.14, depth: 0.73 }, - { role: "ST", lane: 0.5, depth: 0.78 }, - { role: "RW", lane: 0.86, depth: 0.73 }, + { 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: "4-4-2", + name: "Clássica", shape: "4-4-2", slots: [ - { role: "GK", lane: 0.5, depth: 0.055 }, - { role: "LB", lane: 0.12, depth: 0.25 }, - { role: "CB", lane: 0.37, depth: 0.22 }, - { role: "CB", lane: 0.63, depth: 0.22 }, - { role: "RB", lane: 0.88, depth: 0.25 }, - { role: "LW", lane: 0.12, depth: 0.51 }, - { role: "CM", lane: 0.38, depth: 0.46 }, - { role: "CM", lane: 0.62, depth: 0.46 }, - { role: "RW", lane: 0.88, depth: 0.51 }, - { role: "ST", lane: 0.37, depth: 0.76 }, - { role: "ST", lane: 0.63, depth: 0.76 }, + { 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: "3-5-2", + name: "Pressão", shape: "3-5-2", slots: [ - { role: "GK", lane: 0.5, depth: 0.055 }, - { role: "CB", lane: 0.22, depth: 0.23 }, - { role: "CB", lane: 0.5, depth: 0.19 }, - { role: "CB", lane: 0.78, depth: 0.23 }, - { role: "LW", lane: 0.08, depth: 0.48 }, - { role: "CM", lane: 0.31, depth: 0.47 }, - { role: "DM", lane: 0.5, depth: 0.4 }, - { role: "CM", lane: 0.69, depth: 0.47 }, - { role: "RW", lane: 0.92, depth: 0.48 }, - { role: "ST", lane: 0.37, depth: 0.76 }, - { role: "ST", lane: 0.63, depth: 0.76 }, + { 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: "4-2-3-1", + name: "Controle", shape: "4-2-3-1", slots: [ - { role: "GK", lane: 0.5, depth: 0.055 }, - { role: "LB", lane: 0.12, depth: 0.25 }, - { role: "CB", lane: 0.37, depth: 0.22 }, - { role: "CB", lane: 0.63, depth: 0.22 }, - { role: "RB", lane: 0.88, depth: 0.25 }, - { role: "DM", lane: 0.35, depth: 0.42 }, - { role: "DM", lane: 0.65, depth: 0.42 }, - { role: "LW", lane: 0.16, depth: 0.65 }, - { role: "AM", lane: 0.5, depth: 0.61 }, - { role: "RW", lane: 0.84, depth: 0.65 }, - { role: "ST", lane: 0.5, depth: 0.8 }, + { 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 getFormation(id: string): Formation { - return FORMATIONS.find((formation) => formation.id === id) ?? FORMATIONS[0]; +export function formationById(id: string) { + return FORMATIONS_11.find((formation) => formation.id === id) ?? FORMATIONS_11[0]; } From 226b0098976be779cea62e3566ecbd18db6d17e3 Mon Sep 17 00:00:00 2001 From: erereck <131626550+erereck@users.noreply.github.com> Date: Tue, 18 Aug 2026 21:56:14 -0300 Subject: [PATCH 05/16] add 11v11 physics engine --- app/botao-11/engine.ts | 482 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 482 insertions(+) create mode 100644 app/botao-11/engine.ts diff --git a/app/botao-11/engine.ts b/app/botao-11/engine.ts new file mode 100644 index 00000000..0665257d --- /dev/null +++ b/app/botao-11/engine.ts @@ -0,0 +1,482 @@ +import { formationById } from "./formations"; +import { createRng, hashSeed } from "./rng"; +import type { Body, MatchSetup, MatchState, Shot, Side } from "./types"; + +export const FIELD = { + width: 1160, + height: 720, + inset: 34, + goalWidth: 196, + goalDepth: 36, + discRadius: 15, + keeperRadius: 17, + ballRadius: 8, + postRadius: 5, + centerRadius: 92, + areaDepth: 176, + areaWidth: 356, +}; + +export const GOAL_TOP = (FIELD.height - FIELD.goalWidth) / 2; +export const GOAL_BOTTOM = GOAL_TOP + FIELD.goalWidth; +export const MAX_PULL = 154; + +const DISC_FRICTION = 1.36; +const BALL_FRICTION = 0.93; +const STOP_SPEED = 10; +const MAX_SPEED = 1080; +const MAX_RESOLVE_SECONDS = 7.5; +const RESTITUTION_DISC_DISC = 0.86; +const RESTITUTION_DISC_BALL = 0.95; +const RESTITUTION_WALL = 0.68; +const RESTITUTION_POST = 0.78; +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 510 + clamp(power, 0, 100) * 2.65; +} + +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") { + 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; + const 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 stillMoving = movingBodies(state).some((body) => body.vx !== 0 || body.vy !== 0); + if (!stillMoving || 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; + } +} From 48f2e07e57329c993f2b1f4c113e51372a5b5a97 Mon Sep 17 00:00:00 2001 From: erereck <131626550+erereck@users.noreply.github.com> Date: Tue, 18 Aug 2026 21:56:40 -0300 Subject: [PATCH 06/16] add 11v11 cpu --- app/botao-11/cpu.ts | 188 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 188 insertions(+) create mode 100644 app/botao-11/cpu.ts diff --git a/app/botao-11/cpu.ts b/app/botao-11/cpu.ts new file mode 100644 index 00000000..0a4886e6 --- /dev/null +++ b/app/botao-11/cpu.ts @@ -0,0 +1,188 @@ +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[] = []; + + for (const { disc } of byDistance.slice(0, 6)) { + 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")); + } + + const ownX = ownGoalX(side); + const attackX = attackGoalX(side); + for (const { disc, distance } of byDistance.slice(4)) { + if (disc.role === "GK" && distance > 220) 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) * 74, y: disc.y + rng.range(-54, 54) }; + [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")); + 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 < 28 && 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 < 280) score -= (280 - 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; + const covering = mine.filter((disc) => side === "user" ? disc.x < ball.x : disc.x > ball.x).length; + score += Math.min(covering, 5) * 34; + 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, 120); + } + 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; +} From 1dae44428716c9e47fd34598fe510e8c0f18d66d Mon Sep 17 00:00:00 2001 From: erereck <131626550+erereck@users.noreply.github.com> Date: Tue, 18 Aug 2026 21:57:53 -0300 Subject: [PATCH 07/16] add playable 11v11 laboratory route --- app/botao-11/page.tsx | 387 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 387 insertions(+) create mode 100644 app/botao-11/page.tsx diff --git a/app/botao-11/page.tsx b/app/botao-11/page.tsx new file mode 100644 index 00000000..ff09b14e --- /dev/null +++ b/app/botao-11/page.tsx @@ -0,0 +1,387 @@ +"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, + GOAL_BOTTOM, + GOAL_TOP, + MAX_PULL, + beginShot, + ballOf, + createMatch, + distanceForSpeed, + resumeAfterGoal, + shotSpeedFor, + stepMatch, +} from "./engine"; +import { FORMATIONS_11 } from "./formations"; +import type { Body, MatchState, Side, TeamPreset } from "./types"; +import styles from "./botao11.module.css"; + +type Camera = { x: number; y: number; zoom: number; targetX: number; targetY: number; targetZoom: number; manualUntil: number }; +type AimState = { bodyId: string; pointerX: number; pointerY: number } | null; +type PanState = { pointerId: number; lastX: number; lastY: number } | null; + +const DEFAULT_SEED = 2026081811; +const MATCH_SECONDS = 240; +const CAMERA_MANUAL_MS = 2600; + +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 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 default function Botao11Page() { + const canvasRef = useRef(null); + const wrapperRef = useRef(null); + const matchRef = useRef(null); + const cameraRef = useRef({ x: FIELD.width / 2, y: FIELD.height / 2, zoom: 1, targetX: FIELD.width / 2, targetY: FIELD.height / 2, targetZoom: 1, manualUntil: 0 }); + 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 [autoCamera, setAutoCamera] = useState(true); + 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 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; + cpuThinkingRef.current = false; + cpuThinkDueRef.current = null; + goalStartedRef.current = null; + setCpuThinkMs(null); + cameraRef.current = { x: FIELD.width / 2, y: FIELD.height / 2, zoom: 1, targetX: FIELD.width / 2, targetY: FIELD.height / 2, targetZoom: 1, manualUntil: 0 }; + 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 = Math.max(camera.targetZoom, 1.12); + camera.manualUntil = 0; + }, []); + + useEffect(() => { + const onKeyDown = (event: KeyboardEvent) => { + if (event.target instanceof HTMLInputElement || event.target instanceof HTMLSelectElement || event.target instanceof HTMLTextAreaElement) return; + if (event.key.toLowerCase() === "f") focusBall(); + if (event.key.toLowerCase() === "r") { const next = seed + 1; setSeed(next); resetMatch(next); } + if (event.key === " ") { setAutoCamera((value) => !value); event.preventDefault(); } + }; + window.addEventListener("keydown", onKeyDown); + return () => window.removeEventListener("keydown", onKeyDown); + }, [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.75 && 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 }; + cameraRef.current.manualUntil = performance.now() + CAMERA_MANUAL_MS; + }, [pickUserDisc, screenToWorld]); + + const onPointerMove = useCallback((event: ReactPointerEvent) => { + const world = screenToWorld(event.clientX, event.clientY); + if (aimRef.current) { aimRef.current.pointerX = world.x; aimRef.current.pointerY = world.y; return; } + if (panRef.current?.pointerId === event.pointerId) { + const camera = cameraRef.current; + const dx = event.clientX - panRef.current.lastX; + const dy = event.clientY - panRef.current.lastY; + camera.x -= dx / camera.zoom; + camera.y -= dy / camera.zoom; + camera.targetX = camera.x; + camera.targetY = camera.y; + camera.manualUntil = performance.now() + CAMERA_MANUAL_MS; + panRef.current.lastX = event.clientX; + panRef.current.lastY = event.clientY; + return; + } + hoverBodyRef.current = pickUserDisc(world.x, world.y)?.id ?? null; + }, [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.82; + const maxZoom = fit * 2.25; + 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; + camera.manualUntil = performance.now() + CAMERA_MANUAL_MS; + }, [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 dt = frameDt; + while (dt > 0) { const slice = Math.min(dt, 1 / 120); stepMatch(state, slice); dt -= slice; } + const ball = ballOf(state); + 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.26; camera.targetZoom = camera.zoom; } + if (autoCamera && now >= camera.manualUntil) { + const aim = aimRef.current; + const activeDisc = aim ? state.bodies.find((body) => body.id === aim.bodyId) : null; + if (activeDisc) { + camera.targetX = activeDisc.x * 0.56 + ball.x * 0.44; + camera.targetY = activeDisc.y * 0.56 + ball.y * 0.44; + camera.targetZoom = fit * 1.54; + } else if (state.phase === "resolving") { + const speed = Math.hypot(ball.vx, ball.vy); + camera.targetX = ball.x; + camera.targetY = ball.y; + camera.targetZoom = fit * (speed > 380 ? 1.13 : 1.23); + } else { + camera.targetX = ball.x * 0.72 + FIELD.width / 2 * 0.28; + camera.targetY = ball.y * 0.72 + FIELD.height / 2 * 0.28; + camera.targetZoom = fit * 1.28; + } + } + const follow = 1 - Math.exp(-7.5 * 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 = 90; + 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 >= 1250) { 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 + 360; + 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; } + render(canvas, state, camera, aimRef.current, hoverBodyRef.current, showRoles, showMinimap); + if (now - lastUiSync > 180) { lastUiSync = now; setUiVersion((value) => value + 1); } + frame = requestAnimationFrame(draw); + }; + frame = requestAnimationFrame(draw); + return () => cancelAnimationFrame(frame); + }, [autoCamera, 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 ? "CARREGANDO" : 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ÓRIOFUTBOBO 11×11
+
{userClub.abbr}{state?.score.user ?? 0}{formatClock(state?.clock ?? MATCH_SECONDS)}{state?.score.cpu ?? 0}{cpuClub.abbr}
+
{phaseLabel}{state?.turns ?? 0} turnos
+
+
+ +
+ event.preventDefault()} onPointerDown={onPointerDown} onPointerMove={onPointerMove} onPointerUp={onPointerUp} onPointerCancel={onPointerUp} onWheel={onWheel} /> +
ARRASTA A PEÇA PARA TRÁS E SOLTAfundo: pan · roda: zoom · F: bola · espaço: câmera auto · R: nova partida
+ {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

} +
+
+
+ ); +} + +function render(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 = (value: number) => value * 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.75); + bg.addColorStop(0, "#101a16"); bg.addColorStop(1, "#050806"); ctx.fillStyle = bg; ctx.fillRect(0, 0, width, height); + ctx.save(); ctx.shadowColor = "rgba(0,0,0,.7)"; ctx.shadowBlur = sr(36); 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 / 10; + for (let index = 0; index < 10; index += 1) if (index % 2 !== 0) { ctx.fillStyle = "rgba(255,255,255,.028)"; ctx.fillRect(sx(index * stripeWidth), sy(0), sr(stripeWidth), sr(FIELD.height)); } + ctx.strokeStyle = "rgba(241,255,244,.86)"; ctx.lineWidth = Math.max(1.5, sr(2.4)); 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)); ctx.strokeRect(sx(0), sy(GOAL_TOP - 44), sr(72), sr(FIELD.goalWidth + 88)); ctx.strokeRect(sx(FIELD.width - 72), sy(GOAL_TOP - 44), sr(72), sr(FIELD.goalWidth + 88)); + const drawNet = (left: boolean) => { const x = left ? sx(-FIELD.goalDepth) : sx(FIELD.width); const netW = sr(FIELD.goalDepth); ctx.fillStyle = "rgba(230,242,232,.06)"; ctx.fillRect(x, sy(GOAL_TOP), netW, sr(FIELD.goalWidth)); ctx.strokeStyle = "rgba(235,248,237,.22)"; ctx.lineWidth = Math.max(1, sr(1)); for (let i = 0; i <= 5; i += 1) { const yy = GOAL_TOP + FIELD.goalWidth * i / 5; ctx.beginPath(); ctx.moveTo(left ? sx(-FIELD.goalDepth) : sx(FIELD.width), sy(yy)); ctx.lineTo(left ? sx(0) : sx(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.fillText("VOCÊ ATACA →", sx(FIELD.width / 2), sy(FIELD.height - 20)); + if (aim) { + const disc = state.bodies.find((body) => body.id === aim.bodyId); + if (disc) { + const dx = disc.x - aim.pointerX, dy = disc.y - aim.pointerY, rawPull = Math.hypot(dx, dy), pull = Math.min(MAX_PULL, rawPull); + if (pull > 2) { + const nx = dx / rawPull, ny = dy / rawPull, speed = shotSpeedFor(disc.power) * Math.min(1, pull / MAX_PULL), travel = distanceForSpeed(speed), endX = disc.x + nx * travel, endY = disc.y + ny * travel; + ctx.strokeStyle = "rgba(255,255,255,.76)"; ctx.lineWidth = Math.max(2, sr(2.4)); 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,.35)"; 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,.8)"; 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 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") { ctx.save(); ctx.shadowColor = "rgba(0,0,0,.55)"; 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), selected = aim?.bodyId === body.id, hovered = hoverBodyId === body.id; + 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.7) { ctx.fillStyle = "rgba(4,10,7,.76)"; const label = body.role ?? "", w = sr(30), h = sr(13); 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") { ctx.beginPath(); ctx.arc(sx(body.x), sy(body.y), sr(body.radius), 0, Math.PI * 2); ctx.fill(); } + if (showMinimap) drawMinimap(ctx, state, width, height, camera); +} + +function drawMinimap(ctx: CanvasRenderingContext2D, state: MatchState, width: number, height: number, camera: Camera) { + const mapW = Math.min(330, width * 0.21), mapH = mapW * FIELD.height / FIELD.width, x = width - mapW - 22, y = 22; + ctx.save(); ctx.fillStyle = "rgba(4,9,6,.82)"; ctx.strokeStyle = "rgba(255,255,255,.18)"; ctx.lineWidth = 2; roundRect(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, 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, viewH = height / camera.zoom; ctx.strokeStyle = "rgba(255,212,94,.82)"; 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 roundRect(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(); +} From 2963de4855a02fc88e86943be94d9a808b1c9c2a Mon Sep 17 00:00:00 2001 From: erereck <131626550+erereck@users.noreply.github.com> Date: Tue, 18 Aug 2026 21:58:18 -0300 Subject: [PATCH 08/16] style 11v11 laboratory --- app/botao-11/botao11.module.css | 63 +++++++++++++++++++++++++++++++++ 1 file changed, 63 insertions(+) create mode 100644 app/botao-11/botao11.module.css 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; } } From f6ddb0884b81fedfa13cdd0dda225d860eaa9b6c Mon Sep 17 00:00:00 2001 From: erereck <131626550+erereck@users.noreply.github.com> Date: Tue, 18 Aug 2026 21:58:33 -0300 Subject: [PATCH 09/16] document 11v11 laboratory --- app/botao-11/README.md | 38 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) create mode 100644 app/botao-11/README.md diff --git a/app/botao-11/README.md b/app/botao-11/README.md new file mode 100644 index 00000000..fe062c33 --- /dev/null +++ b/app/botao-11/README.md @@ -0,0 +1,38 @@ +# 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. From 6fd55c93879ac17fff589c28083a5286a4329cb6 Mon Sep 17 00:00:00 2001 From: erereck <131626550+erereck@users.noreply.github.com> Date: Tue, 18 Aug 2026 22:24:21 -0300 Subject: [PATCH 10/16] chore: trigger 11v11 preview deployment --- app/botao-11/README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/app/botao-11/README.md b/app/botao-11/README.md index fe062c33..6743bc89 100644 --- a/app/botao-11/README.md +++ b/app/botao-11/README.md @@ -36,3 +36,5 @@ uma pergunta antes de integrar qualquer coisa à carreira: **futebol de botão c 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. + + From 48461345888ce21fbdccc407721330549658249f Mon Sep 17 00:00:00 2001 From: erereck <131626550+erereck@users.noreply.github.com> Date: Tue, 18 Aug 2026 22:36:08 -0300 Subject: [PATCH 11/16] tune 11v11 pacing and field scale --- app/botao-11/engine.ts | 58 +++++++++++++++++++++++++----------------- 1 file changed, 35 insertions(+), 23 deletions(-) diff --git a/app/botao-11/engine.ts b/app/botao-11/engine.ts index 0665257d..96241c31 100644 --- a/app/botao-11/engine.ts +++ b/app/botao-11/engine.ts @@ -3,33 +3,33 @@ import { createRng, hashSeed } from "./rng"; import type { Body, MatchSetup, MatchState, Shot, Side } from "./types"; export const FIELD = { - width: 1160, - height: 720, - inset: 34, - goalWidth: 196, - goalDepth: 36, + width: 1480, + height: 860, + inset: 42, + goalWidth: 216, + goalDepth: 42, discRadius: 15, keeperRadius: 17, ballRadius: 8, postRadius: 5, - centerRadius: 92, - areaDepth: 176, - areaWidth: 356, + 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 = 154; - -const DISC_FRICTION = 1.36; -const BALL_FRICTION = 0.93; -const STOP_SPEED = 10; -const MAX_SPEED = 1080; -const MAX_RESOLVE_SECONDS = 7.5; -const RESTITUTION_DISC_DISC = 0.86; -const RESTITUTION_DISC_BALL = 0.95; -const RESTITUTION_WALL = 0.68; -const RESTITUTION_POST = 0.78; +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 { @@ -71,7 +71,7 @@ function teamControl(strength: number) { } export function shotSpeedFor(power: number) { - return 510 + clamp(power, 0, 100) * 2.65; + return 555 + clamp(power, 0, 100) * 2.72; } export function minShotSpeed(power: number) { @@ -388,6 +388,7 @@ function walls(body: Body) { 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; @@ -403,7 +404,15 @@ function friction(body: Body, dt: number) { const factor = Math.exp(-body.friction * dt); body.vx *= factor; body.vy *= factor; - const speed = Math.hypot(body.vx, body.vy); + 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; @@ -462,8 +471,11 @@ export function stepMatch(state: MatchState, dt: number) { integrate(state, safeDt); if (checkGoal(state)) return; - const stillMoving = movingBodies(state).some((body) => body.vx !== 0 || body.vy !== 0); - if (!stillMoving || state.resolveElapsed >= MAX_RESOLVE_SECONDS) { + 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; From d4c3a3a44620ada9c523c1f05d14b73a5edea127 Mon Sep 17 00:00:00 2001 From: erereck <131626550+erereck@users.noreply.github.com> Date: Tue, 18 Aug 2026 22:36:39 -0300 Subject: [PATCH 12/16] retune 11v11 cpu for larger pitch --- app/botao-11/cpu.ts | 50 +++++++++++++++++++++++++++++++++++---------- 1 file changed, 39 insertions(+), 11 deletions(-) diff --git a/app/botao-11/cpu.ts b/app/botao-11/cpu.ts index 0a4886e6..00fd4cc0 100644 --- a/app/botao-11/cpu.ts +++ b/app/botao-11/cpu.ts @@ -18,6 +18,7 @@ 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 { @@ -31,7 +32,11 @@ function shotTowards(disc: Body, point: Point, ratio: number, kind: Candidate["k 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 }]; + return [ + { x, y: mid }, + { x, y: GOAL_TOP + 34 }, + { x, y: GOAL_BOTTOM - 34 }, + ]; } function contactPoint(ball: Body, disc: Body, target: Point): Point { @@ -67,7 +72,8 @@ function buildCandidates(state: MatchState, side: Side, rng: Rng): Candidate[] { .sort((a, b) => a.distance - b.distance); const candidates: Candidate[] = []; - for (const { disc } of byDistance.slice(0, 6)) { + // 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) @@ -82,15 +88,25 @@ function buildCandidates(state: MatchState, side: Side, rng: Rng): Candidate[] { 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")); + + 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 > 220) 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) * 74, y: disc.y + rng.range(-54, 54) }; + 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; @@ -101,11 +117,15 @@ function buildCandidates(state: MatchState, side: Side, rng: Rng): Candidate[] { }); } - if (!candidates.length && byDistance[0]) candidates.push(shotTowards(byDistance[0].disc, ball, 0.75, "clear")); + 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 < 28 && others.length) { + while (picked.length < 30 && others.length) { const index = Math.floor(rng.next() * others.length); picked.push(others.splice(index, 1)[0]); } @@ -118,20 +138,27 @@ function evaluate(state: MatchState, side: Side, before: Record) { 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 < 280) score -= (280 - ownDistance) * 4.8; + 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; @@ -139,7 +166,7 @@ function evaluate(state: MatchState, side: Side, before: Record) { 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, 120); + spacing += Math.min(nearest, 165); } score += spacing * 0.08; return score; @@ -162,7 +189,7 @@ function jitter(candidate: Candidate, disc: Body, strength: number, rng: Rng): S 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 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 }; @@ -174,6 +201,7 @@ export function chooseCpuShot(state: MatchState, side: Side = "cpu") { 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; From 3064e8f3fa08e1b644114125696b4b76d824f1fe Mon Sep 17 00:00:00 2001 From: erereck <131626550+erereck@users.noreply.github.com> Date: Tue, 18 Aug 2026 22:39:30 -0300 Subject: [PATCH 13/16] add 11v11 v2 canvas renderer --- app/botao-11/render-v2.ts | 352 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 352 insertions(+) create mode 100644 app/botao-11/render-v2.ts 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(); +} From 4658b0af2ad95761449a2b0425e5e4687f3db33c Mon Sep 17 00:00:00 2001 From: erereck <131626550+erereck@users.noreply.github.com> Date: Tue, 18 Aug 2026 22:39:49 -0300 Subject: [PATCH 14/16] style 11v11 v2 lab --- app/botao-11/lab-v2.module.css | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 app/botao-11/lab-v2.module.css 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}} From eba8934c314ed38ff0103c0e0a01ae5a05aba284 Mon Sep 17 00:00:00 2001 From: erereck <131626550+erereck@users.noreply.github.com> Date: Tue, 18 Aug 2026 22:40:37 -0300 Subject: [PATCH 15/16] add 11v11 v2 interaction layer --- app/botao-11/LabV2.tsx | 373 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 373 insertions(+) create mode 100644 app/botao-11/LabV2.tsx 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

} +
+
+
+ ); +} From b7484c80a7700d72a4a7f0cf38c9d9086e5173e5 Mon Sep 17 00:00:00 2001 From: erereck <131626550+erereck@users.noreply.github.com> Date: Tue, 18 Aug 2026 22:40:43 -0300 Subject: [PATCH 16/16] switch 11v11 lab to v2 --- app/botao-11/page.tsx | 388 +----------------------------------------- 1 file changed, 1 insertion(+), 387 deletions(-) diff --git a/app/botao-11/page.tsx b/app/botao-11/page.tsx index ff09b14e..3eba2ed9 100644 --- a/app/botao-11/page.tsx +++ b/app/botao-11/page.tsx @@ -1,387 +1 @@ -"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, - GOAL_BOTTOM, - GOAL_TOP, - MAX_PULL, - beginShot, - ballOf, - createMatch, - distanceForSpeed, - resumeAfterGoal, - shotSpeedFor, - stepMatch, -} from "./engine"; -import { FORMATIONS_11 } from "./formations"; -import type { Body, MatchState, Side, TeamPreset } from "./types"; -import styles from "./botao11.module.css"; - -type Camera = { x: number; y: number; zoom: number; targetX: number; targetY: number; targetZoom: number; manualUntil: number }; -type AimState = { bodyId: string; pointerX: number; pointerY: number } | null; -type PanState = { pointerId: number; lastX: number; lastY: number } | null; - -const DEFAULT_SEED = 2026081811; -const MATCH_SECONDS = 240; -const CAMERA_MANUAL_MS = 2600; - -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 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 default function Botao11Page() { - const canvasRef = useRef(null); - const wrapperRef = useRef(null); - const matchRef = useRef(null); - const cameraRef = useRef({ x: FIELD.width / 2, y: FIELD.height / 2, zoom: 1, targetX: FIELD.width / 2, targetY: FIELD.height / 2, targetZoom: 1, manualUntil: 0 }); - 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 [autoCamera, setAutoCamera] = useState(true); - 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 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; - cpuThinkingRef.current = false; - cpuThinkDueRef.current = null; - goalStartedRef.current = null; - setCpuThinkMs(null); - cameraRef.current = { x: FIELD.width / 2, y: FIELD.height / 2, zoom: 1, targetX: FIELD.width / 2, targetY: FIELD.height / 2, targetZoom: 1, manualUntil: 0 }; - 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 = Math.max(camera.targetZoom, 1.12); - camera.manualUntil = 0; - }, []); - - useEffect(() => { - const onKeyDown = (event: KeyboardEvent) => { - if (event.target instanceof HTMLInputElement || event.target instanceof HTMLSelectElement || event.target instanceof HTMLTextAreaElement) return; - if (event.key.toLowerCase() === "f") focusBall(); - if (event.key.toLowerCase() === "r") { const next = seed + 1; setSeed(next); resetMatch(next); } - if (event.key === " ") { setAutoCamera((value) => !value); event.preventDefault(); } - }; - window.addEventListener("keydown", onKeyDown); - return () => window.removeEventListener("keydown", onKeyDown); - }, [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.75 && 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 }; - cameraRef.current.manualUntil = performance.now() + CAMERA_MANUAL_MS; - }, [pickUserDisc, screenToWorld]); - - const onPointerMove = useCallback((event: ReactPointerEvent) => { - const world = screenToWorld(event.clientX, event.clientY); - if (aimRef.current) { aimRef.current.pointerX = world.x; aimRef.current.pointerY = world.y; return; } - if (panRef.current?.pointerId === event.pointerId) { - const camera = cameraRef.current; - const dx = event.clientX - panRef.current.lastX; - const dy = event.clientY - panRef.current.lastY; - camera.x -= dx / camera.zoom; - camera.y -= dy / camera.zoom; - camera.targetX = camera.x; - camera.targetY = camera.y; - camera.manualUntil = performance.now() + CAMERA_MANUAL_MS; - panRef.current.lastX = event.clientX; - panRef.current.lastY = event.clientY; - return; - } - hoverBodyRef.current = pickUserDisc(world.x, world.y)?.id ?? null; - }, [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.82; - const maxZoom = fit * 2.25; - 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; - camera.manualUntil = performance.now() + CAMERA_MANUAL_MS; - }, [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 dt = frameDt; - while (dt > 0) { const slice = Math.min(dt, 1 / 120); stepMatch(state, slice); dt -= slice; } - const ball = ballOf(state); - 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.26; camera.targetZoom = camera.zoom; } - if (autoCamera && now >= camera.manualUntil) { - const aim = aimRef.current; - const activeDisc = aim ? state.bodies.find((body) => body.id === aim.bodyId) : null; - if (activeDisc) { - camera.targetX = activeDisc.x * 0.56 + ball.x * 0.44; - camera.targetY = activeDisc.y * 0.56 + ball.y * 0.44; - camera.targetZoom = fit * 1.54; - } else if (state.phase === "resolving") { - const speed = Math.hypot(ball.vx, ball.vy); - camera.targetX = ball.x; - camera.targetY = ball.y; - camera.targetZoom = fit * (speed > 380 ? 1.13 : 1.23); - } else { - camera.targetX = ball.x * 0.72 + FIELD.width / 2 * 0.28; - camera.targetY = ball.y * 0.72 + FIELD.height / 2 * 0.28; - camera.targetZoom = fit * 1.28; - } - } - const follow = 1 - Math.exp(-7.5 * 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 = 90; - 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 >= 1250) { 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 + 360; - 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; } - render(canvas, state, camera, aimRef.current, hoverBodyRef.current, showRoles, showMinimap); - if (now - lastUiSync > 180) { lastUiSync = now; setUiVersion((value) => value + 1); } - frame = requestAnimationFrame(draw); - }; - frame = requestAnimationFrame(draw); - return () => cancelAnimationFrame(frame); - }, [autoCamera, 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 ? "CARREGANDO" : 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ÓRIOFUTBOBO 11×11
-
{userClub.abbr}{state?.score.user ?? 0}{formatClock(state?.clock ?? MATCH_SECONDS)}{state?.score.cpu ?? 0}{cpuClub.abbr}
-
{phaseLabel}{state?.turns ?? 0} turnos
-
-
- -
- event.preventDefault()} onPointerDown={onPointerDown} onPointerMove={onPointerMove} onPointerUp={onPointerUp} onPointerCancel={onPointerUp} onWheel={onWheel} /> -
ARRASTA A PEÇA PARA TRÁS E SOLTAfundo: pan · roda: zoom · F: bola · espaço: câmera auto · R: nova partida
- {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

} -
-
-
- ); -} - -function render(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 = (value: number) => value * 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.75); - bg.addColorStop(0, "#101a16"); bg.addColorStop(1, "#050806"); ctx.fillStyle = bg; ctx.fillRect(0, 0, width, height); - ctx.save(); ctx.shadowColor = "rgba(0,0,0,.7)"; ctx.shadowBlur = sr(36); 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 / 10; - for (let index = 0; index < 10; index += 1) if (index % 2 !== 0) { ctx.fillStyle = "rgba(255,255,255,.028)"; ctx.fillRect(sx(index * stripeWidth), sy(0), sr(stripeWidth), sr(FIELD.height)); } - ctx.strokeStyle = "rgba(241,255,244,.86)"; ctx.lineWidth = Math.max(1.5, sr(2.4)); 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)); ctx.strokeRect(sx(0), sy(GOAL_TOP - 44), sr(72), sr(FIELD.goalWidth + 88)); ctx.strokeRect(sx(FIELD.width - 72), sy(GOAL_TOP - 44), sr(72), sr(FIELD.goalWidth + 88)); - const drawNet = (left: boolean) => { const x = left ? sx(-FIELD.goalDepth) : sx(FIELD.width); const netW = sr(FIELD.goalDepth); ctx.fillStyle = "rgba(230,242,232,.06)"; ctx.fillRect(x, sy(GOAL_TOP), netW, sr(FIELD.goalWidth)); ctx.strokeStyle = "rgba(235,248,237,.22)"; ctx.lineWidth = Math.max(1, sr(1)); for (let i = 0; i <= 5; i += 1) { const yy = GOAL_TOP + FIELD.goalWidth * i / 5; ctx.beginPath(); ctx.moveTo(left ? sx(-FIELD.goalDepth) : sx(FIELD.width), sy(yy)); ctx.lineTo(left ? sx(0) : sx(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.fillText("VOCÊ ATACA →", sx(FIELD.width / 2), sy(FIELD.height - 20)); - if (aim) { - const disc = state.bodies.find((body) => body.id === aim.bodyId); - if (disc) { - const dx = disc.x - aim.pointerX, dy = disc.y - aim.pointerY, rawPull = Math.hypot(dx, dy), pull = Math.min(MAX_PULL, rawPull); - if (pull > 2) { - const nx = dx / rawPull, ny = dy / rawPull, speed = shotSpeedFor(disc.power) * Math.min(1, pull / MAX_PULL), travel = distanceForSpeed(speed), endX = disc.x + nx * travel, endY = disc.y + ny * travel; - ctx.strokeStyle = "rgba(255,255,255,.76)"; ctx.lineWidth = Math.max(2, sr(2.4)); 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,.35)"; 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,.8)"; 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 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") { ctx.save(); ctx.shadowColor = "rgba(0,0,0,.55)"; 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), selected = aim?.bodyId === body.id, hovered = hoverBodyId === body.id; - 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.7) { ctx.fillStyle = "rgba(4,10,7,.76)"; const label = body.role ?? "", w = sr(30), h = sr(13); 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") { ctx.beginPath(); ctx.arc(sx(body.x), sy(body.y), sr(body.radius), 0, Math.PI * 2); ctx.fill(); } - if (showMinimap) drawMinimap(ctx, state, width, height, camera); -} - -function drawMinimap(ctx: CanvasRenderingContext2D, state: MatchState, width: number, height: number, camera: Camera) { - const mapW = Math.min(330, width * 0.21), mapH = mapW * FIELD.height / FIELD.width, x = width - mapW - 22, y = 22; - ctx.save(); ctx.fillStyle = "rgba(4,9,6,.82)"; ctx.strokeStyle = "rgba(255,255,255,.18)"; ctx.lineWidth = 2; roundRect(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, 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, viewH = height / camera.zoom; ctx.strokeStyle = "rgba(255,212,94,.82)"; 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 roundRect(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(); -} +export { default } from "./LabV2";