From df5f86b363e430a028d04cba68523e7c32fab3d5 Mon Sep 17 00:00:00 2001 From: ivyvi <130733091+yerxiiiii@users.noreply.github.com> Date: Mon, 7 Sep 2026 23:59:55 +0800 Subject: [PATCH] feat(football): coach tactics, chase AI, and dual-team FPV Add a coach strategy panel with EN/ZH, SSL-inspired chase approach and support roles, live match stats board, and chaser eye-cam PiPs so spectators can tune tactics and see both teams' first-person view. Co-authored-by: Cursor --- app/src/football/FootballApp.jsx | 3 +- app/src/football/FootballCanvas.jsx | 4 + app/src/football/FootballHud.jsx | 208 +++++++++++-- app/src/football/FootballTitle.jsx | 45 ++- app/src/football/LangToggle.jsx | 78 +++++ app/src/football/StrategyBoard.jsx | 235 ++++++++++++++ app/src/football/StrategyPanel.jsx | 210 +++++++++++++ app/src/football/TeamFpv.jsx | 141 +++++++++ app/src/football/hud-logic.js | 40 ++- app/src/football/i18n.js | 189 +++++++++++ app/src/game/audio.js | 5 +- app/src/game/football/ai/index.js | 248 +++++++++++---- app/src/game/football/match-stats.js | 140 +++++++++ app/src/game/football/strategy.js | 136 ++++++++ app/src/game/football/tactics-board.js | 18 ++ app/src/game/game.js | 414 ++++++++++++++++++++++++- app/src/store.js | 12 +- app/src/ui/Hud.jsx | 4 +- app/test/ai.test.js | 92 +++++- app/test/strategy.test.js | 65 ++++ app/test/tactics-board.test.js | 129 ++++++++ 21 files changed, 2283 insertions(+), 133 deletions(-) create mode 100644 app/src/football/LangToggle.jsx create mode 100644 app/src/football/StrategyBoard.jsx create mode 100644 app/src/football/StrategyPanel.jsx create mode 100644 app/src/football/TeamFpv.jsx create mode 100644 app/src/football/i18n.js create mode 100644 app/src/game/football/match-stats.js create mode 100644 app/src/game/football/strategy.js create mode 100644 app/src/game/football/tactics-board.js create mode 100644 app/test/strategy.test.js create mode 100644 app/test/tactics-board.test.js diff --git a/app/src/football/FootballApp.jsx b/app/src/football/FootballApp.jsx index 428730c..c716df0 100644 --- a/app/src/football/FootballApp.jsx +++ b/app/src/football/FootballApp.jsx @@ -12,6 +12,7 @@ import FootballHud from "./FootballHud.jsx"; import FootballTitle from "./FootballTitle.jsx"; import { Halftone, CrtOverlay } from "../ui/Overlays.jsx"; import { useGame } from "../store.js"; +import { detectLocale } from "./i18n.js"; export default function FootballApp() { // prebootDone marks the shell ready; menuOpen raises the title/boot gate @@ -20,7 +21,7 @@ export default function FootballApp() { // ducks the ambient bed). `entered` latches on the first Kick Off - // FootballTitle sets it alongside menuOpen:false. useEffect(() => { - useGame.setState({ prebootDone: true, menuOpen: true }); + useGame.setState({ prebootDone: true, menuOpen: true, locale: detectLocale() }); }, []); // The match itself already runs from boot (game.js kicks off during diff --git a/app/src/football/FootballCanvas.jsx b/app/src/football/FootballCanvas.jsx index 99c9092..88ef1ef 100644 --- a/app/src/football/FootballCanvas.jsx +++ b/app/src/football/FootballCanvas.jsx @@ -29,6 +29,10 @@ function FootballGame() { useFrame((_, dt) => { gameApi.frame?.(Math.min(dt, 0.05)); }); + // After the main R3F pass: blit team FPV eye-cams into HUD canvases. + useFrame((_, dt) => { + gameApi.renderTeamFpv?.(Math.min(dt, 0.05)); + }, -1); return ( <> diff --git a/app/src/football/FootballHud.jsx b/app/src/football/FootballHud.jsx index cc8d979..a7dd6d8 100644 --- a/app/src/football/FootballHud.jsx +++ b/app/src/football/FootballHud.jsx @@ -3,12 +3,16 @@ // palette, zero-radius panel plates with cream keyline frames). import Box from "@mui/material/Box"; import { keyframes } from "@mui/material/styles"; +import { useState } from "react"; import { useGame } from "../store.js"; -import { uiClick } from "../game/audio.js"; +import { uiClick, isMuted, setMuted } from "../game/audio.js"; import { ORANGE, MONO } from "../theme.js"; import { ANTON, COMIC_INK, CREAM, COMIC_ORANGE } from "../ui/comic.jsx"; import { GitHubLink } from "../ui/GitHubLink.jsx"; import { formatClock, stateLabel, eventLabel, recentEvents } from "./hud-logic.js"; +import { t, teamLabel, strategyTag } from "./i18n.js"; +import StrategyBoard from "./StrategyBoard.jsx"; +import TeamFpv from "./TeamFpv.jsx"; // ── Design tokens (shared with Hud.jsx language) ────────────────────────── const FRAME_W = 2; @@ -94,6 +98,7 @@ function BackArrowIcon() { } function BackButton() { + const locale = useGame((s) => s.locale) || "en"; return ( - Back + {t(locale, "back")} + + + ); +} + +function SoundMuteButton() { + const [muted, setMutedState] = useState(isMuted); + return ( + + { + const next = !muted; + setMuted(next); + setMutedState(next); + if (!next) uiClick(); + }} + sx={{ + appearance: "none", + border: "none", + background: "transparent", + color: muted ? "rgba(250,248,242,0.45)" : CREAM, + cursor: "pointer", + fontFamily: MONO, + fontSize: "0.65rem", + letterSpacing: "0.1em", + textTransform: "uppercase", + lineHeight: 1, + "&:hover": { color: ORANGE }, + }} + > + {muted ? "MUTED" : "SFX"} + ); } @@ -154,11 +204,12 @@ function Scoreboard() { const score = useGame((s) => s.score); const matchTime = useGame((s) => s.matchTime); const matchState = useGame((s) => s.matchState); + const locale = useGame((s) => s.locale) || "en"; const red = score?.red ?? 0; const blue = score?.blue ?? 0; const time = formatClock(matchTime ?? 0); - const label = stateLabel(matchState); + const label = stateLabel(matchState, locale); const isGoal = matchState === "GOAL"; return ( @@ -232,19 +283,32 @@ function Scoreboard() { ); } -// ── Full-time result sticker ────────────────────────────────────────────── +// ── Full-time result + tactics card ─────────────────────────────────────── // Reads store.matchResult ('red' | 'blue' | 'draw'), written by game.js on -// the fulltime event. While it is still null the board keeps the plain -// FULL TIME label, so the selector tolerates the field being absent. +// the fulltime event. tacticsCard freezes possession / shots / strategy tags. export function MatchResultBanner() { const matchState = useGame((s) => s.matchState); const matchResult = useGame((s) => s.matchResult ?? null); + const tacticsCard = useGame((s) => s.tacticsCard); + const locale = useGame((s) => s.locale) || "en"; if (matchState !== "FULLTIME" || !matchResult) return null; const win = matchResult === "red" || matchResult === "blue"; const accent = matchResult === "red" ? RED_ACCENT : matchResult === "blue" ? BLUE_ACCENT : COMIC_ORANGE; - const headline = win ? `${matchResult.toUpperCase()} WINS` : "DRAW"; - const sub = win ? "FULL TIME — MATCH WINNER" : "FULL TIME — HONOURS EVEN"; + const teamName = win ? teamLabel(locale, matchResult) : ""; + const headline = win + ? (locale === "zh" ? `${teamName}胜` : `${matchResult.toUpperCase()} WINS`) + : (locale === "zh" ? "平局" : "DRAW"); + const sub = win + ? (locale === "zh" ? "全场结束 — 胜方" : "FULL TIME — MATCH WINNER") + : (locale === "zh" ? "全场结束 — 双方战平" : "FULL TIME — HONOURS EVEN"); + + const redTag = strategyTag(locale, tacticsCard?.strategy?.red); + const blueTag = strategyTag(locale, tacticsCard?.strategy?.blue); + const shotsR = tacticsCard?.shots?.red ?? 0; + const shotsB = tacticsCard?.shots?.blue ?? 0; + const possPct = Math.round(clamp01(tacticsCard?.possession?.redPct ?? 0.5) * 100); + const hasCard = !!tacticsCard; return ( <> @@ -273,6 +337,7 @@ export function MatchResultBanner() { display: "flex", flexDirection: "column", alignItems: "center", + gap: "0.65rem", rotate: "-1.5deg", animation: `${slamIn} 0.45s cubic-bezier(0.2, 1.4, 0.4, 1) both`, "@media (prefers-reduced-motion: reduce)": { animation: "none" }, @@ -291,7 +356,6 @@ export function MatchResultBanner() { padding: "10px 34px 12px", boxShadow: `7px 7px 0 ${COMIC_INK}, 7px 7px 0 2px ${accent}55`, "&::after": { - // Diagonal hatch texture over the glass, tinted with the accent. content: '""', position: "absolute", inset: 0, @@ -330,13 +394,104 @@ export function MatchResultBanner() { {sub} + + {hasCard ? ( + + + {t(locale, "cardSub")} + + + {redTag} + + {t(locale, "boardVs")} + + {blueTag} + + + + {t(locale, "boardShots")}{" "} + + {shotsR} + + – + + {shotsB} + + + + {t(locale, "boardPoss")}{" "} + + {possPct}% + + + + + ) : null} ); } +function clamp01(v) { + const n = Number(v); + if (!Number.isFinite(n)) return 0.5; + return Math.max(0, Math.min(1, n)); +} + function EventTicker() { const matchEvents = useGame((s) => s.matchEvents); + const locale = useGame((s) => s.locale) || "en"; const events = recentEvents(matchEvents, 3); if (events.length === 0) return null; @@ -345,7 +500,7 @@ function EventTicker() { - {eventLabel(ev)} + {eventLabel(ev, locale)} ))} @@ -388,6 +543,7 @@ function EventTicker() { function PenaltyIndicator() { const ducksState = useGame((s) => s.ducksState); + const locale = useGame((s) => s.locale) || "en"; if (!ducksState) return null; const penalized = ducksState.filter((d) => d.penalized); if (penalized.length === 0) return null; @@ -396,13 +552,16 @@ function PenaltyIndicator() { {penalized.map((d) => ( @@ -419,7 +578,7 @@ function PenaltyIndicator() { textTransform: "uppercase", }} > - #{d.id} SIN-BIN + #{d.id} {t(locale, "sinBin")} ))} @@ -438,15 +597,22 @@ export default function FootballHud() { }} > + - {/* Top-right corner is free (scoreboard centres, Back sits top-left): - the repo link keeps its title-screen SPOT metrics, flipped to - fixed and re-armed for clicks inside this pointer-events:none - shell. */} - + + ); } diff --git a/app/src/football/FootballTitle.jsx b/app/src/football/FootballTitle.jsx index b351dad..243e827 100644 --- a/app/src/football/FootballTitle.jsx +++ b/app/src/football/FootballTitle.jsx @@ -25,6 +25,9 @@ import { INK, ORANGE, MONO } from "../theme.js"; import { ComicButton, ComicTitle, HalftoneRamp, ANTON, CREAM } from "../ui/comic.jsx"; import { PreorderButton } from "../ui/Hud.jsx"; import { GitHubLink } from "../ui/GitHubLink.jsx"; +import StrategyPanel from "./StrategyPanel.jsx"; +import LangToggle from "./LangToggle.jsx"; +import { t } from "./i18n.js"; const rowIn = keyframes` from { transform: translateY(12px); opacity: 0; } @@ -68,11 +71,7 @@ const Kbd = styled("kbd")(() => ({ // Spectator cheat strip: football has no manual control (AI drives all six // ducks), so the arcade instruction card lists camera verbs only. -const SPECTATOR_SHORTCUTS = [ - { caps: ["Scroll"], name: "Zoom" }, - { caps: ["Drag"], name: "Orbit" }, - { caps: ["R"], name: "Reset Camera" }, -]; +// Labels come from i18n at render time. // #rrggbb -> rgba() at the halftone's alpha. const tint = (hex, a) => @@ -84,6 +83,8 @@ export default function FootballTitle({ onKickOff }) { const bootFailed = useGame((s) => s.bootFailed); const padConnected = useGame((s) => s.padConnected); const touchMode = useGame((s) => s.touchMode); + const entered = useGame((s) => s.entered); + const locale = useGame((s) => s.locale) || "en"; const [closing, setClosing] = useState(false); const prevOpen = useRef(menuOpen); // Latches once the kickoff has fired, so a pause reopen reads "Resume" @@ -227,8 +228,18 @@ export default function FootballTitle({ onKickOff }) { if (!menuOpen && !closing) return null; // After the first kickoff the overlay is a pause screen. - const ctaLabel = kickoffFired.current ? "Resume" : "Kick Off"; - const enterHint = padConnected ? "press A" : touchMode ? null : "press Enter"; + const ctaLabel = kickoffFired.current ? t(locale, "resume") : t(locale, "kickOff"); + const enterHint = padConnected + ? t(locale, "pressA") + : touchMode + ? null + : t(locale, "pressEnter"); + + const SPECTATOR = [ + { caps: ["Scroll"], name: t(locale, "zoom") }, + { caps: ["Drag"], name: t(locale, "orbit") }, + { caps: ["R"], name: t(locale, "resetCamera") }, + ]; return ( } {ready && } + {ready && } {/* Boot gate: one centered spinner on bare ink with the match's step line until fonts, brand art and the game core are in. A @@ -366,7 +378,7 @@ export default function FootballTitle({ onKickOff }) { color: "rgba(255, 255, 255, 0.5)", }} > - Loading teams... + {t(locale, "loadingTeams")} )} @@ -403,7 +415,7 @@ export default function FootballTitle({ onKickOff }) { ...row(0.08), }} > - Boot failed - reload to retry + {t(locale, "bootFailed")} ) : ( <> @@ -435,18 +447,21 @@ export default function FootballTitle({ onKickOff }) { ...row(0.24), }} > - Six ducks. One ball. Zero joysticks - the same trained policies - that drive the real robot play the whole match themselves. + {t(locale, "tagline")} + + + + {/* CTA + key prompt travel as one block, centred under the title. */} @@ -546,7 +561,7 @@ export default function FootballTitle({ onKickOff }) { }, }} > - {SPECTATOR_SHORTCUTS.map((s) => ( + {SPECTATOR.map((s) => ( - spectator mode - the ducks play themselves + {t(locale, "coachFooter")} diff --git a/app/src/football/LangToggle.jsx b/app/src/football/LangToggle.jsx new file mode 100644 index 0000000..9d94e3d --- /dev/null +++ b/app/src/football/LangToggle.jsx @@ -0,0 +1,78 @@ +// EN / 中 toggle for football title & pause overlay. +import Box from "@mui/material/Box"; +import { useGame } from "../store.js"; +import { ORANGE, MONO } from "../theme.js"; +import { ANTON, CREAM, COMIC_INK } from "../ui/comic.jsx"; +import { persistLocale, t } from "./i18n.js"; + +export default function LangToggle({ sx }) { + const locale = useGame((s) => s.locale) || "en"; + + const setLocale = (next) => { + if (next === locale) return; + persistLocale(next); + useGame.setState({ locale: next }); + }; + + const chip = (id, label) => { + const on = locale === id; + return ( + setLocale(id)} + sx={{ + appearance: "none", + cursor: "pointer", + border: `2px solid ${on ? ORANGE : "rgba(255,255,255,0.22)"}`, + borderRadius: 0, + background: on ? "rgba(255,122,47,0.2)" : "rgba(0,0,0,0.35)", + color: on ? CREAM : "rgba(255,255,255,0.7)", + fontFamily: id === "zh" ? MONO : ANTON, + fontSize: "0.72rem", + fontWeight: 700, + letterSpacing: id === "en" ? "0.08em" : "0.02em", + lineHeight: 1, + minWidth: "2.1rem", + px: "0.5rem", + py: "0.4rem", + transition: "border-color 0.12s ease, background 0.12s ease", + WebkitTapHighlightColor: "transparent", + "&:hover": { borderColor: ORANGE }, + "&:focus-visible": { + outline: `2px dashed ${CREAM}`, + outlineOffset: 2, + }, + }} + > + {label} + + ); + }; + + return ( + + {chip("en", t(locale, "langEn"))} + {chip("zh", t(locale, "langZh"))} + + ); +} diff --git a/app/src/football/StrategyBoard.jsx b/app/src/football/StrategyBoard.jsx new file mode 100644 index 0000000..67c1a9a --- /dev/null +++ b/app/src/football/StrategyBoard.jsx @@ -0,0 +1,235 @@ +// Live cumulative match report — possession tug + shots + strategy tags. +import Box from "@mui/material/Box"; +import { useGame } from "../store.js"; +import { uiClick } from "../game/audio.js"; +import { MONO } from "../theme.js"; +import { ANTON, CREAM, COMIC_INK } from "../ui/comic.jsx"; +import { DEFAULT_STRATEGY, normalizeStrategy } from "../game/football/strategy.js"; +import { + t, + formationLabel, + strategyTag, + teamLabel, +} from "./i18n.js"; + +const RED_ACCENT = "#ff4466"; +const BLUE_ACCENT = "#4488ff"; +const GLASS = "rgba(16, 16, 24, 0.88)"; + +function PossBar({ redPct, locale }) { + const pct = Math.round(clamp01(redPct) * 100); + return ( + + + {pct}% + {t(locale, "boardPoss")} + {100 - pct}% + + + + + + + ); +} + +function clamp01(v) { + const n = Number(v); + if (!Number.isFinite(n)) return 0.5; + return Math.max(0, Math.min(1, n)); +} + +function TeamTags({ team, strategy, locale, isUser, onOpenCoach }) { + const accent = team === "red" ? RED_ACCENT : BLUE_ACCENT; + const strat = normalizeStrategy(strategy || DEFAULT_STRATEGY); + const line = [ + formationLabel(locale, strat.formation), + strategyTag(locale, strat), + ].join(" · "); + + return ( + { + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + onOpenCoach?.(); + } + } : undefined} + > + + {teamLabel(locale, team)} + + + {line} + + + ); +} + +/** + * Bottom report: red tags | possession + shots | blue tags. + * Click user side to reopen coach desk. + */ +export default function StrategyBoard() { + const entered = useGame((s) => s.entered); + const menuOpen = useGame((s) => s.menuOpen); + const matchState = useGame((s) => s.matchState); + const locale = useGame((s) => s.locale) || "en"; + const userTeam = useGame((s) => s.userTeam) || "red"; + const board = useGame((s) => s.tacticsBoard); + + // Hide under the fulltime tactics card — avoid duplicating the same numbers. + if (!entered || menuOpen || matchState === "FULLTIME") return null; + + const openCoach = () => { + uiClick(); + useGame.setState({ menuOpen: true }); + }; + + const redPct = board?.possession?.redPct ?? 0.5; + const shotsR = board?.shots?.red ?? 0; + const shotsB = board?.shots?.blue ?? 0; + + return ( + *": { pointerEvents: "auto" }, + }} + > + + + + {shotsR} + + {t(locale, "boardShots")} + + {shotsB} + + + + + + + + + ); +} diff --git a/app/src/football/StrategyPanel.jsx b/app/src/football/StrategyPanel.jsx new file mode 100644 index 0000000..64042f7 --- /dev/null +++ b/app/src/football/StrategyPanel.jsx @@ -0,0 +1,210 @@ +// Coach tactics panel for football mode: pick side, formation, style, press. +// Style/press apply live; formation is locked after Kick Off (parent passes +// formationLocked). Pure UI — writes through gameApi when booted, else store. +import Box from "@mui/material/Box"; +import Typography from "@mui/material/Typography"; +import { useGame, gameApi } from "../store.js"; +import { MONO, ORANGE } from "../theme.js"; +import { ANTON, CREAM, COMIC_INK } from "../ui/comic.jsx"; +import { + DEFAULT_STRATEGY, + FORMATION_IDS, + STYLE_IDS, + PRESS_IDS, + normalizeStrategy, +} from "../game/football/strategy.js"; +import { + t, + formationLabel, + styleLabel, + pressLabel, + teamLabel, +} from "./i18n.js"; + +const RED_ACCENT = "#ff4466"; +const BLUE_ACCENT = "#4488ff"; + +function patchStrategy(partial, opts) { + if (typeof gameApi.setTeamStrategy === "function") { + return gameApi.setTeamStrategy(partial, opts); + } + const prev = useGame.getState().userStrategy || DEFAULT_STRATEGY; + const next = normalizeStrategy({ ...prev, ...partial }); + useGame.setState({ userStrategy: next }); + return next; +} + +function patchTeam(team) { + if (typeof gameApi.setUserTeam === "function") { + return gameApi.setUserTeam(team); + } + useGame.setState({ userTeam: team }); + return team; +} + +function ChipGroup({ label, options, labels, value, onChange, disabled }) { + return ( + + + {label} + + + {options.map((id) => { + const on = value === id; + return ( + onChange(id)} + sx={{ + appearance: "none", + cursor: disabled ? "default" : "pointer", + border: `2px solid ${on ? ORANGE : "rgba(255,255,255,0.22)"}`, + borderRadius: 0, + background: on ? "rgba(255,122,47,0.18)" : "rgba(0,0,0,0.25)", + color: on ? CREAM : "rgba(255,255,255,0.72)", + fontFamily: ANTON, + fontSize: "0.72rem", + letterSpacing: "0.06em", + textTransform: "uppercase", + lineHeight: 1, + px: "0.65rem", + py: "0.45rem", + opacity: disabled ? 0.45 : 1, + transition: "border-color 0.12s ease, background 0.12s ease", + WebkitTapHighlightColor: "transparent", + "&:hover": disabled ? undefined : { borderColor: ORANGE }, + "&:focus-visible": { + outline: `2px dashed ${CREAM}`, + outlineOffset: 2, + }, + }} + > + {labels[id] || id} + + ); + })} + + + ); +} + +/** + * @param {{ compact?: boolean, formationLocked?: boolean }} props + */ +export default function StrategyPanel({ compact = false, formationLocked = false }) { + const userTeam = useGame((s) => s.userTeam) || "red"; + const userStrategy = useGame((s) => s.userStrategy) || DEFAULT_STRATEGY; + const locale = useGame((s) => s.locale) || "en"; + const strat = normalizeStrategy(userStrategy); + + const formationLabels = Object.fromEntries( + FORMATION_IDS.map((id) => [id, formationLabel(locale, id)]), + ); + const styleLabels = Object.fromEntries( + STYLE_IDS.map((id) => [id, styleLabel(locale, id)]), + ); + const pressLabels = Object.fromEntries( + PRESS_IDS.map((id) => [id, pressLabel(locale, id)]), + ); + + return ( + + + {t(locale, "coachDesk")} + + + patchTeam(tm)} + disabled={formationLocked} + /> + + + patchStrategy({ formation })} + /> + patchStrategy({ style })} + /> + patchStrategy({ press })} + /> + + + + {teamLabel(locale, userTeam)} · {formationLabels[strat.formation]} · {styleLabels[strat.style]} · {t(locale, "pressWord")} {pressLabels[strat.press]} + {formationLocked ? ` · ${t(locale, "formationLocked")}` : ""} + + + ); +} diff --git a/app/src/football/TeamFpv.jsx b/app/src/football/TeamFpv.jsx new file mode 100644 index 0000000..7d84519 --- /dev/null +++ b/app/src/football/TeamFpv.jsx @@ -0,0 +1,141 @@ +// Dual first-person vision at the top — red left, blue right. +// Each slot owns an opaque ; gameApi blits chaser eye-cams into them. +import { useEffect, useRef } from "react"; +import Box from "@mui/material/Box"; +import { useGame, gameApi } from "../store.js"; +import { MONO } from "../theme.js"; +import { ANTON, COMIC_INK } from "../ui/comic.jsx"; +import { t, teamLabel } from "./i18n.js"; + +const RED_ACCENT = "#ff4466"; +const BLUE_ACCENT = "#4488ff"; +const FPV_W = 320; +const FPV_H = 200; + +function FpvSlot({ team, locale, canvasRef }) { + const accent = team === "red" ? RED_ACCENT : BLUE_ACCENT; + return ( + + + + {teamLabel(locale, team)} + + + {t(locale, "fpvLabel")} + + + + + ); +} + +/** + * Below the scoreboard / chrome row so Back · SFX · GitHub never sit inside + * the feed. Canvases receive real eye-cam frames from gameApi.renderTeamFpv. + */ +export default function TeamFpv() { + const entered = useGame((s) => s.entered); + const bootDone = useGame((s) => s.bootDone); + const menuOpen = useGame((s) => s.menuOpen); + const matchState = useGame((s) => s.matchState); + const locale = useGame((s) => s.locale) || "en"; + const redRef = useRef(null); + const blueRef = useRef(null); + + const active = entered && bootDone && !menuOpen && matchState !== "FULLTIME"; + + useEffect(() => { + if (!active) { + gameApi.setFpvSlots?.(null); + return undefined; + } + const register = () => { + gameApi.setFpvSlots?.({ + red: redRef.current, + blue: blueRef.current, + }); + }; + register(); + // Boot may finish a tick after first paint — re-register once more. + const t = window.setTimeout(register, 50); + return () => { + window.clearTimeout(t); + gameApi.setFpvSlots?.(null); + }; + }, [active]); + + if (!active) return null; + + return ( + + + + + + ); +} diff --git a/app/src/football/hud-logic.js b/app/src/football/hud-logic.js index c397137..6a15b99 100644 --- a/app/src/football/hud-logic.js +++ b/app/src/football/hud-logic.js @@ -3,6 +3,7 @@ // ui/keyboard-layout.js / ui/keyboard-layout.test.js). import { MATCH_DURATION_S } from "../game/football/constants.js"; +import { t } from "./i18n.js"; // Countdown clock: matchTime is seconds ELAPSED, the board shows what is // left of the regulation duration, clamped at 00:00. @@ -26,8 +27,11 @@ export const STATE_LABELS = { FULLTIME: "FULL TIME", }; -export function stateLabel(matchState) { - if (!matchState) return STATE_LABELS.IDLE; +export function stateLabel(matchState, locale = "en") { + if (!matchState) return t(locale, "state_IDLE"); + const key = `state_${matchState}`; + const localized = t(locale, key); + if (localized !== key) return localized; return STATE_LABELS[matchState] ?? String(matchState).replace(/_/g, " ").toUpperCase(); } @@ -47,22 +51,30 @@ const EVENT_ICONS = { const cap = (s) => s.charAt(0).toUpperCase() + s.slice(1); +function teamToken(ev, locale) { + if (!ev.team) return ""; + if (locale === "zh") { + return ev.team === "red" ? " 红队" : ev.team === "blue" ? " 蓝队" : ` ${ev.team}`; + } + return ` ${cap(ev.team)}`; +} + // One ticker line for a matchEvents entry {type, team, time, payload}. -export function eventLabel(ev) { +export function eventLabel(ev, locale = "en") { const icon = EVENT_ICONS[ev.type] || "\u2022"; - const team = ev.team ? ` ${cap(ev.team)}` : ""; + const team = teamToken(ev, locale); switch (ev.type) { - case "goal": return `${icon} GOAL!${team} Team scores!`; - case "yellow_card": return `${icon} Yellow card \u2014${team}`; - case "red_card": return `${icon} Red card \u2014${team}`; - case "corner": return `${icon} Corner kick \u2014${team}`; + case "goal": return `${icon} ${t(locale, "ev_goal", { team })}`; + case "yellow_card": return `${icon} ${t(locale, "ev_yellow", { team })}`; + case "red_card": return `${icon} ${t(locale, "ev_red", { team })}`; + case "corner": case "corner_red": - case "corner_blue": return `${icon} Corner kick \u2014${team}`; - case "foul": return `${icon} Foul \u2014${team}`; - case "penalty": return `${icon} Penalty \u2014${team}`; - case "kickoff": return `${icon} Kick off!`; - case "halftime": return `${icon} Half time`; - case "fulltime": return `${icon} Full time`; + case "corner_blue": return `${icon} ${t(locale, "ev_corner", { team })}`; + case "foul": return `${icon} ${t(locale, "ev_foul", { team })}`; + case "penalty": return `${icon} ${t(locale, "ev_penalty", { team })}`; + case "kickoff": return `${icon} ${t(locale, "ev_kickoff")}`; + case "halftime": return `${icon} ${t(locale, "ev_halftime")}`; + case "fulltime": return `${icon} ${t(locale, "ev_fulltime")}`; default: return `${icon}${team} ${String(ev.type).replace(/_/g, " ")}`; } } diff --git a/app/src/football/i18n.js b/app/src/football/i18n.js new file mode 100644 index 0000000..b7da548 --- /dev/null +++ b/app/src/football/i18n.js @@ -0,0 +1,189 @@ +// Football UI strings — EN / 中文. No i18n framework; locale lives on the store. + +export const LOCALES = ["en", "zh"]; + +const STRINGS = { + en: { + loadingTeams: "Loading teams...", + bootFailed: "Boot failed - reload to retry", + tagline: + "Six ducks. One ball. Zero joysticks — set your side's tactics, then watch the trained policies play the match.", + kickOff: "Kick Off", + resume: "Resume", + pressA: "press A", + pressEnter: "press Enter", + zoom: "Zoom", + orbit: "Orbit", + resetCamera: "Reset Camera", + coachFooter: "coach mode — pick tactics, ducks play themselves", + coachDesk: "Coach desk", + yourSide: "Your side", + formation: "Formation", + style: "Style", + press: "Press", + red: "Red", + blue: "Blue", + attack: "Attack", + balanced: "Balanced", + defend: "Defend", + low: "Low", + medium: "Med", + high: "High", + pressWord: "press", + formationLocked: "formation locked", + teamTactics: "Team tactics", + back: "Back", + sinBin: "SIN-BIN", + openCoach: "Open coach desk", + langEn: "EN", + langZh: "中", + // Live match report (cumulative) + boardPoss: "Possession", + boardShots: "Shots", + boardVs: "vs", + pitchSim: "Live pitch", + pitchSimHint: "△ chaser ring · nose = facing", + fpvLabel: "FPV · chaser", + cardTitle: "Tactics card", + cardSub: "FULL TIME — STRATEGY REPORT", + // HUD match states + state_IDLE: "STANDBY", + state_KICKOFF: "KICKOFF", + state_PLAYING: "PLAYING", + state_DEAD_BALL: "DEAD BALL", + state_SET_PIECE: "SET PIECE", + state_GOAL: "GOAL!", + state_HALFTIME: "HALF TIME", + state_FULLTIME: "FULL TIME", + // Events + ev_goal: "GOAL!{team} Team scores!", + ev_yellow: "Yellow card —{team}", + ev_red: "Red card —{team}", + ev_corner: "Corner kick —{team}", + ev_foul: "Foul —{team}", + ev_penalty: "Penalty —{team}", + ev_kickoff: "Kick off!", + ev_halftime: "Half time", + ev_fulltime: "Full time", + }, + zh: { + loadingTeams: "正在加载队伍…", + bootFailed: "启动失败 — 刷新重试", + tagline: "六只鸭子,一颗球,零手柄 — 调好己方战术,看着训练好的策略自己踢球。", + kickOff: "开球", + resume: "继续", + pressA: "按 A", + pressEnter: "按 Enter", + zoom: "缩放", + orbit: "环绕", + resetCamera: "重置镜头", + coachFooter: "教练模式 — 选战术,鸭子自己踢", + coachDesk: "教练席", + yourSide: "己方", + formation: "阵型", + style: "风格", + press: "压迫", + red: "红队", + blue: "蓝队", + attack: "进攻", + balanced: "均衡", + defend: "防守", + low: "低", + medium: "中", + high: "高", + pressWord: "压迫", + formationLocked: "阵型已锁定", + teamTactics: "球队战术", + back: "返回", + sinBin: "暂罚", + openCoach: "打开教练席", + langEn: "EN", + langZh: "中", + boardPoss: "控球", + boardShots: "射门", + boardVs: "对", + pitchSim: "实时球场", + pitchSimHint: "△ 追球圈 · 尖端=朝向", + fpvLabel: "第一视角 · 追球手", + cardTitle: "战术卡", + cardSub: "全场结束 — 战术战报", + state_IDLE: "待命", + state_KICKOFF: "开球", + state_PLAYING: "进行中", + state_DEAD_BALL: "死球", + state_SET_PIECE: "定位球", + state_GOAL: "进球!", + state_HALFTIME: "中场", + state_FULLTIME: "全场结束", + ev_goal: "进球!{team}得分", + ev_yellow: "黄牌 —{team}", + ev_red: "红牌 —{team}", + ev_corner: "角球 —{team}", + ev_foul: "犯规 —{team}", + ev_penalty: "点球 —{team}", + ev_kickoff: "开球!", + ev_halftime: "半场", + ev_fulltime: "全场结束", + }, +}; + +const FORMATION = { + en: { "2f1gk": "2F · 1GK", "1f1d1gk": "1F · 1D · 1GK" }, + zh: { "2f1gk": "2前·1门", "1f1d1gk": "1前·1卫·1门" }, +}; + +const STORAGE_KEY = "microduck-locale"; + +export function detectLocale() { + try { + const saved = localStorage.getItem(STORAGE_KEY); + if (LOCALES.includes(saved)) return saved; + } catch { + /* ignore */ + } + if (typeof navigator !== "undefined" && /^zh\b/i.test(navigator.language || "")) { + return "zh"; + } + return "en"; +} + +export function persistLocale(locale) { + try { + localStorage.setItem(STORAGE_KEY, locale); + } catch { + /* ignore */ + } +} + +export function t(locale, key, vars = {}) { + const pack = STRINGS[locale] || STRINGS.en; + let s = pack[key] ?? STRINGS.en[key] ?? key; + for (const [k, v] of Object.entries(vars)) { + s = s.replaceAll(`{${k}}`, v); + } + return s; +} + +export function formationLabel(locale, id) { + return (FORMATION[locale] || FORMATION.en)[id] || id; +} + +export function styleLabel(locale, id) { + return t(locale, id); +} + +export function pressLabel(locale, id) { + return t(locale, id); +} + +export function teamLabel(locale, id) { + return t(locale, id); +} + +/** Short coach tag: "Attack · High" / "进攻 · 高" */ +export function strategyTag(locale, strategy) { + if (!strategy) return "—"; + const style = styleLabel(locale, strategy.style); + const press = pressLabel(locale, strategy.press); + return `${style} · ${press}`; +} diff --git a/app/src/game/audio.js b/app/src/game/audio.js index 13e95cc..ffd333e 100644 --- a/app/src/game/audio.js +++ b/app/src/game/audio.js @@ -21,7 +21,10 @@ import { signed } from "./signed.js"; // point early-returns, master gain pinned to 0, context never resumed). // Node-returning helpers (busNode, createEmitter) keep building their // graph either way so callers stay untouched. -const SOUND_DISABLED = true; +// Football 3v3 needs fall / kick / collision cues; keep this false so the +// Kenney impact bank (public/assets/sfx) and voice banks can play. HUD mute +// still works via setMuted(). +const SOUND_DISABLED = false; // ── Context, master, buses ──────────────────────────────────────────── const MASTER_LEVEL = 0.9; diff --git a/app/src/game/football/ai/index.js b/app/src/game/football/ai/index.js index fc1f773..73af8b5 100644 --- a/app/src/game/football/ai/index.js +++ b/app/src/game/football/ai/index.js @@ -5,13 +5,13 @@ // // No THREE / MuJoCo / ONNX dependencies. Only imports data constants. -import { FIELD_HALF_L, GOAL_WIDTH } from '../constants.js'; +import { FIELD_HALF_L, FIELD_HALF_W, GOAL_WIDTH } from '../constants.js'; // ═══════════════════════════════════════════════════════════════════════════════ // TUNE — all adjustable parameters in one place // ═══════════════════════════════════════════════════════════════════════════════ -const TUNE = { +export const BASE_TUNE = { // Locomotion limits (mirrors game/constants.js VEL_FWD / VEL_BACK / VEL_ANG) VX_MAX: 0.25, VX_MIN: -0.2, @@ -24,17 +24,19 @@ const TUNE = { SHOOT_SPEED: 0.25, // raised above walk-policy dead-zone (~0.2 m/s) AIM_SPEED: 0.22, // raised above walk-policy dead-zone (~0.2 m/s) - // Deadlock breaking: lateral-offset approach + kick cooldown. - // Two chasers arriving head-on at the mid-circle trap the ball and trade - // cancelled kicks; the repeated one-shot kick imbalance topples them. The - // chaser therefore aims at a point offset to the ball's side-rear instead of - // the ball centre, and after several wasted kicks it stops kicking and - // repositions laterally for a short cooldown. - APPROACH_OFFSET: 0.4, // lateral offset of the CHASE aim point (m) + // Deadlock breaking: after wasted at-feet kicks, stop kicking and shuffle + // laterally for a short cooldown (breaks two-chaser mid-circle topple loops). + APPROACH_OFFSET: 0.4, // kept for PRESS overlays / tactics metrics KICK_COOLDOWN_TICKS: 15, // ticks a stuck chaser stops kicking & repositions (1.5s @10Hz) KICK_HOLD_BEFORE_COOLDOWN: 2,// consecutive at-feet kick attempts before arming the cooldown - // Chase / return + // Chase approach (ER-Force MoveToStaticBall): stand at ball − shotDir × r, + // not goto(ball). Slow ball → static offset; fast ball → short ball+v·t. + BALL_SLOW_EPS: 0.12, // |v| below this → static approach + BALL_PREDICT_T: 0.7, // max prediction horizon (s) + APPROACH_ARRIVE_EPS: 0.12, // on approach spot → step into ball + + // Chase / return — kickoff ~0.9 m to approach spot @ 0.25 m/s CHASE_SPEED: 0.25, RETURN_SPEED: 0.25, // raised above walk-policy dead-zone (~0.2 m/s) @@ -50,6 +52,14 @@ const TUNE = { FORMATION_X_RETREAT: -0.3, FORMATION_SLOT_EPS: 0.3, // "at slot" distance + // SSL-inspired role assignment / support (ER-Force cost, TIGERs support lane). + // Still outputs {vx,wz,kick} — no path planner / messaging bus. + CHASE_HYSTERESIS: 0.28, // sticky bonus so chaser does not flicker every tick + FACE_COST_WEIGHT: 0.35, // metres-equivalent for facing away from the ball + SUPPORT_AHEAD: 0.55, // support stands this far past the ball (attack axis) + SUPPORT_LATERAL: 0.7, // lateral offset → open lane opposite the chaser + SECOND_PRESS_DIST: 0, // >0: non-chaser within range soft-contests (high press) + // Goalkeeper GK_LINE_OFFSET: 0.1, GK_Y_MARGIN: 0.1, @@ -72,6 +82,10 @@ const TUNE = { TEAMMATE_BALL_DIST: 0.5, }; +// Active TUNE for the current decideAll call. Strategy overlays swap this for +// one team tick, then restore BASE_TUNE so tests / compat agents stay stable. +let TUNE = BASE_TUNE; + // ═══════════════════════════════════════════════════════════════════════════════ // Utility functions (pure math, no deps) // ═══════════════════════════════════════════════════════════════════════════════ @@ -154,20 +168,79 @@ function getOpponents(duck, allDucks, team) { } // ═══════════════════════════════════════════════════════════════════════════════ -// Chaser selection +// Chaser selection (SSL / ER-Force style cost, not pure Euclidean) // ═══════════════════════════════════════════════════════════════════════════════ -function pickChaser(ducks, ball) { - let best = null, bestDist = Infinity; +/** + * Ball-get cost: distance + facing penalty − sticky hysteresis. + * Lower is better. Exported for tactics-board + tests. + */ +export function chaseCost(duck, ball, prevChaserId = -1) { + const [x, y] = duckXY(duck); + const dist = distanceTo(x, y, ball.x, ball.y); + const toBall = angleTo(x, y, ball.x, ball.y); + const yaw = duck.yaw || 0; + // 0 when facing the ball, up to 2 when facing away → weighted into metres. + const face = 1 - Math.cos(angleDiff(yaw, toBall)); + let cost = dist + TUNE.FACE_COST_WEIGHT * face; + if (duck.id === prevChaserId) cost -= TUNE.CHASE_HYSTERESIS; + return cost; +} + +/** Assign primary ball-getter among field players. */ +export function assignChaserId(ducks, ball, prevChaserId = -1) { + let best = null; + let bestCost = Infinity; for (const d of ducks) { - if (d.role === 'goalkeeper' || d.fallen || d.penalized) continue; - const [x, y] = duckXY(d); - const dist = distanceTo(x, y, ball.x, ball.y); - if (dist < bestDist) { bestDist = dist; best = d; } + if (d.role === 'goalkeeper' || d.fallen || d.penalized || d.sentOff) continue; + const c = chaseCost(d, ball, prevChaserId); + if (c < bestCost) { + bestCost = c; + best = d; + } } return best ? best.id : -1; } +function prevChaserIdOf(ducks) { + for (const d of ducks) { + if (d._ai?.holdingChase) return d.id; + } + return -1; +} + +function markChaserHold(ducks, chaserId) { + for (const d of ducks) { + if (d.id === chaserId) { + if (!d._ai) { + d._ai = { + aimTicks: 0, stallTicks: 0, escapeTicks: 0, + prevX: 0, prevY: 0, kickCooldown: 0, kickHoldTicks: 0, + }; + } + d._ai.holdingChase = true; + } else if (d._ai) { + d._ai.holdingChase = false; + } + } +} + +/** Support lane opposite the chaser (TIGERs-style free position near the ball). */ +function supportSlot(duck, ctx) { + const { ball, attackDir, chaserId, ducks } = ctx; + const spawnY = duck.spawnY != null ? duck.spawnY : 0; + let latSign = spawnY >= 0 ? 1 : -1; + const chaser = ducks.find((d) => d.id === chaserId); + if (chaser) { + const [, cy] = duckXY(chaser); + latSign = (cy - ball.y) >= 0 ? -1 : 1; + } + return { + x: clamp(ball.x + attackDir * TUNE.SUPPORT_AHEAD, -FIELD_HALF_L + 0.3, FIELD_HALF_L - 0.3), + y: clamp(ball.y + latSign * TUNE.SUPPORT_LATERAL, -FIELD_HALF_W + 0.3, FIELD_HALF_W - 0.3), + }; +} + // ═══════════════════════════════════════════════════════════════════════════════ // Context builder // ═══════════════════════════════════════════════════════════════════════════════ @@ -179,7 +252,9 @@ function buildCtx(ducks, gs) { const defendGoalX = -attackDir * FIELD_HALF_L; const ball = gs.ball; const allDucks = gs.allDucks || gs.ducks || ducks; - const chaserId = pickChaser(ducks, ball); + const prevId = gs.prevChaserId != null ? gs.prevChaserId : prevChaserIdOf(ducks); + const chaserId = assignChaserId(ducks, ball, prevId); + markChaserHold(ducks, chaserId); return { team, attackDir, targetGoalX, defendGoalX, ball, allDucks, chaserId, ducks }; } @@ -242,6 +317,37 @@ function gkDecide(duck, ctx) { ); } +function ballSpeed(ball) { + return Math.hypot(ball.vx || 0, ball.vy || 0); +} + +/** + * ER-Force MoveToStaticBall / short intercept. + * Approach = predictedBall − shootDir × kickRadius (stand behind ball on the + * shot axis). Slow/still ball → no prediction; fast ball → ball + v·t with + * t ≈ dist/CHASE_SPEED capped by BALL_PREDICT_T. + */ +export function chaseApproachPoint(ball, targetGoalX, selfX, selfY) { + const r = TUNE.SHOOT_DIST * 0.95; + let bx = ball.x; + let by = ball.y; + const vx = ball.vx || 0; + const vy = ball.vy || 0; + if (ballSpeed(ball) > TUNE.BALL_SLOW_EPS) { + const distNow = Math.hypot(bx - selfX, by - selfY); + const t = clamp(distNow / Math.max(TUNE.CHASE_SPEED, 0.05), 0, TUNE.BALL_PREDICT_T); + bx += vx * t; + by += vy * t; + } + const gdx = targetGoalX - bx; + const gdy = 0 - by; + const glen = Math.hypot(gdx, gdy) || 1; + return { + x: bx - (gdx / glen) * r, + y: by - (gdy / glen) * r, + }; +} + /** * Chaser: CHASE / AIM / SHOOT (merged forward logic). * Includes AIM fuse — if aiming too long, force back to CHASE. @@ -249,7 +355,7 @@ function gkDecide(duck, ctx) { function chaserDecide(duck, ctx) { const [sx, sy] = duckXY(duck); const yaw = duck.yaw || 0; - const { ball, attackDir, targetGoalX } = ctx; + const { ball, targetGoalX } = ctx; const bd = distToBall(duck, ball); const toBall = angleToBall(duck, ball); const toGoal = angleTo(sx, sy, targetGoalX, 0); @@ -289,77 +395,92 @@ function chaserDecide(duck, ctx) { } return limitCommand(TUNE.SHOOT_SPEED, turnToward(yaw, toGoal), true); } - // AIM — with fuse + // AIM — turn toward goal; after a short wind-up, poke-kick even if not + // perfectly aligned so we don't stand on the ball forever walking. ai.kickHoldTicks = 0; ai.aimTicks++; if (ai.aimTicks > TUNE.AIM_MAX_TICKS) { - // Fuse blown: back off and re-approach + // Fuse blown: re-approach via shot-axis stand point ai.aimTicks = 0; + const ap = chaseApproachPoint(ball, targetGoalX, sx, sy); + const toAp = angleTo(sx, sy, ap.x, ap.y); return limitCommand( - moveToward(yaw, toBall, TUNE.CHASE_SPEED), - turnToward(yaw, toBall), + moveToward(yaw, toAp, TUNE.CHASE_SPEED), + turnToward(yaw, toAp), ); } + const pokeKick = ai.aimTicks >= 20; return limitCommand( moveToward(yaw, toGoal, TUNE.AIM_SPEED), turnToward(yaw, toGoal), + pokeKick, ); } - // CHASE: approach the ball from its side-rear rather than head-on. Aiming at - // an offset point keeps two opposing chasers from meeting exactly at the ball - // centre (the head-on collision that traps it) and lines the body up for an - // angled strike. Offset side follows the duck's own Y relative to the ball. + // CHASE: MoveToStaticBall / intercept — go to (ball − shotDir × r), not ball centre. ai.aimTicks = 0; ai.kickHoldTicks = 0; - const offset = TUNE.APPROACH_OFFSET; - const dy = sy - ball.y; - const lateralSign = dy >= 0 ? 1 : -1; - const targetX = ball.x - attackDir * offset * 0.3; // slightly behind the ball - const targetY = ball.y + lateralSign * offset; // offset to the side - const toTarget = angleTo(sx, sy, targetX, targetY); + const ap = chaseApproachPoint(ball, targetGoalX, sx, sy); + const apDist = distanceTo(sx, sy, ap.x, ap.y); + const aimAng = apDist < TUNE.APPROACH_ARRIVE_EPS ? toBall : angleTo(sx, sy, ap.x, ap.y); return limitCommand( - moveToward(yaw, toTarget, TUNE.CHASE_SPEED), - turnToward(yaw, toTarget), + moveToward(yaw, aimAng, TUNE.CHASE_SPEED), + turnToward(yaw, aimAng), ); } /** - * Formation: RETURN to slot (merged forward RETURN + defender GUARD/SUPPORT). - * Non-chaser field ducks hold formation positions. + * Formation / support: non-chaser field ducks. + * Own half → cover/retreat slot. Attack half → TIGERs-style support lane near + * the ball. High press (SECOND_PRESS_DIST) → soft second contest. */ function formationDecide(duck, ctx) { const [sx, sy] = duckXY(duck); const yaw = duck.yaw || 0; const { ball, attackDir, defendGoalX, targetGoalX, allDucks, team } = ctx; - // Compute formation slot based on ball position const ballInOwnHalf = ball.x * attackDir < 0; const spawnX = duck.spawnX != null ? duck.spawnX : (attackDir > 0 ? -0.8 : 0.8); const spawnY = duck.spawnY != null ? duck.spawnY : 0; + // Defender-specific: GUARD / INTERCEPT / CLEAR / SUPPORT + if (duck.role === 'defender') { + return defenderFormation(duck, ctx, sx, sy, yaw, ball, attackDir, defendGoalX, targetGoalX, allDucks, team); + } + + const bd = distToBall(duck, ball); + + // High press: second player soft-contests loose ball (does not shoot — chaser owns kick). + if ( + !ballInOwnHalf && + TUNE.SECOND_PRESS_DIST > 0 && + bd < TUNE.SECOND_PRESS_DIST && + bd > TUNE.SHOOT_DIST + ) { + const toB = angleToBall(duck, ball); + return limitCommand( + moveToward(yaw, toB, TUNE.CHASE_SPEED * 0.85), + turnToward(yaw, toB), + ); + } + let slotX, slotY; if (ballInOwnHalf) { - // Defensive: pull back toward own half + // Defensive cover: pull back toward own half (spawn-anchored) slotX = attackDir * Math.max(Math.abs(spawnX), 1.0) + TUNE.FORMATION_X_RETREAT * attackDir; slotY = spawnY; } else { - // Attacking: push into opponent half - slotX = attackDir * Math.max(Math.abs(spawnX), 1.0) + TUNE.FORMATION_X_ADVANCE * attackDir; - slotY = spawnY; - } - - // Clamp slot to field bounds - slotX = clamp(slotX, -FIELD_HALF_L + 0.3, FIELD_HALF_L - 0.3); - - // Defender-specific: GUARD / INTERCEPT / CLEAR / SUPPORT - if (duck.role === 'defender') { - return defenderFormation(duck, ctx, sx, sy, yaw, ball, attackDir, defendGoalX, targetGoalX, allDucks, team); + // Attack support lane near the ball (not a deep static spawn mirror) + const slot = supportSlot(duck, ctx); + slotX = slot.x; + slotY = slot.y; + // Blend a little of formation advance so style overlays still matter + slotX += TUNE.FORMATION_X_ADVANCE * attackDir * 0.35; + slotX = clamp(slotX, -FIELD_HALF_L + 0.3, FIELD_HALF_L - 0.3); } const atSlot = distanceTo(sx, sy, slotX, slotY) < TUNE.FORMATION_SLOT_EPS; if (atSlot) { - // Parked: face the play return limitCommand(0, turnToward(yaw, attackDir > 0 ? 0 : Math.PI)); } const toSlot = angleTo(sx, sy, slotX, slotY); @@ -438,18 +559,27 @@ function applyAntiStuck(cmd) { /** * Compute locomotion commands for one team's ducks this tick. * @param {Array} ducks Team ducks: [{id, pos:[x,y], yaw, role, fallen, penalized, team, spawnX?, spawnY?, _ai?}] - * @param {Object} gs {ball:{x,y,vx,vy}, allDucks:[...], team, time?} + * @param {Object} gs {ball:{x,y,vx,vy}, allDucks:[...], team, time?, tuneOverlay?} * @returns {Array<{vx:number, wz:number, kick:boolean}>} */ export function decideAll(ducks, gs) { if (!ducks || !ducks.length) return []; - const ctx = buildCtx(ducks, gs); - return ducks.map(d => { - if (d.fallen || d.penalized) return { vx: 0, wz: 0, kick: false }; - if (d.role === 'goalkeeper') return gkDecide(d, ctx); - if (d.id === ctx.chaserId) return chaserDecide(d, ctx); - return formationDecide(d, ctx); - }).map(cmd => applyAntiStuck(cmd)); + const prevTune = TUNE; + const overlay = gs?.tuneOverlay; + TUNE = overlay && Object.keys(overlay).length + ? { ...BASE_TUNE, ...overlay } + : BASE_TUNE; + try { + const ctx = buildCtx(ducks, gs); + return ducks.map(d => { + if (d.fallen || d.penalized) return { vx: 0, wz: 0, kick: false }; + if (d.role === 'goalkeeper') return gkDecide(d, ctx); + if (d.id === ctx.chaserId) return chaserDecide(d, ctx); + return formationDecide(d, ctx); + }).map(cmd => applyAntiStuck(cmd)); + } finally { + TUNE = prevTune; + } } // ═══════════════════════════════════════════════════════════════════════════════ diff --git a/app/src/game/football/match-stats.js b/app/src/game/football/match-stats.js new file mode 100644 index 0000000..43871ff --- /dev/null +++ b/app/src/game/football/match-stats.js @@ -0,0 +1,140 @@ +// Cumulative match report — possession time + shot attempts. +// Replaces the old vanity meters (press line / compact / threat bars). + +import { FIELD_HALF_L } from './constants.js'; +import { DEFAULT_STRATEGY, normalizeStrategy } from './strategy.js'; + +const SHOT_COOLDOWN_S = 2.5; +/** Max distance duck↔ball to count as a deliberate strike (m). */ +const SHOT_BALL_DIST = 0.42; +/** Max |yaw − bearing-to-goal| for a shot (~35°). */ +const SHOT_FACE_GOAL = 0.61; +/** Ball must be past midfield toward the attack (attacking half). */ +const SHOT_MIN_ATTACK_X = 0.05; + +function angleDiff(a, b) { + let d = b - a; + while (d > Math.PI) d -= 2 * Math.PI; + while (d < -Math.PI) d += 2 * Math.PI; + return d; +} + +/** + * True when this kick is a goal-directed shot, not a poke / clearance / scramble. + * Exported for unit tests. + * + * @param {'red'|'blue'} team + * @param {{ x: number, y?: number }} ball + * @param {{ x: number, y: number, yaw: number }} kicker + */ +export function isGoalDirectedShot(team, ball, kicker) { + if (team !== 'red' && team !== 'blue') return false; + if (!ball || !kicker) return false; + if (![ball.x, kicker.x, kicker.y, kicker.yaw].every(Number.isFinite)) return false; + + const dir = team === 'red' ? 1 : -1; + // Attacking half only — midfield scrums / own-half clearances out. + if (ball.x * dir < SHOT_MIN_ATTACK_X) return false; + + const by = Number.isFinite(ball.y) ? ball.y : 0; + const bd = Math.hypot(kicker.x - ball.x, kicker.y - by); + if (bd > SHOT_BALL_DIST) return false; + + const goalX = dir * FIELD_HALF_L; + const toGoal = Math.atan2(0 - kicker.y, goalX - kicker.x); + if (Math.abs(angleDiff(kicker.yaw, toGoal)) > SHOT_FACE_GOAL) return false; + + return true; +} + +/** + * Mutable accumulator. Call tick() every referee step while PLAYING; + * tryNoteShot() when a field duck starts a kick (filters to goal-aimed shots). + */ +export function createMatchStats() { + let possRed = 0; + let possBlue = 0; + let shotsRed = 0; + let shotsBlue = 0; + let coolRed = 0; + let coolBlue = 0; + + function reset() { + possRed = 0; + possBlue = 0; + shotsRed = 0; + shotsBlue = 0; + coolRed = 0; + coolBlue = 0; + } + + /** + * @param {number} dt + * @param {{ matchState: string, lastTouchTeam: ?string }} ctx + */ + function tick(dt, ctx) { + const d = Number.isFinite(dt) ? Math.max(0, dt) : 0; + coolRed = Math.max(0, coolRed - d); + coolBlue = Math.max(0, coolBlue - d); + if (ctx?.matchState !== 'PLAYING') return; + if (ctx.lastTouchTeam === 'red') possRed += d; + else if (ctx.lastTouchTeam === 'blue') possBlue += d; + } + + /** + * @param {'red'|'blue'} team + * @param {{ x: number, y?: number }} ball + * @param {{ x: number, y: number, yaw: number }} kicker + * @returns {boolean} + */ + function tryNoteShot(team, ball, kicker) { + if (!isGoalDirectedShot(team, ball, kicker)) return false; + if (team === 'red') { + if (coolRed > 0) return false; + coolRed = SHOT_COOLDOWN_S; + shotsRed += 1; + } else { + if (coolBlue > 0) return false; + coolBlue = SHOT_COOLDOWN_S; + shotsBlue += 1; + } + return true; + } + + function snapshot() { + const total = possRed + possBlue; + return { + possession: { + red: +possRed.toFixed(2), + blue: +possBlue.toFixed(2), + redPct: total > 1e-6 ? possRed / total : 0.5, + }, + shots: { red: shotsRed, blue: shotsBlue }, + }; + } + + return { reset, tick, tryNoteShot, snapshot }; +} + +/** + * Live / fulltime board payload. + * @param {object} args + * @param {{ snapshot: Function }} args.stats + * @param {object} [args.strategyByTeam] + * @param {{ red: number, blue: number }} [args.score] + */ +export function buildMatchReport({ stats, strategyByTeam = {}, score = null }) { + const snap = stats?.snapshot ? stats.snapshot() : { + possession: { red: 0, blue: 0, redPct: 0.5 }, + shots: { red: 0, blue: 0 }, + }; + return { + possession: snap.possession, + shots: snap.shots, + strategy: { + red: normalizeStrategy(strategyByTeam.red || DEFAULT_STRATEGY), + blue: normalizeStrategy(strategyByTeam.blue || DEFAULT_STRATEGY), + }, + score: score ? { red: score.red | 0, blue: score.blue | 0 } : null, + }; +} diff --git a/app/src/game/football/strategy.js b/app/src/game/football/strategy.js new file mode 100644 index 0000000..e47e5f3 --- /dev/null +++ b/app/src/game/football/strategy.js @@ -0,0 +1,136 @@ +// Coach-facing strategy presets for 3v3 football. +// Maps UI choices (formation / style / press) onto spawn roles and TUNE overlays. +// Opponent teams without a user strategy keep DEFAULT_STRATEGY. + +import { SPAWN_POSITIONS } from './constants.js'; + +export const FORMATION_IDS = ['2f1gk', '1f1d1gk']; +export const STYLE_IDS = ['attack', 'balanced', 'defend']; +export const PRESS_IDS = ['low', 'medium', 'high']; + +export const DEFAULT_STRATEGY = Object.freeze({ + formation: '2f1gk', + style: 'balanced', + press: 'medium', +}); + +/** 1F+1D+1GK spawn table (same duck id layout as SPAWN_POSITIONS). */ +const SPAWN_1F1D1GK = [ + { team: 'red', role: 'forward', x: -0.8, y: 0.5, yaw: 0 }, + { team: 'red', role: 'defender', x: -1.6, y: -0.3, yaw: 0 }, + { team: 'red', role: 'goalkeeper', x: -2.8, y: 0.0, yaw: 0 }, + { team: 'blue', role: 'forward', x: 0.8, y: 0.5, yaw: Math.PI }, + { team: 'blue', role: 'defender', x: 1.6, y: -0.3, yaw: Math.PI }, + { team: 'blue', role: 'goalkeeper', x: 2.8, y: 0.0, yaw: Math.PI }, +]; + +export const FORMATION_SPAWNS = Object.freeze({ + '2f1gk': SPAWN_POSITIONS, + '1f1d1gk': SPAWN_1F1D1GK, +}); + +const STYLE_OVERLAYS = { + attack: { + FORMATION_X_ADVANCE: 0.55, + FORMATION_X_RETREAT: -0.1, + SHOOT_ANGLE: 0.34, + SHOOT_DIST: 0.4, + SUPPORT_X_FRAC: 0.55, + GUARD_X_FRAC: 0.45, + SUPPORT_AHEAD: 0.7, + SUPPORT_LATERAL: 0.6, + }, + balanced: {}, + defend: { + FORMATION_X_ADVANCE: 0.1, + FORMATION_X_RETREAT: -0.55, + SHOOT_ANGLE: 0.2, + SHOOT_DIST: 0.3, + SUPPORT_X_FRAC: 0.28, + GUARD_X_FRAC: 0.68, + GK_DIVE_DIST: 1.7, + SUPPORT_AHEAD: 0.35, + SUPPORT_LATERAL: 0.85, + }, +}; + +const PRESS_OVERLAYS = { + low: { + APPROACH_OFFSET: 0.55, + FORMATION_X_ADVANCE: 0.15, + CHASE_SPEED: 0.22, + DEF_CHASE_SPEED: 0.22, + SUPPORT_AHEAD: 0.35, + SUPPORT_LATERAL: 0.9, + SECOND_PRESS_DIST: 0, + CHASE_HYSTERESIS: 0.35, + }, + medium: { + // Kickoff / open play: second forward also walks toward a loose ball so + // the opening does not look like one duck hunting while the other parks. + SECOND_PRESS_DIST: 1.15, + }, + high: { + APPROACH_OFFSET: 0.22, + FORMATION_X_ADVANCE: 0.5, + CHASE_SPEED: 0.25, + DEF_CHASE_SPEED: 0.25, + DEF_CLEAR_DIST: 0.45, + TEAMMATE_BALL_DIST: 0.65, + SUPPORT_AHEAD: 0.45, + SUPPORT_LATERAL: 0.55, + SECOND_PRESS_DIST: 1.35, + FACE_COST_WEIGHT: 0.25, + CHASE_HYSTERESIS: 0.2, + }, +}; + +export function normalizeStrategy(partial = {}) { + const formation = FORMATION_IDS.includes(partial.formation) + ? partial.formation + : DEFAULT_STRATEGY.formation; + const style = STYLE_IDS.includes(partial.style) + ? partial.style + : DEFAULT_STRATEGY.style; + const press = PRESS_IDS.includes(partial.press) + ? partial.press + : DEFAULT_STRATEGY.press; + return { formation, style, press }; +} + +/** Merge style + press overlays into a flat TUNE patch (later keys win). */ +export function getTuneOverlay(strategy) { + const s = normalizeStrategy(strategy); + return { + ...STYLE_OVERLAYS[s.style], + ...PRESS_OVERLAYS[s.press], + }; +} + +/** + * Build the six-slot spawn table from per-team strategies. + * Each team's three slots come from that team's formation; positions/roles + * stay aligned to fixed duck ids 0..5. + */ +export function buildSpawnTable(strategyByTeam = {}) { + return SPAWN_POSITIONS.map((base, id) => { + const strat = normalizeStrategy(strategyByTeam[base.team]); + const table = FORMATION_SPAWNS[strat.formation] || FORMATION_SPAWNS['2f1gk']; + const slot = table[id] || base; + return { + team: slot.team, + role: slot.role, + x: slot.x, + y: slot.y, + yaw: slot.yaw, + }; + }); +} + +export function strategiesForUser(userTeam, userStrategy) { + const mine = normalizeStrategy(userStrategy); + return { + red: userTeam === 'red' ? mine : { ...DEFAULT_STRATEGY }, + blue: userTeam === 'blue' ? mine : { ...DEFAULT_STRATEGY }, + }; +} diff --git a/app/src/game/football/tactics-board.js b/app/src/game/football/tactics-board.js new file mode 100644 index 0000000..4c5943b --- /dev/null +++ b/app/src/game/football/tactics-board.js @@ -0,0 +1,18 @@ +// Live match report board helpers. +// Kept as the public name `buildTacticsBoard` so game.js / store stay stable; +// content is cumulative possession + shots (see match-stats.js), not vanity meters. + +export { assignChaserId as pickChaserId } from './ai/index.js'; +export { createMatchStats, buildMatchReport } from './match-stats.js'; + +import { buildMatchReport } from './match-stats.js'; + +/** + * @param {object} args + * @param {{ snapshot: Function }} args.stats + * @param {object} [args.strategyByTeam] + * @param {{ red: number, blue: number }} [args.score] + */ +export function buildTacticsBoard(args) { + return buildMatchReport(args); +} diff --git a/app/src/game/game.js b/app/src/game/game.js index 8a05abb..3aa3f97 100644 --- a/app/src/game/game.js +++ b/app/src/game/game.js @@ -63,13 +63,21 @@ import { createBallVisual } from "./ball-visual.js"; // goal-openings branch only runs when fieldConfig.walls asks for it). import { getGoalCollisionGeoms, createGoalMesh } from "./football/goal.js"; import { GOAL_WIDTH, SPAWN_POSITIONS, BALL_SPAWN, PENALTY_DURATION_S, FIELD_HALF_W } from "./football/constants.js"; +import { + DEFAULT_STRATEGY, + buildSpawnTable, + getTuneOverlay, + normalizeStrategy, + strategiesForUser, +} from "./football/strategy.js"; +import { buildTacticsBoard, createMatchStats } from "./football/tactics-board.js"; // Multi-duck match plumbing: SANDBOX_CONFIG is the default matchConfig that // reproduces the single-duck arena exactly; FOOTBALL_CONFIG (passed in by // FootballCanvas) drives the 3v3 pitch. createDuckInstance holds per-duck // runtime state; createAgent builds the role-specific AI. import { SANDBOX_CONFIG } from "./football/match-config.js"; import { createDuckInstance } from "./football/duck-instance.js"; -import { createAgent, decideAll } from "./football/ai/index.js"; +import { createAgent, decideAll, assignChaserId } from "./football/ai/index.js"; import { createReferee } from "./football/referee.js"; import { createCelebration } from "./football/celebration.js"; import { initStickers } from "./stickers.js"; @@ -199,6 +207,15 @@ async function boot({ scene, camera, renderer, matchConfig: cfg }) { const setStore = useGame.setState; const store = useGame.getState; + // Coach tactics: mutable spawn table + per-team strategy. Style/press apply + // every AI tick; formation roles/spawns apply at kickoff (locked after start). + let activeSpawns = SPAWN_POSITIONS.map((s) => ({ ...s })); + let strategyByTeam = strategiesForUser( + store().userTeam || "red", + store().userStrategy || DEFAULT_STRATEGY, + ); + let formationLocked = false; + bootNote("Microduck BIOS v1.0"); bootLine("MEMORY CHECK")("640K OK"); bootLine("DUCK FIRMWARE")("PRESENT"); @@ -651,7 +668,7 @@ async function boot({ scene, camera, renderer, matchConfig: cfg }) { // snapshot and writes a locomotion twist (+ occasional kick) onto the duck. if (matchConfig.ai) { for (const duck of ducks) { - const s = SPAWN_POSITIONS[duck.id]; + const s = activeSpawns[duck.id] || SPAWN_POSITIONS[duck.id]; duck.agent = createAgent(duck, { team: duck.team, role: duck.role, spawnX: s.x, spawnY: s.y, }); @@ -703,6 +720,7 @@ async function boot({ scene, camera, renderer, matchConfig: cfg }) { // ── Football: referee integration state ── let referee = null; let celebration = null; + let matchStats = isFootball ? createMatchStats() : null; let prevBallX = 0, prevBallY = 0; let matchStoreTick = 0; @@ -1282,10 +1300,23 @@ async function boot({ scene, camera, renderer, matchConfig: cfg }) { if (++duck.fallDebounce >= FALL_DEBOUNCE_STEPS) { duck.fallDebounce = 0; duck.recovery = { state: "fallen", steps: 0 }; + // Soft body thud (same Kenney thump bank as sandbox bumps). + playSfx("thump", { + gain: 0.28, + rate: 0.48 + Math.random() * 0.08, + }); + haptics.pulse("fall"); } } else { duck.fallDebounce = 0; const now = performance.now(); + if (duck.fallenSince == null) { + playSfx("thump", { + gain: 0.28, + rate: 0.48 + Math.random() * 0.08, + }); + haptics.pulse("fall"); + } duck.fallenSince ??= now; if (now - duck.fallenSince > 1000) resetDuck(duck); } @@ -1366,7 +1397,7 @@ async function boot({ scene, camera, renderer, matchConfig: cfg }) { // fallen, penalized, team, spawnX, spawnY, _ai }. _ai is persisted on the // real duck so the AIM fuse (aimTicks) survives across ticks. const views = teamDucks.map((d) => { - const sp = SPAWN_POSITIONS[d.id] || {}; + const sp = activeSpawns[d.id] || SPAWN_POSITIONS[d.id] || {}; if (!d._ai) d._ai = { aimTicks: 0, stallTicks: 0, escapeTicks: 0, prevX: 0, prevY: 0 }; return { id: d.id, @@ -1381,7 +1412,14 @@ async function boot({ scene, camera, renderer, matchConfig: cfg }) { _ai: d._ai, }; }); - const cmds = decideAll(views, { ball: gs.ball, allDucks: gs.ducks, team: teamDucks[0].team }); + const team = teamDucks[0].team; + const tuneOverlay = getTuneOverlay(strategyByTeam[team] || DEFAULT_STRATEGY); + const cmds = decideAll(views, { + ball: gs.ball, + allDucks: gs.ducks, + team, + tuneOverlay, + }); for (let i = 0; i < teamDucks.length; i++) { const duck = teamDucks[i]; const c = cmds[i] || { vx: 0, wz: 0, kick: false }; @@ -1403,6 +1441,18 @@ async function boot({ scene, camera, renderer, matchConfig: cfg }) { duck.mode = duck._lastKick === "kickL" ? "kickR" : "kickL"; duck._lastKick = duck.mode; duck.kickRun = { steps: 0 }; + // Swing whoosh — ball contact thump is handled in stepAudioFootball. + playSfx("thump", { + gain: 0.18, + rate: 1.35 + Math.random() * 0.2, + }); + haptics.pulse("kick"); + // Only goal-aimed strikes (at feet + facing opp goal + attacking half). + matchStats?.tryNoteShot(duck.team, gs.ball, { + x: Number.isFinite(duck.pos?.[0]) ? duck.pos[0] : 0, + y: Number.isFinite(duck.pos?.[1]) ? duck.pos[1] : 0, + yaw: Number.isFinite(duck.yaw) ? duck.yaw : 0, + }); } } } @@ -1532,7 +1582,7 @@ async function boot({ scene, camera, renderer, matchConfig: cfg }) { } function returnDuckFromPenalty(duck) { - const sp = SPAWN_POSITIONS[duck.id]; + const sp = activeSpawns[duck.id] || SPAWN_POSITIONS[duck.id]; placeDuck(duck, [sp.x, sp.y, 0.12], sp.yaw ?? 0); // Full per-duck state reset (mirrors executeKickoff) so the duck // doesn't re-enter play with stale recovery/action state that would @@ -1553,11 +1603,14 @@ async function boot({ scene, camera, renderer, matchConfig: cfg }) { function executeKickoff() { placeBall(BALL_SPAWN); + // Apply formation roles/spawns at each kickoff so pre-match coach choices + // (and a fresh match restart) stick; mid-match style/press already live. + applyFormationFromStrategies(); for (const duck of ducks) { // Sent-off ducks stay off for good; sin-binned ducks keep their spot // (and their ticking timer) until penalty_returned brings them back. if (duck.sentOff || duck.penaltyTimer > 0) continue; - const sp = SPAWN_POSITIONS[duck.id]; + const sp = activeSpawns[duck.id] || SPAWN_POSITIONS[duck.id]; placeDuck(duck, [sp.x, sp.y, 0.12], sp.yaw ?? 0); duck.recovery = null; duck.fallDebounce = 0; @@ -1585,6 +1638,53 @@ async function boot({ scene, camera, renderer, matchConfig: cfg }) { prevBallY = payload.pos ? payload.pos[1] : prevBallY; } + /** Rebuild activeSpawns + duck roles/agents from strategyByTeam. */ + function applyFormationFromStrategies() { + if (!isFootball) return; + activeSpawns = buildSpawnTable(strategyByTeam); + for (const duck of ducks) { + const sp = activeSpawns[duck.id]; + if (!sp) continue; + duck.role = sp.role; + duck.agent = createAgent(duck, { + team: duck.team, role: duck.role, spawnX: sp.x, spawnY: sp.y, + }); + } + } + + /** + * Coach panel → game. Style/press take effect next AI tick. + * Formation changes only apply when not locked (pre-kickoff / fresh match). + */ + function setTeamStrategy(partial = {}, opts = {}) { + if (!isFootball) return store().userStrategy; + const allowFormation = !formationLocked || opts.forceFormation; + const prev = normalizeStrategy(store().userStrategy || DEFAULT_STRATEGY); + const next = normalizeStrategy({ + ...prev, + ...partial, + formation: allowFormation + ? (partial.formation ?? prev.formation) + : prev.formation, + }); + const team = opts.team || store().userTeam || "red"; + setStore({ userTeam: team, userStrategy: next }); + strategyByTeam = strategiesForUser(team, next); + if (allowFormation && next.formation !== prev.formation) { + applyFormationFromStrategies(); + } + return next; + } + + function setUserTeam(team) { + if (team !== "red" && team !== "blue") return store().userTeam; + const strat = store().userStrategy || DEFAULT_STRATEGY; + setStore({ userTeam: team }); + strategyByTeam = strategiesForUser(team, strat); + if (!formationLocked) applyFormationFromStrategies(); + return team; + } + function handleRefereeEvent(type, payload) { switch (type) { case "goal": { @@ -1612,7 +1712,7 @@ async function boot({ scene, camera, renderer, matchConfig: cfg }) { case "kickoff": { // A fresh kick-off clears any stale winner from the previous match so // the UI never shows an outdated result during open play. - setStore({ matchResult: null }); + setStore({ matchResult: null, tacticsCard: null }); executeKickoff(); break; } @@ -1695,10 +1795,20 @@ async function boot({ scene, camera, renderer, matchConfig: cfg }) { // Persist the winner ('red' | 'blue' | 'draw') so the UI can show the // result; the referee owns the verdict, the event payload is a fallback. const result = referee?.getResult?.() ?? payload?.result ?? null; + const scoreNow = referee.getScore(); + const card = matchStats + ? buildTacticsBoard({ + stats: matchStats, + strategyByTeam, + score: scoreNow, + }) + : null; setStore({ matchState: "FULLTIME", - score: referee.getScore(), + score: scoreNow, matchResult: result, + tacticsBoard: card, + tacticsCard: card, }); celebration?.cancel(); break; @@ -1808,6 +1918,10 @@ async function boot({ scene, camera, renderer, matchConfig: cfg }) { referee.step(CTRL_DT, gs); prevBallX = gs.ball.pos[0]; prevBallY = gs.ball.pos[1]; + matchStats?.tick(CTRL_DT, { + matchState: referee.getState(), + lastTouchTeam: referee.getLastTouchTeam(), + }); } for (const duck of ducks) updateDuckStateFootball(duck); // Ball watchdog: a solver glitch that tunnels the ball out of the pitch @@ -1822,6 +1936,86 @@ async function boot({ scene, camera, renderer, matchConfig: cfg }) { } // Perf gate: measures realised ctrlHz over a 2 s window, degrades once. maybeDegradePerf(performance.now()); + // Football SFX: ball impacts, duck–duck bumps (teammate vs opponent). + stepAudioFootball(); + } + + // ── Football-only audio (multi-duck) ───────────────────────────────── + // Sandbox stepAudioSim() is single-duck + arena walls; here we only need + // ball thumps and body collisions. Fall / kick one-shots fire at the + // state transitions above. Uses the same Kenney thump bank as the official + // arena assets (public/assets/sfx) — microduck_sounds is vocalisations. + const FB_BALL_DV = 0.5; + const FB_BALL_DEBOUNCE_MS = 90; + const FB_HIT_DIST = 0.32; // trunk centres closer than this ≈ contact + const FB_HIT_REL_V = 0.22; // m/s closing speed to count as a bump + const FB_HIT_DEBOUNCE_MS = 280; + const fbBallPrevV = [0, 0, 0]; + let fbBallPrevValid = false; + let fbBallThumpAt = 0; + const fbHitAt = new Map(); // "i-j" -> last ms + + function stepAudioFootball() { + const now = performance.now(); + const v = data.qvel; + // Ball impacts (kick contact, wall bounce, duck shove). + if (ballActive) { + const b = ballDofAdr; + if (fbBallPrevValid) { + const dv = Math.hypot( + v[b] - fbBallPrevV[0], v[b + 1] - fbBallPrevV[1], v[b + 2] - fbBallPrevV[2]); + if (dv > FB_BALL_DV && now - fbBallThumpAt > FB_BALL_DEBOUNCE_MS) { + fbBallThumpAt = now; + const u = Math.min(1, (dv - FB_BALL_DV) / 3.5); + playSfx("thump", { + gain: 0.16 + 0.42 * u, + rate: 1.2 - 0.4 * u + Math.random() * 0.08, + out: ballEmitter.node, + }); + } + } + fbBallPrevV[0] = v[b]; fbBallPrevV[1] = v[b + 1]; fbBallPrevV[2] = v[b + 2]; + fbBallPrevValid = true; + } else { + fbBallPrevValid = false; + } + + // Duck–duck collisions: pairwise distance + relative planar speed. + for (let i = 0; i < ducks.length; i++) { + const a = ducks[i]; + if (a.penaltyTimer > 0 || a.sentOff) continue; + const da = a.addrs.freejointDofAdr; + const ax = a.pos[0], ay = a.pos[1]; + const avx = v[da], avy = v[da + 1]; + for (let j = i + 1; j < ducks.length; j++) { + const bDuck = ducks[j]; + if (bDuck.penaltyTimer > 0 || bDuck.sentOff) continue; + const dx = bDuck.pos[0] - ax; + const dy = bDuck.pos[1] - ay; + const dist = Math.hypot(dx, dy); + if (dist > FB_HIT_DIST || dist < 1e-4) continue; + const db = bDuck.addrs.freejointDofAdr; + const rvx = v[db] - avx; + const rvy = v[db + 1] - avy; + // Closing component along the separation vector. + const closing = -(rvx * dx + rvy * dy) / dist; + if (closing < FB_HIT_REL_V) continue; + const key = `${i}-${j}`; + const last = fbHitAt.get(key) || 0; + if (now - last < FB_HIT_DEBOUNCE_MS) continue; + fbHitAt.set(key, now); + const sameTeam = a.team === bDuck.team; + const u = Math.min(1, (closing - FB_HIT_REL_V) / 0.8); + // Teammate bumps: softer / higher; opponent: heavier thud. + playSfx("thump", { + gain: sameTeam ? (0.1 + 0.18 * u) : (0.18 + 0.32 * u), + rate: sameTeam + ? (0.95 + 0.2 * Math.random()) + : (0.55 + 0.15 * u + Math.random() * 0.08), + }); + } + } + haptics.tick(); } // Sync one duck's render rig from qpos (per-duck version of syncRig). @@ -1839,6 +2033,12 @@ async function boot({ scene, camera, renderer, matchConfig: cfg }) { function frameFootball(dt) { for (const duck of ducks) syncOneRig(duck); if (ball) ball.sync(data.qpos, ballQposAdr, ballActive); + // Spatial audio: listener on camera, ball emitter for thumps. + updateListener(camera); + if (ballActive) { + const q = data.qpos; + ballEmitter.setPosition(q[ballQposAdr], q[ballQposAdr + 2], -q[ballQposAdr + 1]); + } // Celebration camera override (goal replay swing) if (celebration?.isActive()) { celebration.drive(dt); @@ -1857,15 +2057,37 @@ async function boot({ scene, camera, renderer, matchConfig: cfg }) { matchStoreTick += dt; if (matchStoreTick > 0.25 && referee) { matchStoreTick = 0; - setStore({ + const fin = (x) => (Number.isFinite(x) ? x : 0); + const duckSnap = ducks.map((d) => ({ + id: d.id, + team: d.team, + role: d.role, + x: fin(d.pos[0]), + y: fin(d.pos[1]), + yaw: fin(d.yaw), + fallen: !!d.recovery, + penalized: d.penaltyTimer > 0, + sentOff: !!d.sentOff, + })); + const q = data.qpos; + const lastTouch = referee.getLastTouchTeam(); + const stateNow = referee.getState(); + const patch = { matchTime: referee.getMatchTime(), - matchState: referee.getState(), - lastTouchTeam: referee.getLastTouchTeam(), - ducksState: ducks.map((d) => ({ - id: d.id, team: d.team, role: d.role, - penalized: d.penaltyTimer > 0, sentOff: d.sentOff, - })), - }); + matchState: stateNow, + lastTouchTeam: lastTouch, + ducksState: duckSnap, + ballState: { x: fin(q[ballQposAdr]), y: fin(q[ballQposAdr + 1]) }, + }; + // Freeze the live board once fulltime — tacticsCard owns the final view. + if (matchStats && stateNow !== "FULLTIME") { + patch.tacticsBoard = buildTacticsBoard({ + stats: matchStats, + strategyByTeam, + score: referee.getScore(), + }); + } + setStore(patch); } } @@ -3082,6 +3304,145 @@ async function boot({ scene, camera, renderer, matchConfig: cfg }) { return true; } + // ── Dual team FPV (chaser eye-cams → HUD via RenderTarget) ─ + // Scissor-into-main-canvas failed (transparent holes just showed the + // overview cam, and Back/GitHub sat in the windows). Opaque canvases + + // RT blit are reliable. + let fpvSlots = null; // { red: HTMLCanvasElement, blue: HTMLCanvasElement } + const FPV_RT_W = 320; + const FPV_RT_H = 200; + const fpvCamRed = new THREE.PerspectiveCamera(68, FPV_RT_W / FPV_RT_H, 0.03, 24); + const fpvCamBlue = new THREE.PerspectiveCamera(68, FPV_RT_W / FPV_RT_H, 0.03, 24); + const fpvRt = new THREE.WebGLRenderTarget(FPV_RT_W, FPV_RT_H, { + type: THREE.UnsignedByteType, + format: THREE.RGBAFormat, + depthBuffer: true, + stencilBuffer: false, + }); + const fpvPixelBuf = new Uint8Array(FPV_RT_W * FPV_RT_H * 4); + const fpvFlipBuf = new Uint8ClampedArray(FPV_RT_W * FPV_RT_H * 4); + const _fpvEye = new THREE.Vector3(); + const _fpvLook = new THREE.Vector3(); + const FPV_EYE_H = 0.16; + const FPV_LOOK_AHEAD = 1.35; + const FPV_LOOK_DOWN = 0.08; + let fpvAcc = 0; + const FPV_PERIOD = 1 / 18; // ~18 Hz is enough for PiP + + function setFpvSlots(slots) { + fpvSlots = slots; + } + + function pickTeamChaser(team) { + if (!isFootball || !ducks.length) return null; + const q = data.qpos; + const ball = { + x: Number.isFinite(q[ballQposAdr]) ? q[ballQposAdr] : 0, + y: Number.isFinite(q[ballQposAdr + 1]) ? q[ballQposAdr + 1] : 0, + }; + const teamDucks = ducks + .filter((d) => d.team === team && !(d.penaltyTimer > 0) && !d.sentOff) + .map((d) => ({ + id: d.id, + role: d.role, + yaw: Number.isFinite(d.yaw) ? d.yaw : 0, + fallen: !!d.recovery, + penalized: false, + sentOff: false, + pos: [ + Number.isFinite(d.pos[0]) ? d.pos[0] : 0, + Number.isFinite(d.pos[1]) ? d.pos[1] : 0, + ], + })); + const id = assignChaserId(teamDucks, ball); + if (id < 0) { + // Fallback: any standing field duck, else any teammate with a rig + return ducks.find((d) => d.team === team && d.role !== "goalkeeper" && !d.recovery) + || ducks.find((d) => d.team === team) || null; + } + return ducks.find((d) => d.id === id) || null; + } + + function placeFpvCam(cam, duck) { + if (!duck?.rig) return false; + const trunk = duck.rig.bodies.get("trunk_base"); + if (!trunk) return false; + trunk.updateWorldMatrix(true, false); + trunk.getWorldPosition(_fpvEye); + _fpvEye.y += FPV_EYE_H; + const yaw = Number.isFinite(duck.yaw) ? duck.yaw : 0; + // MJCF forward (cos,sin,0) → three (cos, 0, -sin) + _fpvLook.set( + _fpvEye.x + Math.cos(yaw) * FPV_LOOK_AHEAD, + _fpvEye.y - FPV_LOOK_DOWN, + _fpvEye.z - Math.sin(yaw) * FPV_LOOK_AHEAD, + ); + cam.position.copy(_fpvEye); + cam.up.set(0, 1, 0); + cam.lookAt(_fpvLook); + cam.updateMatrixWorld(); + return true; + } + + function blitRtToCanvas(canvas) { + if (!canvas?.getContext) return; + renderer.readRenderTargetPixels(fpvRt, 0, 0, FPV_RT_W, FPV_RT_H, fpvPixelBuf); + // WebGL is bottom-up; canvas ImageData is top-down — flip rows. + const row = FPV_RT_W * 4; + for (let y = 0; y < FPV_RT_H; y++) { + const src = (FPV_RT_H - 1 - y) * row; + fpvFlipBuf.set(fpvPixelBuf.subarray(src, src + row), y * row); + } + const ctx = canvas.getContext("2d"); + if (!ctx) return; + if (canvas.width !== FPV_RT_W) canvas.width = FPV_RT_W; + if (canvas.height !== FPV_RT_H) canvas.height = FPV_RT_H; + ctx.putImageData(new ImageData(fpvFlipBuf, FPV_RT_W, FPV_RT_H), 0, 0); + } + + function renderOneFpv(team, cam, canvas) { + if (!canvas) return; + const duck = pickTeamChaser(team); + if (!placeFpvCam(cam, duck)) { + const ctx = canvas.getContext("2d"); + if (ctx) { + ctx.fillStyle = "#050508"; + ctx.fillRect(0, 0, canvas.width || FPV_RT_W, canvas.height || FPV_RT_H); + } + return; + } + const hid = duck?.rig?.placer; + const wasVis = hid ? hid.visible : true; + if (hid) hid.visible = false; + const prev = renderer.getRenderTarget(); + const prevColor = new THREE.Color(); + renderer.getClearColor(prevColor); + const prevAlpha = renderer.getClearAlpha(); + renderer.setClearColor(0x08080c, 1); + renderer.setRenderTarget(fpvRt); + renderer.clear(); + renderer.render(scene, cam); + renderer.setRenderTarget(prev); + renderer.setClearColor(prevColor, prevAlpha); + if (hid) hid.visible = wasVis; + blitRtToCanvas(canvas); + } + + function renderTeamFpv(dt = FPV_PERIOD) { + if (!isFootball || !fpvSlots || !renderer) return; + fpvAcc += dt; + if (fpvAcc < FPV_PERIOD) return; + fpvAcc = 0; + // Prefer live DOM query so late boot / remount still finds canvases. + const red = fpvSlots.red + || document.querySelector("canvas[data-fpv='red']"); + const blue = fpvSlots.blue + || document.querySelector("canvas[data-fpv='blue']"); + if (!red && !blue) return; + renderOneFpv("red", fpvCamRed, red); + renderOneFpv("blue", fpvCamBlue, blue); + } + function syncButtons() { const sitting = mode === "sitstand" && sitFlag === 1; const label = @@ -3117,6 +3478,18 @@ async function boot({ scene, camera, renderer, matchConfig: cfg }) { resetSim, spawnBall: () => spawnBall(), startEntrance: () => ceremony?.startEntrance(), + // Coach tactics (football only) + setTeamStrategy: (partial, opts) => setTeamStrategy(partial, opts), + setUserTeam: (team) => setUserTeam(team), + setFpvSlots, + renderTeamFpv, + getStrategy: () => ({ + userTeam: store().userTeam, + userStrategy: store().userStrategy, + strategyByTeam: { ...strategyByTeam }, + formationLocked, + activeSpawns: activeSpawns.map((s) => ({ ...s })), + }), }); // Deterministic hooks for automated verification (rAF pauses in @@ -3229,6 +3602,15 @@ async function boot({ scene, camera, renderer, matchConfig: cfg }) { duck.cards.yellow = 0; duck.cards.red = 0; } + // Sync coach choices, then lock formation for the rest of the match. + strategyByTeam = strategiesForUser( + store().userTeam || "red", + store().userStrategy || DEFAULT_STRATEGY, + ); + applyFormationFromStrategies(); + formationLocked = true; + matchStats?.reset(); + setStore({ tacticsCard: null, tacticsBoard: null, matchResult: null }); if (referee) referee.startMatch(); else { cacheDuckPoses(); spawnBallFootball(); } }, diff --git a/app/src/store.js b/app/src/store.js index 3ed5adb..097d2fd 100644 --- a/app/src/store.js +++ b/app/src/store.js @@ -44,7 +44,17 @@ export const useGame = create( lastTouchTeam: null, // 'red' | 'blue' setPieceType: null, // 'kickoff'|'throw_in'|'goal_kick'|'corner_red'|'corner_blue' matchEvents: [], // recent events for UI consumption [{type, team, time, payload}] - ducksState: [], // [{id, team, role, pos2D, fallen, penalized}] — 4Hz throttled + ducksState: [], // [{id, team, role, x, y, yaw, fallen, penalized, sentOff}] — 4Hz + ballState: null, // { x, y } — 4Hz, for top pitch radar + + // Coach panel: user picks a side and tactics; opponent keeps defaults. + userTeam: "red", // "red" | "blue" + userStrategy: { formation: "2f1gk", style: "balanced", press: "medium" }, + locale: "en", // "en" | "zh" — set from detectLocale on football mount + // Live dual-team match report (4 Hz) — possession + shots + strategy tags + tacticsBoard: null, + // Frozen fulltime tactics card (same shape as tacticsBoard) + tacticsCard: null, })), ); diff --git a/app/src/ui/Hud.jsx b/app/src/ui/Hud.jsx index a8438a5..8245b51 100644 --- a/app/src/ui/Hud.jsx +++ b/app/src/ui/Hud.jsx @@ -405,9 +405,7 @@ function Quickbar() { })} - {/* Sound is fully disabled for now (SOUND_DISABLED in audio.js) - - the mute toggle comes back with the finished sound design. */} - {/* */} + ); } diff --git a/app/test/ai.test.js b/app/test/ai.test.js index 9319b84..2e58d0a 100644 --- a/app/test/ai.test.js +++ b/app/test/ai.test.js @@ -7,9 +7,10 @@ import assert from 'node:assert/strict'; import { angleTo, distanceTo, normalizeAngle, angleDiff, clamp, lerp, BaseAgent, ForwardAgent, DefenderAgent, GoalkeeperAgent, - VX_MAX, VX_MIN, WZ_MAX, SHOOT_SPEED, - createAgent, decideAll, + VX_MAX, VX_MIN, WZ_MAX, SHOOT_SPEED, SHOOT_DIST, + createAgent, decideAll, assignChaserId, chaseCost, chaseApproachPoint, } from '../src/game/football/ai/index.js'; +import { FIELD_HALF_L } from '../src/game/football/constants.js'; /** * Build a minimal gameState for tests. @@ -396,6 +397,93 @@ describe('decideAll', () => { const cmds = decideAll([], { ball: { x: 0, y: 0, vx: 0, vy: 0 }, allDucks: [], team: 'red' }); assert.deepEqual(cmds, []); }); + + it('kickoff chaser closes inside SHOOT_DIST instead of orbiting the approach ring', () => { + // Reproduce: spawn at kickoff, integrate CHASE until near the old offset + // parking spot (~0.4 m). Must keep driving at the ball and eventually kick. + const ducks = [ + { id: 0, pos: [-0.8, 0.5], yaw: 0, role: 'forward', fallen: false, penalized: false, team: 'red', spawnX: -0.8, spawnY: 0.5 }, + { id: 1, pos: [-0.8, -0.5], yaw: 0, role: 'forward', fallen: false, penalized: false, team: 'red', spawnX: -0.8, spawnY: -0.5 }, + { id: 2, pos: [-2.8, 0], yaw: 0, role: 'goalkeeper', fallen: false, penalized: false, team: 'red', spawnX: -2.8, spawnY: 0 }, + ]; + const ball = { x: 0, y: 0, vx: 0, vy: 0 }; + const gs = { ball, allDucks: ducks, team: 'red' }; + const dt = 0.1; + let kicked = false; + let minBd = Infinity; + for (let t = 0; t < 120; t++) { + const cmds = decideAll(ducks, gs); + const d0 = Math.hypot(ducks[0].pos[0] - ball.x, ducks[0].pos[1] - ball.y); + const d1 = Math.hypot(ducks[1].pos[0] - ball.x, ducks[1].pos[1] - ball.y); + const ci = d0 <= d1 ? 0 : 1; + const c = cmds[ci]; + const duck = ducks[ci]; + duck.yaw += c.wz * dt; + duck.pos[0] += Math.cos(duck.yaw) * c.vx * dt; + duck.pos[1] += Math.sin(duck.yaw) * c.vx * dt; + const bd = Math.hypot(duck.pos[0] - ball.x, duck.pos[1] - ball.y); + if (bd < minBd) minBd = bd; + if (c.kick) { kicked = true; break; } + } + assert.ok(minBd <= SHOOT_DIST, `never reached shoot range (minBd=${minBd})`); + assert.ok(kicked, 'chaser never issued a kick after closing on the ball'); + }); + + it('chaseCost prefers a facing duck over a slightly closer back-to-ball duck', () => { + const ball = { x: 0, y: 0 }; + const facing = { id: 0, pos: [-0.9, 0], yaw: 0, role: 'forward' }; // faces +X toward ball + const closerAway = { id: 1, pos: [-0.75, 0], yaw: Math.PI, role: 'forward' }; // closer but faces away + assert.ok(chaseCost(facing, ball) < chaseCost(closerAway, ball)); + assert.equal(assignChaserId([facing, closerAway], ball), 0); + }); + + it('chase hysteresis keeps the previous chaser when costs are close', () => { + const ball = { x: 0, y: 0 }; + const a = { id: 0, pos: [-0.8, 0.1], yaw: 0, role: 'forward' }; + const b = { id: 1, pos: [-0.78, -0.1], yaw: 0, role: 'forward' }; + // Without hysteresis b is slightly closer; with prev=0, a should stick. + assert.equal(assignChaserId([a, b], ball, -1), 1); + assert.equal(assignChaserId([a, b], ball, 0), 0); + }); + + it('chaseApproachPoint sits behind a still ball on the shot axis (MoveToStaticBall)', () => { + const ball = { x: 0, y: 0, vx: 0, vy: 0 }; + const ap = chaseApproachPoint(ball, FIELD_HALF_L, -0.8, 0.5); + // Red attacks +X → approach is at −r on X, near centre line + assert.ok(ap.x < -0.2 && ap.x > -0.4, `expected behind ball, got x=${ap.x}`); + assert.ok(Math.abs(ap.y) < 0.05, `expected on shot axis, got y=${ap.y}`); + }); + + it('chaseApproachPoint leads a fast ball with a short prediction', () => { + const ball = { x: 0, y: 0, vx: 0.8, vy: 0 }; + const apStill = chaseApproachPoint({ x: 0, y: 0, vx: 0, vy: 0 }, FIELD_HALF_L, -1, 0); + const apFast = chaseApproachPoint(ball, FIELD_HALF_L, -1, 0); + assert.ok(apFast.x > apStill.x, 'fast ball approach should shift toward travel direction'); + }); + + it('non-chaser support stays near the ball instead of deep spawn mirror', () => { + const ducks = [ + { id: 0, pos: [-0.8, 0.5], yaw: 0, role: 'forward', fallen: false, penalized: false, team: 'red', spawnX: -0.8, spawnY: 0.5, _ai: {} }, + { id: 1, pos: [-0.8, -0.5], yaw: 0, role: 'forward', fallen: false, penalized: false, team: 'red', spawnX: -0.8, spawnY: -0.5, _ai: {} }, + { id: 2, pos: [-2.8, 0], yaw: 0, role: 'goalkeeper', fallen: false, penalized: false, team: 'red', spawnX: -2.8, spawnY: 0 }, + ]; + // Ball in attack half; duck 0 is chaser (closer). Duck 1 should move toward support near ball, not x≈1.3 spawn advance. + ducks[0].pos = [-0.2, 0.2]; + const ball = { x: 0.5, y: 0, vx: 0, vy: 0 }; + const cmds = decideAll(ducks, { ball, allDucks: ducks, team: 'red' }); + assert.equal(ducks[0]._ai.holdingChase, true); + // Support command: positive vx toward +X (ball side) rather than spinning in place only + assert.ok(cmds[1].vx > 0 || Math.abs(cmds[1].wz) > 0); + // Integrate a few ticks — support should reduce distance to a near-ball lane (x around ball+ahead) + for (let t = 0; t < 25; t++) { + const c = decideAll(ducks, { ball, allDucks: ducks, team: 'red' })[1]; + ducks[1].yaw += c.wz * 0.1; + ducks[1].pos[0] += Math.cos(ducks[1].yaw) * c.vx * 0.1; + ducks[1].pos[1] += Math.sin(ducks[1].yaw) * c.vx * 0.1; + } + assert.ok(ducks[1].pos[0] > -0.3, `support still deep (x=${ducks[1].pos[0]})`); + assert.ok(Math.abs(ducks[1].pos[0] - ball.x) < 1.2, 'support should linger near ball lane'); + }); }); // ═══════════════════════════════════════════════════════════════════════════════ diff --git a/app/test/strategy.test.js b/app/test/strategy.test.js new file mode 100644 index 0000000..f70ef48 --- /dev/null +++ b/app/test/strategy.test.js @@ -0,0 +1,65 @@ +// Strategy preset → spawn / TUNE overlay tests (no engine deps). +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; + +import { + DEFAULT_STRATEGY, + normalizeStrategy, + getTuneOverlay, + buildSpawnTable, + strategiesForUser, +} from '../src/game/football/strategy.js'; +import { decideAll, BASE_TUNE } from '../src/game/football/ai/index.js'; + +describe('strategy presets', () => { + it('normalizeStrategy falls back to defaults', () => { + assert.deepEqual(normalizeStrategy({}), DEFAULT_STRATEGY); + assert.equal(normalizeStrategy({ formation: 'nope' }).formation, '2f1gk'); + assert.equal(normalizeStrategy({ style: 'attack' }).style, 'attack'); + }); + + it('attack overlay pushes formation advance higher than defend', () => { + const atk = getTuneOverlay({ style: 'attack', press: 'medium' }); + const def = getTuneOverlay({ style: 'defend', press: 'medium' }); + assert.ok(atk.FORMATION_X_ADVANCE > (def.FORMATION_X_ADVANCE ?? BASE_TUNE.FORMATION_X_ADVANCE)); + assert.ok(def.FORMATION_X_RETREAT < (atk.FORMATION_X_RETREAT ?? BASE_TUNE.FORMATION_X_RETREAT)); + }); + + it('buildSpawnTable enables defender roles for 1f1d1gk', () => { + const table = buildSpawnTable({ + red: { formation: '1f1d1gk', style: 'balanced', press: 'medium' }, + blue: { ...DEFAULT_STRATEGY }, + }); + assert.equal(table[0].role, 'forward'); + assert.equal(table[1].role, 'defender'); + assert.equal(table[2].role, 'goalkeeper'); + assert.equal(table[3].role, 'forward'); + assert.equal(table[4].role, 'forward'); // blue still 2F + assert.equal(table[5].role, 'goalkeeper'); + }); + + it('strategiesForUser only overrides the user team', () => { + const map = strategiesForUser('blue', { formation: '1f1d1gk', style: 'attack', press: 'high' }); + assert.equal(map.blue.formation, '1f1d1gk'); + assert.equal(map.red.formation, '2f1gk'); + assert.equal(map.red.style, 'balanced'); + }); + + it('decideAll accepts tuneOverlay without breaking chase', () => { + const ducks = [ + { id: 0, pos: [0, 0], yaw: 0, role: 'forward', team: 'red', spawnX: -0.8, spawnY: 0.5 }, + { id: 1, pos: [-1, 0.5], yaw: 0, role: 'forward', team: 'red', spawnX: -0.8, spawnY: -0.5 }, + { id: 2, pos: [-2.8, 0], yaw: 0, role: 'goalkeeper', team: 'red', spawnX: -2.8, spawnY: 0 }, + ]; + const gs = { + ball: { x: 0.2, y: 0, vx: 0, vy: 0 }, + allDucks: ducks, + team: 'red', + tuneOverlay: getTuneOverlay({ style: 'attack', press: 'high' }), + }; + const cmds = decideAll(ducks, gs); + assert.equal(cmds.length, 3); + assert.ok(Number.isFinite(cmds[0].vx)); + assert.equal(typeof cmds[0].kick, 'boolean'); + }); +}); diff --git a/app/test/tactics-board.test.js b/app/test/tactics-board.test.js new file mode 100644 index 0000000..324162e --- /dev/null +++ b/app/test/tactics-board.test.js @@ -0,0 +1,129 @@ +// Unit tests for cumulative match stats / report board. +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; + +import { + createMatchStats, + buildMatchReport, + buildTacticsBoard, + pickChaserId, +} from '../src/game/football/tactics-board.js'; +import { isGoalDirectedShot } from '../src/game/football/match-stats.js'; + +describe('isGoalDirectedShot', () => { + it('accepts a red strike at feet facing +X goal in the attack half', () => { + assert.equal( + isGoalDirectedShot('red', { x: 1.2, y: 0 }, { x: 0.9, y: 0, yaw: 0 }), + true, + ); + }); + + it('rejects midfield / own-half pokes', () => { + assert.equal( + isGoalDirectedShot('red', { x: -0.5, y: 0 }, { x: -0.6, y: 0, yaw: 0 }), + false, + ); + }); + + it('rejects kicks while facing away from goal', () => { + assert.equal( + isGoalDirectedShot('red', { x: 1.0, y: 0 }, { x: 0.7, y: 0, yaw: Math.PI }), + false, + ); + }); + + it('rejects kicks when the ball is not at the feet', () => { + assert.equal( + isGoalDirectedShot('red', { x: 1.5, y: 0 }, { x: 0.5, y: 0, yaw: 0 }), + false, + ); + }); +}); + +describe('match-stats', () => { + it('accumulates possession only while PLAYING', () => { + const stats = createMatchStats(); + stats.tick(1.0, { matchState: 'KICKOFF', lastTouchTeam: 'red' }); + stats.tick(2.0, { matchState: 'PLAYING', lastTouchTeam: 'red' }); + stats.tick(1.0, { matchState: 'PLAYING', lastTouchTeam: 'blue' }); + const snap = stats.snapshot(); + assert.equal(snap.possession.red, 2); + assert.equal(snap.possession.blue, 1); + assert.ok(Math.abs(snap.possession.redPct - 2 / 3) < 1e-9); + }); + + it('only counts goal-directed shots, with cooldown', () => { + const stats = createMatchStats(); + const aim = { x: 0.9, y: 0, yaw: 0 }; + assert.equal(stats.tryNoteShot('red', { x: 1.2, y: 0 }, aim), true); + assert.equal(stats.tryNoteShot('red', { x: 1.3, y: 0 }, aim), false); // cooldown + // Scramble kick facing wrong way — not a shot + assert.equal( + stats.tryNoteShot('blue', { x: -1.0, y: 0 }, { x: -0.7, y: 0, yaw: 0 }), + false, + ); + // Blue facing their goal (−X) + assert.equal( + stats.tryNoteShot('blue', { x: -1.2, y: 0 }, { x: -0.9, y: 0, yaw: Math.PI }), + true, + ); + const snap = stats.snapshot(); + assert.equal(snap.shots.red, 1); + assert.equal(snap.shots.blue, 1); + }); + + it('reset clears counters', () => { + const stats = createMatchStats(); + stats.tick(3, { matchState: 'PLAYING', lastTouchTeam: 'red' }); + stats.tryNoteShot('red', { x: 1.2, y: 0 }, { x: 0.9, y: 0, yaw: 0 }); + stats.reset(); + const snap = stats.snapshot(); + assert.equal(snap.possession.red, 0); + assert.equal(snap.shots.red, 0); + assert.equal(snap.possession.redPct, 0.5); + }); +}); + +describe('buildMatchReport', () => { + it('attaches normalized strategies and score', () => { + const stats = createMatchStats(); + stats.tick(4, { matchState: 'PLAYING', lastTouchTeam: 'red' }); + stats.tick(1, { matchState: 'PLAYING', lastTouchTeam: 'blue' }); + stats.tryNoteShot('red', { x: 1.2, y: 0 }, { x: 0.9, y: 0, yaw: 0 }); + const report = buildMatchReport({ + stats, + strategyByTeam: { + red: { formation: '1f1d1gk', style: 'attack', press: 'high' }, + blue: { style: 'defend' }, + }, + score: { red: 2, blue: 1 }, + }); + assert.equal(report.strategy.red.style, 'attack'); + assert.equal(report.strategy.red.press, 'high'); + assert.equal(report.strategy.blue.style, 'defend'); + assert.equal(report.strategy.blue.press, 'medium'); + assert.equal(report.shots.red, 1); + assert.equal(report.score.red, 2); + assert.ok(report.possession.redPct > 0.7); + }); + + it('buildTacticsBoard is an alias of buildMatchReport', () => { + const stats = createMatchStats(); + const a = buildTacticsBoard({ stats, strategyByTeam: {} }); + const b = buildMatchReport({ stats, strategyByTeam: {} }); + assert.deepEqual(a.shots, b.shots); + assert.deepEqual(a.strategy, b.strategy); + }); +}); + +describe('pickChaserId', () => { + it('still picks nearest non-GK field duck', () => { + const ball = { x: 0.1, y: 0 }; + const red = [ + { id: 0, team: 'red', role: 'forward', pos: [-0.5, 0.4], yaw: 0 }, + { id: 1, team: 'red', role: 'forward', pos: [-1.0, -0.4], yaw: 0 }, + { id: 2, team: 'red', role: 'goalkeeper', pos: [-2.8, 0], yaw: 0 }, + ]; + assert.equal(pickChaserId(red, ball), 0); + }); +});