From 3b8b7f64e8e8944ce77d5228b07dbf4b9dddf184 Mon Sep 17 00:00:00 2001 From: ivyvi <130733091+yerxiiiii@users.noreply.github.com> Date: Wed, 9 Sep 2026 00:02:39 +0800 Subject: [PATCH 1/2] Improve chase reclaim, FPV eye-cam, and continuous strategy bars. Unstick wrong-side chasers so free teammates can claim the ball, soften FPV ball-stare framing, and ship coach energy knobs plus commentary/RL smoke scaffolding. Co-authored-by: Cursor --- app/package.json | 1 + app/src/football/CommentaryDanmaku.jsx | 204 ++++++ app/src/football/FootballHud.jsx | 28 +- app/src/football/StrategyPanel.jsx | 349 ++++++++-- app/src/football/commentary.js | 302 ++++++++ app/src/football/hud-logic.js | 8 + app/src/football/i18n.js | 62 +- app/src/game/audio.js | 55 ++ app/src/game/football/ai/index.js | 636 ++++++++++++++--- app/src/game/football/strategy.js | 535 ++++++++++++--- app/src/game/game.js | 228 +++--- app/src/store.js | 41 +- app/test/ai.test.js | 272 +++++++- app/test/commentary.test.js | 89 +++ app/test/strategy.test.js | 170 ++++- app/test/tactics-board.test.js | 2 +- app/tools/football-env-smoke.mjs | 51 ++ app/tools/football-env.mjs | 198 ++++++ app/tools/lib/football-sim.mjs | 913 +++++++++++++++++++++++++ 19 files changed, 3754 insertions(+), 390 deletions(-) create mode 100644 app/src/football/CommentaryDanmaku.jsx create mode 100644 app/src/football/commentary.js create mode 100644 app/test/commentary.test.js create mode 100644 app/tools/football-env-smoke.mjs create mode 100644 app/tools/football-env.mjs create mode 100644 app/tools/lib/football-sim.mjs diff --git a/app/package.json b/app/package.json index 52e25e1..2354c11 100644 --- a/app/package.json +++ b/app/package.json @@ -11,6 +11,7 @@ "dev": "vite", "test": "node --test \"test/**/*.test.js\"", "test:headless": "node tools/headless-match.mjs", + "test:env": "node tools/football-env-smoke.mjs --steps=20", "build": "vite build", "preview": "vite preview" }, diff --git a/app/src/football/CommentaryDanmaku.jsx b/app/src/football/CommentaryDanmaku.jsx new file mode 100644 index 0000000..e5eea8a --- /dev/null +++ b/app/src/football/CommentaryDanmaku.jsx @@ -0,0 +1,204 @@ +// Horizontal danmaku commentary — Bilibili-style right→left floaters. +// Consumes matchEvents + soft situational cues; lines from commentary.js. +import Box from "@mui/material/Box"; +import { keyframes } from "@mui/material/styles"; +import { useEffect, useRef, useState } from "react"; +import { useGame } from "../store.js"; +import { playDanmakuCue } from "../game/audio.js"; +import { ANTON, CREAM, COMIC_INK } from "../ui/comic.jsx"; +import { + canIdle, + canSpeak, + DANGER_COOLDOWN_S, + lineForDanger, + lineForEvent, + lineForIdle, +} from "./commentary.js"; + +const RED_ACCENT = "#ff4466"; +const BLUE_ACCENT = "#4488ff"; +const LANES = 5; +const LIFETIME_MS = 7200; +const MAX_ON_SCREEN = 4; +const TICK_MS = 500; + +const drift = keyframes` + from { transform: translateX(0); opacity: 0; } + 8% { opacity: 1; } + 88% { opacity: 1; } + to { transform: translateX(calc(-100vw - 100%)); opacity: 0.15; } +`; + +let nextId = 1; + +function eventSig(ev) { + return `${ev.type}|${ev.team ?? ""}|${ev.time ?? ""}|${ev.payload?.duckId ?? ""}`; +} + +export default function CommentaryDanmaku() { + const matchEvents = useGame((s) => s.matchEvents); + const matchState = useGame((s) => s.matchState); + const score = useGame((s) => s.score); + const ballState = useGame((s) => s.ballState); + const locale = useGame((s) => s.locale) || "en"; + const danmakuEnabled = useGame((s) => s.danmakuEnabled !== false); + + const [items, setItems] = useState([]); + const seenRef = useRef(new Set()); + const lastSpeakAt = useRef(0); + const lastDangerAt = useRef(0); + const laneCursor = useRef(0); + const prevState = useRef(matchState); + const scoreRef = useRef(score); + const ballRef = useRef(ballState); + const localeRef = useRef(locale); + const stateRef = useRef(matchState); + const enabledRef = useRef(danmakuEnabled); + + scoreRef.current = score; + ballRef.current = ballState; + localeRef.current = locale; + stateRef.current = matchState; + enabledRef.current = danmakuEnabled; + + function spawn(line) { + if (!enabledRef.current || !line?.text) return; + const now = performance.now(); + if (!canSpeak((now - lastSpeakAt.current) / 1000)) return; + lastSpeakAt.current = now; + const lane = laneCursor.current % LANES; + laneCursor.current += 1; + const id = nextId++; + const entry = { + id, + text: line.text, + team: line.team, + kind: line.kind, + lane, + duration: LIFETIME_MS + (lane % 3) * 400, + }; + playDanmakuCue(line.kind); + setItems((prev) => { + const next = [...prev, entry]; + return next.length > MAX_ON_SCREEN + 2 + ? next.slice(next.length - (MAX_ON_SCREEN + 2)) + : next; + }); + window.setTimeout(() => { + setItems((prev) => prev.filter((it) => it.id !== id)); + }, entry.duration + 80); + } + + // Hard events from the referee ring. + useEffect(() => { + if (!Array.isArray(matchEvents)) return; + for (const ev of matchEvents) { + const sig = eventSig(ev); + if (seenRef.current.has(sig)) continue; + seenRef.current.add(sig); + // Cap seen set so long sessions don't leak. + if (seenRef.current.size > 80) { + seenRef.current = new Set([...seenRef.current].slice(-40)); + } + if (!danmakuEnabled) continue; + const line = lineForEvent(ev, localeRef.current); + if (line) spawn(line); + } + }, [matchEvents, danmakuEnabled]); + + // Kickoff never hits matchEvents (handleRefereeEvent only places the ball). + useEffect(() => { + const prev = prevState.current; + prevState.current = matchState; + if (prev === matchState) return; + if (!danmakuEnabled) return; + // Skip post-goal restart — goal line already covered the moment. + if (matchState === "KICKOFF" && prev !== "GOAL") { + spawn(lineForEvent({ type: "kickoff" }, localeRef.current)); + } + }, [matchState, danmakuEnabled]); + + // Idle filler + danger-zone soft lines (wall clock, not physics Hz). + useEffect(() => { + if (!danmakuEnabled) return; + const id = window.setInterval(() => { + const now = performance.now(); + const since = (now - lastSpeakAt.current) / 1000; + const st = stateRef.current; + if (st !== "PLAYING") return; + + const ball = ballRef.current; + if ( + ball && + (now - lastDangerAt.current) / 1000 >= DANGER_COOLDOWN_S && + canSpeak(since) + ) { + const danger = lineForDanger(ball, localeRef.current); + if (danger) { + lastDangerAt.current = now; + spawn(danger); + return; + } + } + + if (canIdle({ matchState: st, sinceLastLineS: since })) { + spawn(lineForIdle({ score: scoreRef.current }, localeRef.current)); + } + }, TICK_MS); + return () => window.clearInterval(id); + }, [danmakuEnabled]); + + // Clear on fresh kickoff after fulltime / idle reset, or when toggled off. + useEffect(() => { + if (matchState === "IDLE") { + seenRef.current = new Set(); + setItems([]); + } else if (!danmakuEnabled) { + setItems([]); + } + }, [matchState, danmakuEnabled]); + + if (!danmakuEnabled || items.length === 0) return null; + + return ( + + {items.map((it) => ( + + {it.text} + + ))} + + ); +} diff --git a/app/src/football/FootballHud.jsx b/app/src/football/FootballHud.jsx index a7dd6d8..b73dcb1 100644 --- a/app/src/football/FootballHud.jsx +++ b/app/src/football/FootballHud.jsx @@ -3,14 +3,14 @@ // 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, isMuted, setMuted } from "../game/audio.js"; +import { uiClick } 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 CommentaryDanmaku from "./CommentaryDanmaku.jsx"; import StrategyBoard from "./StrategyBoard.jsx"; import TeamFpv from "./TeamFpv.jsx"; @@ -153,13 +153,14 @@ function BackButton() { {t(locale, "back")} - + ); } -function SoundMuteButton() { - const [muted, setMutedState] = useState(isMuted); +function DanmakuToggleButton() { + const locale = useGame((s) => s.locale) || "en"; + const enabled = useGame((s) => s.danmakuEnabled !== false); return ( { - const next = !muted; - setMuted(next); - setMutedState(next); - if (!next) uiClick(); + const next = !enabled; + useGame.setState({ danmakuEnabled: next }); + try { localStorage.setItem("microduck-danmaku", next ? "1" : "0"); } catch { /* private mode */ } + uiClick(); }} sx={{ appearance: "none", border: "none", background: "transparent", - color: muted ? "rgba(250,248,242,0.45)" : CREAM, + color: enabled ? CREAM : "rgba(250,248,242,0.45)", cursor: "pointer", fontFamily: MONO, fontSize: "0.65rem", @@ -194,7 +195,7 @@ function SoundMuteButton() { "&:hover": { color: ORANGE }, }} > - {muted ? "MUTED" : "SFX"} + {enabled ? t(locale, "danmakuOn") : t(locale, "danmakuOff")} ); @@ -610,6 +611,7 @@ export default function FootballHud() { }} /> + diff --git a/app/src/football/StrategyPanel.jsx b/app/src/football/StrategyPanel.jsx index 64042f7..d523b15 100644 --- a/app/src/football/StrategyPanel.jsx +++ b/app/src/football/StrategyPanel.jsx @@ -1,6 +1,5 @@ -// 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. +// Coach tactics panel: formation + style presets + 0..1 energy-bar knobs + NL hints. +import { useMemo, useState } from "react"; import Box from "@mui/material/Box"; import Typography from "@mui/material/Typography"; import { useGame, gameApi } from "../store.js"; @@ -10,14 +9,21 @@ import { DEFAULT_STRATEGY, FORMATION_IDS, STYLE_IDS, - PRESS_IDS, + KNOB_IDS, + PRIMARY_KNOB_IDS, normalizeStrategy, + mergeStrategy, + strategyFromStylePreset, + parseStrategyHints, + knobsDiff, + knobImpact, + knobPoles, + strategyFingerprint, } from "../game/football/strategy.js"; import { t, formationLabel, styleLabel, - pressLabel, teamLabel, } from "./i18n.js"; @@ -28,23 +34,28 @@ 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 }); + const team = opts?.team || useGame.getState().userTeam || "red"; + const userTeam = useGame.getState().userTeam || "red"; + const prev = normalizeStrategy( + team === userTeam + ? (useGame.getState().userStrategy || DEFAULT_STRATEGY) + : (useGame.getState().opponentStrategy || DEFAULT_STRATEGY), + ); + const next = mergeStrategy(prev, partial); + if (team === userTeam) useGame.setState({ userStrategy: next }); + else useGame.setState({ opponentStrategy: next }); return next; } function patchTeam(team) { - if (typeof gameApi.setUserTeam === "function") { - return gameApi.setUserTeam(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 ( - + {labels[id] || id} @@ -102,24 +107,166 @@ function ChipGroup({ label, options, labels, value, onChange, disabled }) { ); } +/** Analog energy bar: drag 0..1. */ +function EnergyBar({ label, hint, poles, value, onChange }) { + const pct = Math.round(Math.max(0, Math.min(1, value)) * 100); + const [lo, hi] = poles || ["0", "1"]; + return ( + + + + {label} + + + {pct} + + + {hint ? ( + + {hint} + + ) : null} + + + onChange(Number(e.target.value))} + aria-label={label} + sx={{ + position: "absolute", + inset: 0, + width: "100%", + margin: 0, + appearance: "none", + background: "transparent", + cursor: "pointer", + "&::-webkit-slider-thumb": { + appearance: "none", + width: 14, + height: 18, + background: CREAM, + border: `2px solid ${COMIC_INK}`, + boxShadow: `2px 2px 0 ${COMIC_INK}`, + }, + "&::-moz-range-thumb": { + width: 14, + height: 18, + background: CREAM, + border: `2px solid ${COMIC_INK}`, + borderRadius: 0, + }, + "&::-webkit-slider-runnable-track": { background: "transparent" }, + "&::-moz-range-track": { background: "transparent" }, + }} + /> + + + {lo} + {hi} + + + ); +} + /** * @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 opponentStrategy = useGame((s) => s.opponentStrategy) || DEFAULT_STRATEGY; const locale = useGame((s) => s.locale) || "en"; - const strat = normalizeStrategy(userStrategy); + + const [editTarget, setEditTarget] = useState("user"); + const [fineOpen, setFineOpen] = useState(false); + const [hintText, setHintText] = useState(""); + const [hintPreview, setHintPreview] = useState(null); + + const editTeam = editTarget === "user" ? userTeam : (userTeam === "red" ? "blue" : "red"); + const rawStrat = editTarget === "user" ? userStrategy : opponentStrategy; + const strat = normalizeStrategy(rawStrat); 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)]), - ); + const styleLabels = { + ...Object.fromEntries(STYLE_IDS.map((id) => [id, styleLabel(locale, id)])), + custom: t(locale, "custom"), + }; + const styleValue = STYLE_IDS.includes(strat.style) ? strat.style : "custom"; + const applyOpts = { team: editTeam }; + + const onStyle = (style) => { + if (style === "custom") return; + const next = strategyFromStylePreset(style, strat.formation); + patchStrategy({ style: next.style, knobs: next.knobs }, applyOpts); + }; + + const onKnob = (id, value) => { + patchStrategy({ + style: "custom", + knobs: { ...strat.knobs, [id]: value }, + }, applyOpts); + }; + + const onHintChange = (value) => { + setHintText(value); + const { knobs } = parseStrategyHints(value); + const diff = knobsDiff(strat.knobs, knobs); + setHintPreview(diff.length ? { knobs, diff } : (value.trim() ? { knobs: {}, diff: [] } : null)); + }; + + const onHintApply = () => { + const { knobs } = parseStrategyHints(hintText); + const diff = knobsDiff(strat.knobs, knobs); + if (!diff.length) return; + patchStrategy({ style: "custom", knobs: { ...strat.knobs, ...knobs } }, applyOpts); + setHintPreview(null); + setHintText(""); + }; + + const fp = useMemo(() => strategyFingerprint(strat), [strat]); + const secondaryIds = KNOB_IDS.filter((id) => !PRIMARY_KNOB_IDS.includes(id)); return ( patchTeam(tm)} + onChange={patchTeam} disabled={formationLocked} /> + + patchStrategy({ formation })} + disabled={formationLocked && editTarget === "user"} + onChange={(formation) => patchStrategy({ formation }, applyOpts)} /> patchStrategy({ style })} + value={styleValue} + onChange={onStyle} /> - patchStrategy({ press })} + + + + {PRIMARY_KNOB_IDS.map((id) => ( + onKnob(id, v)} + /> + ))} + + + + onHintChange(e.target.value)} + placeholder={t(locale, "hintPlaceholder")} + sx={{ + flex: 1, + minWidth: 0, + appearance: "none", + border: "2px solid rgba(255,255,255,0.2)", + background: "rgba(0,0,0,0.35)", + color: CREAM, + fontFamily: MONO, + fontSize: "0.62rem", + px: "0.55rem", + py: "0.45rem", + outline: "none", + "&:focus": { borderColor: ORANGE }, + }} /> + + {t(locale, "hintApply")} + + {hintPreview ? ( + + {hintPreview.diff.length + ? `${t(locale, "hintPreview")}: ${hintPreview.diff.map((d) => `${d.id} ${Math.round(d.from * 100)}→${Math.round(d.to * 100)}`).join(", ")}` + : t(locale, "hintEmpty")} + + ) : null} + + setFineOpen((v) => !v)} + sx={{ + appearance: "none", + cursor: "pointer", + border: "none", + background: "transparent", + color: ORANGE, + fontFamily: MONO, + fontSize: "0.56rem", + fontWeight: 600, + letterSpacing: "0.12em", + textTransform: "uppercase", + textAlign: "left", + p: 0, + }} + > + {fineOpen ? t(locale, "fineTuneHide") : t(locale, "fineTuneShow")} + + + {fineOpen ? ( + + {secondaryIds.map((id) => ( + onKnob(id, v)} + /> + ))} + + ) : null} - {teamLabel(locale, userTeam)} · {formationLabels[strat.formation]} · {styleLabels[strat.style]} · {t(locale, "pressWord")} {pressLabels[strat.press]} - {formationLocked ? ` · ${t(locale, "formationLocked")}` : ""} + {teamLabel(locale, editTeam)} · {formationLabels[strat.formation]} · {styleLabels[styleValue]} + {" · "} + {fp} + {formationLocked && editTarget === "user" ? ` · ${t(locale, "formationLocked")}` : ""} ); diff --git a/app/src/football/commentary.js b/app/src/football/commentary.js new file mode 100644 index 0000000..c2d2339 --- /dev/null +++ b/app/src/football/commentary.js @@ -0,0 +1,302 @@ +// Rule-based match commentary for danmaku. Pure + testable — no React. +// Tone: esports roast with light duck flavor. Locale: en | zh. + +import { FIELD_HALF_L, PENALTY_AREA_L } from "../game/football/constants.js"; + +/** Seconds of silence before idle filler can fire while PLAYING. */ +export const IDLE_GAP_S = 22; +/** Min gap between any two commentary spawns (anti-spam). */ +export const GLOBAL_COOLDOWN_S = 2.2; +/** Soft "ball in box" lines cooldown. */ +export const DANGER_COOLDOWN_S = 28; +/** |x| past this counts as attacking-third pressure (m). */ +export const DANGER_X = FIELD_HALF_L - PENALTY_AREA_L; // 1.8 + +const TEAM = { + en: { red: "Red", blue: "Blue" }, + zh: { red: "红队", blue: "蓝队" }, +}; + +function teamName(locale, team) { + const pack = TEAM[locale] || TEAM.en; + return pack[team] || team || ""; +} + +function pick(list, rng = Math.random) { + if (!list?.length) return null; + const i = Math.floor(rng() * list.length) % list.length; + return list[i]; +} + +function fill(template, vars) { + if (!template) return null; + let s = template; + for (const [k, v] of Object.entries(vars)) { + s = s.replaceAll(`{${k}}`, v); + } + return s; +} + +// ── Line banks ──────────────────────────────────────────────────────────── +// Keys mirror matchEvents.type (+ soft: shot, idle_*, danger, kickoff_start, +// extra_time, fulltime_result). Multiple variants per key; pick() chooses. + +export const LINES = { + en: { + goal: [ + "GOAL! {team} finally put one in the net!", + "It's in! {team} ducks go wild!", + "{team} scores — the silence is over!", + "What a strike! {team} takes the lead… or pads it.", + ], + goal_disallowed: [ + "No goal — VAR energy without the VAR.", + "Waved off! {team} celebration cut short.", + ], + shot: [ + "{team} lets fly — keeper's problem now!", + "Shot from {team}! Close one!", + "{team} testing the goalmouth — duck bravery!", + ], + corner_red: [ + "Corner to Red — crowded box incoming.", + "Red corner. Someone please clear this.", + ], + corner_blue: [ + "Corner to Blue — set-piece lottery!", + "Blue corner. Wings out, eyes on the ball.", + ], + throw_in: [ + "Throw-in — {team} restarts the chaos.", + "Out of play. {team} gets the throw.", + ], + goal_kick: [ + "Goal kick — {team} resets from the back.", + "Keeper's ball. Deep breath, {team}.", + ], + penalty: [ + "Sin-bin! {team} duck parked for a nap.", + "Off you go — {team} down a bird.", + ], + penalty_reset: [ + "Still in the bin — clock refreshed for {team}.", + ], + yellow_card: [ + "Yellow! {team} getting chatty with the ref.", + "Booking for {team} — careful now.", + ], + red_card: [ + "RED CARD! {team} is a duck short.", + "Sent off! {team} in serious trouble.", + ], + kickoff: [ + "Kickoff! Six ducks. One ball. No mercy.", + "We're live — let the waddle war begin!", + ], + extra_time: [ + "Extra time! Golden goal — next score wins.", + "Into extras. One touch of glory left.", + ], + fulltime: [ + "Full time! What a scrap.", + "Whistle! Ducks collapse in a heap.", + ], + idle_stalemate: [ + "Midfield laundry cycle continues…", + "Still 0 drama per minute. Patience.", + "Someone please invent a shot.", + "Possession ping-pong. Classic duckball.", + "The ball has trust issues with both goals.", + ], + idle_tied: [ + "Deadlocked. Tension in the feathers.", + "Scoreboard frozen — who blinks first?", + ], + idle_leading: [ + "{team} protecting the lead like it's treasure.", + "{team} ahead — can they close this out?", + ], + idle_trailing: [ + "{team} hunting an equalizer. Wings out!", + "Comeback window open for {team}.", + ], + danger: [ + "Ball in the box! {team} under the pump!", + "Danger zone — {team} scrambling!", + "Scramble at the near post!", + ], + }, + zh: { + goal: [ + "进了!{team}总算踢进一个!", + "球进了!{team}鸭子炸窝了!", + "{team}得分 — 沉默结束!", + "好球!{team}改写比分!", + ], + goal_disallowed: [ + "进球无效 — 没有 VAR 也有 VAR 味。", + "吹掉了!{team}庆祝白跳了。", + ], + shot: [ + "{team}起脚打门 — 门将接招!", + "{team}射门!险些破门!", + "{team}试射 — 鸭子胆真肥!", + ], + corner_red: [ + "红队角球 — 禁区要挤成罐头了。", + "红队角球。求一个解围。", + ], + corner_blue: [ + "蓝队角球 — 定位球开盲盒!", + "蓝队角球。展翅盯球。", + ], + throw_in: [ + "界外球 — {team}重新开闹。", + "出界了。{team}掷界外。", + ], + goal_kick: [ + "球门球 — {team}从后场重启。", + "门将的球。{team}深呼吸。", + ], + penalty: [ + "暂罚!{team}一只鸭去罚站了。", + "下场歇着 — {team}少一人。", + ], + penalty_reset: [ + "还在罚站 — {team}计时重来。", + ], + yellow_card: [ + "黄牌!{team}跟裁判侃上了。", + "{team}吃牌 — 悠着点。", + ], + red_card: [ + "红牌!{team}少一只鸭了。", + "罚下!{team}麻烦大了。", + ], + kickoff: [ + "开球!六只鸭,一颗球,不讲武德。", + "比赛开始 — 摇摆战争开打!", + ], + extra_time: [ + "加时!金球制 — 进一个就赢。", + "进入加时。荣耀只差一脚。", + ], + fulltime: [ + "全场完!打得真惨烈。", + "吹哨了!鸭子摊成一地。", + ], + idle_stalemate: [ + "中场还在洗衣服…", + "每分钟零剧情。再等等。", + "拜托谁射一下吧。", + "控球乒乓。经典鸭球。", + "这球对两个球门都有阴影。", + ], + idle_tied: [ + "僵住了。羽毛都紧绷。", + "比分冻结 — 谁先眨眼?", + ], + idle_leading: [ + "{team}护分护得像护宝。", + "{team}领先 — 能不能收住?", + ], + idle_trailing: [ + "{team}在追平。翅膀张开!", + "{team}逆转窗口开着。", + ], + danger: [ + "球进禁区了!{team}顶不住!", + "危险!{team}在忙乱解围!", + "近门柱混战!", + ], + }, +}; + +/** Map raw matchEvents.type → line bank key. */ +export function bankKeyForEvent(type) { + if (!type) return null; + if (type === "corner" || type === "corner_red" || type === "corner_blue") { + return type === "corner" ? "corner_red" : type; + } + return type; +} + +/** + * Build one commentary string for a hard/soft match event. + * @returns {{ text: string, team: ?string, kind: string } | null} + */ +export function lineForEvent(ev, locale = "en", rng = Math.random) { + if (!ev?.type) return null; + const key = bankKeyForEvent(ev.type); + const pack = LINES[locale] || LINES.en; + const bank = pack[key] || LINES.en[key]; + if (!bank) return null; + const team = teamName(locale, ev.team); + const text = fill(pick(bank, rng), { team }); + if (!text) return null; + return { text, team: ev.team || null, kind: key }; +} + +/** + * Idle / soft situational line while PLAYING. + * @param {object} ctx + * @param {'en'|'zh'} locale + * @param {() => number} rng + * @returns {{ text: string, team: ?string, kind: string } | null} + */ +export function lineForIdle(ctx, locale = "en", rng = Math.random) { + const pack = LINES[locale] || LINES.en; + const score = ctx?.score || { red: 0, blue: 0 }; + const red = score.red | 0; + const blue = score.blue | 0; + + let key = "idle_stalemate"; + let team = null; + if (red === blue) { + key = rng() < 0.55 ? "idle_tied" : "idle_stalemate"; + } else if (rng() < 0.5) { + key = "idle_leading"; + team = red > blue ? "red" : "blue"; + } else { + key = "idle_trailing"; + team = red > blue ? "blue" : "red"; + } + + const bank = pack[key] || LINES.en[key]; + const text = fill(pick(bank, rng), { team: teamName(locale, team) }); + if (!text) return null; + return { text, team, kind: key }; +} + +/** + * Ball deep in a penalty area → danger line. + * @returns {{ text: string, team: ?string, kind: string } | null} + */ +export function lineForDanger(ball, locale = "en", rng = Math.random) { + if (!ball || !Number.isFinite(ball.x)) return null; + if (Math.abs(ball.x) < DANGER_X) return null; + // Defending team is the one whose goal is threatened. + const defending = ball.x > 0 ? "blue" : "red"; + const pack = LINES[locale] || LINES.en; + const text = fill(pick(pack.danger || LINES.en.danger, rng), { + team: teamName(locale, defending), + }); + if (!text) return null; + return { text, team: defending, kind: "danger" }; +} + +/** + * Decide whether idle filler is allowed given silence clocks. + * @param {{ matchState: string, sinceLastLineS: number }} ctx + */ +export function canIdle(ctx) { + if (ctx?.matchState !== "PLAYING") return false; + return (ctx.sinceLastLineS ?? 0) >= IDLE_GAP_S; +} + +/** + * Global anti-spam gate. + */ +export function canSpeak(sinceLastLineS) { + return (sinceLastLineS ?? Infinity) >= GLOBAL_COOLDOWN_S; +} diff --git a/app/src/football/hud-logic.js b/app/src/football/hud-logic.js index 6a15b99..7111982 100644 --- a/app/src/football/hud-logic.js +++ b/app/src/football/hud-logic.js @@ -37,6 +37,7 @@ export function stateLabel(matchState, locale = "en") { const EVENT_ICONS = { goal: "\u26BD", + shot: "\uD83D\uDCA5", yellow_card: "\uD83D\uDFE8", red_card: "\uD83D\uDFE5", corner: "\uD83D\uDCD0", @@ -47,6 +48,9 @@ const EVENT_ICONS = { kickoff: "\uD83C\uDFC1", halftime: "\u23F1\uFE0F", fulltime: "\uD83C\uDFC6", + extra_time: "\u23F1\uFE0F", + throw_in: "\uD83E\uDD3E", + goal_kick: "\uD83D\uDD04", }; const cap = (s) => s.charAt(0).toUpperCase() + s.slice(1); @@ -65,6 +69,7 @@ export function eventLabel(ev, locale = "en") { const team = teamToken(ev, locale); switch (ev.type) { case "goal": return `${icon} ${t(locale, "ev_goal", { team })}`; + case "shot": return `${icon} ${t(locale, "ev_shot", { team })}`; case "yellow_card": return `${icon} ${t(locale, "ev_yellow", { team })}`; case "red_card": return `${icon} ${t(locale, "ev_red", { team })}`; case "corner": @@ -75,6 +80,9 @@ export function eventLabel(ev, locale = "en") { case "kickoff": return `${icon} ${t(locale, "ev_kickoff")}`; case "halftime": return `${icon} ${t(locale, "ev_halftime")}`; case "fulltime": return `${icon} ${t(locale, "ev_fulltime")}`; + case "extra_time": return `${icon} ${t(locale, "ev_extra")}`; + case "throw_in": return `${icon} ${t(locale, "ev_throw", { team })}`; + case "goal_kick": return `${icon} ${t(locale, "ev_goalkick", { team })}`; default: return `${icon}${team} ${String(ev.type).replace(/_/g, " ")}`; } } diff --git a/app/src/football/i18n.js b/app/src/football/i18n.js index b7da548..9e290c5 100644 --- a/app/src/football/i18n.js +++ b/app/src/football/i18n.js @@ -1,5 +1,7 @@ // Football UI strings — EN / 中文. No i18n framework; locale lives on the store. +import { describeStrategy } from "../game/football/strategy.js"; + export const LOCALES = ["en", "zh"]; const STRINGS = { @@ -21,6 +23,16 @@ const STRINGS = { formation: "Formation", style: "Style", press: "Press", + fineTune: "Fine tune", + fineTuneHide: "Hide fine tune", + fineTuneShow: "Show fine tune", + hintPlaceholder: "e.g. high press, don't blast, keeper home", + hintApply: "Apply", + hintPreview: "Will change", + hintEmpty: "No tactics matched — try other words", + rivalTactics: "Rival tactics", + editingSide: "Editing", + custom: "Custom", red: "Red", blue: "Blue", attack: "Attack", @@ -32,7 +44,18 @@ const STRINGS = { pressWord: "press", formationLocked: "formation locked", teamTactics: "Team tactics", + knob_lineHeight: "Line", + knob_press: "Press", + knob_shootGreed: "Shoot", + knob_supportWidth: "Width", + knob_supportDepth: "Depth", + knob_approach: "Approach", + knob_clearStyle: "Clear", + knob_gkRush: "GK", + knob_spacing: "Spacing", back: "Back", + danmakuOn: "CHAT", + danmakuOff: "CHAT OFF", sinBin: "SIN-BIN", openCoach: "Open coach desk", langEn: "EN", @@ -57,6 +80,7 @@ const STRINGS = { state_FULLTIME: "FULL TIME", // Events ev_goal: "GOAL!{team} Team scores!", + ev_shot: "Shot —{team}", ev_yellow: "Yellow card —{team}", ev_red: "Red card —{team}", ev_corner: "Corner kick —{team}", @@ -65,6 +89,9 @@ const STRINGS = { ev_kickoff: "Kick off!", ev_halftime: "Half time", ev_fulltime: "Full time", + ev_extra: "Extra time!", + ev_throw: "Throw-in —{team}", + ev_goalkick: "Goal kick —{team}", }, zh: { loadingTeams: "正在加载队伍…", @@ -83,6 +110,16 @@ const STRINGS = { formation: "阵型", style: "风格", press: "压迫", + fineTune: "细调", + fineTuneHide: "收起细调", + fineTuneShow: "展开细调", + hintPlaceholder: "例如:高位逼抢,别乱射,门将别出门", + hintApply: "应用", + hintPreview: "将修改", + hintEmpty: "没有匹配到战术词 — 换个说法试试", + rivalTactics: "对方战术", + editingSide: "正在编辑", + custom: "自定义", red: "红队", blue: "蓝队", attack: "进攻", @@ -94,7 +131,18 @@ const STRINGS = { pressWord: "压迫", formationLocked: "阵型已锁定", teamTactics: "球队战术", + knob_lineHeight: "站位线", + knob_press: "逼抢", + knob_shootGreed: "射门", + knob_supportWidth: "宽度", + knob_supportDepth: "前插", + knob_approach: "接近", + knob_clearStyle: "解围", + knob_gkRush: "门将", + knob_spacing: "间距", back: "返回", + danmakuOn: "弹幕", + danmakuOff: "弹幕关", sinBin: "暂罚", openCoach: "打开教练席", langEn: "EN", @@ -116,6 +164,7 @@ const STRINGS = { state_HALFTIME: "中场", state_FULLTIME: "全场结束", ev_goal: "进球!{team}得分", + ev_shot: "射门 —{team}", ev_yellow: "黄牌 —{team}", ev_red: "红牌 —{team}", ev_corner: "角球 —{team}", @@ -124,6 +173,9 @@ const STRINGS = { ev_kickoff: "开球!", ev_halftime: "半场", ev_fulltime: "全场结束", + ev_extra: "加时!", + ev_throw: "界外球 —{team}", + ev_goalkick: "球门球 —{team}", }, }; @@ -180,10 +232,12 @@ export function teamLabel(locale, id) { return t(locale, id); } -/** Short coach tag: "Attack · High" / "进攻 · 高" */ +/** Short coach tag: style preset or top differing knobs. */ export function strategyTag(locale, strategy) { if (!strategy) return "—"; - const style = styleLabel(locale, strategy.style); - const press = pressLabel(locale, strategy.press); - return `${style} · ${press}`; + const d = describeStrategy(strategy, locale); + if (d.style && d.style !== "custom") { + return `${styleLabel(locale, d.style)} · ${pressLabel(locale, d.press)}`; + } + return d.summary || "—"; } diff --git a/app/src/game/audio.js b/app/src/game/audio.js index ffd333e..cbbc251 100644 --- a/app/src/game/audio.js +++ b/app/src/game/audio.js @@ -149,6 +149,61 @@ export function uiClick() { playSfx("click", { gain: 0.16, rate: 0.95 + Math.random() * 0.1 }); } +// ── Danmaku commentary cues (synthesized, keyed by line.kind) ───────── +// Short retro blips that fire when a commentary line spawns. Keeps the +// mix alive without physics collision spam. Master mute still silences. +const DANMAKU_CUES = { + goal: { freqs: [523, 784, 1046], gap: 0.07, dur: 0.14, gain: 0.14, type: "square" }, + fulltime: { freqs: [392, 523, 659], gap: 0.08, dur: 0.16, gain: 0.12, type: "triangle" }, + kickoff: { freqs: [440, 554, 659], gap: 0.06, dur: 0.11, gain: 0.11, type: "square" }, + extra_time: { freqs: [587, 740], gap: 0.07, dur: 0.12, gain: 0.11, type: "square" }, + shot: { freqs: [880, 1320], gap: 0.04, dur: 0.07, gain: 0.1, type: "triangle" }, + danger: { freqs: [740, 990], gap: 0.045, dur: 0.08, gain: 0.1, type: "square" }, + yellow_card: { freqs: [330, 280], gap: 0.09, dur: 0.12, gain: 0.1, type: "square" }, + red_card: { freqs: [220, 165], gap: 0.1, dur: 0.16, gain: 0.12, type: "sawtooth" }, + penalty: { freqs: [247, 196], gap: 0.09, dur: 0.14, gain: 0.11, type: "square" }, + penalty_reset: { freqs: [220], gap: 0, dur: 0.1, gain: 0.08, type: "triangle" }, + goal_disallowed: { freqs: [400, 300], gap: 0.08, dur: 0.11, gain: 0.09, type: "triangle" }, + corner_red: { freqs: [698], gap: 0, dur: 0.09, gain: 0.08, type: "triangle" }, + corner_blue: { freqs: [622], gap: 0, dur: 0.09, gain: 0.08, type: "triangle" }, + throw_in: { freqs: [520], gap: 0, dur: 0.07, gain: 0.07, type: "triangle" }, + goal_kick: { freqs: [466], gap: 0, dur: 0.08, gain: 0.07, type: "triangle" }, + idle_stalemate: { freqs: [490], gap: 0, dur: 0.05, gain: 0.05, type: "sine" }, + idle_tied: { freqs: [520], gap: 0, dur: 0.05, gain: 0.05, type: "sine" }, + idle_leading: { freqs: [560], gap: 0, dur: 0.055, gain: 0.055, type: "sine" }, + idle_trailing: { freqs: [430], gap: 0, dur: 0.055, gain: 0.055, type: "sine" }, +}; + +const DANMAKU_FALLBACK = { freqs: [600], gap: 0, dur: 0.06, gain: 0.06, type: "triangle" }; + +export function playDanmakuCue(kind) { + if (SOUND_DISABLED || muted) return; + const c = audioCtx(); + if (c.state === "suspended") return; + const cue = DANMAKU_CUES[kind] || DANMAKU_FALLBACK; + const t0 = c.currentTime + 0.01; + const out = c.createGain(); + out.gain.value = 1; + out.connect(buses.sfx); + cue.freqs.forEach((f, i) => { + const start = t0 + i * cue.gap; + const osc = c.createOscillator(); + osc.type = cue.type; + osc.frequency.value = f * (0.98 + Math.random() * 0.04); + const g = c.createGain(); + g.gain.setValueAtTime(0, start); + g.gain.linearRampToValueAtTime(cue.gain, start + 0.008); + g.gain.exponentialRampToValueAtTime(0.001, start + cue.dur); + osc.connect(g); + g.connect(out); + osc.start(start); + osc.stop(start + cue.dur + 0.02); + if (i === cue.freqs.length - 1) { + osc.onended = () => out.disconnect(); + } + }); +} + // ── Spatial listener + emitters ─────────────────────────────────────── // Listener follows the three.js camera (three world coords). Emitters are // equalpower panners: cheap, and plenty for "the duck is over there". diff --git a/app/src/game/football/ai/index.js b/app/src/game/football/ai/index.js index 73af8b5..ee65f7e 100644 --- a/app/src/game/football/ai/index.js +++ b/app/src/game/football/ai/index.js @@ -24,17 +24,18 @@ export const BASE_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: 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 approach (ER-Force MoveToStaticBall): stand at ball − shotDir × r, - // not goto(ball). Slow ball → static offset; fast ball → short ball+v·t. + // Chase approach (Booster approach_target): stand at ball − shotDir × offset. + // 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 + APPROACH_ARRIVE_EPS: 0.18, // slightly loose so we commit into the ball + APPROACH_OFFSET: 0.32, // tighter stand-behind (less orbit distance) + 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 + AIM_CREEP: 0.22, // while AIM: keep walking into the ball (no freeze-stare) + AIM_SOFT_TICKS: 8, // after this many AIM ticks, widen align window + ORBIT_COMMIT_TICKS: 14, // stop pure orbit; cut toward approach even if path skims + ORBIT_RADIUS: 0.40, // tighter lateral ring (was 0.55 → endless circles) // Chase / return — kickoff ~0.9 m to approach spot @ 0.25 m/s CHASE_SPEED: 0.25, @@ -56,9 +57,27 @@ export const BASE_TUNE = { // 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 + // Booster ball_claim_score role bias (Booster CENTER −0.20 / SIDE −0.10) + CLAIM_FORWARD_BONUS: 0.20, // prefer forwards (Booster CENTER −0.20) + CLAIM_DEFENDER_BONUS: 0.10, // slight preference vs pure distance + CLAIM_ATTACK_HALF_BONUS: 0.15, // already upfield when ball is attacking + CLAIM_TIE_MARGIN: 0.12, // costs within this → lowest duck id (Booster) + CLAIM_WRONG_SIDE_PENALTY: 0.75, // metres: standing on the attack side of the ball + CLAIM_STUCK_ORBIT: 16, // orbitTicks above this → drop sticky claim + CLAIM_STUCK_AT_FEET: 12, // atFeetTicks without behind → drop sticky claim 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) + // Strategy regimes (set by coach card overlay; 0/1 flags) + LINE_HOLD_MID: 0, // 1 → non-chasers stay in own half + LINE_PUSH_MID: 0, // 1 → attack support stays past midfield + + // Soft walk-target repulsion (Booster navigation / support spacing lite). + // Pushes waypoints away from nearby ducks so paths don't stack on one spot. + AVOID_RADIUS: 0.55, // start repelling when a blocker is within this of the target + AVOID_STRENGTH: 0.40, // max push (m) when coincident with a blocker + // Non-chasers must stay outside this ring so only one duck owns the ball. + BALL_KEEP_OUT: 0.85, // Goalkeeper GK_LINE_OFFSET: 0.1, @@ -135,6 +154,18 @@ function moveToward(selfYaw, targetAngle, maxVx = TUNE.VX_MAX) { return c <= 0 ? 0 : maxVx * c; } +/** + * AIM creep: always walk longitudinally toward a bearing (forward or reverse). + * Unlike moveToward, never freezes at vx=0 when the target is beside/behind. + */ +function creepToward(selfYaw, targetAngle, speed = TUNE.AIM_CREEP) { + const c = Math.cos(angleDiff(selfYaw, targetAngle)); + if (c >= 0.15) return speed; + if (c <= -0.15) return clamp(-speed, TUNE.VX_MIN, TUNE.VX_MAX); + // Nearly sideways — still ease in so gait doesn't stall while turning. + return speed * 0.7; +} + function limitCommand(vx, wz, kick = false) { return { vx: Number.isFinite(vx) ? clamp(vx, TUNE.VX_MIN, TUNE.VX_MAX) : 0, @@ -172,10 +203,16 @@ function getOpponents(duck, allDucks, team) { // ═══════════════════════════════════════════════════════════════════════════════ /** - * Ball-get cost: distance + facing penalty − sticky hysteresis. - * Lower is better. Exported for tactics-board + tests. + * Booster-style ball claim cost (lower is better). + * distance + facing − role/half bonuses − sticky hysteresis. + * Exported for tactics-board + tests. + * + * @param {object} duck + * @param {{x:number,y:number}} ball + * @param {number} [prevChaserId=-1] + * @param {number} [attackDir=1] +1 red / −1 blue */ -export function chaseCost(duck, ball, prevChaserId = -1) { +export function chaseCost(duck, ball, prevChaserId = -1, attackDir = 1) { const [x, y] = duckXY(duck); const dist = distanceTo(x, y, ball.x, ball.y); const toBall = angleTo(x, y, ball.x, ball.y); @@ -183,23 +220,66 @@ export function chaseCost(duck, ball, prevChaserId = -1) { // 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; + + // ball_claim_score role bias (Booster CENTER −0.20 / SIDE −0.10) + const role = duck.role || 'forward'; + if (role === 'forward') cost -= TUNE.CLAIM_FORWARD_BONUS; + else if (role === 'defender') cost -= TUNE.CLAIM_DEFENDER_BONUS; + + // Already upfield while ball is in attack half → slight preference + if (ball.x * attackDir > 0 && x * attackDir > -0.3) { + cost -= TUNE.CLAIM_ATTACK_HALF_BONUS; + } + + // Wrong side of the shot axis (between ball and opponent goal) → expensive. + // Stops a scrumming attack-side duck from forever owning the claim. + const targetGoalX = attackDir * FIELD_HALF_L; + if (!isBehindBall(x, y, ball, targetGoalX)) { + cost += TUNE.CLAIM_WRONG_SIDE_PENALTY; + } + 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; +/** + * Sticky claim expires when the holder is circling / staring without getting behind. + * Free teammate can then become the finder instead of idling outside BALL_KEEP_OUT. + */ +function chaserClaimExpired(duck, ball, attackDir) { + if (!duck?._ai) return false; + const ai = duck._ai; + const [x, y] = duckXY(duck); + const targetGoalX = attackDir * FIELD_HALF_L; + const behind = isBehindBall(x, y, ball, targetGoalX); + if ((ai.orbitTicks | 0) >= TUNE.CLAIM_STUCK_ORBIT && !behind) return true; + if ((ai.atFeetTicks | 0) >= TUNE.CLAIM_STUCK_AT_FEET && !behind) return true; + return false; +} + +/** + * Assign primary ball-getter among field players. + * Ties within CLAIM_TIE_MARGIN → lowest duck id (Booster select_chaser). + */ +export function assignChaserId(ducks, ball, prevChaserId = -1, attackDir = 1) { + const scored = []; for (const d of ducks) { if (d.role === 'goalkeeper' || d.fallen || d.penalized || d.sentOff) continue; - const c = chaseCost(d, ball, prevChaserId); - if (c < bestCost) { - bestCost = c; - best = d; - } + scored.push({ + id: d.id, + cost: chaseCost(d, ball, prevChaserId, attackDir), + }); + } + if (!scored.length) return -1; + scored.sort((a, b) => (a.cost - b.cost) || (a.id - b.id)); + const best = scored[0].cost; + const margin = TUNE.CLAIM_TIE_MARGIN; + let pick = scored[0].id; + for (const s of scored) { + if (s.cost > best + margin) break; + if (s.id < pick) pick = s.id; } - return best ? best.id : -1; + return pick; } function prevChaserIdOf(ducks) { @@ -235,9 +315,151 @@ function supportSlot(duck, ctx) { const [, cy] = duckXY(chaser); latSign = (cy - ball.y) >= 0 ? -1 : 1; } + let x = ball.x + attackDir * TUNE.SUPPORT_AHEAD; + let y = ball.y + latSign * TUNE.SUPPORT_LATERAL; + // Corner: don't join the pack — sit more central so one chaser owns the ball. + const nearCorner = Math.abs(ball.x) > FIELD_HALF_L - 0.95 + && Math.abs(ball.y) > FIELD_HALF_W - 0.95; + if (nearCorner) { + x = ball.x * 0.5 + attackDir * 0.35; + y = ball.y * 0.3; + } + return { + x: clamp(x, -FIELD_HALF_L + 0.3, FIELD_HALF_L - 0.3), + y: clamp(y, -FIELD_HALF_W + 0.3, FIELD_HALF_W - 0.3), + }; +} + +/** + * Soft-repel a walk waypoint away from nearby ducks (Booster spacing lite). + * Does not plan a path — only nudges the target so slots / approaches don't + * land on top of another body. Field-clamped. + * + * @param {number} tx + * @param {number} ty + * @param {{id:number}} self + * @param {Array} blockers all ducks on the pitch (team + opponents) + * @returns {{x:number,y:number}} + */ +export function repelWalkTarget(tx, ty, self, blockers) { + let x = tx; + let y = ty; + const sid = self && self.id; + const radius = TUNE.AVOID_RADIUS; + const strength = TUNE.AVOID_STRENGTH; + if (!(radius > 0) || !(strength > 0) || !blockers || !blockers.length) { + return { + x: clamp(x, -FIELD_HALF_L + 0.3, FIELD_HALF_L - 0.3), + y: clamp(y, -FIELD_HALF_W + 0.3, FIELD_HALF_W - 0.3), + }; + } + for (const o of blockers) { + if (!o || o.id === sid || o.fallen || o.penalized || o.sentOff) continue; + const [ox, oy] = duckXY(o); + if (!Number.isFinite(ox) || !Number.isFinite(oy)) continue; + let dx = x - ox; + let dy = y - oy; + let d = Math.hypot(dx, dy); + if (d >= radius) continue; + if (d < 1e-4) { + // Coincident: push along +Y so the nudge is deterministic. + x += 0; + y += strength; + continue; + } + const push = ((radius - d) / radius) * strength; + x += (dx / d) * push; + y += (dy / d) * push; + } + return { + x: clamp(x, -FIELD_HALF_L + 0.3, FIELD_HALF_L - 0.3), + y: clamp(y, -FIELD_HALF_W + 0.3, FIELD_HALF_W - 0.3), + }; +} + +/** Apply repelWalkTarget unless near the ball (chaser must not lose the approach). */ +function avoidWalkPoint(tx, ty, duck, ctx) { + const bd = distToBall(duck, ctx.ball); + // Within ~1.2 m the approach geometry matters more than teammate spacing. + if (bd <= 1.2) return { x: tx, y: ty }; + const blockers = ctx.allDucks || ctx.ducks || []; + return repelWalkTarget(tx, ty, duck, blockers); +} + +/** + * Side-step waypoint around the ball toward the approach spot. + * Never aims through the ball (that shoves it into our own net). + * @param {number} [blend=0] 0..1 pull the orbit point toward the approach (spiral in) + */ +export function lateralOrbitPoint(sx, sy, ball, apx, apy, blend = 0) { + const bx = ball.x; + const by = ball.y || 0; + let ax = apx - bx; + let ay = apy - by; + const al = Math.hypot(ax, ay) || 1; + ax /= al; + ay /= al; + // Perpendicular; pick the side already closer to the duck. + let px = -ay; + let py = ax; + if ((sx - bx) * px + (sy - by) * py < 0) { + px = -px; + py = -py; + } + const bd = Math.hypot(sx - bx, sy - by); + const radius = Math.min(TUNE.ORBIT_RADIUS, Math.max(0.28, bd * 0.85)); + let x = bx + px * radius; + let y = by + py * radius; + const t = clamp(blend, 0, 1); + if (t > 0) { + x = x + (apx - x) * t; + y = y + (apy - y) * t; + } + return { + x: clamp(x, -FIELD_HALF_L + 0.25, FIELD_HALF_L - 0.25), + y: clamp(y, -FIELD_HALF_W + 0.25, FIELD_HALF_W - 0.25), + }; +} + +/** True when walking straight to the target would skim through the ball. */ +function pathSkimsBall(sx, sy, tx, ty, ball, rad = TUNE.SHOOT_DIST * 0.85) { + const bx = ball.x; + const by = ball.y || 0; + const abx = tx - sx; + const aby = ty - sy; + const len = Math.hypot(abx, aby); + if (len < 1e-4) return false; + const t = clamp(((bx - sx) * abx + (by - sy) * aby) / (len * len), 0, 1); + const cx = sx + abx * t; + const cy = sy + aby * t; + return Math.hypot(bx - cx, by - cy) < rad; +} + +/** + * Cover slot between the ball and our own goal (defensive screen). + * Kept outside BALL_KEEP_OUT so non-chasers do not join the pile. + */ +function defensiveCoverSlot(duck, ctx) { + const { ball, defendGoalX, attackDir } = ctx; + const spawnY = duck.spawnY != null ? duck.spawnY : 0; + let x = ball.x + (defendGoalX - ball.x) * 0.4; + let y = clamp(ball.y * 0.45 + spawnY * 0.4, -FIELD_HALF_W + 0.3, FIELD_HALF_W - 0.3); + // Keep outside the ball ring toward our goal. + const dx = x - ball.x; + const dy = y - (ball.y || 0); + const d = Math.hypot(dx, dy); + if (d < TUNE.BALL_KEEP_OUT) { + const awayX = defendGoalX - ball.x; + const awayY = 0 - (ball.y || 0); + const al = Math.hypot(awayX, awayY) || 1; + x = ball.x + (awayX / al) * TUNE.BALL_KEEP_OUT; + y = (ball.y || 0) + (awayY / al) * TUNE.BALL_KEEP_OUT * 0.25 + spawnY * 0.2; + } + // Prefer slightly toward attackDir of midfield so we face upfield after cover. + x += attackDir * 0.05; 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), + x: clamp(x, -FIELD_HALF_L + 0.3, FIELD_HALF_L - 0.3), + y: clamp(y, -FIELD_HALF_W + 0.3, FIELD_HALF_W - 0.3), }; } @@ -252,8 +474,14 @@ function buildCtx(ducks, gs) { const defendGoalX = -attackDir * FIELD_HALF_L; const ball = gs.ball; const allDucks = gs.allDucks || gs.ducks || ducks; - const prevId = gs.prevChaserId != null ? gs.prevChaserId : prevChaserIdOf(ducks); - const chaserId = assignChaserId(ducks, ball, prevId); + let prevId = gs.prevChaserId != null ? gs.prevChaserId : prevChaserIdOf(ducks); + // Stuck holder (wrong-side orbit / stare) loses sticky claim so a free + // teammate can become the ball-finder instead of parking outside keep-out. + if (prevId >= 0) { + const holder = ducks.find((d) => d.id === prevId); + if (holder && chaserClaimExpired(holder, ball, attackDir)) prevId = -1; + } + const chaserId = assignChaserId(ducks, ball, prevId, attackDir); markChaserHold(ducks, chaserId); return { team, attackDir, targetGoalX, defendGoalX, ball, allDucks, chaserId, ducks }; } @@ -322,13 +550,14 @@ function ballSpeed(ball) { } /** - * 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. + * Booster approach_target + ER-Force short intercept. + * Stand behind the ball on the shot axis when the pitch allows it. + * Near corners the ideal spot is out of bounds — pick the best in-bounds + * arc point that stays kick-safe (anti-goal side of the ball) with real + * separation, swinging toward pitch center instead of collapsing onto the ball. */ export function chaseApproachPoint(ball, targetGoalX, selfX, selfY) { - const r = TUNE.SHOOT_DIST * 0.95; + const r = TUNE.APPROACH_OFFSET; let bx = ball.x; let by = ball.y; const vx = ball.vx || 0; @@ -342,15 +571,92 @@ export function chaseApproachPoint(ball, targetGoalX, selfX, selfY) { 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, - }; + const ux = gdx / glen; + const uy = gdy / glen; + const margin = 0.28; + const xmin = -FIELD_HALF_L + margin; + const xmax = FIELD_HALF_L - margin; + const ymin = -FIELD_HALF_W + margin; + const ymax = FIELD_HALF_W - margin; + + // Open pitch: classic stand-behind on the shot axis. + const idealX = bx - ux * r; + const idealY = by - uy * r; + if (idealX >= xmin && idealX <= xmax && idealY >= ymin && idealY <= ymax) { + return { x: idealX, y: idealY }; + } + + const backAng = Math.atan2(-uy, -ux); // opposite shotDir + const toCenterAng = Math.atan2(-by, -bx); + const sweep = angleDiff(backAng, toCenterAng); + + const cands = []; + // Corner / edge: arc toward pitch center at several radii so we keep + // (ap-ball)·shotDir < 0 after field clamp (plain clamp collapsed onto ball). + for (const rr of [r * 0.7, r, r * 1.25, r * 1.55]) { + for (let i = 0; i <= 12; i++) { + const ang = normalizeAngle(backAng + sweep * (i / 12)); + cands.push({ x: bx + Math.cos(ang) * rr, y: by + Math.sin(ang) * rr }); + } + } + const inwardX = -Math.sign(bx || targetGoalX) || -1; + cands.push({ x: bx + inwardX * r * 1.4, y: by * 0.7 }); + cands.push({ x: bx + inwardX * r * 1.1, y: by - Math.sign(by || 1) * r * 0.5 }); + + let best = null; + let bestScore = -Infinity; + for (const c of cands) { + const cx = clamp(c.x, xmin, xmax); + const cy = clamp(c.y, ymin, ymax); + const dx = cx - bx; + const dy = cy - by; + const sep = Math.hypot(dx, dy); + if (sep < r * 0.4) continue; + const proj = dx * ux + dy * uy; + if (proj >= 0) continue; + const score = -proj * 2 + + Math.min(sep, r * 1.2) + - 0.04 * (Math.abs(cx) + Math.abs(cy)); + if (score > bestScore) { + bestScore = score; + best = { x: cx, y: cy }; + } + } + if (!best) { + const cl = Math.hypot(bx, by) || 1; + best = { + x: clamp(bx - (bx / cl) * r * 1.3, xmin, xmax), + y: clamp(by - (by / cl) * r * 1.3, ymin, ymax), + }; + } + return best; +} + +/** + * True when the duck is on the far side of the ball from the attack goal + * (i.e. standing behind the ball on the shot axis — Booster approach side). + * Near the touchline a tiny soft margin is allowed so corner approaches that + * are field-ward but not perfectly anti-goal still count as kick-safe. + */ +export function isBehindBall(sx, sy, ball, targetGoalX) { + const gdx = targetGoalX - ball.x; + const gdy = 0 - (ball.y || 0); + const glen = Math.hypot(gdx, gdy) || 1; + const ddx = sx - ball.x; + const ddy = sy - (ball.y || 0); + const proj = (ddx * gdx + ddy * gdy) / glen; + const nearEdge = Math.abs(ball.x) > FIELD_HALF_L - 0.7 + || Math.abs(ball.y || 0) > FIELD_HALF_W - 0.7; + return nearEdge ? proj < 0.1 : proj < 0; } /** - * Chaser: CHASE / AIM / SHOOT (merged forward logic). - * Includes AIM fuse — if aiming too long, force back to CHASE. + * Chaser: CHASE → ARRIVE (behind ball) → AIM / SHOOT. + * + * Geometry that matters (not "look at the ball"): + * own goal ── duck(behind) ── ball ──► opponent goal + * Kick only when behind on the shot axis AND roughly facing ball→goal. + * Orbit is a short spiral toward the approach — not an endless ring. */ function chaserDecide(duck, ctx) { const [sx, sy] = duckXY(duck); @@ -358,81 +664,166 @@ function chaserDecide(duck, ctx) { const { ball, targetGoalX } = ctx; const bd = distToBall(duck, ball); const toBall = angleToBall(duck, ball); - const toGoal = angleTo(sx, sy, targetGoalX, 0); + const shotDir = angleTo(ball.x, ball.y, targetGoalX, 0); + const ap = chaseApproachPoint(ball, targetGoalX, sx, sy); + const apDist = distanceTo(sx, sy, ap.x, ap.y); + const arrived = apDist <= TUNE.APPROACH_ARRIVE_EPS; + const behind = isBehindBall(sx, sy, ball, targetGoalX); + const ballAhead = Math.cos(angleDiff(yaw, toBall)) > 0.12; + const alignErr = Math.abs(angleDiff(yaw, shotDir)); - // Initialize per-duck AI state if absent if (!duck._ai) { - duck._ai = { aimTicks: 0, stallTicks: 0, escapeTicks: 0, prevX: sx, prevY: sy, kickCooldown: 0, kickHoldTicks: 0 }; + duck._ai = { + aimTicks: 0, stallTicks: 0, escapeTicks: 0, prevX: sx, prevY: sy, + kickCooldown: 0, kickHoldTicks: 0, atFeetTicks: 0, pokeArmed: false, + orbitTicks: 0, + }; } const ai = duck._ai; - // game.js / the compat classes create _ai without the kick-cooldown fields; - // lazily add them so the cross-frame state persists on the real duck object. if (ai.kickCooldown === undefined) ai.kickCooldown = 0; if (ai.kickHoldTicks === undefined) ai.kickHoldTicks = 0; + if (ai.atFeetTicks === undefined) ai.atFeetTicks = 0; + if (ai.pokeArmed === undefined) ai.pokeArmed = false; + if (ai.orbitTicks === undefined) ai.orbitTicks = 0; - // Tick the kick cooldown down once per decision. if (ai.kickCooldown > 0) ai.kickCooldown--; - // Ball at feet? - if (bd <= TUNE.SHOOT_DIST) { - const aligned = Math.abs(angleDiff(yaw, toGoal)) <= TUNE.SHOOT_ANGLE; - if (aligned) { + if (bd <= TUNE.SHOOT_DIST) ai.atFeetTicks++; + else { + ai.atFeetTicks = 0; + ai.pokeArmed = false; + } + const stuckAtFeet = ai.atFeetTicks >= 10; + + // Wrong side → short lateral spiral toward approach (not a permanent orbit). + // At feet: stay pure-lateral until off the shot axis so we never shove the ball in. + if (!behind && bd <= Math.max(TUNE.SHOOT_DIST * 2.4, 0.85)) { + ai.aimTicks = 0; + ai.kickHoldTicks = 0; + ai.orbitTicks++; + const apSafe = avoidWalkPoint(ap.x, ap.y, duck, ctx); + const by = ball.y || 0; + const atFeet = bd <= TUNE.SHOOT_DIST; + const offAxis = Math.abs(sy - by) >= 0.22; + const skim = pathSkimsBall(sx, sy, apSafe.x, apSafe.y, ball) || atFeet; + // Once clear of the shot axis, commit to the approach (don't park on the ring). + const commit = offAxis || (!atFeet && ai.orbitTicks >= TUNE.ORBIT_COMMIT_TICKS); + let wp; + if (atFeet && !offAxis) { + wp = lateralOrbitPoint(sx, sy, ball, apSafe.x, apSafe.y, 0); + } else if (commit) { + // Prefer approach; mild blend only if still very close and path skims hard. + wp = (atFeet && skim) + ? lateralOrbitPoint(sx, sy, ball, apSafe.x, apSafe.y, 0.75) + : apSafe; + } else if (skim) { + const blend = clamp(ai.orbitTicks / TUNE.ORBIT_COMMIT_TICKS, 0, 0.45); + wp = lateralOrbitPoint(sx, sy, ball, apSafe.x, apSafe.y, blend); + } else { + wp = apSafe; + } + const toWp = angleTo(sx, sy, wp.x, wp.y); + // Forward-only near the ball — reverse creep shoves through to the attack side. + const vx = (atFeet || bd < 0.55) + ? moveToward(yaw, toWp, TUNE.CHASE_SPEED) + : (commit || offAxis) + ? creepToward(yaw, toWp, TUNE.CHASE_SPEED) + : moveToward(yaw, toWp, TUNE.CHASE_SPEED); + return limitCommand(vx, turnToward(yaw, toWp)); + } + ai.orbitTicks = 0; + + // ENGAGE only when behind the ball. + const canEngage = bd <= TUNE.SHOOT_DIST && behind; + if (canEngage) { + const by = ball.y || 0; + const onAxis = Math.abs(sy - by) <= 0.20; + // Behind but wide of the shot axis → slide onto approach first (don't AIM-walk past). + if (!onAxis) { + ai.aimTicks = 0; + ai.kickHoldTicks = 0; + // Hold the behind-X while bleeding off |y|; forward-only so we don't reverse through the ball. + const slide = { + x: ball.x - Math.cos(shotDir) * TUNE.APPROACH_OFFSET, + y: sy * 0.55, + }; + const toSlide = angleTo(sx, sy, slide.x, slide.y); + return limitCommand( + moveToward(yaw, toSlide, TUNE.CHASE_SPEED), + turnToward(yaw, toSlide), + ); + } + const soft = stuckAtFeet || ai.aimTicks >= TUNE.AIM_SOFT_TICKS; + const alignLim = soft ? TUNE.SHOOT_ANGLE * 1.85 : TUNE.SHOOT_ANGLE; + const aligned = alignErr <= alignLim; + if (aligned && ballAhead) { ai.aimTicks = 0; - // Cooldown active → stop kicking and shuffle laterally to re-approach from - // a better angle. This is what breaks the symmetric mid-circle deadlock. if (ai.kickCooldown > 0) { ai.kickHoldTicks = 0; const lateralWz = (sy > ball.y) ? -TUNE.WZ_MAX * 0.5 : TUNE.WZ_MAX * 0.5; - return limitCommand(TUNE.AIM_SPEED * 0.5, lateralWz, false); + return limitCommand(TUNE.AIM_CREEP * 0.6, lateralWz, false); } - // Count consecutive at-feet kick attempts. A single clean strike (ball - // flies off → CHASE resets the counter) is never penalised; only a - // sustained stuck-at-the-ball situation arms the cooldown after this kick. ai.kickHoldTicks++; if (ai.kickHoldTicks >= TUNE.KICK_HOLD_BEFORE_COOLDOWN) { ai.kickHoldTicks = 0; ai.kickCooldown = TUNE.KICK_COOLDOWN_TICKS; } - return limitCommand(TUNE.SHOOT_SPEED, turnToward(yaw, toGoal), true); + return limitCommand(TUNE.SHOOT_SPEED, turnToward(yaw, shotDir), true); } - // 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. + // AIM — creep along shotDir while turning; scale down when badly misaligned. ai.kickHoldTicks = 0; ai.aimTicks++; if (ai.aimTicks > TUNE.AIM_MAX_TICKS) { - // Fuse blown: re-approach via shot-axis stand point ai.aimTicks = 0; - const ap = chaseApproachPoint(ball, targetGoalX, sx, sy); + ai.atFeetTicks = 0; const toAp = angleTo(sx, sy, ap.x, ap.y); return limitCommand( moveToward(yaw, toAp, TUNE.CHASE_SPEED), - turnToward(yaw, toAp), + turnToward(yaw, shotDir), ); } - const pokeKick = ai.aimTicks >= 20; + const alignFactor = clamp(1 - alignErr / (Math.PI * 0.55), 0.35, 1); return limitCommand( - moveToward(yaw, toGoal, TUNE.AIM_SPEED), - turnToward(yaw, toGoal), - pokeKick, + creepToward(yaw, shotDir, TUNE.AIM_CREEP * alignFactor), + turnToward(yaw, shotDir), + false, ); } - // CHASE: MoveToStaticBall / intercept — go to (ball − shotDir × r), not ball centre. + // CHASE: walk to approach; spiral if the straight line skims the ball. ai.aimTicks = 0; ai.kickHoldTicks = 0; - 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); + const apSafe = avoidWalkPoint(ap.x, ap.y, duck, ctx); + let dest = apSafe; + if (pathSkimsBall(sx, sy, apSafe.x, apSafe.y, ball)) { + ai.orbitTicks++; + const blend = ai.orbitTicks >= TUNE.ORBIT_COMMIT_TICKS + ? 0.7 + : clamp(ai.orbitTicks / TUNE.ORBIT_COMMIT_TICKS, 0, 0.5); + dest = lateralOrbitPoint(sx, sy, ball, apSafe.x, apSafe.y, blend); + } else { + ai.orbitTicks = 0; + } + const toDest = angleTo(sx, sy, dest.x, dest.y); + const arrivedSafe = distanceTo(sx, sy, apSafe.x, apSafe.y) <= TUNE.APPROACH_ARRIVE_EPS; + if (arrived || arrivedSafe) { + return limitCommand( + moveToward(yaw, toBall, TUNE.CHASE_SPEED), + turnToward(yaw, shotDir), + ); + } + // Face a blend of walk heading and shot axis so we arrive already half-aimed. + const faceAng = normalizeAngle(toDest + 0.35 * angleDiff(toDest, shotDir)); return limitCommand( - moveToward(yaw, aimAng, TUNE.CHASE_SPEED), - turnToward(yaw, aimAng), + moveToward(yaw, toDest, TUNE.CHASE_SPEED), + turnToward(yaw, faceAng), ); } /** * 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. + * Own half → screen between ball and own goal (do NOT pile onto the ball). + * Attack half → support lane; high press soft-contests outside BALL_KEEP_OUT. */ function formationDecide(duck, ctx) { const [sx, sy] = duckXY(duck); @@ -440,48 +831,70 @@ function formationDecide(duck, ctx) { const { ball, attackDir, defendGoalX, targetGoalX, allDucks, team } = ctx; 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; + const bd = distToBall(duck, ball); + const faceUpfield = attackDir > 0 ? 0 : Math.PI; - // Defender-specific: GUARD / INTERCEPT / CLEAR / SUPPORT + // Defender-specific: GUARD / CLEAR / SUPPORT (no all-hands INTERCEPT pile) if (duck.role === 'defender') { return defenderFormation(duck, ctx, sx, sy, yaw, ball, attackDir, defendGoalX, targetGoalX, allDucks, team); } - const bd = distToBall(duck, ball); + // Hard keep-out: non-chasers must back off so the chaser can get behind the ball. + if (bd < TUNE.BALL_KEEP_OUT) { + const away = angleTo(ball.x, ball.y, sx, sy); + return limitCommand( + moveToward(yaw, away, TUNE.RETURN_SPEED), + turnToward(yaw, faceUpfield), + ); + } + + const ballPinnedCorner = Math.abs(ball.x) > FIELD_HALF_L - 1.0 + && Math.abs(ball.y) > FIELD_HALF_W - 1.0; - // High press: second player soft-contests loose ball (does not shoot — chaser owns kick). + // High press: soft second contest — stay outside keep-out, never at feet. if ( !ballInOwnHalf && + !ballPinnedCorner && TUNE.SECOND_PRESS_DIST > 0 && bd < TUNE.SECOND_PRESS_DIST && - bd > TUNE.SHOOT_DIST + bd > TUNE.BALL_KEEP_OUT ) { const toB = angleToBall(duck, ball); return limitCommand( - moveToward(yaw, toB, TUNE.CHASE_SPEED * 0.85), + moveToward(yaw, toB, TUNE.CHASE_SPEED * 0.7), turnToward(yaw, toB), ); } let slotX, slotY; if (ballInOwnHalf) { - // 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; + const cover = defensiveCoverSlot(duck, ctx); + slotX = cover.x; + slotY = cover.y; } else { - // 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); } + // Coach line regimes — hard geometry, not just soft advance numbers. + if (TUNE.LINE_HOLD_MID) { + // Own half only (just shy of mid so the shape reads as "parked"). + if (slotX * attackDir > -0.2) slotX = -0.25 * attackDir; + } else if (TUNE.LINE_PUSH_MID && !ballInOwnHalf) { + if (slotX * attackDir < 0.55) slotX = 0.55 * attackDir; + } + slotX = clamp(slotX, -FIELD_HALF_L + 0.3, FIELD_HALF_L - 0.3); + + const slotSafe = avoidWalkPoint(slotX, slotY, duck, ctx); + slotX = slotSafe.x; + slotY = slotSafe.y; + const atSlot = distanceTo(sx, sy, slotX, slotY) < TUNE.FORMATION_SLOT_EPS; if (atSlot) { - return limitCommand(0, turnToward(yaw, attackDir > 0 ? 0 : Math.PI)); + return limitCommand(0, turnToward(yaw, faceUpfield)); } const toSlot = angleTo(sx, sy, slotX, slotY); return limitCommand( @@ -490,15 +903,38 @@ function formationDecide(duck, ctx) { ); } -/** Defender-specific formation logic: GUARD / INTERCEPT / CLEAR / SUPPORT */ +/** Defender-specific formation logic: GUARD / CLEAR / SUPPORT */ function defenderFormation(duck, ctx, sx, sy, yaw, ball, attackDir, defendGoalX, targetGoalX, allDucks, team) { const bd = distToBall(duck, ball); const ballInOwnHalf = ball.x * attackDir < 0; + const faceUpfield = attackDir > 0 ? 0 : Math.PI; + + // Keep-out: non-chaser defenders never stand on the ball. + if (bd < TUNE.BALL_KEEP_OUT) { + const away = angleTo(ball.x, ball.y, sx, sy); + return limitCommand( + moveToward(yaw, away, TUNE.DEF_CHASE_SPEED), + turnToward(yaw, faceUpfield, TUNE.DEF_TURN_GAIN), + ); + } - // CLEAR: ball at feet in own half → hoof it + // CLEAR only if somehow at feet (should be rare for non-chaser) — still require behind. if (bd <= TUNE.DEF_CLEAR_DIST && ballInOwnHalf) { - const toGoal = angleTo(sx, sy, targetGoalX, 0); - return limitCommand(TUNE.DEF_CHASE_SPEED * 0.8, turnToward(yaw, toGoal, TUNE.DEF_TURN_GAIN), true); + const shotDir = angleTo(ball.x, ball.y, targetGoalX, 0); + if (isBehindBall(sx, sy, ball, targetGoalX) + && Math.abs(angleDiff(yaw, shotDir)) <= TUNE.SHOOT_ANGLE * 1.4) { + return limitCommand(TUNE.DEF_CHASE_SPEED * 0.8, turnToward(yaw, shotDir, TUNE.DEF_TURN_GAIN), true); + } + const ap = chaseApproachPoint(ball, targetGoalX, sx, sy); + const wp = pathSkimsBall(sx, sy, ap.x, ap.y, ball) + ? lateralOrbitPoint(sx, sy, ball, ap.x, ap.y) + : ap; + const toWp = angleTo(sx, sy, wp.x, wp.y); + return limitCommand( + moveToward(yaw, toWp, TUNE.DEF_CHASE_SPEED), + turnToward(yaw, toWp, TUNE.DEF_TURN_GAIN), + false, + ); } // SUPPORT: teammate on ball up-field @@ -510,30 +946,38 @@ function defenderFormation(duck, ctx, sx, sy, yaw, ball, attackDir, defendGoalX, }); if (teammateOnBall) { const supX = attackDir * FIELD_HALF_L * TUNE.SUPPORT_X_FRAC; - const toSup = angleTo(sx, sy, supX, ball.y); + const safe = avoidWalkPoint(supX, ball.y, duck, ctx); + const toSup = angleTo(sx, sy, safe.x, safe.y); return limitCommand( moveToward(yaw, toSup, TUNE.DEF_CHASE_SPEED), turnToward(yaw, toSup, TUNE.DEF_TURN_GAIN), ); } - // INTERCEPT: ball loose in own half + // Own half: screen between ball and goal — do NOT rush the ball (chaser owns it). if (ballInOwnHalf) { - const toB = angleToBall(duck, ball); + const cover = defensiveCoverSlot(duck, ctx); + const safe = avoidWalkPoint(cover.x, cover.y, duck, ctx); + const atCover = distanceTo(sx, sy, safe.x, safe.y) < 0.25; + if (atCover) { + return limitCommand(0, turnToward(yaw, faceUpfield, TUNE.DEF_TURN_GAIN)); + } + const toCover = angleTo(sx, sy, safe.x, safe.y); return limitCommand( - moveToward(yaw, toB, TUNE.DEF_CHASE_SPEED), - turnToward(yaw, toB, TUNE.DEF_TURN_GAIN), + moveToward(yaw, toCover, TUNE.DEF_CHASE_SPEED), + turnToward(yaw, toCover, TUNE.DEF_TURN_GAIN), ); } // GUARD: hold the guard line const guardY = clamp(ball.y * TUNE.GUARD_Y_TRACK, -TUNE.GUARD_Y_MAX, TUNE.GUARD_Y_MAX); const guardX = defendGoalX * TUNE.GUARD_X_FRAC; - const atGuard = distanceTo(sx, sy, guardX, guardY) < 0.25; + const guardSafe = avoidWalkPoint(guardX, guardY, duck, ctx); + const atGuard = distanceTo(sx, sy, guardSafe.x, guardSafe.y) < 0.25; if (atGuard) { - return limitCommand(0, turnToward(yaw, attackDir > 0 ? 0 : Math.PI, TUNE.DEF_TURN_GAIN)); + return limitCommand(0, turnToward(yaw, faceUpfield, TUNE.DEF_TURN_GAIN)); } - const toGuard = angleTo(sx, sy, guardX, guardY); + const toGuard = angleTo(sx, sy, guardSafe.x, guardSafe.y); return limitCommand( moveToward(yaw, toGuard, TUNE.DEF_CHASE_SPEED), turnToward(yaw, toGuard, TUNE.DEF_TURN_GAIN), diff --git a/app/src/game/football/strategy.js b/app/src/game/football/strategy.js index e47e5f3..3e457fd 100644 --- a/app/src/game/football/strategy.js +++ b/app/src/game/football/strategy.js @@ -1,27 +1,100 @@ -// 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. +// Coach Strategy Card (v2) — continuous 0..1 energy knobs → TUNE overlay. +// Same JSON is the handshake payload for NL / blocks / PvP lobby. +// Legacy discrete levels (push/low/…) are migrated on normalize. -import { SPAWN_POSITIONS } from './constants.js'; +import { SPAWN_POSITIONS, FIELD_HALF_L } from './constants.js'; + +export const STRATEGY_VERSION = 2; export const FORMATION_IDS = ['2f1gk', '1f1d1gk']; export const STYLE_IDS = ['attack', 'balanced', 'defend']; +/** @deprecated discrete press ids — kept for tag buckets / old saves */ export const PRESS_IDS = ['low', 'medium', 'high']; +export const KNOB_IDS = [ + 'lineHeight', + 'press', + 'shootGreed', + 'supportWidth', + 'supportDepth', + 'approach', + 'clearStyle', + 'gkRush', + 'spacing', +]; + +/** Always-on energy bars (biggest visual regimes). */ +export const PRIMARY_KNOB_IDS = ['lineHeight', 'press', 'shootGreed', 'gkRush']; + +/** Legacy discrete → 0..1 */ +const LEGACY_LEVEL_VALUE = Object.freeze({ + sit: 0, push: 1, + low: 0, high: 1, + patient: 0, greedy: 1, + narrow: 0, wide: 1, + near: 0, far: 1, + tight: 0, // approach: 0=tight rush, 1=patient stand-behind + hold: 0, boot: 1, + home: 0, sweeper: 1, + pack: 0, spread: 1, + balanced: 0.5, medium: 0.5, normal: 0.5, +}); + +/** Balanced mid-rail defaults. */ +export const DEFAULT_KNOBS = Object.freeze({ + lineHeight: 0.5, + press: 0.5, + shootGreed: 0.5, + supportWidth: 0.5, + supportDepth: 0.5, + approach: 0.5, + clearStyle: 0.5, + gkRush: 0.5, + spacing: 0.5, +}); + +/** Style presets — extreme enough to read in ~30s of play. */ +export const STYLE_PRESET_KNOBS = Object.freeze({ + attack: Object.freeze({ + lineHeight: 0.92, + press: 0.82, + shootGreed: 0.88, + supportWidth: 0.35, + supportDepth: 0.85, + approach: 0.35, + clearStyle: 0.55, + gkRush: 0.45, + spacing: 0.4, + }), + balanced: Object.freeze({ ...DEFAULT_KNOBS }), + defend: Object.freeze({ + lineHeight: 0.12, + press: 0.1, + shootGreed: 0.18, + supportWidth: 0.8, + supportDepth: 0.2, + approach: 0.75, + clearStyle: 0.25, + gkRush: 0.85, + spacing: 0.75, + }), +}); + export const DEFAULT_STRATEGY = Object.freeze({ + version: STRATEGY_VERSION, formation: '2f1gk', style: 'balanced', press: 'medium', + knobs: { ...DEFAULT_KNOBS }, }); -/** 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 }, + { team: 'red', role: 'forward', x: -0.55, y: 0.55, yaw: 0 }, + { team: 'red', role: 'defender', x: -2.0, y: -0.35, yaw: 0 }, + { team: 'red', role: 'goalkeeper', x: -2.85, y: 0.0, yaw: 0 }, + { team: 'blue', role: 'forward', x: 0.55, y: 0.55, yaw: Math.PI }, + { team: 'blue', role: 'defender', x: 2.0, y: -0.35, yaw: Math.PI }, + { team: 'blue', role: 'goalkeeper', x: 2.85, y: 0.0, yaw: Math.PI }, ]; export const FORMATION_SPAWNS = Object.freeze({ @@ -29,108 +102,402 @@ export const FORMATION_SPAWNS = Object.freeze({ '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, - }, -}; +function clamp01(v) { + const n = Number(v); + if (!Number.isFinite(n)) return 0.5; + return Math.max(0, Math.min(1, n)); +} -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, - }, -}; +function lerp(a, b, t) { + return a + (b - a) * clamp01(t); +} + +/** Coerce legacy string levels or numbers into 0..1. */ +export function coerceKnobValue(raw) { + if (typeof raw === 'string' && Object.prototype.hasOwnProperty.call(LEGACY_LEVEL_VALUE, raw)) { + return LEGACY_LEVEL_VALUE[raw]; + } + return clamp01(raw); +} + +export function sanitizeKnobs(raw = {}) { + const out = { ...DEFAULT_KNOBS }; + for (const id of KNOB_IDS) { + if (raw[id] != null) out[id] = coerceKnobValue(raw[id]); + } + return out; +} + +function knobsEqual(a, b, eps = 0.04) { + for (const id of KNOB_IDS) { + if (Math.abs(a[id] - b[id]) > eps) return false; + } + return true; +} + +export function pressBucket(v) { + const t = coerceKnobValue(v); + if (t < 0.34) return 'low'; + if (t < 0.67) return 'medium'; + return 'high'; +} +/** Return style id if knobs near a preset; else null. */ +export function matchStylePreset(knobs) { + const k = sanitizeKnobs(knobs); + for (const style of STYLE_IDS) { + if (knobsEqual(k, STYLE_PRESET_KNOBS[style])) return style; + } + return null; +} + +/** + * Normalize any partial / legacy card into a v2 Strategy Card (0..1 knobs). + */ 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 }; + + const hasKnobs = partial.knobs && typeof partial.knobs === 'object'; + const styleIn = partial.style === 'custom' + ? 'custom' + : (STYLE_IDS.includes(partial.style) ? partial.style : null); + + let knobs; + let style; + + if (hasKnobs) { + knobs = sanitizeKnobs({ ...DEFAULT_KNOBS, ...partial.knobs }); + if (PRESS_IDS.includes(partial.press) && partial.knobs.press == null) { + knobs.press = coerceKnobValue(partial.press); + } + const matched = matchStylePreset(knobs); + if (styleIn === 'custom') style = matched || 'custom'; + else if (styleIn && knobsEqual(knobs, STYLE_PRESET_KNOBS[styleIn])) style = styleIn; + else style = matched || 'custom'; + } else if (styleIn && styleIn !== 'custom') { + knobs = sanitizeKnobs({ ...STYLE_PRESET_KNOBS[styleIn] }); + if (PRESS_IDS.includes(partial.press)) knobs.press = coerceKnobValue(partial.press); + style = matchStylePreset(knobs) || styleIn; + } else { + knobs = sanitizeKnobs({ ...DEFAULT_KNOBS }); + if (PRESS_IDS.includes(partial.press)) { + knobs.press = coerceKnobValue(partial.press); + style = matchStylePreset(knobs) || 'custom'; + } else { + style = 'balanced'; + } + } + + return { + version: STRATEGY_VERSION, + formation, + style, + press: pressBucket(knobs.press), + knobs, + }; } -/** Merge style + press overlays into a flat TUNE patch (later keys win). */ -export function getTuneOverlay(strategy) { - const s = normalizeStrategy(strategy); +export function strategyFromStylePreset(style, formation) { + const s = STYLE_IDS.includes(style) ? style : 'balanced'; + return normalizeStrategy({ + formation: formation || DEFAULT_STRATEGY.formation, + style: s, + knobs: { ...STYLE_PRESET_KNOBS[s] }, + }); +} + +export function mergeStrategy(prev, partial = {}) { + const base = normalizeStrategy(prev); + + if (partial.style && STYLE_IDS.includes(partial.style)) { + const presetKnobs = { ...STYLE_PRESET_KNOBS[partial.style] }; + if (partial.knobs == null || knobsEqual(sanitizeKnobs(partial.knobs), presetKnobs)) { + let card = strategyFromStylePreset(partial.style, partial.formation ?? base.formation); + if (partial.press != null && coerceKnobValue(partial.press) !== card.knobs.press) { + card = normalizeStrategy({ + ...card, + knobs: { ...card.knobs, press: coerceKnobValue(partial.press) }, + }); + } + return card; + } + } + + const nextKnobs = partial.knobs + ? sanitizeKnobs({ ...base.knobs, ...partial.knobs }) + : { ...base.knobs }; + if (partial.press != null && (!partial.knobs || partial.knobs.press == null)) { + nextKnobs.press = coerceKnobValue(partial.press); + } + const forceCustom = partial.style === 'custom' || !!partial.knobs; + return normalizeStrategy({ + formation: partial.formation ?? base.formation, + style: forceCustom ? 'custom' : (partial.style ?? base.style), + knobs: nextKnobs, + }); +} + +/** + * Compile continuous knobs → TUNE patch. + * Ranges are intentionally wide so attack vs defend is readable in-game. + * LINE_HOLD_MID / LINE_PUSH_MID are hard regime flags for decideAll. + */ +export function knobsToOverlay(knobsIn) { + const k = sanitizeKnobs(knobsIn); + const lh = k.lineHeight; + const pr = k.press; + const sg = k.shootGreed; return { - ...STYLE_OVERLAYS[s.style], - ...PRESS_OVERLAYS[s.press], + // Line / shape — huge geometric swing + FORMATION_X_ADVANCE: lerp(-0.2, 1.15, lh), + FORMATION_X_RETREAT: lerp(-1.2, 0.35, lh), + GUARD_X_FRAC: lerp(0.82, 0.36, lh), + SUPPORT_X_FRAC: lerp(0.18, 0.72, lh), + LINE_HOLD_MID: lh < 0.35 ? 1 : 0, + LINE_PUSH_MID: lh > 0.65 ? 1 : 0, + + // Press — 0 = only chaser; 1 = second man hunts from ~2.4 m + SECOND_PRESS_DIST: lerp(0, 2.45, pr), + CHASE_HYSTERESIS: lerp(0.42, 0.1, pr), + FACE_COST_WEIGHT: lerp(0.45, 0.2, pr), + CHASE_SPEED: lerp(0.2, 0.25, pr), + DEF_CHASE_SPEED: lerp(0.2, 0.25, pr), + + // Shoot greed + SHOOT_ANGLE: lerp(0.12, 0.58, sg), + SHOOT_DIST: lerp(0.28, 0.58, sg), + + SUPPORT_LATERAL: lerp(0.3, 1.3, k.supportWidth), + SUPPORT_AHEAD: lerp(0.1, 1.2, k.supportDepth), + APPROACH_OFFSET: lerp(0.16, 0.78, k.approach), + DEF_CLEAR_DIST: lerp(0.24, 0.55, k.clearStyle), + TEAMMATE_BALL_DIST: lerp(0.35, 0.9, k.clearStyle), + GK_DIVE_DIST: lerp(0.85, 2.35, k.gkRush), + AVOID_RADIUS: lerp(0.28, 0.95, k.spacing), + AVOID_STRENGTH: lerp(0.18, 0.7, k.spacing), }; } +export function getTuneOverlay(strategy) { + return knobsToOverlay(normalizeStrategy(strategy).knobs); +} + /** - * 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. + * Spawn table + lineHeight bias so kickoff posture already looks different. */ 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; + const lh = strat.knobs.lineHeight; + const push = (lh - 0.5) * 1.1; // ±0.55 m + const sign = slot.team === 'red' ? 1 : -1; + let bias = push * sign; + if (slot.role === 'goalkeeper') bias *= 0.12; + else if (slot.role === 'defender') bias *= 0.55; + const lim = FIELD_HALF_L - 0.35; + const x = Math.max(-lim, Math.min(lim, slot.x + bias)); return { team: slot.team, role: slot.role, - x: slot.x, + x, y: slot.y, yaw: slot.yaw, }; }); } -export function strategiesForUser(userTeam, userStrategy) { +export function strategiesForUser(userTeam, userStrategy, opponentStrategy) { const mine = normalizeStrategy(userStrategy); + const theirs = normalizeStrategy(opponentStrategy ?? DEFAULT_STRATEGY); + return { + red: userTeam === 'red' ? mine : theirs, + blue: userTeam === 'blue' ? mine : theirs, + }; +} + +export function buildStrategyByTeam(strategyByTeam = {}) { return { - red: userTeam === 'red' ? mine : { ...DEFAULT_STRATEGY }, - blue: userTeam === 'blue' ? mine : { ...DEFAULT_STRATEGY }, + red: normalizeStrategy(strategyByTeam.red || DEFAULT_STRATEGY), + blue: normalizeStrategy(strategyByTeam.blue || DEFAULT_STRATEGY), }; } + +export function strategyFingerprint(strategy) { + const s = normalizeStrategy(strategy); + const raw = `${s.formation}|${KNOB_IDS.map((id) => s.knobs[id].toFixed(2)).join(',')}`; + let h = 2166136261; + for (let i = 0; i < raw.length; i++) { + h ^= raw.charCodeAt(i); + h = Math.imul(h, 16777619); + } + return (h >>> 0).toString(16).padStart(8, '0'); +} + +const IMPACT_COPY = { + en: { + lineHeight: '0 sit deep · 1 push high (hard midline lock)', + press: '0 only chaser · 1 second man hunts hard', + shootGreed: '0 wait to aim · 1 blast early', + supportWidth: 'Support lane width', + supportDepth: 'How far past the ball the 2nd man sits', + approach: '0 rush the ball · 1 stand further behind', + clearStyle: '0 hold · 1 boot clearances', + gkRush: '0 stay home · 1 sweeper keeper', + spacing: 'Teammate spacing', + }, + zh: { + lineHeight: '0 回收死守 · 1 全线压上(会锁中线)', + press: '0 只一人追 · 1 第二人死命逼抢', + shootGreed: '0 对准再射 · 1 见空档就踢', + supportWidth: '支援横向拉开', + supportDepth: '第二人前插深度', + approach: '0 贴球冲 · 1 绕到球后更远', + clearStyle: '0 控住 · 1 直接解围', + gkRush: '0 门将死守 · 1 出门清道夫', + spacing: '队友站位间距', + }, +}; + +const POLE_LABELS = { + en: { + lineHeight: ['Sit', 'Push'], + press: ['Park', 'Hunt'], + shootGreed: ['Patient', 'Greedy'], + supportWidth: ['Narrow', 'Wide'], + supportDepth: ['Near', 'Far'], + approach: ['Tight', 'Patient'], + clearStyle: ['Hold', 'Boot'], + gkRush: ['Home', 'Sweep'], + spacing: ['Pack', 'Spread'], + }, + zh: { + lineHeight: ['回收', '压上'], + press: ['摆烂', '逼抢'], + shootGreed: ['稳射', '抢射'], + supportWidth: ['收窄', '拉开'], + supportDepth: ['贴球', '前插'], + approach: ['贴身', '绕后'], + clearStyle: ['控住', '解围'], + gkRush: ['死守', '清道夫'], + spacing: ['紧凑', '疏开'], + }, +}; + +export function knobImpact(locale, knobId) { + const pack = IMPACT_COPY[locale] || IMPACT_COPY.en; + return pack[knobId] || IMPACT_COPY.en[knobId] || knobId; +} + +export function knobPoles(locale, knobId) { + const pack = POLE_LABELS[locale] || POLE_LABELS.en; + return pack[knobId] || POLE_LABELS.en[knobId] || ['0', '1']; +} + +/** @deprecated discrete labels — maps 0..1 to a short pole word */ +export function knobLevelLabel(locale, level) { + if (typeof level === 'number' || (typeof level === 'string' && /^-?\d/.test(level))) { + const v = coerceKnobValue(level); + return `${Math.round(v * 100)}%`; + } + return String(level); +} + +export function describeStrategy(strategy, locale = 'en') { + const s = normalizeStrategy(strategy); + const diffs = []; + const highlights = []; + for (const id of PRIMARY_KNOB_IDS) { + if (Math.abs(s.knobs[id] - 0.5) < 0.08) continue; + const [lo, hi] = knobPoles(locale, id); + const label = s.knobs[id] >= 0.5 ? hi : lo; + highlights.push(label); + diffs.push(`${id}:${s.knobs[id].toFixed(2)}`); + } + return { + formation: s.formation, + style: s.style, + press: s.press, + knobs: s.knobs, + fingerprint: strategyFingerprint(s), + highlights, + summary: highlights.length ? highlights.join(' · ') : (locale === 'zh' ? '均衡' : 'Balanced'), + impacts: Object.fromEntries(KNOB_IDS.map((id) => [id, knobImpact(locale, id)])), + diffs, + }; +} + +export function parseStrategyHints(text) { + const raw = String(text || ''); + if (!raw.trim()) return { knobs: {} }; + const t = raw.toLowerCase(); + const knobs = {}; + + if (/高位逼抢|高位压迫|全力逼抢|press\s*high|high\s*press|gegenpress/.test(t) || + (/逼抢|压迫|press/.test(t) && /高|high|强/.test(t))) { + knobs.press = 0.9; + knobs.lineHeight = 0.9; + } else if (/低位|摆大巴|park\s*the\s*bus|low\s*press|press\s*low/.test(t) || + (/逼抢|压迫|press/.test(t) && /低|low|弱/.test(t))) { + knobs.press = 0.1; + knobs.lineHeight = 0.1; + } + + if (/压上|压着打|push\s*up|high\s*line/.test(t)) knobs.lineHeight = 0.9; + if (/回收|缩着|坐深|sit\s*deep|low\s*block/.test(t)) knobs.lineHeight = 0.1; + + if (/别乱射|稳一点.*射|对准再射|patient\s*shoot|don't\s*blast|no\s*rush\s*shot/.test(t) || + /稳射|耐心射/.test(t)) { + knobs.shootGreed = 0.15; + } else if (/抢射|见缝就射|greedy\s*shoot|shoot\s*early|blast/.test(t)) { + knobs.shootGreed = 0.9; + } + + if (/门将别|别瞎出门|门将死守|keeper\s*home|gk\s*home|stay\s*home/.test(t)) { + knobs.gkRush = 0.1; + } else if (/清道夫|门将出击|sweeper\s*keeper|gk\s*rush/.test(t)) { + knobs.gkRush = 0.9; + } + + if (/解围|清出去|boot\s*it|clear\s*it/.test(t)) knobs.clearStyle = 0.9; + if (/别解围|控住再出|hold\s*possession/.test(t)) knobs.clearStyle = 0.15; + + if (/绕到球后|站稳再踢|patient\s*approach|stand\s*behind/.test(t)) { + knobs.approach = 0.85; + } else if (/贴着球|直接冲|tight\s*approach|rush\s*the\s*ball/.test(t)) { + knobs.approach = 0.15; + } + + if (/拉开|站开|wide\s*support|spread\s*out/.test(t)) { + knobs.supportWidth = 0.85; + knobs.spacing = 0.85; + } else if (/挤一起|收紧|narrow\s*support|pack\s*tight/.test(t)) { + knobs.supportWidth = 0.15; + knobs.spacing = 0.15; + } + + if (/前插|插上|support\s*far|get\s*ahead/.test(t)) knobs.supportDepth = 0.9; + if (/贴着支援|support\s*near/.test(t)) knobs.supportDepth = 0.15; + + return { knobs }; +} + +export function knobsDiff(before, patchKnobs = {}) { + const a = sanitizeKnobs(before); + const entries = []; + for (const id of KNOB_IDS) { + if (patchKnobs[id] == null) continue; + const to = coerceKnobValue(patchKnobs[id]); + if (Math.abs(to - a[id]) > 0.02) { + entries.push({ id, from: a[id], to }); + } + } + return entries; +} + +/** @deprecated kept so old imports don't crash */ +export const KNOB_LEVELS = null; +export const KNOB_OVERLAYS = null; diff --git a/app/src/game/game.js b/app/src/game/game.js index 3aa3f97..9ebce57 100644 --- a/app/src/game/game.js +++ b/app/src/game/game.js @@ -68,7 +68,9 @@ import { buildSpawnTable, getTuneOverlay, normalizeStrategy, + mergeStrategy, strategiesForUser, + strategyFingerprint, } from "./football/strategy.js"; import { buildTacticsBoard, createMatchStats } from "./football/tactics-board.js"; // Multi-duck match plumbing: SANDBOX_CONFIG is the default matchConfig that @@ -207,12 +209,13 @@ 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 + // Coach tactics: mutable spawn table + per-team strategy cards. Knobs 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, + store().opponentStrategy || DEFAULT_STRATEGY, ); let formationLocked = false; @@ -1226,6 +1229,9 @@ async function boot({ scene, camera, renderer, matchConfig: cfg }) { buf[i++] = kicking ? 0 : cs[0]; // vx buf[i++] = 0; // no strafe: AI writes vx and wz only buf[i++] = kicking ? 0 : cs[2]; // wz + // Head slots stay zero for football: feeding gaze into the walk ONNX + // (large neck/pitch near the ball) topples chasers, then the pile-up + // knocks the rest. FPV is a separate eye-cam (heading-led, light ball bias). for (let k = 3; k < CMD_SIZE; k++) buf[i++] = 0; return buf; } @@ -1436,23 +1442,28 @@ async function boot({ scene, camera, renderer, matchConfig: cfg }) { outVx = outVx === 0 ? IDLE_CREEP : outVx; duck.cmd[0] = outVx; duck.cmd[2] = cmdWz; + // Keep head command slots zero — do not drive walk-policy gaze. + duck.cmd[3] = 0; duck.cmd[4] = 0; duck.cmd[5] = 0; duck.cmd[6] = 0; if (c.kick && !duck.kickRun && duck.mode === "walk" && duck.postKickLock === 0) { // Alternate feet so consecutive kicks don't favour one leg. 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, - }); + // Kick / ball / duck-bump thumps silenced — commentary danmaku cues + // carry the match energy instead (see playDanmakuCue). haptics.pulse("kick"); // Only goal-aimed strikes (at feet + facing opp goal + attacking half). - matchStats?.tryNoteShot(duck.team, gs.ball, { + if (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, - }); + })) { + // Soft event for danmaku commentary (not a referee FSM transition). + setStore({ + matchEvents: [...useGame.getState().matchEvents.slice(-4), + { type: "shot", team: duck.team, time: referee?.getMatchTime() ?? 0 }], + }); + } } } } @@ -1653,23 +1664,40 @@ async function boot({ scene, camera, renderer, matchConfig: cfg }) { } /** - * Coach panel → game. Style/press take effect next AI tick. + * Coach panel → game. Knobs take effect next AI tick. * Formation changes only apply when not locked (pre-kickoff / fresh match). + * opts.team selects which side's card to patch (user or rival). */ function setTeamStrategy(partial = {}, opts = {}) { if (!isFootball) return store().userStrategy; + const userTeam = store().userTeam || "red"; + const team = opts.team === "red" || opts.team === "blue" ? opts.team : userTeam; const allowFormation = !formationLocked || opts.forceFormation; - const prev = normalizeStrategy(store().userStrategy || DEFAULT_STRATEGY); - const next = normalizeStrategy({ - ...prev, + const prev = normalizeStrategy(strategyByTeam[team] || DEFAULT_STRATEGY); + const merged = mergeStrategy(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); + const next = normalizeStrategy(merged); + strategyByTeam = { ...strategyByTeam, [team]: next }; + + const rivalTeam = userTeam === "red" ? "blue" : "red"; + if (team === userTeam) { + setStore({ + userTeam, + userStrategy: next, + opponentStrategy: normalizeStrategy(strategyByTeam[rivalTeam]), + }); + } else { + setStore({ + userTeam, + opponentStrategy: next, + userStrategy: normalizeStrategy(strategyByTeam[userTeam]), + }); + } + if (allowFormation && next.formation !== prev.formation) { applyFormationFromStrategies(); } @@ -1678,9 +1706,17 @@ async function boot({ scene, camera, renderer, matchConfig: cfg }) { function setUserTeam(team) { if (team !== "red" && team !== "blue") return store().userTeam; - const strat = store().userStrategy || DEFAULT_STRATEGY; - setStore({ userTeam: team }); - strategyByTeam = strategiesForUser(team, strat); + const prevUser = store().userTeam || "red"; + if (team === prevUser) return team; + // Swap which card is "mine" vs rival so editing stays attached to sides. + const redCard = normalizeStrategy(strategyByTeam.red); + const blueCard = normalizeStrategy(strategyByTeam.blue); + setStore({ + userTeam: team, + userStrategy: team === "red" ? redCard : blueCard, + opponentStrategy: team === "red" ? blueCard : redCard, + }); + strategyByTeam = { red: redCard, blue: blueCard }; if (!formationLocked) applyFormationFromStrategies(); return team; } @@ -1788,7 +1824,11 @@ async function boot({ scene, camera, renderer, matchConfig: cfg }) { break; } case "extra_time": { - setStore({ matchState: "PLAYING" }); + setStore({ + matchState: "PLAYING", + matchEvents: [...useGame.getState().matchEvents.slice(-4), + { type: "extra_time", team: null, time: referee.getMatchTime() }], + }); break; } case "fulltime": { @@ -1809,6 +1849,8 @@ async function boot({ scene, camera, renderer, matchConfig: cfg }) { matchResult: result, tacticsBoard: card, tacticsCard: card, + matchEvents: [...useGame.getState().matchEvents.slice(-4), + { type: "fulltime", team: result === "draw" ? null : result, time: referee.getMatchTime() }], }); celebration?.cancel(); break; @@ -1893,6 +1935,8 @@ async function boot({ scene, camera, renderer, matchConfig: cfg }) { const cs = duck.cmdSm, c = duck.cmd; cs[0] += (c[0] - cs[0]) * CMD_SMOOTH_ALPHA; cs[2] += (c[2] - cs[2]) * CMD_SMOOTH_ALPHA; + // Head slots unused in football — keep smoothed head at zero. + cs[3] = 0; cs[4] = 0; cs[5] = 0; cs[6] = 0; } const ctrl = data.ctrl; // ONNX inference (per-duck, sequential over the shared pool) timed on its @@ -1941,80 +1985,9 @@ async function boot({ scene, camera, renderer, matchConfig: cfg }) { } // ── 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 - + // Kick / ball / duck-bump thumps are off — match energy comes from + // danmaku commentary cues. Fall thuds still fire at recovery transitions. 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(); } @@ -3311,8 +3284,8 @@ async function boot({ scene, camera, renderer, matchConfig: cfg }) { 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 fpvCamRed = new THREE.PerspectiveCamera(78, FPV_RT_W / FPV_RT_H, 0.04, 24); + const fpvCamBlue = new THREE.PerspectiveCamera(78, FPV_RT_W / FPV_RT_H, 0.04, 24); const fpvRt = new THREE.WebGLRenderTarget(FPV_RT_W, FPV_RT_H, { type: THREE.UnsignedByteType, format: THREE.RGBAFormat, @@ -3323,9 +3296,10 @@ async function boot({ scene, camera, renderer, matchConfig: cfg }) { 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; + const _fpvBall = new THREE.Vector3(); + const FPV_LOOK_AHEAD = 1.6; + const FPV_LOOK_DOWN = 0.02; + const FPV_FWD_BIAS = 0.07; // sit in front of the skull so we don't clip self let fpvAcc = 0; const FPV_PERIOD = 1 / 18; // ~18 Hz is enough for PiP @@ -3363,20 +3337,47 @@ async function boot({ scene, camera, renderer, matchConfig: cfg }) { return ducks.find((d) => d.id === id) || null; } + /** + * True eye-cam from the chaser head: mostly follow body yaw, with a light + * ball bias. Heavy look-at-ball + FOV crush made both PiPs look like the + * same ground-stare "ball cam" (opponent cropped, own legs in frame). + */ 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 head = duck.rig.bodies.get("jaw_soft") + || duck.rig.bodies.get("trunk_base"); + if (!head) return false; + // Parents must be current — football sync writes local trunk poses only. + duck.rig.placer.updateWorldMatrix(true, true); + head.getWorldPosition(_fpvEye); const yaw = Number.isFinite(duck.yaw) ? duck.yaw : 0; - // MJCF forward (cos,sin,0) → three (cos, 0, -sin) + // Forward in three.js XZ (MJCF yaw → cos on X, −sin on Z). + const fx = Math.cos(yaw); + const fz = -Math.sin(yaw); + _fpvEye.x += fx * FPV_FWD_BIAS; + _fpvEye.y += 0.02; + _fpvEye.z += fz * FPV_FWD_BIAS; + // Heading look — keep the horizon / opponents in frame. _fpvLook.set( - _fpvEye.x + Math.cos(yaw) * FPV_LOOK_AHEAD, + _fpvEye.x + fx * FPV_LOOK_AHEAD, _fpvEye.y - FPV_LOOK_DOWN, - _fpvEye.z - Math.sin(yaw) * FPV_LOOK_AHEAD, + _fpvEye.z + fz * FPV_LOOK_AHEAD, ); + const q = data.qpos; + const bx = Number.isFinite(q[ballQposAdr]) ? q[ballQposAdr] : 0; + const by = Number.isFinite(q[ballQposAdr + 1]) ? q[ballQposAdr + 1] : 0; + const bz = Number.isFinite(q[ballQposAdr + 2]) ? q[ballQposAdr + 2] : 0.05; + // Aim slightly above the ball so look-at doesn't pitch into the turf. + _fpvBall.set(bx, Math.max(bz, 0.05) + 0.14, -by); + const dx = bx - (Number.isFinite(duck.pos[0]) ? duck.pos[0] : 0); + const dy = by - (Number.isFinite(duck.pos[1]) ? duck.pos[1] : 0); + const bd = Math.hypot(dx, dy); + const track = Math.max(0, Math.min(1, 1 - bd / 2.4)); + // Cap ball pull — heading stays dominant even at feet. + _fpvLook.lerp(_fpvBall, 0.1 + 0.32 * track); + cam.fov = 82 - 8 * track; + cam.near = 0.05; + cam.updateProjectionMatrix(); cam.position.copy(_fpvEye); cam.up.set(0, 1, 0); cam.lookAt(_fpvLook); @@ -3411,6 +3412,7 @@ async function boot({ scene, camera, renderer, matchConfig: cfg }) { } return; } + // Hide the whole chaser rig so the eye-cam never sees its own mesh. const hid = duck?.rig?.placer; const wasVis = hid ? hid.visible : true; if (hid) hid.visible = false; @@ -3418,12 +3420,15 @@ async function boot({ scene, camera, renderer, matchConfig: cfg }) { const prevColor = new THREE.Color(); renderer.getClearColor(prevColor); const prevAlpha = renderer.getClearAlpha(); + const prevAutoClear = renderer.autoClear; + renderer.autoClear = true; renderer.setClearColor(0x08080c, 1); renderer.setRenderTarget(fpvRt); renderer.clear(); renderer.render(scene, cam); renderer.setRenderTarget(prev); renderer.setClearColor(prevColor, prevAlpha); + renderer.autoClear = prevAutoClear; if (hid) hid.visible = wasVis; blitRtToCanvas(canvas); } @@ -3439,6 +3444,11 @@ async function boot({ scene, camera, renderer, matchConfig: cfg }) { const blue = fpvSlots.blue || document.querySelector("canvas[data-fpv='blue']"); if (!red && !blue) return; + // Never blit the same canvas twice if refs somehow alias. + if (red && blue && red === blue) { + renderOneFpv("red", fpvCamRed, red); + return; + } renderOneFpv("red", fpvCamRed, red); renderOneFpv("blue", fpvCamBlue, blue); } @@ -3486,7 +3496,12 @@ async function boot({ scene, camera, renderer, matchConfig: cfg }) { getStrategy: () => ({ userTeam: store().userTeam, userStrategy: store().userStrategy, + opponentStrategy: store().opponentStrategy, strategyByTeam: { ...strategyByTeam }, + fingerprints: { + red: strategyFingerprint(strategyByTeam.red), + blue: strategyFingerprint(strategyByTeam.blue), + }, formationLocked, activeSpawns: activeSpawns.map((s) => ({ ...s })), }), @@ -3602,10 +3617,11 @@ 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. + // Sync coach choices (both cards), then lock formation for the match. strategyByTeam = strategiesForUser( store().userTeam || "red", store().userStrategy || DEFAULT_STRATEGY, + store().opponentStrategy || DEFAULT_STRATEGY, ); applyFormationFromStrategies(); formationLocked = true; diff --git a/app/src/store.js b/app/src/store.js index 097d2fd..4e380fa 100644 --- a/app/src/store.js +++ b/app/src/store.js @@ -47,10 +47,47 @@ export const useGame = create( 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. + // Coach panel: user + rival strategy cards (v2 continuous knobs). userTeam: "red", // "red" | "blue" - userStrategy: { formation: "2f1gk", style: "balanced", press: "medium" }, + userStrategy: { + version: 2, + formation: "2f1gk", + style: "balanced", + press: "medium", + knobs: { + lineHeight: 0.5, + press: 0.5, + shootGreed: 0.5, + supportWidth: 0.5, + supportDepth: 0.5, + approach: 0.5, + clearStyle: 0.5, + gkRush: 0.5, + spacing: 0.5, + }, + }, + opponentStrategy: { + version: 2, + formation: "2f1gk", + style: "balanced", + press: "medium", + knobs: { + lineHeight: 0.5, + press: 0.5, + shootGreed: 0.5, + supportWidth: 0.5, + supportDepth: 0.5, + approach: 0.5, + clearStyle: 0.5, + gkRush: 0.5, + spacing: 0.5, + }, + }, locale: "en", // "en" | "zh" — set from detectLocale on football mount + // Commentary danmaku overlay (persisted via FootballHud toggle) + danmakuEnabled: (() => { + try { return localStorage.getItem("microduck-danmaku") !== "0"; } catch { return true; } + })(), // Live dual-team match report (4 Hz) — possession + shots + strategy tags tacticsBoard: null, // Frozen fulltime tactics card (same shape as tacticsBoard) diff --git a/app/test/ai.test.js b/app/test/ai.test.js index 2e58d0a..8bccd72 100644 --- a/app/test/ai.test.js +++ b/app/test/ai.test.js @@ -9,6 +9,7 @@ import { BaseAgent, ForwardAgent, DefenderAgent, GoalkeeperAgent, VX_MAX, VX_MIN, WZ_MAX, SHOOT_SPEED, SHOOT_DIST, createAgent, decideAll, assignChaserId, chaseCost, chaseApproachPoint, + isBehindBall, lateralOrbitPoint, repelWalkTarget, BASE_TUNE, } from '../src/game/football/ai/index.js'; import { FIELD_HALF_L } from '../src/game/football/constants.js'; @@ -121,6 +122,7 @@ describe('ForwardAgent', () => { const cmd = agent.decide(gs); assert.equal(cmd.kick, false); assert.ok(Math.abs(cmd.wz) > 0.5); // hard turn toward the goal + assert.ok(Math.abs(cmd.vx) > 0.1, 'AIM must creep into the ball (no freeze-stare)'); }); it('RETURN: yields to a closer teammate in the opponent half', () => { @@ -200,7 +202,8 @@ describe('DefenderAgent', () => { it('CLEAR: ball at feet in the own half → kick toward the opponent goal', () => { const agent = new DefenderAgent(null, redDefenderCfg); - const gs = makeGs({ x: -1.8, y: 0 }, [{ id: 2, team: 'red', role: 'defender', x: -1.5, y: 0, yaw: 0 }], 2); + // Must stand behind the ball on the shot axis (own-goal side), facing +X. + const gs = makeGs({ x: -1.8, y: 0 }, [{ id: 2, team: 'red', role: 'defender', x: -2.1, y: 0, yaw: 0 }], 2); assert.equal(agent.getState(gs), 'CLEAR'); assert.equal(agent.decide(gs).kick, true); }); @@ -411,7 +414,7 @@ describe('decideAll', () => { const dt = 0.1; let kicked = false; let minBd = Infinity; - for (let t = 0; t < 120; t++) { + for (let t = 0; t < 200; 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); @@ -425,7 +428,7 @@ describe('decideAll', () => { if (bd < minBd) minBd = bd; if (c.kick) { kicked = true; break; } } - assert.ok(minBd <= SHOOT_DIST, `never reached shoot range (minBd=${minBd})`); + assert.ok(minBd <= SHOOT_DIST + 0.05, `never reached shoot range (minBd=${minBd})`); assert.ok(kicked, 'chaser never issued a kick after closing on the ball'); }); @@ -437,23 +440,211 @@ describe('decideAll', () => { assert.equal(assignChaserId([facing, closerAway], ball), 0); }); + it('chaseCost prefers a forward over a slightly closer defender (Booster claim bias)', () => { + const ball = { x: 0.5, y: 0 }; + // Gap 0.06 m — smaller than forward−defender bonus (0.10), so role wins. + const fwd = { id: 0, pos: [-0.4, 0], yaw: 0, role: 'forward' }; + const def = { id: 1, pos: [-0.34, 0], yaw: 0, role: 'defender' }; + assert.ok(chaseCost(fwd, ball, -1, 1) < chaseCost(def, ball, -1, 1)); + assert.equal(assignChaserId([fwd, def], ball, -1, 1), 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. + const a = { id: 0, pos: [-1.2, 0.1], yaw: 0, role: 'forward' }; + const b = { id: 1, pos: [-0.6, -0.1], yaw: 0, role: 'forward' }; // clearly closer assert.equal(assignChaserId([a, b], ball, -1), 1); - assert.equal(assignChaserId([a, b], ball, 0), 0); + // Sticky: with prev=0 the hysteresis can overcome a moderate gap + const near = { id: 0, pos: [-0.8, 0.1], yaw: 0, role: 'forward' }; + const nearer = { id: 1, pos: [-0.72, -0.1], yaw: 0, role: 'forward' }; + assert.equal(assignChaserId([near, nearer], ball, 0), 0); + }); + + it('wrong-side claim costs more than a free duck behind the ball', () => { + const ball = { x: 0, y: 0 }; + const scrum = { id: 0, pos: [0.45, 0], yaw: Math.PI, role: 'forward' }; // attack side + const free = { id: 1, pos: [-0.45, 0.1], yaw: 0, role: 'forward' }; // behind, open + assert.ok( + chaseCost(free, ball, -1, 1) + 0.15 < chaseCost(scrum, ball, 0, 1), + 'free behind duck should beat sticky wrong-side scrum by more than tie margin', + ); + assert.equal(assignChaserId([scrum, free], ball, 0, 1), 1); + }); + + it('stuck wrong-side chaser loses claim so teammate can find the ball', () => { + // Holder stuck on attack side with long orbit; free teammate behind must take chase. + const ducks = [ + { + id: 0, pos: [0.4, -0.15], yaw: Math.PI, role: 'forward', fallen: false, penalized: false, + team: 'red', spawnX: -0.8, spawnY: 0.5, + _ai: { + holdingChase: true, orbitTicks: 20, atFeetTicks: 14, + aimTicks: 0, kickCooldown: 0, kickHoldTicks: 0, + }, + }, + { + id: 1, pos: [-0.75, 0.35], yaw: 0, role: 'forward', fallen: false, penalized: false, + team: 'red', spawnX: -0.8, spawnY: -0.5, _ai: { holdingChase: false }, + }, + { id: 2, pos: [-2.8, 0], yaw: 0, role: 'goalkeeper', fallen: false, penalized: false, team: 'red' }, + ]; + const ball = { x: 0, y: 0, vx: 0, vy: 0 }; + decideAll(ducks, { ball, allDucks: ducks, team: 'red' }); + assert.equal(ducks[0]._ai.holdingChase, false, 'stuck scrum must drop claim'); + assert.equal(ducks[1]._ai.holdingChase, true, 'free teammate becomes finder'); + }); + + it('claim tie margin picks the lower duck id when costs nearly match', () => { + const ball = { x: 0, y: 0 }; + const a = { id: 0, pos: [-0.8, 0.05], yaw: 0, role: 'forward' }; + const b = { id: 1, pos: [-0.79, -0.05], yaw: 0, role: 'forward' }; + assert.equal(assignChaserId([a, b], ball, -1), 0); + }); + + it('chase turns to keep a side-ball in the frontal FOV while walking in', () => { + // Ball to the duck's left; approach is ahead — wz should still pull toward the ball. + const ducks = [ + { id: 0, pos: [-1.2, 0], yaw: 0, role: 'forward', fallen: false, penalized: false, team: 'red', spawnX: -0.8, spawnY: 0.5 }, + { id: 1, pos: [-1.5, -0.8], 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' }, + ]; + const ball = { x: -0.4, y: 0.7, vx: 0, vy: 0 }; + const cmds = decideAll(ducks, { ball, allDucks: ducks, team: 'red' }); + assert.equal(ducks[0]._ai.holdingChase, true); + assert.ok(cmds[0].wz > 0.15, `expected leftward turn toward ball, wz=${cmds[0].wz}`); + }); + + it('wrong-side at feet orbits laterally instead of walking through the ball', () => { + // Red attacks +X; duck stands on the +X side of the ball (not behind). + const ducks = [ + { + id: 0, pos: [0.3, 0], yaw: Math.PI, role: 'forward', fallen: false, penalized: false, + team: 'red', spawnX: -0.8, spawnY: 0.5, + _ai: { aimTicks: 0, kickCooldown: 0, kickHoldTicks: 0, atFeetTicks: 5 }, + }, + { id: 1, pos: [-1.5, -0.5], yaw: 0, role: 'forward', fallen: false, penalized: false, team: 'red' }, + { id: 2, pos: [-2.8, 0], yaw: 0, role: 'goalkeeper', fallen: false, penalized: false, team: 'red' }, + ]; + const ball = { x: 0, y: 0, vx: 0, vy: 0 }; + const ap = chaseApproachPoint(ball, FIELD_HALF_L, 0.3, 0); + const wp = lateralOrbitPoint(0.3, 0, ball, ap.x, ap.y); + assert.ok(Math.abs(wp.y) > 0.2, `orbit must be lateral, got ${JSON.stringify(wp)}`); + assert.ok(wp.x > -0.15, `orbit must not dive through ball toward own goal, got x=${wp.x}`); + + let kickCount = 0; + const y0 = ducks[0].pos[1]; + for (let t = 0; t < 25; t++) { + const cmds = decideAll(ducks, { ball, allDucks: ducks, team: 'red' }); + if (cmds[0].kick) kickCount++; + ducks[0].yaw += cmds[0].wz * 0.1; + ducks[0].pos[0] += Math.cos(ducks[0].yaw) * cmds[0].vx * 0.1; + ducks[0].pos[1] += Math.sin(ducks[0].yaw) * cmds[0].vx * 0.1; + } + assert.equal(kickCount, 0, 'must not kick from the attack side of the ball'); + assert.ok(Math.abs(ducks[0].pos[1] - y0) > 0.08, 'should side-step around the ball'); + }); + + it('non-chaser backs off when inside BALL_KEEP_OUT (no pack on the ball)', () => { + const ducks = [ + { + id: 0, pos: [-0.4, 0], yaw: 0, role: 'forward', fallen: false, penalized: false, + team: 'red', spawnX: -0.8, spawnY: 0.5, _ai: { holdingChase: true }, + }, + { + id: 1, pos: [0.2, 0.05], 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' }, + ]; + const ball = { x: 0, y: 0, vx: 0, vy: 0 }; + const cmds = decideAll(ducks, { ball, allDucks: ducks, team: 'red' }); + assert.equal(ducks[0]._ai.holdingChase, true); + // Non-chaser is ~0.2m from ball → must retreat, not press in. + assert.equal(cmds[1].kick, false); + assert.ok(cmds[1].vx > 0.05, 'keep-out should walk away from the ball'); + }); + + it('behind ball and facing shotDir → kick toward opponent goal', () => { + const ducks = [ + { + id: 0, pos: [-0.35, 0], yaw: 0, role: 'forward', fallen: false, penalized: false, + team: 'red', spawnX: -0.8, spawnY: 0.5, + _ai: { aimTicks: 0, kickCooldown: 0, kickHoldTicks: 0, atFeetTicks: 5 }, + }, + { id: 1, pos: [-1.5, -0.5], yaw: 0, role: 'forward', fallen: false, penalized: false, team: 'red' }, + { id: 2, pos: [-2.8, 0], yaw: 0, role: 'goalkeeper', fallen: false, penalized: false, team: 'red' }, + ]; + const ball = { x: 0, y: 0, vx: 0, vy: 0 }; + const cmds = decideAll(ducks, { ball, allDucks: ducks, team: 'red' }); + assert.equal(cmds[0].kick, true); + }); + + it('AIM does not poke while still turning (avoids own-goal boots)', () => { + const ducks = [ + { + id: 0, pos: [-0.3, 0], yaw: Math.PI, role: 'forward', fallen: false, penalized: false, + team: 'red', spawnX: -0.8, spawnY: 0.5, + _ai: { aimTicks: 0, kickCooldown: 0, kickHoldTicks: 0, atFeetTicks: 20, pokeArmed: false }, + }, + { id: 1, pos: [-1.5, -0.5], yaw: 0, role: 'forward', fallen: false, penalized: false, team: 'red' }, + { id: 2, pos: [-2.8, 0], yaw: 0, role: 'goalkeeper', fallen: false, penalized: false, team: 'red' }, + ]; + const ball = { x: 0, y: 0, vx: 0, vy: 0 }; + let kickCount = 0; + for (let t = 0; t < 25; t++) { + const cmds = decideAll(ducks, { ball, allDucks: ducks, team: 'red' }); + if (cmds[0].kick) kickCount++; + ducks[0].yaw += cmds[0].wz * 0.1; + } + assert.equal(kickCount, 0, 'off-angle AIM must not poke'); }); - it('chaseApproachPoint sits behind a still ball on the shot axis (MoveToStaticBall)', () => { + it('chaseApproachPoint sits behind a still ball on the shot axis (Booster approach_target)', () => { 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}`); + // Red attacks +X → approach ≈ −APPROACH_OFFSET on X (~0.32m) + assert.ok(ap.x < -0.25 && ap.x > -0.45, `expected behind ball ~0.32m, got x=${ap.x}`); assert.ok(Math.abs(ap.y) < 0.05, `expected on shot axis, got y=${ap.y}`); }); + it('AIM via decideAll creeps while turning (no vx=0 stare)', () => { + const ducks = [ + { + id: 0, pos: [-0.3, 0], yaw: Math.PI * 0.55, role: 'forward', fallen: false, penalized: false, + team: 'red', spawnX: -0.8, spawnY: 0.5, + _ai: { aimTicks: 2, kickCooldown: 0, kickHoldTicks: 0, atFeetTicks: 5, orbitTicks: 0 }, + }, + { id: 1, pos: [-1.5, -0.5], yaw: 0, role: 'forward', fallen: false, penalized: false, team: 'red' }, + { id: 2, pos: [-2.8, 0], yaw: 0, role: 'goalkeeper', fallen: false, penalized: false, team: 'red' }, + ]; + const ball = { x: 0, y: 0, vx: 0, vy: 0 }; + const cmds = decideAll(ducks, { ball, allDucks: ducks, team: 'red' }); + assert.equal(cmds[0].kick, false); + assert.ok(Math.abs(cmds[0].vx) > 0.1, `AIM creep expected, vx=${cmds[0].vx}`); + assert.ok(Math.abs(cmds[0].wz) > 0.2, 'should still be turning onto shotDir'); + }); + + it('orbit blend pulls the waypoint toward the approach (spiral in)', () => { + const ball = { x: 0, y: 0 }; + const ap = chaseApproachPoint(ball, FIELD_HALF_L, 0.4, 0); + const pure = lateralOrbitPoint(0.4, 0, ball, ap.x, ap.y, 0); + const blended = lateralOrbitPoint(0.4, 0, ball, ap.x, ap.y, 0.65); + const dPure = Math.hypot(pure.x - ap.x, pure.y - ap.y); + const dBlend = Math.hypot(blended.x - ap.x, blended.y - ap.y); + assert.ok(dBlend < dPure - 0.05, `blend should cut in (pure=${dPure}, blend=${dBlend})`); + }); + + it('chaseApproachPoint near a corner stays behind with real separation', () => { + const ball = { x: 2.7, y: 1.8, vx: 0, vy: 0 }; + const ap = chaseApproachPoint(ball, FIELD_HALF_L, 2.0, 1.0); + const sep = Math.hypot(ap.x - ball.x, ap.y - ball.y); + assert.ok(sep > 0.25, `corner approach collapsed (sep=${sep})`); + assert.ok( + isBehindBall(ap.x, ap.y, ball, FIELD_HALF_L), + `corner approach must be behind ball for +X goal (ap=${JSON.stringify(ap)})`, + ); + }); + 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); @@ -461,6 +652,19 @@ describe('decideAll', () => { assert.ok(apFast.x > apStill.x, 'fast ball approach should shift toward travel direction'); }); + it('does not kick while still far from the approach spot', () => { + // Head-on at the ball from far away: must CHASE (no kick) until near approach. + const ducks = [ + { id: 0, pos: [-1.5, 0], yaw: 0, role: 'forward', fallen: false, penalized: false, team: 'red', spawnX: -0.8, spawnY: 0.5 }, + { id: 1, pos: [-2.0, -0.8], 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' }, + ]; + const ball = { x: 0, y: 0, vx: 0, vy: 0 }; + const cmds = decideAll(ducks, { ball, allDucks: ducks, team: 'red' }); + assert.equal(cmds[0].kick, false); + assert.ok(cmds[0].vx > 0.1, 'should walk toward approach'); + }); + 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: {} }, @@ -484,6 +688,54 @@ describe('decideAll', () => { 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'); }); + + it('repelWalkTarget pushes a waypoint off a stacked teammate', () => { + const self = { id: 0, pos: [-1, 0] }; + const blockers = [ + self, + { id: 1, pos: [0.5, 0], fallen: false }, + ]; + const raw = { x: 0.5, y: 0 }; // lands on teammate + const out = repelWalkTarget(raw.x, raw.y, self, blockers); + assert.ok(distanceTo(out.x, out.y, 0.5, 0) > 0.15, 'should leave the teammate footprint'); + assert.ok(Math.abs(out.y) > Math.abs(out.x - 0.5) * 0.5 || out.y !== 0, 'prefer lateral separation'); + }); + + it('repelWalkTarget ignores fallen ducks and leaves distant targets alone', () => { + const self = { id: 0, pos: [0, 0] }; + const far = repelWalkTarget(1.5, 0, self, [ + { id: 1, pos: [-1, 0], fallen: false }, + ]); + assert.equal(far.x, 1.5); + assert.equal(far.y, 0); + + const overFallen = repelWalkTarget(0.5, 0, self, [ + { id: 1, pos: [0.5, 0], fallen: true }, + ]); + assert.equal(overFallen.x, 0.5); + assert.equal(overFallen.y, 0); + }); + + it('support walk veers off when a teammate sits on the support slot', () => { + // Chaser near ball (+y) → support lane is at −SUPPORT_LATERAL; park a body there. + const slotX = 0.5 + BASE_TUNE.SUPPORT_AHEAD + BASE_TUNE.FORMATION_X_ADVANCE * 0.35; + const slotY = -BASE_TUNE.SUPPORT_LATERAL; + const ducks = [ + { id: 0, pos: [0.4, 0.05], yaw: 0, role: 'forward', fallen: false, penalized: false, team: 'red', spawnX: -0.8, spawnY: 0.5, _ai: {} }, + { id: 1, pos: [-0.5, -0.8], 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 }, + { id: 3, pos: [slotX, slotY], yaw: 0, role: 'forward', fallen: false, penalized: false, team: 'blue' }, + ]; + const ball = { x: 0.5, y: 0, vx: 0, vy: 0 }; + for (let t = 0; t < 20; 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; + } + const dBlock = distanceTo(ducks[1].pos[0], ducks[1].pos[1], slotX, slotY); + assert.ok(dBlock > BASE_TUNE.AVOID_RADIUS * 0.45, `walked onto blocker (d=${dBlock})`); + }); }); // ═══════════════════════════════════════════════════════════════════════════════ diff --git a/app/test/commentary.test.js b/app/test/commentary.test.js new file mode 100644 index 0000000..1cc1d6a --- /dev/null +++ b/app/test/commentary.test.js @@ -0,0 +1,89 @@ +// Unit tests for rule-based commentary (danmaku lines). +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { + bankKeyForEvent, + canIdle, + canSpeak, + DANGER_X, + IDLE_GAP_S, + lineForDanger, + lineForEvent, + lineForIdle, + LINES, +} from "../src/football/commentary.js"; + +const rng0 = () => 0; // always first variant + +describe("football/commentary: banks", () => { + it("has matching en/zh keys", () => { + const enKeys = Object.keys(LINES.en).sort(); + const zhKeys = Object.keys(LINES.zh).sort(); + assert.deepEqual(zhKeys, enKeys); + for (const k of enKeys) { + assert.ok(LINES.en[k].length >= 1, `en.${k}`); + assert.ok(LINES.zh[k].length >= 1, `zh.${k}`); + } + }); + + it("maps corner aliases", () => { + assert.equal(bankKeyForEvent("corner_red"), "corner_red"); + assert.equal(bankKeyForEvent("corner_blue"), "corner_blue"); + assert.equal(bankKeyForEvent("corner"), "corner_red"); + assert.equal(bankKeyForEvent("shot"), "shot"); + }); +}); + +describe("football/commentary: lineForEvent", () => { + it("fills team tokens in EN and ZH", () => { + const en = lineForEvent({ type: "goal", team: "red" }, "en", rng0); + assert.ok(en.text.includes("Red") || en.text.includes("GOAL")); + assert.equal(en.team, "red"); + assert.equal(en.kind, "goal"); + + const zh = lineForEvent({ type: "shot", team: "blue" }, "zh", rng0); + assert.ok(zh.text.includes("蓝队")); + assert.equal(zh.kind, "shot"); + }); + + it("returns null for unknown types", () => { + assert.equal(lineForEvent({ type: "teleport" }, "en", rng0), null); + assert.equal(lineForEvent(null, "en", rng0), null); + }); + + it("handles kickoff without a team", () => { + const line = lineForEvent({ type: "kickoff" }, "en", rng0); + assert.ok(line.text.length > 4); + assert.equal(line.team, null); + }); +}); + +describe("football/commentary: idle + danger", () => { + it("gates idle on PLAYING + silence", () => { + assert.equal(canIdle({ matchState: "PLAYING", sinceLastLineS: IDLE_GAP_S }), true); + assert.equal(canIdle({ matchState: "PLAYING", sinceLastLineS: 1 }), false); + assert.equal(canIdle({ matchState: "GOAL", sinceLastLineS: 99 }), false); + }); + + it("gates global cooldown", () => { + assert.equal(canSpeak(0), false); + assert.equal(canSpeak(3), true); + }); + + it("picks trailing/leading when score uneven", () => { + const line = lineForIdle({ score: { red: 2, blue: 0 } }, "en", rng0); + assert.ok(line.kind === "idle_leading" || line.kind === "idle_trailing"); + assert.ok(line.text.length > 4); + }); + + it("emits danger only deep in the box", () => { + assert.equal(lineForDanger({ x: 0 }, "en", rng0), null); + assert.equal(lineForDanger({ x: DANGER_X - 0.01 }, "en", rng0), null); + const d = lineForDanger({ x: DANGER_X + 0.05 }, "en", rng0); + assert.equal(d.kind, "danger"); + assert.equal(d.team, "blue"); // +x threatens blue goal + const r = lineForDanger({ x: -(DANGER_X + 0.05) }, "zh", rng0); + assert.equal(r.team, "red"); + assert.ok(r.text.includes("红") || r.text.length > 2); + }); +}); diff --git a/app/test/strategy.test.js b/app/test/strategy.test.js index f70ef48..ff13849 100644 --- a/app/test/strategy.test.js +++ b/app/test/strategy.test.js @@ -1,65 +1,173 @@ -// Strategy preset → spawn / TUNE overlay tests (no engine deps). +// Strategy card (v2) — continuous 0..1 knobs, presets, overlays, hints. import { describe, it } from 'node:test'; import assert from 'node:assert/strict'; import { DEFAULT_STRATEGY, + DEFAULT_KNOBS, + STYLE_PRESET_KNOBS, + KNOB_IDS, normalizeStrategy, + mergeStrategy, getTuneOverlay, + knobsToOverlay, buildSpawnTable, strategiesForUser, + buildStrategyByTeam, + strategyFingerprint, + strategyFromStylePreset, + parseStrategyHints, + knobsDiff, + matchStylePreset, + describeStrategy, + coerceKnobValue, } 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'); +describe('strategy continuous knobs', () => { + it('normalizeStrategy falls back to 0.5 defaults', () => { + const s = normalizeStrategy({}); + assert.equal(s.formation, DEFAULT_STRATEGY.formation); + assert.equal(s.style, 'balanced'); + for (const id of KNOB_IDS) assert.equal(s.knobs[id], 0.5); }); - 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('migrates legacy discrete levels to 0..1', () => { + assert.equal(coerceKnobValue('push'), 1); + assert.equal(coerceKnobValue('sit'), 0); + assert.equal(coerceKnobValue('high'), 1); + const s = normalizeStrategy({ + knobs: { lineHeight: 'push', press: 'low', shootGreed: 'greedy' }, + }); + assert.equal(s.knobs.lineHeight, 1); + assert.equal(s.knobs.press, 0); + assert.equal(s.knobs.shootGreed, 1); + }); + + it('legacy style+press fills knobs then overrides press', () => { + const s = normalizeStrategy({ style: 'defend', press: 'high' }); + assert.ok(s.knobs.lineHeight < 0.25); + assert.equal(s.knobs.press, 1); + assert.equal(s.press, 'high'); + }); + + it('attack overlay is visibly more advanced than defend', () => { + const atk = getTuneOverlay({ style: 'attack' }); + const def = getTuneOverlay({ style: 'defend' }); + assert.ok(atk.FORMATION_X_ADVANCE > def.FORMATION_X_ADVANCE + 0.5); + assert.ok(def.FORMATION_X_RETREAT < atk.FORMATION_X_RETREAT - 0.5); + assert.equal(def.LINE_HOLD_MID, 1); + assert.equal(atk.LINE_PUSH_MID, 1); + assert.ok(atk.SECOND_PRESS_DIST > 1.5); + assert.ok(def.SECOND_PRESS_DIST < 0.4); + assert.ok(atk.SHOOT_ANGLE > def.SHOOT_ANGLE + 0.2); + }); + + it('knobsToOverlay interpolates continuously', () => { + const lo = knobsToOverlay({ ...DEFAULT_KNOBS, press: 0 }); + const hi = knobsToOverlay({ ...DEFAULT_KNOBS, press: 1 }); + const mid = knobsToOverlay({ ...DEFAULT_KNOBS, press: 0.5 }); + assert.equal(lo.SECOND_PRESS_DIST, 0); + assert.ok(hi.SECOND_PRESS_DIST > 2); + assert.ok(mid.SECOND_PRESS_DIST > lo.SECOND_PRESS_DIST); + assert.ok(mid.SECOND_PRESS_DIST < hi.SECOND_PRESS_DIST); + }); + + it('single knob patch marks custom', () => { + const next = mergeStrategy(strategyFromStylePreset('balanced'), { + knobs: { shootGreed: 0.9 }, + }); + assert.equal(next.knobs.shootGreed, 0.9); + assert.equal(next.style, 'custom'); + assert.ok(getTuneOverlay(next).SHOOT_ANGLE > BASE_TUNE.SHOOT_ANGLE); + }); + + it('style preset with knobs payload keeps style', () => { + const next = mergeStrategy(DEFAULT_STRATEGY, { + style: 'attack', + knobs: { ...STYLE_PRESET_KNOBS.attack }, + }); + assert.equal(next.style, 'attack'); }); - it('buildSpawnTable enables defender roles for 1f1d1gk', () => { - const table = buildSpawnTable({ - red: { formation: '1f1d1gk', style: 'balanced', press: 'medium' }, + it('buildSpawnTable pushes attack line forward vs defend', () => { + const atk = buildSpawnTable({ + red: { style: 'attack' }, + blue: { ...DEFAULT_STRATEGY }, + }); + const def = buildSpawnTable({ + red: { style: 'defend' }, 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'); + // Red forward (id 0) should sit further upfield under attack preset. + assert.ok(atk[0].x > def[0].x + 0.3); }); - 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('strategiesForUser accepts opponent card', () => { + const map = strategiesForUser('red', { style: 'attack' }, { style: 'defend' }); + assert.equal(map.red.style, 'attack'); + assert.equal(map.blue.style, 'defend'); }); - it('decideAll accepts tuneOverlay without breaking chase', () => { + it('buildStrategyByTeam normalizes both sides', () => { + const map = buildStrategyByTeam({ + red: { style: 'attack' }, + blue: { knobs: { press: 0.95 } }, + }); + assert.ok(map.red.knobs.shootGreed > 0.7); + assert.equal(map.blue.knobs.press, 0.95); + assert.equal(map.blue.style, 'custom'); + }); + + it('fingerprint stable; JSON round-trip', () => { + const card = mergeStrategy(strategyFromStylePreset('attack'), { + knobs: { spacing: 0.8, gkRush: 0.2 }, + }); + const again = normalizeStrategy(JSON.parse(JSON.stringify(card))); + assert.equal(strategyFingerprint(card), strategyFingerprint(again)); + assert.deepEqual(getTuneOverlay(again), getTuneOverlay(card)); + }); + + it('decideAll accepts continuous overlay', () => { 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 = { + const cmds = decideAll(ducks, { 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); + tuneOverlay: getTuneOverlay({ style: 'attack' }), + }); assert.equal(cmds.length, 3); - assert.ok(Number.isFinite(cmds[0].vx)); assert.equal(typeof cmds[0].kick, 'boolean'); }); }); + +describe('parseStrategyHints continuous', () => { + it('parses Chinese high-press into high floats', () => { + const { knobs } = parseStrategyHints('高位逼抢,稳一点别乱射,门将别瞎出门'); + assert.ok(knobs.press >= 0.8); + assert.ok(knobs.lineHeight >= 0.8); + assert.ok(knobs.shootGreed <= 0.25); + assert.ok(knobs.gkRush <= 0.2); + }); + + it('knobsDiff lists changed keys', () => { + const diff = knobsDiff(DEFAULT_KNOBS, { press: 0.9, shootGreed: 0.5 }); + assert.equal(diff.length, 1); + assert.equal(diff[0].id, 'press'); + }); + + it('describeStrategy highlights poles away from mid', () => { + const d = describeStrategy({ style: 'attack' }, 'en'); + assert.ok(d.highlights.length >= 1); + assert.equal(d.fingerprint.length, 8); + }); + + it('matchStylePreset detects attack', () => { + assert.equal(matchStylePreset(STYLE_PRESET_KNOBS.attack), 'attack'); + assert.equal(matchStylePreset({ ...DEFAULT_KNOBS, press: 0.9 }), null); + }); +}); diff --git a/app/test/tactics-board.test.js b/app/test/tactics-board.test.js index 324162e..2ea17be 100644 --- a/app/test/tactics-board.test.js +++ b/app/test/tactics-board.test.js @@ -101,7 +101,7 @@ describe('buildMatchReport', () => { 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.strategy.blue.press, 'low'); // defend preset knobs.press assert.equal(report.shots.red, 1); assert.equal(report.score.red, 2); assert.ok(report.possession.redPct > 0.7); diff --git a/app/tools/football-env-smoke.mjs b/app/tools/football-env-smoke.mjs new file mode 100644 index 0000000..0606d05 --- /dev/null +++ b/app/tools/football-env-smoke.mjs @@ -0,0 +1,51 @@ +#!/usr/bin/env node +// Smoke test: boot FootballEnv, random policy for N AI steps, print summary. +// cd app && node tools/football-env-smoke.mjs +// node tools/football-env-smoke.mjs --steps=50 + +import { createFootballEnv } from './football-env.mjs'; + +function arg(name, def) { + const m = process.argv.find((a) => a.startsWith(`--${name}=`)); + return m ? m.split('=')[1] : def; +} + +const STEPS = Math.max(1, Number(arg('steps', '30'))); + +const t0 = Date.now(); +process.stderr.write(`[env-smoke] creating env (MuJoCo WASM + ONNX)…\n`); +const env = await createFootballEnv({ quiet: false, maxEpisodeSteps: STEPS }); +process.stderr.write(`[env-smoke] reset\n`); +let { obs, obsVec } = env.reset(); +process.stderr.write( + `[env-smoke] obsDim=${obsVec.length} actionDim=${env.actionDim} state=${obs.matchState}\n`, +); + +let totalReward = 0; +let last = null; +for (let i = 0; i < STEPS; i++) { + const action = env.sampleAction(); + last = await env.step(action); + totalReward += last.reward; + if ((i + 1) % 10 === 0 || last.terminated || last.truncated) { + process.stderr.write( + `[env-smoke] step=${i + 1}/${STEPS} R=${totalReward.toFixed(3)} ` + + `score=${last.info.score.red}-${last.info.score.blue} state=${last.info.matchState}\n`, + ); + } + if (last.terminated || last.truncated) break; +} + +const wall = ((Date.now() - t0) / 1000).toFixed(1); +const summary = { + steps: last?.info?.aiSteps ?? 0, + totalReward: Number(totalReward.toFixed(4)), + score: last?.info?.score, + matchState: last?.info?.matchState, + terminated: !!last?.terminated, + truncated: !!last?.truncated, + wall_clock_s: Number(wall), +}; +process.stdout.write(JSON.stringify(summary) + '\n'); +process.stderr.write(`[env-smoke] done in ${wall}s\n`); +process.exitCode = last?.info?.exploded ? 3 : 0; diff --git a/app/tools/football-env.mjs b/app/tools/football-env.mjs new file mode 100644 index 0000000..5325e62 --- /dev/null +++ b/app/tools/football-env.mjs @@ -0,0 +1,198 @@ +// football-env.mjs — Gym-like RL env over MuJoCo WASM 3v3. +// Centralized learner controls one team (3 ducks); opponent = decideAll. +// +// Action (10 Hz): length-9 vector +// [vx0, wz0, kick0, vx1, wz1, kick1, vx2, wz2, kick2] +// vx,wz continuous; kick binary ( >0.5 → kick ) +// +// Usage: +// import { createFootballEnv } from './football-env.mjs'; +// const env = await createFootballEnv({ quiet: true }); +// let obs = env.reset(); +// const { obs, reward, terminated, truncated, info } = await env.step(action); + +import { + createFootballSim, + CTRL_DT, + AI_DIVIDER, + VEL_LIMITS, +} from './lib/football-sim.mjs'; +import { getTuneOverlay, normalizeStrategy, DEFAULT_STRATEGY } from '../src/game/football/strategy.js'; +import { MATCH_DURATION_S } from '../src/game/football/constants.js'; + +export { CTRL_DT, AI_DIVIDER, VEL_LIMITS }; + +export const ACTION_DIM = 9; // 3 ducks × (vx, wz, kick) + +/** + * Flatten tactical obs into a fixed Float32Array for neural nets. + * Layout: ball(5) + 6 ducks × (x,y,yaw,fallen,teamSign) + score(2) + time(1) = 5+30+2+1 = 38 + */ +export function encodeObs(obs, learnerTeam = 'red') { + const out = new Float32Array(38); + const b = obs.ball || {}; + out[0] = b.x || 0; + out[1] = b.y || 0; + out[2] = b.z || 0; + out[3] = b.vx || 0; + out[4] = b.vy || 0; + const ducks = obs.ducks || []; + for (let i = 0; i < 6; i++) { + const d = ducks[i] || {}; + const o = 5 + i * 5; + out[o] = d.x || 0; + out[o + 1] = d.y || 0; + out[o + 2] = d.yaw || 0; + out[o + 3] = d.fallen ? 1 : 0; + out[o + 4] = d.team === 'red' ? 1 : d.team === 'blue' ? -1 : 0; + } + out[35] = obs.score?.red || 0; + out[36] = obs.score?.blue || 0; + out[37] = (obs.matchTime || 0) / MATCH_DURATION_S; + // Flip x signs if learning as blue so +X is always "attack". + if (learnerTeam === 'blue') { + out[0] *= -1; + out[3] *= -1; + for (let i = 0; i < 6; i++) { + const o = 5 + i * 5; + out[o] *= -1; + out[o + 2] = Math.atan2(Math.sin(out[o + 2] + Math.PI), Math.cos(out[o + 2] + Math.PI)); + out[o + 4] *= -1; + } + const sr = out[35]; + out[35] = out[36]; + out[36] = sr; + } + return out; +} + +function clamp(v, lo, hi) { + return Math.max(lo, Math.min(hi, v)); +} + +function parseActions(flat, n = 3) { + const actions = []; + for (let i = 0; i < n; i++) { + const o = i * 3; + actions.push({ + vx: clamp(Number(flat[o]) || 0, VEL_LIMITS.vxMin, VEL_LIMITS.vxMax), + wz: clamp(Number(flat[o + 1]) || 0, -VEL_LIMITS.wzMax, VEL_LIMITS.wzMax), + kick: Number(flat[o + 2]) > 0.5, + }); + } + return actions; +} + +/** + * @param {object} [opts] + * @param {'red'|'blue'} [opts.learnerTeam='red'] + * @param {object} [opts.opponentStrategy] strategy card for decideAll side + * @param {boolean} [opts.quiet] + * @param {number} [opts.maxEpisodeSteps] AI steps (10Hz); default ~match length + */ +export async function createFootballEnv(opts = {}) { + const learnerTeam = opts.learnerTeam === 'blue' ? 'blue' : 'red'; + const opponentTeam = learnerTeam === 'red' ? 'blue' : 'red'; + const opponentStrategy = normalizeStrategy(opts.opponentStrategy || DEFAULT_STRATEGY); + const tuneOverlay = getTuneOverlay(opponentStrategy); + const maxEpisodeSteps = opts.maxEpisodeSteps + ?? Math.round(MATCH_DURATION_S / (CTRL_DT * AI_DIVIDER)); + + const sim = await createFootballSim({ quiet: opts.quiet, learnerTeam }); + + let aiSteps = 0; + let lastScore = { red: 0, blue: 0 }; + + function rewardFromEvents(events) { + let r = 0; + for (const e of events) { + if (e.event !== 'goal') continue; + if (e.team === learnerTeam) r += 1; + else if (e.team === opponentTeam) r -= 1; + } + return r; + } + + function pack(obs, reward, terminated, truncated, info = {}) { + return { + obs, + obsVec: encodeObs(obs, learnerTeam), + reward, + terminated, + truncated, + info: { + ...info, + score: obs.score, + matchState: obs.matchState, + aiSteps, + }, + }; + } + + return { + learnerTeam, + opponentTeam, + actionDim: ACTION_DIM, + obsDim: 38, + maxEpisodeSteps, + + reset() { + aiSteps = 0; + const obs = sim.reset(); + lastScore = { ...obs.score }; + sim.drainEvents(); + return pack(obs, 0, false, false, { reset: true }); + }, + + /** + * One tactical step (10 Hz): apply learner actions, opponent decideAll, + * then AI_DIVIDER physics/control ticks. + * @param {ArrayLike} action length-9 + */ + async step(action) { + const acts = parseActions(action, 3); + sim.applyTeamActions(learnerTeam, acts, { idleCreep: false }); + sim.runOpponentDecideAll(opponentTeam, tuneOverlay); + sim.antiStuckCheck(); + + let exploded = false; + for (let i = 0; i < AI_DIVIDER; i++) { + const st = await sim.controlStep(); + if (st.exploded) { + exploded = true; + break; + } + } + aiSteps += 1; + + const events = sim.drainEvents(); + const obs = sim.getObs(); + let reward = rewardFromEvents(events); + + // Tiny shaping: ball progress toward attack goal (learner frame). + const attackSign = learnerTeam === 'red' ? 1 : -1; + const bx = obs.ball?.x || 0; + reward += 0.001 * attackSign * (bx - (lastScore._bx ?? 0)); + lastScore._bx = bx; + + const terminated = obs.matchState === 'FULLTIME' || exploded; + const truncated = !terminated && aiSteps >= maxEpisodeSteps; + + return pack(obs, reward, terminated, truncated, { + events, + exploded, + }); + }, + + /** Random legal action for smoke tests. */ + sampleAction(rng = Math.random) { + const a = new Float32Array(ACTION_DIM); + for (let i = 0; i < 3; i++) { + a[i * 3] = VEL_LIMITS.vxMin + rng() * (VEL_LIMITS.vxMax - VEL_LIMITS.vxMin); + a[i * 3 + 1] = (rng() * 2 - 1) * VEL_LIMITS.wzMax; + a[i * 3 + 2] = rng() < 0.05 ? 1 : 0; + } + return a; + }, + }; +} diff --git a/app/tools/lib/football-sim.mjs b/app/tools/lib/football-sim.mjs new file mode 100644 index 0000000..d889860 --- /dev/null +++ b/app/tools/lib/football-sim.mjs @@ -0,0 +1,913 @@ +// football-sim.mjs — MuJoCo WASM 3v3 sim core for RL env (+ optional headless). +// No DOM/THREE. createFootballSim() → reset / controlStep / applyTeamActions. + +import { fileURLToPath } from 'node:url'; +import path from 'node:path'; +import fs from 'node:fs'; + +// ── src/ 纯模块(无 DOM / 无引擎依赖,可在 Node 直接 import)────────────── +import { + POLICIES, JOINT_NAMES, DEFAULT_POSE, NUM_JOINTS, OBS_SIZE, CMD_SIZE, + ACTION_SCALE, TIMESTEP, DECIMATION, CTRL_DT, BALL_RADIUS, +} from '../../src/game/constants.js'; +import { FOOTBALL_CONFIG } from '../../src/game/football/match-config.js'; +import { + SPAWN_POSITIONS, BALL_SPAWN, AI_DIVIDER, MATCH_DURATION_S, PENALTY_DURATION_S, + FIELD_HALF_W, GOAL_WIDTH, +} from '../../src/game/football/constants.js'; +import { getGoalCollisionGeoms } from '../../src/game/football/goal.js'; +import { createReferee } from '../../src/game/football/referee.js'; +import { createAgent, decideAll } from '../../src/game/football/ai/index.js'; +import { createDuckInstance } from '../../src/game/football/duck-instance.js'; + +// ── 路径 ────────────────────────────────────────────────────────────────── +const HERE = path.dirname(fileURLToPath(import.meta.url)); +const APP_ROOT = path.resolve(HERE, '../..'); +const PUBLIC = path.join(APP_ROOT, 'public'); +const MODEL_DIR = path.join(PUBLIC, 'robot', 'mjlab'); +const MESH_DIR = path.join(MODEL_DIR, 'meshes'); +const POLICY_DIR = path.join(PUBLIC, 'policies'); +const MUJOCO_WASM = path.join(APP_ROOT, 'node_modules', '@mujoco', 'mujoco', 'mujoco.wasm'); +const ORT_WASM_DIR = path.join(APP_ROOT, 'node_modules', 'onnxruntime-web', 'dist') + path.sep; + +const info = (...a) => { if (optsQuiet()) return; process.stderr.write(`[football-sim] ${a.join(" ")}\n`); }; +function optsQuiet() { return !!globalThis.__FOOTBALL_SIM_QUIET; } + +// game.js 闭包内的足球相关常量(未导出),在此按同值复刻。 +const IDLE_CREEP = 0.22; // 命令静止时的轻微前移,避开 INACTIVE 判罚(高于 walk 死区) +// 亚阈值兜底:decideAll 的 cos 缩放可能返回一个极小正 vx(如近垂直接球), +// 恰好落在 referee 的 IDLE_SPEED_EPS 附近被读作"静止"。任何 (0, MIN_EFFECTIVE_VX) +// 区间的 vx 都会被抬到一个真正的步行速度,再进入 IDLE_CREEP 静止规则。 +const MIN_EFFECTIVE_VX = 0.22; +const CMD_SMOOTH_ALPHA = 0.25; // 50Hz 一阶低通,抹平 10Hz 决策跳变 +const KICK_STEPS = 25; // 踢球 one-shot 窗口(控制步) +const POST_KICK_LOCK_STEPS = 20; // 踢球后命令归零的宽限步数 +const FALL_DEBOUNCE_STEPS = 10; // gz>-0.5 持续 0.2s 才判定摔倒 +const FALL_SETTLE_STEPS = 15; // 摔倒后 ctrl 冻结的 settle 步数 +const RECOVER_UPRIGHT_STEPS = 50; // gz<-0.85 持续 1s 判定起身成功 +const RECOVER_GIVEUP_STEPS = 300; // 6s 起身失败则复位 + +// ═════════════════════════════════════════════════════════════════════════ +// 极简 XML DOM —— Node 无 DOMParser,且仓库无 xml 依赖,故自建。 +// 只需 element/attribute 级别的操作:MJCF 由 onshape-to-robot 生成,规整无 +// CDATA / 命名空间,注释与文本(空白)可直接丢弃。 +// ═════════════════════════════════════════════════════════════════════════ +function mkEl(tag, attrs = {}) { + return { tag, attrs: { ...attrs }, children: [], parent: null }; +} + +function findTagEnd(str, start) { + let q = null; + for (let i = start + 1; i < str.length; i++) { + const ch = str[i]; + if (q) { if (ch === q) q = null; continue; } + if (ch === '"' || ch === "'") { q = ch; continue; } + if (ch === '>') return i; + } + return -1; +} + +function parseAttrs(content) { + const m = /^\s*([^\s]+)([\s\S]*)$/.exec(content); + if (!m) return { tag: content.trim(), attrs: {} }; + const tag = m[1]; + const rest = m[2]; + const attrs = {}; + const re = /([^\s=]+)\s*=\s*"([^"]*)"/g; + let a; + while ((a = re.exec(rest))) attrs[a[1]] = a[2]; + return { tag, attrs }; +} + +function parseXml(str) { + const doc = mkEl('#document'); + const stack = [doc]; + let i = 0; + while (i < str.length) { + const lt = str.indexOf('<', i); + if (lt === -1) break; + if (str.startsWith('', lt) + 3; continue; } + if (str.startsWith('', lt) + 2; continue; } + if (str.startsWith('', lt) + 1; continue; } + if (str[lt + 1] === '/') { i = str.indexOf('>', lt) + 1; stack.pop(); continue; } + const tagEnd = findTagEnd(str, lt); + if (tagEnd === -1) break; + const inner = str.slice(lt + 1, tagEnd); + const selfClose = inner.endsWith('/'); + const { tag, attrs } = parseAttrs(selfClose ? inner.slice(0, -1) : inner); + const node = mkEl(tag, attrs); + node.parent = stack[stack.length - 1]; + node.parent.children.push(node); + if (!selfClose) stack.push(node); + i = tagEnd + 1; + } + return doc.children[0]; // +} + +function escAttr(v) { + return String(v).replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); +} + +function serialize(node, indent = 0) { + const pad = ' '.repeat(indent); + const attr = Object.entries(node.attrs).map(([k, v]) => ` ${k}="${escAttr(v)}"`).join(''); + if (node.children.length === 0) return `${pad}<${node.tag}${attr}/>`; + const kids = node.children.map((c) => serialize(c, indent + 1)).join('\n'); + return `${pad}<${node.tag}${attr}>\n${kids}\n${pad}`; +} + +function* walk(node) { yield node; for (const c of node.children) yield* walk(c); } +function findAll(root, tag) { return [...walk(root)].filter((n) => n.tag === tag); } +function findFirst(root, tag) { return [...walk(root)].find((n) => n.tag === tag) ?? null; } +function removeNode(node) { + if (!node || !node.parent) return; + const i = node.parent.children.indexOf(node); + if (i >= 0) node.parent.children.splice(i, 1); + node.parent = null; +} +function cloneNode(node) { + const c = mkEl(node.tag, node.attrs); + for (const ch of node.children) { const cc = cloneNode(ch); cc.parent = c; c.children.push(cc); } + return c; +} +function appendChild(parent, child) { child.parent = parent; parent.children.push(child); return child; } +function insertBefore(parent, child, ref) { + child.parent = parent; + const i = parent.children.indexOf(ref); + if (i < 0) parent.children.push(child); + else parent.children.splice(i, 0, child); +} + +// ═════════════════════════════════════════════════════════════════════════ +// buildFootballXml —— 复刻 game.js:buildPhysicsXml 的 football 分支。 +// 产出六只前缀化鸭子 + 球场 + 球门的单一 MJCF 字符串,以及需要装进 VFS 的 +// mesh 文件名列表。 +// ═════════════════════════════════════════════════════════════════════════ +const PREFIX_ATTRS = ['name', 'site', 'body', 'objname', 'joint', 'body1', 'body2', + 'target', 'tendon', 'refsite']; + +function prefixSubtree(node, prefix) { + for (const attr of PREFIX_ATTRS) { + const val = node.attrs[attr]; + // 用 '/' 守卫,避免给已带前缀的名字二次加前缀。 + if (val && !val.includes('/')) node.attrs[attr] = prefix + val; + } + for (const child of node.children) prefixSubtree(child, prefix); +} + +function buildFootballXml() { + const field = FOOTBALL_CONFIG.field; + const duckConfigs = FOOTBALL_CONFIG.ducks; + const ballPark = FOOTBALL_CONFIG.ball.parkPos; + + const src = fs.readFileSync(path.join(MODEL_DIR, 'robot_allcollisions.xml'), 'utf8'); + const root = parseXml(src); + + // 1) 去掉 visual geoms(contype=0,对动力学无意义),再删掉不再被引用的 mesh。 + for (const g of findAll(root, 'geom')) { + if (g.attrs.class === 'visual') removeNode(g); + } + const usedMeshes = new Set( + findAll(root, 'geom').map((g) => g.attrs.mesh).filter(Boolean), + ); + const asset = findFirst(root, 'asset'); + for (const m of findAll(root, 'mesh')) { + const name = m.attrs.name ?? (m.attrs.file || '').replace(/\.stl$/i, ''); + if (!usedMeshes.has(name)) removeNode(m); + } + + // 2) timestep option。 + appendChild(root, mkEl('option', { timestep: String(TIMESTEP) })); + + const worldbody = findFirst(root, 'worldbody'); + // 3) 地板。 + appendChild(worldbody, mkEl('geom', { + name: 'floor', type: 'plane', size: '0 0 0.05', pos: '0 0 0', + })); + + // 4) 围墙(goal-openings 模式):±X 端墙各留一个 GOAL_WIDTH 的球门口。 + const halfX = field.halfX, halfY = field.halfY; + const ht = 0.05 / 2, hh = 0.25 / 2; + const offX = halfX + ht, offY = halfY + ht; + const spanX = halfX + 0.05, spanY = halfY + 0.05; + const goalHalf = GOAL_WIDTH / 2; + const segHalf = (spanY - goalHalf) / 2; + const segC = goalHalf + segHalf; + const wallDefs = []; + for (const sx of [1, -1]) { + for (const sy of [1, -1]) { + wallDefs.push({ + name: `wall_${sx > 0 ? 'p' : 'n'}x_${sy > 0 ? 'p' : 'n'}y`, + pos: `${sx * offX} ${sy * segC} ${hh}`, size: `${ht} ${segHalf} ${hh}`, + }); + } + } + wallDefs.push( + { name: 'wall_py', pos: `0 ${offY} ${hh}`, size: `${spanX} ${ht} ${hh}` }, + { name: 'wall_ny', pos: `0 ${-offY} ${hh}`, size: `${spanX} ${ht} ${hh}` }, + ); + for (const w of wallDefs) { + appendChild(worldbody, mkEl('geom', { name: w.name, type: 'box', pos: w.pos, size: w.size })); + } + + // 5) 球门碰撞体(实心门柱/横梁/球网,只朝球场留开口)。euler 用弧度 + // ()——getGoalCollisionGeoms 已按此产出。 + for (const g of [...getGoalCollisionGeoms('red'), ...getGoalCollisionGeoms('blue')]) { + const attrs = { name: g.name, type: g.type, pos: g.pos, size: g.size }; + if (g.euler) attrs.euler = g.euler; + appendChild(worldbody, mkEl('geom', attrs)); + } + + // 6) 球:轻质自由球体,追加在机器人 body 之后(trunk freejoint 保持在 qpos 首位)。 + const ballBody = mkEl('body', { name: 'ball', pos: ballPark }); + appendChild(ballBody, mkEl('freejoint', { name: 'ball_freejoint' })); + appendChild(ballBody, mkEl('geom', { + name: 'ball_geom', type: 'sphere', size: String(BALL_RADIUS), + mass: '0.03', friction: '0.4 0.01 0.003', solref: '0.03 0.4', condim: '6', + })); + appendChild(worldbody, ballBody); + + // 7) 多鸭注入:克隆已 strip 的 trunk_base 子树 6 次并前缀化,传感器/执行器 + // 按鸭序重注入,使 qpos/ctrl/sensordata 布局为 duck0..duck5, ball。 + const origBody = findFirst(worldbody, 'body'); // + const origClone = cloneNode(origBody); + removeNode(origBody); + const sensorParent = findFirst(root, 'sensor'); + const actuatorParent = findFirst(root, 'actuator'); + const origSensors = sensorParent ? sensorParent.children.map(cloneNode) : []; + const origActuators = actuatorParent ? actuatorParent.children.map(cloneNode) : []; + if (sensorParent) sensorParent.children = []; + if (actuatorParent) actuatorParent.children = []; + for (const dc of duckConfigs) { + const bodyClone = cloneNode(origClone); + prefixSubtree(bodyClone, dc.prefix); + insertBefore(worldbody, bodyClone, ballBody); + if (sensorParent) for (const s of origSensors) { + const sc = cloneNode(s); prefixSubtree(sc, dc.prefix); appendChild(sensorParent, sc); + } + if (actuatorParent) for (const a of origActuators) { + const ac = cloneNode(a); prefixSubtree(ac, dc.prefix); appendChild(actuatorParent, ac); + } + } + + // 8) STAND keyframe:每鸭 freejoint(7) + 14 hinge,最后球 freejoint(7); + // ctrl 为六段 DEFAULT_POSE。 + const poseByName = new Map(JOINT_NAMES.map((n, i) => [n, DEFAULT_POSE[i]])); + const pose14 = Array.from(DEFAULT_POSE).join(' '); + const qposParts = []; + const ctrlParts = []; + for (const dc of duckConfigs) { + const [sx, sy, sz] = dc.spawn; + const yaw = dc.yaw ?? 0; + const qw = Math.cos(yaw / 2), qz = Math.sin(yaw / 2); + qposParts.push(`${sx} ${sy} ${sz} ${qw} 0 0 ${qz}`); + const duckBody = findAll(worldbody, 'body') + .find((b) => b.attrs.name === `${dc.prefix}trunk_base`); + const hinges = findAll(duckBody, 'joint') + .map((j) => poseByName.get((j.attrs.name || '').split('/').pop()) ?? 0) + .join(' '); + qposParts.push(hinges); + ctrlParts.push(pose14); + } + qposParts.push(`${ballPark} 1 0 0 0`); + const kf = mkEl('keyframe'); + appendChild(kf, mkEl('key', { + name: 'STAND', qpos: qposParts.join(' '), ctrl: ctrlParts.join(' '), + })); + appendChild(root, kf); + + const meshFiles = findAll(root, 'mesh').map((m) => m.attrs.file).filter(Boolean); + return { xml: serialize(root), meshFiles }; +} + +// ═════════════════════════════════════════════════════════════════════════ +// 运行时加载:MuJoCo WASM + VFS meshes + ONNX 会话。 +// ═════════════════════════════════════════════════════════════════════════ +async function loadRuntimes() { + const loadMujocoFactory = (await import('@mujoco/mujoco')).default; + const mujoco = await loadMujocoFactory({ + locateFile: (p) => (p.endsWith('.wasm') ? MUJOCO_WASM : p), + }); + // onnxruntime-web 的 Node 入口用 fs 加载 wasm,仅需把 wasmPaths 指向 dist 目录。 + const ort = await import('onnxruntime-web'); + ort.env.wasm.wasmPaths = ORT_WASM_DIR; + ort.env.wasm.numThreads = 1; + return { mujoco, ort }; +} + +async function loadSessions(ort) { + // 足球鸭子只会用到 walk / kickL / kickR / stand(起身)四个策略。 + // POLICIES.* 是浏览器相对路径("./policies/x.onnx"),映射到磁盘 public/policies。 + const load = (rel) => ort.InferenceSession.create( + new Uint8Array(fs.readFileSync(path.join(POLICY_DIR, path.basename(rel)))), + { executionProviders: ['wasm'] }, + ); + const [walk, kickL, kickR, stand] = await Promise.all([ + load(POLICIES.walk), load(POLICIES.kickL), load(POLICIES.kickR), load(POLICIES.stand), + ]); + return { walk, kickL, kickR, stand }; +} + +function buildVfs(mujoco, meshFiles) { + const vfs = new mujoco.MjVFS(); + for (const f of meshFiles) { + const buf = fs.readFileSync(path.join(MESH_DIR, f)); + // meshdir="assets",编译器按 "assets/" 查找。 + vfs.addBuffer(`assets/${f}`, new Uint8Array(buf)); + } + return vfs; +} + +// ═════════════════════════════════════════════════════════════════════════ +// 地址解析(复刻 resolveAddrs,去掉只服务于渲染的 kinematics/extraJoints)。 +// ═════════════════════════════════════════════════════════════════════════ +function resolveAddrs(mujoco, model, prefix) { + return { + qposAdr: JOINT_NAMES.map((n) => model.jnt(prefix + n).qposadr), + dofAdr: JOINT_NAMES.map((n) => model.jnt(prefix + n).dofadr), + freejointQposAdr: model.jnt(prefix + 'trunk_base_freejoint').qposadr, + freejointDofAdr: model.jnt(prefix + 'trunk_base_freejoint').dofadr, + gyroAdr: model.sensor(prefix + 'imu_ang_vel').adr, + trunkId: mujoco.mj_name2id(model, mujoco.mjtObj.mjOBJ_BODY.value, prefix + 'trunk_base'), + }; +} + +// 把世界 -Z 旋进 trunk 坐标系(= game.js 的 projected gravity)。 +// 返回 [gx, gy, gz];直立时 gz ≈ -1。 +function projGravity(xq) { + const w = xq[0], x = xq[1], y = xq[2], z = xq[3]; + return [ + 2 * w * y - 2 * x * z, + -2 * w * x - 2 * y * z, + -1 + 2 * x * x + 2 * y * y, + ]; +} + +// ═════════════════════════════════════════════════════════════════════════ +// 主程序 +// ═════════════════════════════════════════════════════════════════════════ + +export async function createFootballSim(opts = {}) { + if (opts.quiet) globalThis.__FOOTBALL_SIM_QUIET = true; +const { xml, meshFiles } = buildFootballXml(); + info(`MJCF built: ${meshFiles.length} collision meshes`); + + const { mujoco, ort } = await loadRuntimes(); + info('runtimes loaded (mujoco wasm + onnxruntime wasm)'); + + const vfs = buildVfs(mujoco, meshFiles); + const model = mujoco.MjModel.from_xml_string(xml, vfs); + const data = new mujoco.MjData(model); + const nDucks = FOOTBALL_CONFIG.ducks.length; + if (model.nq !== nDucks * 21 + 7 || model.nu !== nDucks * NUM_JOINTS) { + throw new Error(`model shape mismatch: nq=${model.nq} (want ${nDucks * 21 + 7}), nu=${model.nu} (want ${nDucks * NUM_JOINTS})`); + } + info(`compiled: nq=${model.nq} nu=${model.nu}`); + + const sessions = await loadSessions(ort); + info('ONNX sessions ready: walk/kickL/kickR/stand'); + + const standKeyId = mujoco.mj_name2id(model, mujoco.mjtObj.mjOBJ_KEY.value, 'STAND'); + const ballQposAdr = model.jnt('ball_freejoint').qposadr; + const ballDofAdr = model.jnt('ball_freejoint').dofadr; + + // ── 鸭子实例 + 地址 + AI agent ── + const ducks = FOOTBALL_CONFIG.ducks.map((dc, i) => { + const addrs = resolveAddrs(mujoco, model, dc.prefix); + addrs.ctrlOffset = i * NUM_JOINTS; + const d = createDuckInstance(i, dc, addrs, null); + const s = SPAWN_POSITIONS[i]; + d.agent = createAgent(d, { team: d.team, role: d.role, spawnX: s.x, spawnY: s.y }); + return d; + }); + + // ── 观测 / 状态构建(复刻 game.js 的 per-duck 逻辑)── + function buildObsForDuck(duck) { + const { qposAdr: qa, dofAdr: da, gyroAdr: ga, trunkId: ti } = duck.addrs; + const buf = duck.obsBuf; + const q = data.qpos, v = data.qvel, s = data.sensordata; + let i = 0; + for (let a = 0; a < 3; a++) buf[i++] = s[ga + a]; + const g = projGravity(data.body(ti).xquat); + buf[i++] = g[0]; buf[i++] = g[1]; buf[i++] = g[2]; + for (let j = 0; j < NUM_JOINTS; j++) buf[i++] = q[qa[j]] - DEFAULT_POSE[j]; + for (let j = 0; j < NUM_JOINTS; j++) buf[i++] = v[da[j]]; + for (let j = 0; j < NUM_JOINTS; j++) buf[i++] = duck.lastAction[j]; + const kicking = duck.mode === 'kickL' || duck.mode === 'kickR'; + const cs = duck.cmdSm; + buf[i++] = kicking ? 0 : cs[0]; // vx + buf[i++] = 0; // 无横移 + buf[i++] = kicking ? 0 : cs[2]; // wz + for (let k = 3; k < CMD_SIZE; k++) buf[i++] = 0; + return buf; + } + + function duckProjGravZ(duck) { return projGravity(data.body(duck.addrs.trunkId).xquat)[2]; } + function duckPoseIsDead(duck) { + const z = data.qpos[duck.addrs.freejointQposAdr + 2]; + const gz = duckProjGravZ(duck); + if (!Number.isFinite(z) || !Number.isFinite(gz)) return 'exploded'; + if (gz > -0.5 || z < 0.02) return 'fallen'; + return null; + } + + function cacheDuckPoses() { + const q = data.qpos; + for (const duck of ducks) { + const fj = duck.addrs.freejointQposAdr; + duck.pos[0] = q[fj]; duck.pos[1] = q[fj + 1]; duck.pos[2] = q[fj + 2]; + duck.yaw = Math.atan2( + 2 * (q[fj + 3] * q[fj + 6] + q[fj + 4] * q[fj + 5]), + 1 - 2 * (q[fj + 5] * q[fj + 5] + q[fj + 6] * q[fj + 6]), + ); + } + } + + function buildGameState() { + const q = data.qpos, v = data.qvel; + return { + ball: { + x: q[ballQposAdr], y: q[ballQposAdr + 1], z: q[ballQposAdr + 2], + vx: v[ballDofAdr], vy: v[ballDofAdr + 1], + }, + ducks: ducks.map((d) => ({ + id: d.id, team: d.team, role: d.role, + x: d.pos[0], y: d.pos[1], yaw: d.yaw, fallen: !!d.recovery, + })), + self: null, + }; + } + + function buildRefereeState() { + const q = data.qpos; + return { + ball: { pos: [q[ballQposAdr], q[ballQposAdr + 1], q[ballQposAdr + 2]] }, + ducks: ducks + .filter((d) => !(d.penaltyTimer > 0) && !d.sentOff) + .map((d) => ({ + id: d.id, team: d.team, pos: [d.pos[0], d.pos[1], d.pos[2]], fallen: !!d.recovery, + })), + }; + } + + // ── 放置 / 复位(去掉渲染层的 applyVariant / celebration / store)── + function placeBall(pos) { + const q = data.qpos, v = data.qvel; + q[ballQposAdr] = pos[0]; q[ballQposAdr + 1] = pos[1]; q[ballQposAdr + 2] = pos[2]; + q[ballQposAdr + 3] = 1; q[ballQposAdr + 4] = 0; q[ballQposAdr + 5] = 0; q[ballQposAdr + 6] = 0; + for (let i = 0; i < 6; i++) v[ballDofAdr + i] = 0; + mujoco.mj_forward(model, data); + ballTeleported = true; + } + + function placeDuck(duck, pos, yaw = 0) { + const q = data.qpos, v = data.qvel; + const fj = duck.addrs.freejointQposAdr; + q[fj] = pos[0]; q[fj + 1] = pos[1]; q[fj + 2] = pos[2]; + q[fj + 3] = Math.cos(yaw / 2); q[fj + 4] = 0; q[fj + 5] = 0; q[fj + 6] = Math.sin(yaw / 2); + for (let j = 0; j < NUM_JOINTS; j++) q[duck.addrs.qposAdr[j]] = DEFAULT_POSE[j]; + const fd = duck.addrs.freejointDofAdr; + for (let i = 0; i < 6; i++) v[fd + i] = 0; + for (let j = 0; j < NUM_JOINTS; j++) v[duck.addrs.dofAdr[j]] = 0; + mujoco.mj_forward(model, data); + } + + function resetDuckState(duck) { + duck.recovery = null; duck.fallDebounce = 0; duck.fallenSince = null; + duck.mode = 'walk'; duck.kickRun = null; duck.postKickLock = 0; + duck.lastAction.fill(0); duck.cmd.fill(0); duck.cmdSm.fill(0); + } + + function resetDuck(duck) { + const dc = FOOTBALL_CONFIG.ducks[duck.id]; + placeDuck(duck, dc.spawn, dc.yaw ?? 0); + resetDuckState(duck); + } + + function placeDuckOffField(duck) { placeDuck(duck, [duck.pos[0], FIELD_HALF_W + 0.5, 0.12], 0); } + + function returnDuckFromPenalty(duck) { + const sp = SPAWN_POSITIONS[duck.id]; + placeDuck(duck, [sp.x, sp.y, 0.12], sp.yaw ?? 0); + resetDuckState(duck); + } + + function executeKickoff() { + placeBall(BALL_SPAWN); + for (const duck of ducks) { + if (duck.sentOff || duck.penaltyTimer > 0) continue; + const sp = SPAWN_POSITIONS[duck.id]; + placeDuck(duck, [sp.x, sp.y, 0.12], sp.yaw ?? 0); + resetDuckState(duck); + } + } + + function spawnBallFootball() { placeBall(BALL_SPAWN); } + + + const events = []; + function pushEvent(obj) { events.push(obj); } + function drainEvents() { return events.splice(0, events.length); } + +// ── AI 决策(10Hz)── + // 与 game.js 一致:整队鸭子通过 decideAll() 一起决策,chaser 选择与阵型槽 + // 能看到全队全貌;sin-bin / sent-off 鸭子整体跳过,recovery 鸭子发零 twist + // (stand 策略接管)且不进入 decideAll,避免被选为 chaser。 + function runAiDecisions() { + const gs = buildGameState(); + const teams = new Map(); + for (const duck of ducks) { + if (!duck.agent) continue; + if (duck.penaltyTimer > 0 || duck.sentOff) continue; + if (duck.recovery) { duck.cmd[0] = 0; duck.cmd[2] = 0; continue; } + const arr = teams.get(duck.team); + if (arr) arr.push(duck); else teams.set(duck.team, [duck]); + } + for (const teamDucks of teams.values()) processTeamDecisions(teamDucks, gs); + } + + // 对一队运行 decideAll() 并把返回命令写回真鸭子,套用与 game.js 相同的 + // non-finite 守卫、亚阈值 vx 兜底、IDLE_CREEP 静止兜底与踢球 one-shot。 + function processTeamDecisions(teamDucks, gs) { + if (!teamDucks.length) return; + const fin = (x) => (Number.isFinite(x) ? x : 0); + // 构造 decideAll 期望的纯视图。_ai 持久化在真鸭子身上,使 AIM 保险丝 + // (aimTicks)与踢球冷却 / 侧向偏置接近的跨帧状态(kickCooldown / + // kickHoldTicks)跨 tick 存活。chaserDecide 也会对缺字段惰性补齐,此处 + // 显式初始化以保持与真实鸭子状态结构一致。 + const views = teamDucks.map((d) => { + const sp = SPAWN_POSITIONS[d.id] || {}; + if (!d._ai) d._ai = { aimTicks: 0, stallTicks: 0, escapeTicks: 0, prevX: 0, prevY: 0, kickCooldown: 0, kickHoldTicks: 0 }; + return { + id: d.id, + pos: [fin(d.pos[0]), fin(d.pos[1])], + yaw: fin(d.yaw), + role: d.role, + fallen: !!d.recovery, + penalized: false, + team: d.team, + spawnX: d.agent?.spawnX ?? sp.x, + spawnY: d.agent?.spawnY ?? sp.y, + _ai: d._ai, + }; + }); + const cmds = decideAll(views, { ball: gs.ball, allDucks: gs.ducks, team: teamDucks[0].team }); + for (let i = 0; i < teamDucks.length; i++) { + const duck = teamDucks[i]; + const c = cmds[i] || { vx: 0, wz: 0, kick: false }; + const cmdVx = Number.isFinite(c.vx) ? c.vx : 0; + const cmdWz = Number.isFinite(c.wz) ? c.wz : 0; + // 亚阈值兜底:把极小正 vx 抬到有效步行速度,避免贴球蠕动被判静止。 + let outVx = (cmdVx > 0 && cmdVx < MIN_EFFECTIVE_VX) ? MIN_EFFECTIVE_VX : cmdVx; + // 命令静止(停车槽 / 门线)→ 温和的 IDLE_CREEP shuffle。 + outVx = outVx === 0 ? IDLE_CREEP : outVx; + duck.cmd[0] = outVx; + duck.cmd[2] = cmdWz; + if (c.kick && !duck.kickRun && duck.mode === 'walk' && duck.postKickLock === 0) { + duck.mode = duck._lastKick === 'kickL' ? 'kickR' : 'kickL'; + duck._lastKick = duck.mode; + duck.kickRun = { steps: 0 }; + } + } + } + + // 位置停滞看门狗(与 runAiDecisions 同在 AI_HZ 运行)。decideAll 的 cos 死区 + // 与 IDLE_CREEP 仍可能让鸭子物理上卡死(如两只前锋夹球、躯干卡进几何): + // 命令非零但位姿永不变化。检测连续 AI tick 上的实际位移,卡死足够久则用 + // 一段短暂的后退+转向逃逸动作覆盖 twist。 + function antiStuckCheck() { + const STALL_EPS = 0.005; // 每 AI tick 位移阈值 (m) = 10Hz 下 0.05 m/s, + // 低于所有 AI 巡航速度(IDLE_CREEP 0.22 / + // MIN_EFFECTIVE_VX 0.22 / RETURN_SPEED 0.25), + // 保证慢行但仍在动的鸭子不被误判为卡死。 + const STALL_LIMIT = 15; // 连续卡死 AI tick 数 → 逃逸 + const ESCAPE_DURATION = 10; // 逃逸动作长度(AI tick) + for (const duck of ducks) { + // 不与 recovery / sin-bin / send-off 的拥有者争抢,也跳过非 AI 鸭子。 + if (!duck.agent || duck.recovery || duck.penaltyTimer > 0 || duck.sentOff) continue; + const px = Number.isFinite(duck.pos[0]) ? duck.pos[0] : 0; + const py = Number.isFinite(duck.pos[1]) ? duck.pos[1] : 0; + if (!duck._lastPos) { + duck._lastPos = { x: px, y: py }; + duck._stallTicks = 0; + duck._escapeTicks = 0; + continue; + } + // 逃逸模式:覆盖 AI twist 直到动作预算耗尽。 + if (duck._escapeTicks > 0) { + duck.cmd[0] = -0.25; // 后退,高于 walk 死区 + duck.cmd[2] = (duck.id % 2 === 0) ? 0.6 : -0.6; // 交替转向 + duck._escapeTicks--; + duck._lastPos = { x: px, y: py }; + continue; + } + const dx = px - duck._lastPos.x; + const dy = py - duck._lastPos.y; + duck._stallTicks = Math.hypot(dx, dy) < STALL_EPS ? duck._stallTicks + 1 : 0; + duck._lastPos = { x: px, y: py }; + if (duck._stallTicks >= STALL_LIMIT) { + duck._escapeTicks = ESCAPE_DURATION; + // 按 id 的负初始值给全队去同步:鸭子同步起步、同步累积 stallTicks, + // 否则会在同一 tick 一起触发逃逸并集体失衡摔倒。id=0 可立即再次触发, + // id=1 需多等 ~0.5s,id=2 需多等 ~1s,依此类推。 + duck._stallTicks = -(duck.id * 5); + } + } + } + + function sessionFor(duck) { + if (duck.recovery?.state === 'recovering') return sessions.stand; + return sessions[duck.mode] ?? sessions.walk; + } + + function updateDuckStateFootball(duck, simTimeMs) { + if (duck.penaltyTimer > 0) duck.penaltyTimer = Math.max(0, duck.penaltyTimer - CTRL_DT); + if ((duck.mode === 'kickL' || duck.mode === 'kickR') && duck.kickRun) { + duck.kickRun.steps++; + if (duck.kickRun.steps >= KICK_STEPS) { + duck.kickRun = null; duck.mode = 'walk'; duck.postKickLock = POST_KICK_LOCK_STEPS; + } + } + if (duck.postKickLock > 0 && duck.mode === 'walk') duck.postKickLock--; + const death = duckPoseIsDead(duck); + if (death === 'exploded') { + resetDuck(duck); + } else if (duck.recovery) { + duck.recovery.steps++; + if (duck.recovery.state === 'fallen') { + if (duck.recovery.steps >= FALL_SETTLE_STEPS) { + duck.recovery = { state: 'recovering', steps: 0, uprightSteps: 0 }; + duck.lastAction.fill(0); + } + } else { + duck.recovery.uprightSteps = duckProjGravZ(duck) < -0.85 ? duck.recovery.uprightSteps + 1 : 0; + if (duck.recovery.uprightSteps >= RECOVER_UPRIGHT_STEPS) { + duck.recovery = null; duck.mode = 'walk'; duck.lastAction.fill(0); + } else if (duck.recovery.steps >= RECOVER_GIVEUP_STEPS) { + resetDuck(duck); + } + } + } else if (death === 'fallen') { + if (duck.mode === 'walk' && duck.postKickLock === 0) { + duck.fallenSince = null; + if (++duck.fallDebounce >= FALL_DEBOUNCE_STEPS) { + duck.fallDebounce = 0; + duck.recovery = { state: 'fallen', steps: 0 }; + everFallen.add(duck.id); + } + } else { + duck.fallDebounce = 0; + duck.fallenSince ??= simTimeMs; + if (simTimeMs - duck.fallenSince > 1000) resetDuck(duck); + } + } else { + duck.fallDebounce = 0; duck.fallenSince = null; + } + } + + // ── 指标 / 日志状态 ── + let ballTeleported = false; + const everFallen = new Set(); + let goals = 0, penalties = 0, maxBallSpeed = 0, ballTotalDistance = 0; + let collectivePenalty = false, exploded = false, ballMoved = false; + let prevBall = null, prevState = 'IDLE'; + const BALL_JUMP_EPS = 0.3; // 单步位移超过此值视为放置 teleport,不计入距离 + const BALL_MOVE_EPS = 0.05; // 累计位移超过此值视为"球被踢动" + + // ── referee ── + const referee = createReferee({ + onEvent: (type, payload) => { + const t = simTime(); + switch (type) { + case 'kickoff': executeKickoff(); break; + case 'playing': break; + case 'goal': + goals++; + pushEvent({ event: 'goal', team: payload.team, t }); + break; + case 'goal_disallowed': pushEvent({ event: 'goal_disallowed', team: payload.team, t }); break; + case 'throw_in': case 'goal_kick': case 'corner_red': case 'corner_blue': + if (payload.pos) placeBall(payload.pos); + pushEvent({ event: 'set_piece', type, team: payload.team, t }); + break; + case 'penalty': { + penalties++; + const duck = ducks[payload.duckId]; + if (duck) { duck.penaltyTimer = PENALTY_DURATION_S; placeDuckOffField(duck); } + pushEvent({ event: 'penalty', duckId: payload.duckId, team: duck?.team, reason: payload.reason, t }); + break; + } + case 'penalty_returned': { + const duck = ducks[payload.duckId]; + if (duck) { duck.penaltyTimer = 0; returnDuckFromPenalty(duck); } + pushEvent({ event: 'penalty_returned', duckId: payload.duckId, t }); + break; + } + case 'penalty_reset': { + const duck = ducks[payload.duckId]; + if (duck) duck.penaltyTimer = PENALTY_DURATION_S; + break; + } + case 'yellow_card': { const d = ducks[payload.duckId]; if (d) d.cards.yellow++; break; } + case 'red_card': { + const d = ducks[payload.duckId]; + if (d) { d.cards.red++; d.sentOff = true; placeDuckOffField(d); } + pushEvent({ event: 'red_card', duckId: payload.duckId, team: d?.team, t }); + break; + } + case 'fulltime': pushEvent({ event: 'fulltime', result: payload.result, score: payload.score, t }); break; + default: break; + } + }, + }); + + let step = 0; + const simTime = () => Number((step * CTRL_DT).toFixed(2)); + + // ── 复位到 STAND keyframe 并开球 ── + mujoco.mj_resetDataKeyframe(model, data, standKeyId); + mujoco.mj_forward(model, data); + for (const d of ducks) d.lastAction.fill(0); + cacheDuckPoses(); + referee.startMatch(); + + let aiPhase = 0; + + // Initial keyframe + kickoff already done above. + // Expose RL / control API. + + function applyCmdToDuck(duck, c, { idleCreep = true } = {}) { + if (!duck || duck.penaltyTimer > 0 || duck.sentOff || duck.recovery) { + if (duck) { duck.cmd[0] = 0; duck.cmd[2] = 0; } + return; + } + const cmdVx = Number.isFinite(c.vx) ? c.vx : 0; + const cmdWz = Number.isFinite(c.wz) ? c.wz : 0; + let outVx = (cmdVx > 0 && cmdVx < MIN_EFFECTIVE_VX) ? MIN_EFFECTIVE_VX : cmdVx; + if (idleCreep && outVx === 0) outVx = IDLE_CREEP; + duck.cmd[0] = outVx; + duck.cmd[2] = cmdWz; + if (c.kick && !duck.kickRun && duck.mode === 'walk' && duck.postKickLock === 0) { + duck.mode = duck._lastKick === 'kickL' ? 'kickR' : 'kickL'; + duck._lastKick = duck.mode; + duck.kickRun = { steps: 0 }; + } + } + + function applyTeamActions(team, actions, { idleCreep = false } = {}) { + const teamDucks = ducks.filter((d) => d.team === team && d.agent && !(d.penaltyTimer > 0) && !d.sentOff && !d.recovery); + for (let i = 0; i < teamDucks.length; i++) { + const a = actions[i] || { vx: 0, wz: 0, kick: false }; + applyCmdToDuck(teamDucks[i], a, { idleCreep }); + } + } + + function runOpponentDecideAll(team, tuneOverlay) { + const gs = buildGameState(); + const teamDucks = ducks.filter((d) => { + if (!d.agent || d.team !== team) return false; + if (d.penaltyTimer > 0 || d.sentOff) return false; + if (d.recovery) { d.cmd[0] = 0; d.cmd[2] = 0; return false; } + return true; + }); + if (!teamDucks.length) return; + const fin = (x) => (Number.isFinite(x) ? x : 0); + const views = teamDucks.map((d) => { + const sp = SPAWN_POSITIONS[d.id] || {}; + if (!d._ai) d._ai = { aimTicks: 0, stallTicks: 0, escapeTicks: 0, prevX: 0, prevY: 0, kickCooldown: 0, kickHoldTicks: 0 }; + return { + id: d.id, + pos: [fin(d.pos[0]), fin(d.pos[1])], + yaw: fin(d.yaw), + role: d.role, + fallen: !!d.recovery, + penalized: false, + team: d.team, + spawnX: d.agent?.spawnX ?? sp.x, + spawnY: d.agent?.spawnY ?? sp.y, + _ai: d._ai, + }; + }); + const cmds = decideAll(views, { + ball: gs.ball, + allDucks: gs.ducks, + team, + tuneOverlay: tuneOverlay || undefined, + }); + for (let i = 0; i < teamDucks.length; i++) { + applyCmdToDuck(teamDucks[i], cmds[i] || { vx: 0, wz: 0, kick: false }, { idleCreep: true }); + } + } + + async function controlStep() { + // 50Hz: smooth + onnx + mj_step + referee + for (const duck of ducks) { + const cs = duck.cmdSm, c = duck.cmd; + cs[0] += (c[0] - cs[0]) * CMD_SMOOTH_ALPHA; + cs[2] += (c[2] - cs[2]) * CMD_SMOOTH_ALPHA; + } + const ctrl = data.ctrl; + for (const duck of ducks) { + if (duck.penaltyTimer > 0 || duck.sentOff || duck.recovery?.state === 'fallen') continue; + const feeds = { obs: new ort.Tensor('float32', buildObsForDuck(duck), [1, OBS_SIZE]) }; + const out = await sessionFor(duck).run(feeds); + const act = out.actions.data; + duck.lastAction.set(act); + const off = duck.addrs.ctrlOffset; + for (let j = 0; j < NUM_JOINTS; j++) ctrl[off + j] = DEFAULT_POSE[j] + act[j] * ACTION_SCALE; + } + ballTeleported = false; + for (let s = 0; s < DECIMATION; s++) mujoco.mj_step(model, data); + cacheDuckPoses(); + referee.step(CTRL_DT, buildRefereeState()); + for (const duck of ducks) updateDuckStateFootball(duck, step * CTRL_DT * 1000); + const refState = referee.getState(); + if (!refState || refState === 'PLAYING' || refState === 'KICKOFF') { + const q = data.qpos; + const limX = FOOTBALL_CONFIG.field.halfX + 0.1; + const limY = FOOTBALL_CONFIG.field.halfY + 0.1; + if (Math.abs(q[ballQposAdr]) > limX || Math.abs(q[ballQposAdr + 1]) > limY) spawnBallFootball(); + } + const q = data.qpos; + const bx = q[ballQposAdr], by = q[ballQposAdr + 1], bz = q[ballQposAdr + 2]; + if (!Number.isFinite(bx) || !Number.isFinite(by) || !Number.isFinite(bz) + || ducks.some((d) => !Number.isFinite(d.pos[0]) || !Number.isFinite(d.pos[1]))) { + exploded = true; + } + step += 1; + return { state: refState, exploded }; + } + + function reset() { + events.length = 0; + goals = 0; + penalties = 0; + exploded = false; + step = 0; + aiPhase = 0; + everFallen.clear(); + for (const d of ducks) { + d.penaltyTimer = 0; + d.sentOff = false; + d.cards.yellow = 0; + d.cards.red = 0; + resetDuckState(d); + d._ai = undefined; + d._lastPos = undefined; + d._stallTicks = 0; + d._escapeTicks = 0; + } + mujoco.mj_resetDataKeyframe(model, data, standKeyId); + mujoco.mj_forward(model, data); + for (const d of ducks) d.lastAction.fill(0); + cacheDuckPoses(); + // Recreate referee for clean FSM + // NOTE: referee is const — call startMatch after executeKickoff path + referee.startMatch(); + return getObs(); + } + + function getObs() { + cacheDuckPoses(); + const gs = buildGameState(); + const score = referee.getScore(); + return { + ball: gs.ball, + ducks: gs.ducks, + score: { red: score.red, blue: score.blue }, + matchState: referee.getState(), + matchTime: referee.getMatchTime?.() ?? step * CTRL_DT, + step, + }; + } + + function teamDuckIds(team) { + return ducks.filter((d) => d.team === team).map((d) => d.id); + } + + info('football-sim ready'); + + return { + CTRL_DT, + AI_DIVIDER, + ducks, + learnerTeam: opts.learnerTeam === 'blue' ? 'blue' : 'red', + reset, + getObs, + drainEvents, + applyTeamActions, + runOpponentDecideAll, + runAiDecisions, + antiStuckCheck, + controlStep, + teamDuckIds, + getScore: () => referee.getScore(), + getState: () => referee.getState(), + isExploded: () => exploded, + }; +} + +export const VEL_LIMITS = { vxMin: -0.2, vxMax: 0.25, wzMax: 1.0 }; +export { CTRL_DT, AI_DIVIDER }; + From 4b20668109db949a21681dafff40d0c63cd27ac5 Mon Sep 17 00:00:00 2001 From: ivyvi <130733091+yerxiiiii@users.noreply.github.com> Date: Wed, 9 Sep 2026 01:10:11 +0800 Subject: [PATCH 2/2] Harden goal detection, add per-team loco, and persist match debug logs. Slow rolls and post-glance paths no longer get stolen as goal-kicks; each side can pick walk/rollers independently; every kickoff keeps a downloadable forensic log via football.matchLog. Co-authored-by: Cursor --- app/src/football/FootballCanvas.jsx | 5 +- app/src/football/FootballHud.jsx | 68 ++- app/src/football/FootballTitle.jsx | 10 +- app/src/football/StrategyPanel.jsx | 71 +++ app/src/football/i18n.js | 24 + app/src/game/football/ai/index.js | 318 +++++++++++- app/src/game/football/duck-instance.js | 2 + app/src/game/football/loco.js | 66 +++ app/src/game/football/match-debug-log.js | 238 +++++++++ app/src/game/football/match-stats.js | 5 +- app/src/game/football/referee.js | 83 +++- app/src/game/football/strategy.js | 14 +- app/src/game/game.js | 602 +++++++++++++++++++++-- app/src/store.js | 13 +- app/test/ai.test.js | 157 +++++- app/test/loco.test.js | 34 ++ app/test/match-debug-log.test.js | 35 ++ app/test/referee.test.js | 74 ++- app/test/strategy.test.js | 2 +- 19 files changed, 1720 insertions(+), 101 deletions(-) create mode 100644 app/src/game/football/loco.js create mode 100644 app/src/game/football/match-debug-log.js create mode 100644 app/test/loco.test.js create mode 100644 app/test/match-debug-log.test.js diff --git a/app/src/football/FootballCanvas.jsx b/app/src/football/FootballCanvas.jsx index 88ef1ef..ac61008 100644 --- a/app/src/football/FootballCanvas.jsx +++ b/app/src/football/FootballCanvas.jsx @@ -29,7 +29,8 @@ function FootballGame() { useFrame((_, dt) => { gameApi.frame?.(Math.min(dt, 0.05)); }); - // After the main R3F pass: blit team FPV eye-cams into HUD canvases. + // After the main R3F pass: blit team FPV into HUD canvases. Must restore + // the full CSS viewport afterward or the *next* main pass paints black. useFrame((_, dt) => { gameApi.renderTeamFpv?.(Math.min(dt, 0.05)); }, -1); @@ -49,7 +50,7 @@ export default function FootballCanvas() { + ); } @@ -201,6 +202,55 @@ function DanmakuToggleButton() { ); } +function SpeedToggleButton() { + const locale = useGame((s) => s.locale) || "en"; + const simSpeed = useGame((s) => s.simSpeed) || 1; + const n = simSpeed === 2 || simSpeed === 3 ? simSpeed : 1; + return ( + + { + if (typeof gameApi.cycleSimSpeed === "function") { + gameApi.cycleSimSpeed(); + } else { + const cur = useGame.getState().simSpeed; + const next = cur >= 3 ? 1 : (Number(cur) || 1) + 1; + useGame.setState({ simSpeed: next }); + try { localStorage.setItem("microduck-sim-speed", String(next)); } catch { /* private */ } + } + uiClick(); + }} + sx={{ + appearance: "none", + border: "none", + background: "transparent", + color: n > 1 ? ORANGE : CREAM, + cursor: "pointer", + fontFamily: MONO, + fontSize: "0.65rem", + letterSpacing: "0.08em", + textTransform: "uppercase", + lineHeight: 1, + minWidth: "2.4em", + "&:hover": { color: ORANGE }, + }} + > + {t(locale, "speedLabel", { n: String(n) })} + + + ); +} + function Scoreboard() { const score = useGame((s) => s.score); const matchTime = useGame((s) => s.matchTime); @@ -306,6 +356,9 @@ export function MatchResultBanner() { const redTag = strategyTag(locale, tacticsCard?.strategy?.red); const blueTag = strategyTag(locale, tacticsCard?.strategy?.blue); + const locoTag = tacticsCard?.loco === "rollers" + ? t(locale, "locoRollers") + : t(locale, "locoLegs"); const shotsR = tacticsCard?.shots?.red ?? 0; const shotsB = tacticsCard?.shots?.blue ?? 0; const possPct = Math.round(clamp01(tacticsCard?.possession?.redPct ?? 0.5) * 100); @@ -448,6 +501,19 @@ export function MatchResultBanner() { {t(locale, "boardVs")} {blueTag} + + {t(locale, "locoMode")} · {locoTag} + s.touchMode); const entered = useGame((s) => s.entered); const locale = useGame((s) => s.locale) || "en"; + const rollersLoading = useGame((s) => s.rollersLoading); + const locoSwitching = useGame((s) => s.locoSwitching); const [closing, setClosing] = useState(false); const prevOpen = useRef(menuOpen); // Latches once the kickoff has fired, so a pause reopen reads "Resume" @@ -137,9 +139,10 @@ export default function FootballTitle({ onKickOff }) { }, [brandReady]); // No MenuDuck stage here: the live pitch (six ducks, already playing) - // is the character select - it renders behind this overlay and takes - // over the moment the gate drops. - const ready = fontsReady && brandReady && (bootDone || bootFailed); + // Hold Kick Off while a loco pack is still compiling. + const locoBusy = !!(rollersLoading || locoSwitching); + const ready = fontsReady && brandReady && (bootDone || bootFailed) && !locoBusy; + // Keep the overlay mounted through the 0.35 s closing fade. useEffect(() => { @@ -470,6 +473,7 @@ export default function FootballTitle({ onKickOff }) { size="medium" onDark data-cta="1" + disabled={!ready} onClick={kickoff} > {/* Pad prompt: ink circle with the A face button, mirroring diff --git a/app/src/football/StrategyPanel.jsx b/app/src/football/StrategyPanel.jsx index d523b15..e6f1d7b 100644 --- a/app/src/football/StrategyPanel.jsx +++ b/app/src/football/StrategyPanel.jsx @@ -53,6 +53,62 @@ function patchTeam(team) { return team; } +function LocoPicker({ compact }) { + const locale = useGame((s) => s.locale) || "en"; + const userTeam = useGame((s) => s.userTeam) || "red"; + const locoByTeam = useGame((s) => s.locoByTeam) || { red: "legs", blue: "legs" }; + const rollersLoading = useGame((s) => s.rollersLoading); + const locoSwitching = useGame((s) => s.locoSwitching); + const busy = rollersLoading || locoSwitching; + const rival = userTeam === "red" ? "blue" : "red"; + const myLoco = locoByTeam[userTeam] === "rollers" ? "rollers" : "legs"; + const rivalLoco = locoByTeam[rival] === "rollers" ? "rollers" : "legs"; + const labels = { + legs: t(locale, "locoLegs"), + rollers: busy ? t(locale, "locoLoading") : t(locale, "locoRollers"), + }; + return ( + + gameApi.setTeamLoco?.(userTeam, name)} + disabled={busy} + /> + gameApi.setTeamLoco?.(rival, name)} + disabled={busy} + /> + + {t(locale, "locoSplitHint")} + + + ); +} + function ChipGroup({ label, options, labels, value, onChange, disabled }) { return ( @@ -310,6 +366,21 @@ export default function StrategyPanel({ compact = false, formationLocked = false disabled={formationLocked} /> + + + + {t(locale, "tacticsSection")} + + 2/3 of the view + BLOCK_HALF_W: 0.14, // opponent half-width for angular size (m) + BLOCK_HOLD_TICKS: 30, // 3.0s @ 10Hz AI + BLOCK_RETREAT_TICKS: 12, // ~1.2s reverse peel + BLOCK_BACK_SPEED: -0.2, // body reverse (VX_MIN) + + // Ball find / reacquire: chaser treats FOV like a camera. + // If the ball leaves the frontal cone for BALL_LOST_TICKS → scan burst. + BALL_FOV: 1.36, // same ~78° cone as FPV / block + BALL_LOST_TICKS: 20, // 2.0s @ 10Hz without ball in view + BALL_SCAN_TICKS: 14, // ~1.4s spin+creep reacquire + BALL_SCAN_SPEED: 0.12, // slow forward while scanning + // Goalkeeper GK_LINE_OFFSET: 0.1, GK_Y_MARGIN: 0.1, @@ -238,7 +260,11 @@ export function chaseCost(duck, ball, prevChaserId = -1, attackDir = 1) { cost += TUNE.CLAIM_WRONG_SIDE_PENALTY; } - if (duck.id === prevChaserId) cost -= TUNE.CHASE_HYSTERESIS; + if (duck.id === prevChaserId) { + cost -= TUNE.CHASE_HYSTERESIS; + // Just kicked — stay on the ball instead of yielding and walking to support. + if ((duck._ai?.postKickClaim | 0) > 0) cost -= 0.45; + } return cost; } @@ -249,6 +275,8 @@ export function chaseCost(duck, ball, prevChaserId = -1, attackDir = 1) { function chaserClaimExpired(duck, ball, attackDir) { if (!duck?._ai) return false; const ai = duck._ai; + // Post-kick: never drop claim mid-follow-through. + if ((ai.postKickClaim | 0) > 0) return false; const [x, y] = duckXY(duck); const targetGoalX = attackDir * FIELD_HALF_L; const behind = isBehindBall(x, y, ball, targetGoalX); @@ -650,6 +678,42 @@ export function isBehindBall(sx, sy, ball, targetGoalX) { return nearEdge ? proj < 0.1 : proj < 0; } +/** + * Pitch / goal awareness: kick impulse follows body yaw. + * Refuse boots whose forward axis points back toward our own goal. + * + * @param {number} yaw + * @param {number} attackDir +1 red / −1 blue + * @param {{x:number,y?:number}} ball + * @param {number} defendGoalX + * @param {number} [minCos] override upfield cosine gate + */ +export function isKickSafe(yaw, attackDir, ball, defendGoalX, minCos) { + const ownHalf = ball.x * attackDir < 0; + const need = minCos != null + ? minCos + : (ownHalf ? TUNE.CLEAR_UPFIELD_COS : TUNE.KICK_UPFIELD_COS); + // Body +X is cos(yaw). Red attacks +X → need cos>0; blue attacks −X → cos<0. + if (Math.cos(yaw) * attackDir < need) return false; + // Prefer opponent goal over own goal bearing (blocks diagonal own-goal boots). + const by = ball.y || 0; + const toOwn = angleTo(ball.x, by, defendGoalX, 0); + const toOpp = angleTo(ball.x, by, -defendGoalX, 0); + if (Math.abs(angleDiff(yaw, toOwn)) + 0.12 < Math.abs(angleDiff(yaw, toOpp))) { + return false; + } + return true; +} + +/** + * Desired kick facing: full shot in attack half; clear straight upfield in own half. + */ +function kickAimYaw(ball, attackDir, targetGoalX) { + const ownHalf = ball.x * attackDir < 0; + if (ownHalf) return attackDir > 0 ? 0 : Math.PI; + return angleTo(ball.x, ball.y || 0, targetGoalX, 0); +} + /** * Chaser: CHASE → ARRIVE (behind ball) → AIM / SHOOT. * @@ -661,16 +725,18 @@ export function isBehindBall(sx, sy, ball, targetGoalX) { function chaserDecide(duck, ctx) { const [sx, sy] = duckXY(duck); const yaw = duck.yaw || 0; - const { ball, targetGoalX } = ctx; + const { ball, targetGoalX, defendGoalX, attackDir } = ctx; const bd = distToBall(duck, ball); const toBall = angleToBall(duck, ball); - const shotDir = angleTo(ball.x, ball.y, targetGoalX, 0); + // Own half → clear straight upfield; attack half → aim opponent goal mouth. + const shotDir = kickAimYaw(ball, attackDir, targetGoalX); const ap = chaseApproachPoint(ball, targetGoalX, sx, sy); const apDist = distanceTo(sx, sy, ap.x, ap.y); const arrived = apDist <= TUNE.APPROACH_ARRIVE_EPS; const behind = isBehindBall(sx, sy, ball, targetGoalX); const ballAhead = Math.cos(angleDiff(yaw, toBall)) > 0.12; const alignErr = Math.abs(angleDiff(yaw, shotDir)); + const ownHalf = ball.x * attackDir < 0; if (!duck._ai) { duck._ai = { @@ -685,8 +751,10 @@ function chaserDecide(duck, ctx) { if (ai.atFeetTicks === undefined) ai.atFeetTicks = 0; if (ai.pokeArmed === undefined) ai.pokeArmed = false; if (ai.orbitTicks === undefined) ai.orbitTicks = 0; + if (ai.postKickClaim === undefined) ai.postKickClaim = 0; if (ai.kickCooldown > 0) ai.kickCooldown--; + if (ai.postKickClaim > 0) ai.postKickClaim--; if (bd <= TUNE.SHOOT_DIST) ai.atFeetTicks++; else { @@ -753,23 +821,42 @@ function chaserDecide(duck, ctx) { turnToward(yaw, toSlide), ); } - const soft = stuckAtFeet || ai.aimTicks >= TUNE.AIM_SOFT_TICKS; - const alignLim = soft ? TUNE.SHOOT_ANGLE * 1.85 : TUNE.SHOOT_ANGLE; + const soft = !ownHalf && (stuckAtFeet || ai.aimTicks >= TUNE.AIM_SOFT_TICKS); + const softMult = TUNE.AIM_SOFT_MULT || 1.35; + // Own half: no soft widen — clears must actually face upfield. + const alignLim = soft ? TUNE.SHOOT_ANGLE * softMult : TUNE.SHOOT_ANGLE; const aligned = alignErr <= alignLim; - if (aligned && ballAhead) { + const kickSafe = isKickSafe(yaw, attackDir, ball, defendGoalX); + // Kick only at true contact range — SHOOT_DIST is for AIM/creep, not air boots. + const inContact = bd <= TUNE.KICK_CONTACT + 1e-4; + if (aligned && ballAhead && inContact && kickSafe) { ai.aimTicks = 0; if (ai.kickCooldown > 0) { ai.kickHoldTicks = 0; - const lateralWz = (sy > ball.y) ? -TUNE.WZ_MAX * 0.5 : TUNE.WZ_MAX * 0.5; - return limitCommand(TUNE.AIM_CREEP * 0.6, lateralWz, false); + // Stay on the ball during cooldown — creep in, don't wander off. + return limitCommand( + creepToward(yaw, toBall, TUNE.AIM_CREEP * 0.7), + turnToward(yaw, shotDir), + false, + ); } ai.kickHoldTicks++; if (ai.kickHoldTicks >= TUNE.KICK_HOLD_BEFORE_COOLDOWN) { ai.kickHoldTicks = 0; ai.kickCooldown = TUNE.KICK_COOLDOWN_TICKS; } + ai.postKickClaim = TUNE.POST_KICK_CLAIM_TICKS; return limitCommand(TUNE.SHOOT_SPEED, turnToward(yaw, shotDir), true); } + // Close enough / aligned but kick not safe yet — walk in or turn upfield, never poke. + if (aligned && ballAhead && !inContact) { + ai.kickHoldTicks = 0; + return limitCommand( + moveToward(yaw, toBall, TUNE.CHASE_SPEED), + turnToward(yaw, shotDir), + false, + ); + } // AIM — creep along shotDir while turning; scale down when badly misaligned. ai.kickHoldTicks = 0; ai.aimTicks++; @@ -783,8 +870,10 @@ function chaserDecide(duck, ctx) { ); } const alignFactor = clamp(1 - alignErr / (Math.PI * 0.55), 0.35, 1); + // In own half while unsafe: turn harder, creep less (don't shove into own net). + const creep = (!kickSafe && ownHalf) ? TUNE.AIM_CREEP * 0.35 : TUNE.AIM_CREEP * alignFactor; return limitCommand( - creepToward(yaw, shotDir, TUNE.AIM_CREEP * alignFactor), + creepToward(yaw, shotDir, creep), turnToward(yaw, shotDir), false, ); @@ -919,11 +1008,12 @@ function defenderFormation(duck, ctx, sx, sy, yaw, ball, attackDir, defendGoalX, } // CLEAR only if somehow at feet (should be rare for non-chaser) — still require behind. - if (bd <= TUNE.DEF_CLEAR_DIST && ballInOwnHalf) { - const shotDir = angleTo(ball.x, ball.y, targetGoalX, 0); + if (bd <= Math.min(TUNE.DEF_CLEAR_DIST, TUNE.KICK_CONTACT) + 1e-4 && ballInOwnHalf) { + const clearDir = attackDir > 0 ? 0 : Math.PI; if (isBehindBall(sx, sy, ball, targetGoalX) - && Math.abs(angleDiff(yaw, shotDir)) <= TUNE.SHOOT_ANGLE * 1.4) { - return limitCommand(TUNE.DEF_CHASE_SPEED * 0.8, turnToward(yaw, shotDir, TUNE.DEF_TURN_GAIN), true); + && Math.abs(angleDiff(yaw, clearDir)) <= TUNE.SHOOT_ANGLE * 1.2 + && isKickSafe(yaw, attackDir, ball, defendGoalX)) { + return limitCommand(TUNE.DEF_CHASE_SPEED * 0.8, turnToward(yaw, clearDir, TUNE.DEF_TURN_GAIN), true); } const ap = chaseApproachPoint(ball, targetGoalX, sx, sy); const wp = pathSkimsBall(sx, sy, ap.x, ap.y, ball) @@ -932,7 +1022,7 @@ function defenderFormation(duck, ctx, sx, sy, yaw, ball, attackDir, defendGoalX, const toWp = angleTo(sx, sy, wp.x, wp.y); return limitCommand( moveToward(yaw, toWp, TUNE.DEF_CHASE_SPEED), - turnToward(yaw, toWp, TUNE.DEF_TURN_GAIN), + turnToward(yaw, clearDir, TUNE.DEF_TURN_GAIN), false, ); } @@ -985,9 +1075,173 @@ function defenderFormation(duck, ctx, sx, sy, yaw, ball, attackDir, defendGoalX, } // ═══════════════════════════════════════════════════════════════════════════════ -// Anti-stuck +// Vision proxies + anti-stuck (FOV ball find / face-off retreat) // ═══════════════════════════════════════════════════════════════════════════════ +/** + * True when the ball lies in the frontal FOV cone (geometric "camera"). + */ +export function ballInFov(sx, sy, yaw, ball, fov = TUNE.BALL_FOV) { + const by = ball.y || 0; + const bearing = angleTo(sx, sy, ball.x, by); + const err = Math.abs(angleDiff(yaw, bearing)); + if (err > fov * 0.5) return false; + return Math.cos(angleDiff(yaw, bearing)) > 0.05; +} + +/** + * Fraction of frontal FOV filled by an opponent (0..1+). + * Proxy for "对方鸭子占画面超过 2/3" without reading pixels. + */ +export function opponentFovFill(sx, sy, yaw, opp) { + const [ox, oy] = duckXY(opp); + const dist = distanceTo(sx, sy, ox, oy); + if (dist < 1e-3) return 1; + const bearing = angleTo(sx, sy, ox, oy); + const err = Math.abs(angleDiff(yaw, bearing)); + const halfFov = TUNE.BLOCK_FOV * 0.5; + if (err > halfFov) return 0; + // Must be in front — behind-the-back blockers don't count as "in view". + if (Math.cos(angleDiff(yaw, bearing)) <= 0.05) return 0; + const ang = 2 * Math.atan(TUNE.BLOCK_HALF_W / dist); + const center = Math.max(0, 1 - err / halfFov); + return (ang / TUNE.BLOCK_FOV) * (0.55 + 0.45 * center); +} + +/** + * Closest opponent that currently fills the most FOV (or null). + */ +function heaviestFovBlocker(duck, ctx) { + const [sx, sy] = duckXY(duck); + const yaw = duck.yaw || 0; + const opps = getOpponents(duck, ctx.allDucks || ctx.ducks || [], ctx.team); + let best = null; + let bestFill = 0; + for (const o of opps) { + if (o.fallen || o.penalized || o.sentOff) continue; + const fill = opponentFovFill(sx, sy, yaw, o); + if (fill > bestFill) { + bestFill = fill; + best = o; + } + } + return best ? { opp: best, fill: bestFill } : null; +} + +/** + * Chaser lost-ball search: if the ball leaves FOV for BALL_LOST_TICKS, + * spin+creep to reacquire instead of walking with our back to the play. + */ +function maybeBallSearch(duck, ctx) { + if (duck.role === 'goalkeeper') return null; + if (duck.id !== ctx.chaserId) return null; + const [sx, sy] = duckXY(duck); + const yaw = duck.yaw || 0; + if (!duck._ai) { + duck._ai = { + aimTicks: 0, stallTicks: 0, escapeTicks: 0, prevX: sx, prevY: sy, + ballLostTicks: 0, scanTicks: 0, scanDir: 1, + }; + } + const ai = duck._ai; + if (ai.ballLostTicks === undefined) ai.ballLostTicks = 0; + if (ai.scanTicks === undefined) ai.scanTicks = 0; + if (ai.scanDir === undefined) ai.scanDir = 1; + + const seen = ballInFov(sx, sy, yaw, ctx.ball); + if (seen) { + ai.ballLostTicks = 0; + // Finish an in-progress scan early once the ball is back in view. + if (ai.scanTicks > 0) ai.scanTicks = 0; + return null; + } + + ai.ballLostTicks++; + const toBall = angleToBall(duck, ctx.ball); + + // Active scan burst: sweep while creeping so we reacquire then chase. + if (ai.scanTicks > 0) { + ai.scanTicks--; + const spin = ai.scanDir * TUNE.WZ_MAX; + const toward = turnToward(yaw, toBall); + // Mostly spin; keep a bias toward the true ball bearing. + const wz = clamp(0.7 * spin + 0.3 * toward, -TUNE.WZ_MAX, TUNE.WZ_MAX); + return limitCommand(TUNE.BALL_SCAN_SPEED, wz, false); + } + + if (ai.ballLostTicks < TUNE.BALL_LOST_TICKS) { + // Soft reacquire: if we've been blind briefly, prefer facing the ball + // over whatever the role policy wanted — handled by returning null and + // letting chase turn; only hard-scan after the full lost window. + return null; + } + + // Trigger a scan sweep. + ai.ballLostTicks = 0; + ai.scanTicks = TUNE.BALL_SCAN_TICKS; + ai.scanDir = ai.scanDir < 0 ? 1 : -1; + return limitCommand(TUNE.BALL_SCAN_SPEED, ai.scanDir * TUNE.WZ_MAX, false); +} + +/** + * If an opponent paints >2/3 of the FOV for BLOCK_HOLD_TICKS, reverse out. + * Skips when the ball is in view and nearer than the blocker (not a true face-off). + */ +function maybeFaceOffRetreat(duck, ctx) { + if (duck.role === 'goalkeeper') return null; + const [sx, sy] = duckXY(duck); + const yaw = duck.yaw || 0; + if (!duck._ai) { + duck._ai = { + aimTicks: 0, stallTicks: 0, escapeTicks: 0, prevX: sx, prevY: sy, + blockTicks: 0, retreatTicks: 0, + }; + } + const ai = duck._ai; + if (ai.blockTicks === undefined) ai.blockTicks = 0; + if (ai.retreatTicks === undefined) ai.retreatTicks = 0; + + const hit = heaviestFovBlocker(duck, ctx); + let blocked = !!(hit && hit.fill >= TUNE.BLOCK_FILL); + // Ball is the focus and closer than the opponent → clutter, not a crash. + if (blocked && hit) { + const bd = distToBall(duck, ctx.ball); + const [ox, oy] = duckXY(hit.opp); + const od = distanceTo(sx, sy, ox, oy); + if (ballInFov(sx, sy, yaw, ctx.ball) && bd < od * 0.9) blocked = false; + } + + // Already peeling — keep reversing with a side turn to break the axis. + if (ai.retreatTicks > 0) { + ai.retreatTicks--; + if (!blocked) ai.blockTicks = 0; + const opp = hit?.opp; + let wz = 0; + if (opp) { + const [ox, oy] = duckXY(opp); + const toOpp = angleTo(sx, sy, ox, oy); + const side = Math.sign(angleDiff(yaw, toOpp)) || (sy >= oy ? 1 : -1); + wz = side * TUNE.WZ_MAX * 0.7; + } else { + wz = (sy >= 0 ? 1 : -1) * TUNE.WZ_MAX * 0.5; + } + return limitCommand(TUNE.BLOCK_BACK_SPEED, wz, false); + } + + if (blocked) ai.blockTicks++; + else ai.blockTicks = 0; + + if (ai.blockTicks < TUNE.BLOCK_HOLD_TICKS) return null; + + ai.blockTicks = 0; + ai.retreatTicks = TUNE.BLOCK_RETREAT_TICKS; + const opp = hit.opp; + const [ox, oy] = duckXY(opp); + const toOpp = angleTo(sx, sy, ox, oy); + const side = Math.sign(angleDiff(yaw, toOpp)) || (sy >= oy ? 1 : -1); + return limitCommand(TUNE.BLOCK_BACK_SPEED, side * TUNE.WZ_MAX * 0.7, false); +} + /** Raise sub-threshold vx to MIN_EFFECTIVE_VX to prevent stalling. */ function applyAntiStuck(cmd) { let { vx, wz, kick } = cmd; @@ -996,6 +1250,15 @@ function applyAntiStuck(cmd) { return { vx, wz, kick }; } +/** Last-line own-goal fuse: strip kick if body yaw aims home. */ +function applyKickSafety(cmd, duck, ctx) { + if (!cmd.kick || duck.role === 'goalkeeper') return cmd; + const yaw = duck.yaw || 0; + if (isKickSafe(yaw, ctx.attackDir, ctx.ball, ctx.defendGoalX)) return cmd; + const aim = kickAimYaw(ctx.ball, ctx.attackDir, ctx.targetGoalX); + return limitCommand(cmd.vx, turnToward(yaw, aim), false); +} + // ═══════════════════════════════════════════════════════════════════════════════ // decideAll — primary interface // ═══════════════════════════════════════════════════════════════════════════════ @@ -1017,10 +1280,17 @@ export function decideAll(ducks, gs) { 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)); + // Priority: crash peel → lost-ball scan → role policy. + const retreat = maybeFaceOffRetreat(d, ctx); + if (retreat) return applyAntiStuck(retreat); + if (d.role === 'goalkeeper') return applyAntiStuck(gkDecide(d, ctx)); + if (d.id === ctx.chaserId) { + const search = maybeBallSearch(d, ctx); + if (search) return applyAntiStuck(search); + return applyKickSafety(applyAntiStuck(chaserDecide(d, ctx)), d, ctx); + } + return applyKickSafety(applyAntiStuck(formationDecide(d, ctx)), d, ctx); + }); } finally { TUNE = prevTune; } diff --git a/app/src/game/football/duck-instance.js b/app/src/game/football/duck-instance.js index 8d81f95..67e6d77 100644 --- a/app/src/game/football/duck-instance.js +++ b/app/src/game/football/duck-instance.js @@ -29,6 +29,8 @@ export function createDuckInstance(id, config, addrs, rig = null) { lastAction: new Float32Array(NUM_JOINTS), // Active policy mode mode: 'walk', + // Per-duck locomotion ('legs' | 'rollers'). Football packs may mix teams. + loco: 'legs', // AI agent (null for sandbox mode, set in Phase 2) agent: null, // Fall recovery state machine (mirrors game.js recovery logic) diff --git a/app/src/game/football/loco.js b/app/src/game/football/loco.js new file mode 100644 index 0000000..74479a9 --- /dev/null +++ b/app/src/game/football/loco.js @@ -0,0 +1,66 @@ +// Locomotion layer (legs | rollers) — orthogonal to the coach Strategy Card. +// +// Hierarchy for rating / A-B tests: +// 1. Strategy card → who chases, where to stand, when to shoot (intent) +// 2. Locomotion → walk vs rollers ONNX + speed/turn ceilings (execution) +// +// Same strategy + different loco ⇒ same decideAll intents; only command +// magnitudes are remapped to the active loco capability. Never fold loco +// into strategyFingerprint / knobs / formation. + +import { + VEL_FWD, VEL_BACK, VEL_ANG, + RVEL_FWD, RVEL_BACK, RVEL_ANG, +} from '../constants.js'; + +export const LOCO_IDS = Object.freeze(['legs', 'rollers']); + +/** Positive speed keys in the TUNE overlay (m/s). */ +const POS_SPEED_KEYS = [ + 'CHASE_SPEED', + 'DEF_CHASE_SPEED', + 'SHOOT_SPEED', + 'AIM_SPEED', + 'AIM_CREEP', + 'RETURN_SPEED', + 'GK_TRACK_SPEED', + 'GK_DIVE_SPEED', + 'MIN_EFFECTIVE_VX', +]; + +/** + * Remap a strategy-compiled TUNE overlay onto the active loco's velocity + * envelope. Strategy knobs are untouched; this only scales execution speeds. + * + * @param {object} tuneOverlay from getTuneOverlay(strategy) + * @param {'legs'|'rollers'} loco + * @returns {object} + */ +export function applyLocoLimits(tuneOverlay, loco) { + const base = tuneOverlay && typeof tuneOverlay === 'object' ? tuneOverlay : {}; + if (loco !== 'rollers') { + return { + ...base, + VX_MAX: base.VX_MAX ?? VEL_FWD, + VX_MIN: base.VX_MIN ?? VEL_BACK, + WZ_MAX: base.WZ_MAX ?? VEL_ANG, + }; + } + + const fwdScale = RVEL_FWD / VEL_FWD; + const out = { + ...base, + VX_MAX: RVEL_FWD, + VX_MIN: RVEL_BACK, + WZ_MAX: RVEL_ANG, // rollers turn slower — capability, not tactics + BLOCK_BACK_SPEED: RVEL_BACK, + }; + + for (const key of POS_SPEED_KEYS) { + const v = base[key]; + if (!Number.isFinite(v)) continue; + // Keep sign; clamp to roller forward ceiling after scale. + out[key] = Math.min(RVEL_FWD, Math.max(0, v * fwdScale)); + } + return out; +} diff --git a/app/src/game/football/match-debug-log.js b/app/src/game/football/match-debug-log.js new file mode 100644 index 0000000..dd6f7e3 --- /dev/null +++ b/app/src/game/football/match-debug-log.js @@ -0,0 +1,238 @@ +// Per-match debug log for goal / OOB / restart forensics. +// Kept in memory during play, flushed to localStorage at fulltime / next +// kickoff / page unload so a missed goal can be inspected after the fact. + +const STORAGE_KEY = 'microduck-match-logs'; +const MAX_MATCHES = 10; +const MAX_EVENTS = 6000; +const DANGER_X = 2.55; // |x| beyond this → sample ball near the goals +const DANGER_MIN_GAP_MS = 120; + +const LOUD = new Set([ + 'match_start', 'match_end', 'goal', 'goal_disallowed', 'goal_kick', + 'corner_red', 'corner_blue', 'throw_in', 'kickoff', 'playing', + 'fulltime', 'watchdog_ball', 'near_miss_goalline', 'shot', +]); + +function round3(n) { + return Math.round(Number(n) * 1000) / 1000; +} + +function ballSnap(pos, vel) { + if (!pos) return null; + const o = { x: round3(pos[0]), y: round3(pos[1]), z: round3(pos[2]) }; + if (vel) o.v = [round3(vel[0]), round3(vel[1]), round3(vel[2])]; + return o; +} + +function readStore() { + try { + const raw = localStorage.getItem(STORAGE_KEY); + if (!raw) return []; + const parsed = JSON.parse(raw); + return Array.isArray(parsed) ? parsed : []; + } catch { + return []; + } +} + +function writeStore(list) { + try { + localStorage.setItem(STORAGE_KEY, JSON.stringify(list)); + return true; + } catch (e) { + console.warn('[match-log] persist failed', e); + return false; + } +} + +/** + * @returns {{ + * startMatch: (info?: object) => string, + * push: (type: string, data?: object) => void, + * noteDanger: (ctx: object) => void, + * flush: (reason?: string) => ?object, + * list: () => object[], + * get: (id?: string) => ?object, + * download: (id?: string) => ?object, + * clear: () => void, + * current: () => object, + * }} + */ +export function createMatchDebugLog() { + let matchId = null; + let meta = null; + let events = []; + let lastDangerAt = 0; + let lastState = null; + let boundUnload = false; + + function ensureUnload() { + if (boundUnload || typeof window === 'undefined') return; + boundUnload = true; + window.addEventListener('beforeunload', () => { flush('unload'); }); + } + + function startMatch(info = {}) { + if (matchId && events.length) flush('next_match'); + ensureUnload(); + matchId = `m-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 6)}`; + meta = { + id: matchId, + startedAt: new Date().toISOString(), + ...info, + }; + events = []; + lastDangerAt = 0; + lastState = null; + push('match_start', { ...info }); + console.info(`[match-log] started ${matchId} — dump: football.matchLog.download()`); + return matchId; + } + + function push(type, data = {}) { + if (!matchId) return; + const entry = { + t: round3(typeof performance !== 'undefined' ? performance.now() : Date.now()), + type, + ...data, + }; + events.push(entry); + if (events.length > MAX_EVENTS) events.splice(0, events.length - MAX_EVENTS); + if (LOUD.has(type)) { + console.info(`[match-log] ${type}`, data); + } + } + + /** + * Throttled near-goal samples + instant samples on state / verdict changes. + * @param {{ + * state: string, + * score: {red:number,blue:number}, + * matchTime: number, + * lastTouch: ?string, + * ball: ?number[], + * vel?: ?number[], + * goal?: ?object, + * oob?: ?object, + * }} ctx + */ + function noteDanger(ctx) { + if (!matchId || !ctx?.ball) return; + const pos = ctx.ball; + const near = Math.abs(pos[0]) >= DANGER_X; + const stateChanged = ctx.state !== lastState; + lastState = ctx.state; + + if (ctx.goal) { + push('goal_verdict', { + goal: ctx.goal, + ball: ballSnap(pos, ctx.vel), + state: ctx.state, + score: ctx.score, + matchTime: round3(ctx.matchTime), + lastTouch: ctx.lastTouch, + }); + return; + } + if (ctx.oob?.kind === 'goalline') { + push('near_miss_goalline', { + oob: { kind: ctx.oob.kind, side: ctx.oob.side }, + ball: ballSnap(pos, ctx.vel), + state: ctx.state, + score: ctx.score, + matchTime: round3(ctx.matchTime), + lastTouch: ctx.lastTouch, + mouthY: round3(Math.abs(pos[1])), + pastPlane: Math.abs(pos[0]) >= 3, + z: round3(pos[2]), + }); + return; + } + if (!near && !stateChanged) return; + const now = typeof performance !== 'undefined' ? performance.now() : Date.now(); + if (!stateChanged && now - lastDangerAt < DANGER_MIN_GAP_MS) return; + lastDangerAt = now; + push('danger', { + ball: ballSnap(pos, ctx.vel), + state: ctx.state, + score: ctx.score, + matchTime: round3(ctx.matchTime), + lastTouch: ctx.lastTouch, + }); + } + + function flush(reason = 'manual') { + if (!matchId || !events.length) return null; + const record = { + id: matchId, + meta: { ...meta, endedAt: new Date().toISOString(), flushReason: reason }, + events: events.slice(), + summary: summarize(events), + }; + const all = readStore().filter((m) => m && m.id !== matchId); + all.unshift(record); + while (all.length > MAX_MATCHES) all.pop(); + writeStore(all); + console.info( + `[match-log] saved ${matchId} (${events.length} events, ${reason})`, + record.summary, + ); + return record; + } + + function list() { + return readStore().map((m) => ({ + id: m.id, + startedAt: m.meta?.startedAt, + flushReason: m.meta?.flushReason, + summary: m.summary, + events: m.events?.length ?? 0, + })); + } + + function get(id) { + const all = readStore(); + if (!id) return all[0] || (matchId ? { id: matchId, meta, events, summary: summarize(events) } : null); + if (id === matchId) return { id: matchId, meta, events, summary: summarize(events) }; + return all.find((m) => m.id === id) || null; + } + + function download(id) { + const rec = get(id); + if (!rec || typeof document === 'undefined') return rec; + const blob = new Blob([JSON.stringify(rec, null, 2)], { type: 'application/json' }); + const a = document.createElement('a'); + a.href = URL.createObjectURL(blob); + a.download = `microduck-match-${rec.id}.json`; + a.click(); + setTimeout(() => URL.revokeObjectURL(a.href), 2000); + return rec; + } + + function clear() { + writeStore([]); + events = []; + matchId = null; + meta = null; + } + + function current() { + return { matchId, meta, events, count: events.length }; + } + + return { startMatch, push, noteDanger, flush, list, get, download, clear, current }; +} + +function summarize(evts) { + const counts = {}; + for (const e of evts) counts[e.type] = (counts[e.type] || 0) + 1; + const goals = evts.filter((e) => e.type === 'goal' || e.type === 'goal_verdict'); + const misses = evts.filter((e) => e.type === 'near_miss_goalline'); + return { + counts, + goals: goals.length, + nearMissGoalline: misses.length, + lastScore: [...evts].reverse().find((e) => e.score)?.score ?? null, + }; +} diff --git a/app/src/game/football/match-stats.js b/app/src/game/football/match-stats.js index 43871ff..353b757 100644 --- a/app/src/game/football/match-stats.js +++ b/app/src/game/football/match-stats.js @@ -122,8 +122,9 @@ export function createMatchStats() { * @param {{ snapshot: Function }} args.stats * @param {object} [args.strategyByTeam] * @param {{ red: number, blue: number }} [args.score] + * @param {'legs'|'rollers'} [args.loco] — sibling rating axis to strategy */ -export function buildMatchReport({ stats, strategyByTeam = {}, score = null }) { +export function buildMatchReport({ stats, strategyByTeam = {}, score = null, loco = 'legs' }) { const snap = stats?.snapshot ? stats.snapshot() : { possession: { red: 0, blue: 0, redPct: 0.5 }, shots: { red: 0, blue: 0 }, @@ -131,10 +132,12 @@ export function buildMatchReport({ stats, strategyByTeam = {}, score = null }) { return { possession: snap.possession, shots: snap.shots, + // Orthogonal axes — never fold loco into strategy fingerprints. strategy: { red: normalizeStrategy(strategyByTeam.red || DEFAULT_STRATEGY), blue: normalizeStrategy(strategyByTeam.blue || DEFAULT_STRATEGY), }, + loco: loco === 'rollers' ? 'rollers' : 'legs', score: score ? { red: score.red | 0, blue: score.blue | 0 } : null, }; } diff --git a/app/src/game/football/referee.js b/app/src/game/football/referee.js index 5f0adf4..e670ec9 100644 --- a/app/src/game/football/referee.js +++ b/app/src/game/football/referee.js @@ -13,7 +13,7 @@ // attacks +X; blue defends +X and attacks -X. import { - FIELD_HALF_L, FIELD_HALF_W, GOAL_HEIGHT, BALL_RADIUS, + FIELD_HALF_L, FIELD_HALF_W, GOAL_HEIGHT, GOAL_DEPTH, BALL_RADIUS, MATCH_DURATION_S, GOLDEN_GOAL_DURATION_S, PENALTY_DURATION_S, BALL_SPAWN, GOAL_LINES, } from './constants.js'; @@ -92,10 +92,73 @@ export function checkGoal(prev, cur) { return null; } +/** + * Ball already sitting past the goal plane inside the mouth (sweep missed — + * e.g. slow creep that first tripped the whole-ball OOB band, or prev was + * already behind the line). Used as a recovery after checkGoal. + * + * @param {number[]} pos + * @returns {?{team: 'red'|'blue', goal: 'red'|'blue'}} + */ +export function checkBallInGoal(pos) { + if (!pos || !Number.isFinite(pos[0])) return null; + const r = BALL_RADIUS; + const z = Number.isFinite(pos[2]) ? pos[2] : 0; + for (const owner of ['red', 'blue']) { + const { x: planeX, halfW } = GOAL_LINES[owner]; + const side = Math.sign(planeX); + const past = side < 0 ? pos[0] <= planeX : pos[0] >= planeX; + if (!past) continue; + // Generous depth: cage back is ~halfL+depth, but a solver tunnel past + // the back panel must still count — only skip absurd escapes. + if (Math.abs(pos[0]) > FIELD_HALF_L + GOAL_DEPTH + 1.0) continue; + if (Math.abs(pos[1]) <= halfW + r && z < GOAL_HEIGHT) { + return { team: OPP[TEAM_OF_GOAL[owner]], goal: owner }; + } + } + return null; +} + +/** Samples along prev→cur for a point already inside the goal volume. */ +const GOAL_PATH_SAMPLES = 4; + +/** + * Full goal test: plane sweep, stranded-in-net recovery, and path samples. + * Path samples catch post-glance frames where the plane intersection drifts + * wide of the mouth while an intermediate point was already past the line + * inside the posts (would otherwise become a false goal-kick). + * + * @param {?number[]} prev + * @param {?number[]} cur + * @returns {?{team: 'red'|'blue', goal: 'red'|'blue'}} + */ +export function checkGoalPath(prev, cur) { + const direct = checkGoal(prev, cur) || checkBallInGoal(cur); + if (direct) return direct; + if (!prev || !cur) return null; + for (let i = 1; i <= GOAL_PATH_SAMPLES; i += 1) { + const t = i / (GOAL_PATH_SAMPLES + 1); + const p = [ + prev[0] + t * (cur[0] - prev[0]), + prev[1] + t * (cur[1] - prev[1]), + prev[2] + t * (cur[2] - prev[2]), + ]; + const hit = checkBallInGoal(p); + if (hit) return hit; + } + return null; +} + /** * Out-of-bounds test: the whole ball must be past the line (partially on * the line stays in play), per 01-rules.md. * + * Goal-line caveat: the whole-ball threshold (|x|+r > halfL) fires at + * |x| > ~2.95, but the goal plane is at ±3.0. A slow roll into the mouth + * would otherwise become a goal-kick/corner before checkGoal can see a + * plane crossing. Balls inside the mouth/cage corridor are left to + * checkGoalPath instead. + * * @param {number[]} pos - Ball pos [x, y, z]. * @returns {?{kind: 'sideline'|'goalline', side: number, pos: number[]}} */ @@ -106,6 +169,16 @@ export function checkOutOfBounds(pos) { return { kind: 'sideline', side: Math.sign(pos[1]), pos }; } if (Math.abs(pos[0]) + r > FIELD_HALF_L) { + const owner = pos[0] < 0 ? 'red' : 'blue'; + const { halfW } = GOAL_LINES[owner]; + const z = Number.isFinite(pos[2]) ? pos[2] : 0; + // Slight z slack so a ball that grazes the bar height is not whistled + // out before it settles under the crossbar inside the net. + const inMouth = Math.abs(pos[1]) <= halfW + r && z < GOAL_HEIGHT + r; + const inCage = Math.abs(pos[0]) <= FIELD_HALF_L + GOAL_DEPTH + r * 2; + if (inMouth && inCage) return null; + // Past the plane in the mouth (any depth short of absurd) → goal path. + if (checkBallInGoal(pos)) return null; return { kind: 'goalline', side: Math.sign(pos[0]), pos }; } return null; @@ -189,7 +262,7 @@ export function decide(prevBallPos, gameState) { const ballPos = gameState && gameState.ball ? gameState.ball.pos : null; return { touches: detectTouches(ballPos, gameState ? gameState.ducks : null), - goal: checkGoal(prevBallPos, ballPos), + goal: checkGoalPath(prevBallPos, ballPos), outOfBounds: checkOutOfBounds(ballPos), }; } @@ -432,9 +505,9 @@ export function createReferee({ onEvent } = {}) { && (touchesSinceSetPiece >= 2 || setPieceType !== 'kickoff')) { setPieceActive = false; } - // Goal (swept) takes priority over out-of-bounds: a ball in the mouth - // is also "past the goal line" but must count as a goal. - const goal = checkGoal(prevBallPos, ballPos); + // Goal (swept / path / stranded) takes priority over out-of-bounds: a + // ball in the mouth is also "past the goal line" but must count as a goal. + const goal = checkGoalPath(prevBallPos, ballPos); if (goal) { onGoal(goal); return; diff --git a/app/src/game/football/strategy.js b/app/src/game/football/strategy.js index 3e457fd..cd1eae9 100644 --- a/app/src/game/football/strategy.js +++ b/app/src/game/football/strategy.js @@ -1,6 +1,12 @@ // Coach Strategy Card (v2) — continuous 0..1 energy knobs → TUNE overlay. // Same JSON is the handshake payload for NL / blocks / PvP lobby. // Legacy discrete levels (push/low/…) are migrated on normalize. +// +// Layering (do not mix): +// Strategy card = tactics intent (formation / knobs) — rated as one axis +// Locomotion = legs | rollers execution — rated as a sibling axis +// Same strategy + different loco must keep identical decideAll intents; +// only applyLocoLimits() remaps command speeds (see football/loco.js). import { SPAWN_POSITIONS, FIELD_HALF_L } from './constants.js'; @@ -265,9 +271,11 @@ export function knobsToOverlay(knobsIn) { CHASE_SPEED: lerp(0.2, 0.25, pr), DEF_CHASE_SPEED: lerp(0.2, 0.25, pr), - // Shoot greed - SHOOT_ANGLE: lerp(0.12, 0.58, sg), - SHOOT_DIST: lerp(0.28, 0.58, sg), + // Shoot greed — keep contact honest (no mid-range air kicks). + SHOOT_ANGLE: lerp(0.14, 0.36, sg), + SHOOT_DIST: lerp(0.30, 0.40, sg), + KICK_CONTACT: lerp(0.26, 0.34, sg), + AIM_SOFT_MULT: lerp(1.15, 1.45, sg), SUPPORT_LATERAL: lerp(0.3, 1.3, k.supportWidth), SUPPORT_AHEAD: lerp(0.1, 1.2, k.supportDepth), diff --git a/app/src/game/game.js b/app/src/game/game.js index 9ebce57..fec2e17 100644 --- a/app/src/game/game.js +++ b/app/src/game/game.js @@ -62,7 +62,7 @@ import { createBallVisual } from "./ball-visual.js"; // Football-mode field plumbing (inert on the default sandbox path: the // 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 { GOAL_WIDTH, SPAWN_POSITIONS, BALL_SPAWN, PENALTY_DURATION_S, FIELD_HALF_W, GOAL_DEPTH } from "./football/constants.js"; import { DEFAULT_STRATEGY, buildSpawnTable, @@ -73,6 +73,7 @@ import { strategyFingerprint, } from "./football/strategy.js"; import { buildTacticsBoard, createMatchStats } from "./football/tactics-board.js"; +import { applyLocoLimits } from "./football/loco.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 @@ -80,7 +81,8 @@ import { buildTacticsBoard, createMatchStats } from "./football/tactics-board.js import { SANDBOX_CONFIG } from "./football/match-config.js"; import { createDuckInstance } from "./football/duck-instance.js"; import { createAgent, decideAll, assignChaserId } from "./football/ai/index.js"; -import { createReferee } from "./football/referee.js"; +import { createReferee, decide as decideReferee } from "./football/referee.js"; +import { createMatchDebugLog } from "./football/match-debug-log.js"; import { createCelebration } from "./football/celebration.js"; import { initStickers } from "./stickers.js"; import { useGame, gameApi, bootLine, bootNote, bootHalt, bootLog } from "../store.js"; @@ -425,12 +427,16 @@ async function boot({ scene, camera, renderer, matchConfig: cfg }) { // the original sensor/actuator sections are removed and re-injected once // per duck, in order, so qpos/ctrl/sensordata lay out as duck0, duck1, // ..., duck5, ball. Skipped entirely for the single-duck sandbox. + // + // Per-duck loco: dc.loco may be 'legs' | 'rollers'. When any duck is on + // rollers, the roller MJCF is loaded as a second body/sensor/actuator + // template and its unique meshes are merged into this doc's assets. const multiDuck = duckConfigs.length > 1; if (multiDuck) { const worldbody = doc.querySelector("worldbody"); const origBody = worldbody.querySelector('body[name="trunk_base"]'); const ballBodyEl = worldbody.querySelector('body[name="ball"]'); - const origClone = origBody.cloneNode(true); + const legsBodyClone = origBody.cloneNode(true); origBody.remove(); const PREFIX_ATTRS = ["name", "site", "body", "objname", "joint", "body1", "body2", "target", "tendon", "refsite"]; @@ -444,18 +450,68 @@ async function boot({ scene, camera, renderer, matchConfig: cfg }) { }; const sensorParent = doc.querySelector("sensor"); const actuatorParent = doc.querySelector("actuator"); - const origSensors = sensorParent ? [...sensorParent.children].map((s) => s.cloneNode(true)) : []; - const origActuators = actuatorParent ? [...actuatorParent.children].map((a) => a.cloneNode(true)) : []; + const legsSensors = sensorParent ? [...sensorParent.children].map((s) => s.cloneNode(true)) : []; + const legsActuators = actuatorParent ? [...actuatorParent.children].map((a) => a.cloneNode(true)) : []; while (sensorParent && sensorParent.firstChild) sensorParent.removeChild(sensorParent.firstChild); while (actuatorParent && actuatorParent.firstChild) actuatorParent.removeChild(actuatorParent.firstChild); + + let rollerBodyClone = null; + let rollerSensors = []; + let rollerActuators = []; + const needsRollers = duckConfigs.some((dc) => (dc.loco || "legs") === "rollers"); + if (needsRollers) { + const rSrc = await (await fetch(signed(`${MODEL_DIR}/robot_allcollisions_rollers.xml`))).text(); + const rDoc = new DOMParser().parseFromString(rSrc, "text/xml"); + for (const g of [...rDoc.querySelectorAll('geom[class="visual"]')]) g.remove(); + const rUsed = new Set( + [...rDoc.querySelectorAll("geom[mesh]")].map((g) => g.getAttribute("mesh")), + ); + for (const m of [...rDoc.querySelectorAll("asset > mesh")]) { + const name = m.getAttribute("name") ?? m.getAttribute("file").replace(/\.stl$/i, ""); + if (!rUsed.has(name)) m.remove(); + } + // Merge roller-only mesh assets into the primary doc. + const asset = doc.querySelector("asset"); + const haveMesh = new Set( + [...asset.querySelectorAll("mesh")].map((m) => m.getAttribute("file")), + ); + for (const m of [...rDoc.querySelectorAll("asset > mesh")]) { + const file = m.getAttribute("file"); + if (file && !haveMesh.has(file)) { + asset.appendChild(m.cloneNode(true)); + haveMesh.add(file); + } + } + // Also merge any materials the roller geoms reference (name-keyed). + const haveMat = new Set( + [...asset.querySelectorAll("material")].map((m) => m.getAttribute("name")), + ); + for (const m of [...rDoc.querySelectorAll("asset > material")]) { + const name = m.getAttribute("name"); + if (name && !haveMat.has(name)) { + asset.appendChild(m.cloneNode(true)); + haveMat.add(name); + } + } + rollerBodyClone = rDoc.querySelector('body[name="trunk_base"]').cloneNode(true); + const rSens = rDoc.querySelector("sensor"); + const rAct = rDoc.querySelector("actuator"); + rollerSensors = rSens ? [...rSens.children].map((s) => s.cloneNode(true)) : []; + rollerActuators = rAct ? [...rAct.children].map((a) => a.cloneNode(true)) : []; + } + for (const dc of duckConfigs) { - const bodyClone = origClone.cloneNode(true); + const isRoller = (dc.loco || "legs") === "rollers"; + const template = isRoller ? rollerBodyClone : legsBodyClone; + const bodyClone = template.cloneNode(true); prefixSubtree(bodyClone, dc.prefix); worldbody.insertBefore(bodyClone, ballBodyEl); - if (sensorParent) for (const s of origSensors) { + const sensSrc = isRoller ? rollerSensors : legsSensors; + const actSrc = isRoller ? rollerActuators : legsActuators; + if (sensorParent) for (const s of sensSrc) { const sc = s.cloneNode(true); prefixSubtree(sc, dc.prefix); sensorParent.appendChild(sc); } - if (actuatorParent) for (const a of origActuators) { + if (actuatorParent) for (const a of actSrc) { const ac = a.cloneNode(true); prefixSubtree(ac, dc.prefix); actuatorParent.appendChild(ac); } } @@ -724,7 +780,9 @@ async function boot({ scene, camera, renderer, matchConfig: cfg }) { let referee = null; let celebration = null; let matchStats = isFootball ? createMatchStats() : null; + let matchLog = isFootball ? createMatchDebugLog() : null; let prevBallX = 0, prevBallY = 0; + let prevBallPosRef = null; // full [x,y,z] for near-goal decide() logging let matchStoreTick = 0; let mode = "walk"; // "walk" | "sitstand" | "roll" | "kickL" | "kickR" | "crouch" | "groundpick" @@ -1176,10 +1234,13 @@ async function boot({ scene, camera, renderer, matchConfig: cfg }) { // Shared ONNX session pool: every duck draws the same walk / kick / stand // sessions as the sandbox duck, selected from that duck's own mode. The // fall-recovery ladder overrides with the get-up policy while it owns the - // duck (mirrors activeSession()). + // duck (mirrors activeSession()). Rollers map walk → drive (sandbox parity). function sessionFor(duck) { if (duck.recovery?.state === "recovering") return sessions.stand; - return sessions[duck.mode] ?? sessions.walk; + const m = duck.mode; + const duckLoco = duck.loco || loco; + if (duckLoco === "rollers" && m === "walk") return sessions.drive ?? sessions.walk; + return sessions[m] ?? sessions.walk; } // Per-duck projected-gravity z (trunk tilt), read from that duck's own @@ -1193,11 +1254,17 @@ async function boot({ scene, camera, renderer, matchConfig: cfg }) { // Per-duck dead-pose test: "fallen" = trunk tipped past ~60 deg or sunk // below the floor; NaN/Inf is a solver explosion (no grace, reset now). + // Rollers: ignore brief z dips from wheel contacts — tip angle only. function duckPoseIsDead(duck) { const fj = duck.addrs.freejointQposAdr; const z = data.qpos[fj + 2]; const gz = duckProjGravZ(duck); if (!Number.isFinite(z) || !Number.isFinite(gz)) return "exploded"; + const isRoller = loco === "rollers" || duck.loco === "rollers"; + if (isRoller) { + if (gz > -0.4) return "fallen"; // ~66° tip; bumps shouldn't count + return null; + } if (gz > -0.5 || z < 0.02) return "fallen"; return null; } @@ -1264,6 +1331,30 @@ async function boot({ scene, camera, renderer, matchConfig: cfg }) { mujoco.mj_forward(model, data); } + // Rollers have no stand-up ONNX: after a spill, stand them back up where + // they are (keep xy + yaw). Spawning back to kickoff looked like ducks + // vanishing into the distance after every collision. + function uprightDuckInPlace(duck) { + const q = data.qpos; + const fj = duck.addrs.freejointQposAdr; + const x = Number.isFinite(q[fj]) ? q[fj] : 0; + const y = Number.isFinite(q[fj + 1]) ? q[fj + 1] : 0; + const w = q[fj + 3], xq = q[fj + 4], yq = q[fj + 5], zq = q[fj + 6]; + const yaw = Number.isFinite(duck.yaw) + ? duck.yaw + : Math.atan2(2 * (w * zq + xq * yq), 1 - 2 * (yq * yq + zq * zq)); + placeDuck(duck, [x, y, 0.12], Number.isFinite(yaw) ? yaw : 0); + duck.recovery = null; + duck.fallDebounce = 0; + duck.fallenSince = null; + duck.mode = "walk"; + duck.lastAction.fill(0); + duck.kickRun = null; + duck.postKickLock = 0; + duck.cmd.fill(0); + duck.cmdSm.fill(0); + } + // Per-duck state machine: penalty sin-bin countdown, the kick one-shot // window, and the fall-recovery ladder (settle -> stand policy -> upright // exit or give-up reset). Mirrors the sandbox controlStep tail per duck. @@ -1301,7 +1392,20 @@ async function boot({ scene, camera, renderer, matchConfig: cfg }) { } } } else if (death === "fallen") { - if (duck.mode === "walk" && duck.postKickLock === 0) { + // Rollers: no get-up policy. Debounce collision tips, then upright + // in place — never teleport to spawn (that read as "vanish / re-enter"). + if (loco === "rollers" || duck.loco === "rollers") { + duck.fallenSince = null; + if (++duck.fallDebounce >= FALL_DEBOUNCE_STEPS) { + duck.fallDebounce = 0; + uprightDuckInPlace(duck); + playSfx("thump", { + gain: 0.22, + rate: 0.52 + Math.random() * 0.08, + }); + haptics.pulse("fall"); + } + } else if (duck.mode === "walk" && duck.postKickLock === 0) { duck.fallenSince = null; if (++duck.fallDebounce >= FALL_DEBOUNCE_STEPS) { duck.fallDebounce = 0; @@ -1419,7 +1523,14 @@ async function boot({ scene, camera, renderer, matchConfig: cfg }) { }; }); const team = teamDucks[0].team; - const tuneOverlay = getTuneOverlay(strategyByTeam[team] || DEFAULT_STRATEGY); + // Strategy card → intent overlay; per-TEAM loco → speed envelope only. + const teamLoco = teamDucks[0]?.loco === "rollers" + || store().locoByTeam?.[team] === "rollers" + ? "rollers" : "legs"; + const tuneOverlay = applyLocoLimits( + getTuneOverlay(strategyByTeam[team] || DEFAULT_STRATEGY), + teamLoco, + ); const cmds = decideAll(views, { ball: gs.ball, allDucks: gs.ducks, @@ -1459,9 +1570,20 @@ async function boot({ scene, camera, renderer, matchConfig: cfg }) { yaw: Number.isFinite(duck.yaw) ? duck.yaw : 0, })) { // Soft event for danmaku commentary (not a referee FSM transition). + const shotTime = referee?.getMatchTime() ?? 0; + matchLog?.push("shot", { + team: duck.team, + duckId: duck.id, + matchTime: shotTime, + ball: { x: gs.ball?.x, y: gs.ball?.y }, + kicker: { + x: Number.isFinite(duck.pos?.[0]) ? duck.pos[0] : 0, + y: Number.isFinite(duck.pos?.[1]) ? duck.pos[1] : 0, + }, + }); setStore({ matchEvents: [...useGame.getState().matchEvents.slice(-4), - { type: "shot", team: duck.team, time: referee?.getMatchTime() ?? 0 }], + { type: "shot", team: duck.team, time: shotTime }], }); } } @@ -1494,7 +1616,7 @@ async function boot({ scene, camera, renderer, matchConfig: cfg }) { } // Escape mode: override the AI twist until the manoeuvre budget runs out. if (duck._escapeTicks > 0) { - duck.cmd[0] = -0.25; // reverse, above walk dead-zone + duck.cmd[0] = duck.loco === "rollers" ? RVEL_BACK : -0.25; // reverse, above loco dead-zone duck.cmd[2] = (duck.id % 2 === 0) ? 0.6 : -0.6; // alternating turn duck._escapeTicks--; duck._lastPos = { x: px, y: py }; @@ -1722,6 +1844,20 @@ async function boot({ scene, camera, renderer, matchConfig: cfg }) { } function handleRefereeEvent(type, payload) { + matchLog?.push(type, { + ...(payload || {}), + matchTime: referee?.getMatchTime?.() ?? payload?.time, + score: referee?.getScore?.() ?? payload?.score, + ball: (() => { + try { + return { + x: data.qpos[ballQposAdr], + y: data.qpos[ballQposAdr + 1], + z: data.qpos[ballQposAdr + 2], + }; + } catch { return null; } + })(), + }); switch (type) { case "goal": { setStore({ @@ -1841,6 +1977,7 @@ async function boot({ scene, camera, renderer, matchConfig: cfg }) { stats: matchStats, strategyByTeam, score: scoreNow, + loco, }) : null; setStore({ @@ -1853,6 +1990,7 @@ async function boot({ scene, camera, renderer, matchConfig: cfg }) { { type: "fulltime", team: result === "draw" ? null : result, time: referee.getMatchTime() }], }); celebration?.cancel(); + matchLog?.flush("fulltime"); break; } } @@ -1914,12 +2052,12 @@ async function boot({ scene, camera, renderer, matchConfig: cfg }) { if (hz < PERF_CTRL_HZ_FLOOR) { degraded = true; aiDivider = (matchConfig.ai?.divider ?? 1) * 2; // 10 Hz -> 5 Hz - if (renderer?.setPixelRatio) { - renderer.setPixelRatio(Math.max(1, renderer.getPixelRatio() * 0.5)); - } + // Do NOT touch renderer.setPixelRatio here — R3F owns the drawing + // buffer size/DPR. Halving DPR out-of-band desynced the main canvas + // (pitch vanished) while FPV RTs kept working. AI throttle alone. console.warn( `[football] perf gate: ctrlHz ${hz.toFixed(1)} < ${PERF_CTRL_HZ_FLOOR} for` - + ` ${PERF_WINDOW_MS}ms -> aiDivider ${aiDivider} (5 Hz), dpr halved`, + + ` ${PERF_WINDOW_MS}ms -> aiDivider ${aiDivider} (5 Hz)`, ); } } @@ -1959,9 +2097,30 @@ async function boot({ scene, camera, renderer, matchConfig: cfg }) { // Referee step (football mode): reads fresh ball/duck poses, emits events. if (referee) { const gs = buildRefereeState(); + const stateBefore = referee.getState(); + const verdict = matchLog && (stateBefore === "PLAYING" || stateBefore === "KICKOFF") + ? decideReferee(prevBallPosRef, gs) + : null; referee.step(CTRL_DT, gs); + if (verdict) { + matchLog.noteDanger({ + state: referee.getState(), + score: referee.getScore(), + matchTime: referee.getMatchTime(), + lastTouch: referee.getLastTouchTeam(), + ball: gs.ball.pos, + vel: [ + data.qvel[ballDofAdr], + data.qvel[ballDofAdr + 1], + data.qvel[ballDofAdr + 2], + ], + goal: verdict.goal, + oob: verdict.outOfBounds, + }); + } prevBallX = gs.ball.pos[0]; prevBallY = gs.ball.pos[1]; + prevBallPosRef = gs.ball.pos.slice(); matchStats?.tick(CTRL_DT, { matchState: referee.getState(), lastTouchTeam: referee.getLastTouchTeam(), @@ -1970,13 +2129,23 @@ async function boot({ scene, camera, renderer, matchConfig: cfg }) { for (const duck of ducks) updateDuckStateFootball(duck); // Ball watchdog: a solver glitch that tunnels the ball out of the pitch // brings it back to the centre spot. Suppressed while the referee owns - // ball placement (GOAL / DEAD_BALL / SET_PIECE states). + // ball placement (GOAL / DEAD_BALL / SET_PIECE states). Allow the full + // goal-cage depth so a ball sitting in the net is not yeeted before the + // referee can award the goal. const refState = referee?.getState(); if (ballActive && (!refState || refState === "PLAYING" || refState === "KICKOFF")) { const q = data.qpos; - const limX = matchConfig.field.halfX + 0.1; + const limX = matchConfig.field.halfX + 0.1 + (GOAL_DEPTH || 0.3); const limY = matchConfig.field.halfY + 0.1; - if (Math.abs(q[ballQposAdr]) > limX || Math.abs(q[ballQposAdr + 1]) > limY) spawnBallFootball(); + if (Math.abs(q[ballQposAdr]) > limX || Math.abs(q[ballQposAdr + 1]) > limY) { + matchLog?.push("watchdog_ball", { + ball: { x: q[ballQposAdr], y: q[ballQposAdr + 1], z: q[ballQposAdr + 2] }, + limX, limY, + state: refState, + matchTime: referee?.getMatchTime?.() ?? 0, + }); + spawnBallFootball(); + } } // Perf gate: measures realised ctrlHz over a 2 s window, degrades once. maybeDegradePerf(performance.now()); @@ -1999,11 +2168,36 @@ async function boot({ scene, camera, renderer, matchConfig: cfg }) { tg.position.set(q[fj], q[fj + 1], q[fj + 2]); tg.quaternion.set(q[fj + 4], q[fj + 5], q[fj + 6], q[fj + 3]); for (let j = 0; j < NUM_JOINTS; j++) setJoint(duck.rig, JOINT_NAMES[j], q[duck.addrs.qposAdr[j]]); + // Passive roller wheels (visual only). + for (const ej of duck.addrs.extraJoints || []) setJoint(duck.rig, ej.name, q[ej.adr]); + } + + // Soft broadcast follow: ease orbit target toward the ball while holding + // a high camera so the pitch never disappears into a turf close-up. + const _broadcastTgt = new THREE.Vector3(); + const _broadcastCam = new THREE.Vector3(); + const BROADCAST_H = 4.2; + const BROADCAST_BACK = 5.2; + const BROADCAST_EASE = 0.045; + function updateBroadcastCam() { + const q = data.qpos; + const bx = Number.isFinite(q[ballQposAdr]) ? q[ballQposAdr] : 0; + const by = Number.isFinite(q[ballQposAdr + 1]) ? q[ballQposAdr + 1] : 0; + _broadcastTgt.set(bx, 0, -by); + controls.target.lerp(_broadcastTgt, BROADCAST_EASE); + // Desired cam: south of the ball at fixed height (world +Z toward cam). + _broadcastCam.set(controls.target.x, BROADCAST_H, controls.target.z + BROADCAST_BACK); + camera.position.lerp(_broadcastCam, BROADCAST_EASE); + // Rescue if anything (orbit drag / celebration handoff) buried the cam. + if (camera.position.y < 2.4) camera.position.y = BROADCAST_H; + camera.lookAt(controls.target); } // Football per-frame render drive: sync every rig, follow the ball, keep // the orbit camera alive, drive celebration, throttle match state to store. function frameFootball(dt) { + // Heal any FPV/RT viewport corruption before R3F paints this frame. + if (typeof restoreMainFramebuffer === "function") restoreMainFramebuffer(); for (const duck of ducks) syncOneRig(duck); if (ball) ball.sync(data.qpos, ballQposAdr, ballActive); // Spatial audio: listener on camera, ball emitter for thumps. @@ -2022,7 +2216,9 @@ async function boot({ scene, camera, renderer, matchConfig: cfg }) { controls.target.copy(override.lookAt); } } else { - controls.update(); + // Broadcast cam owns framing; skip OrbitControls.update so damping + // can't yank the overview into the turf. + updateBroadcastCam(); } if (ball) ball.drive(() => spawnBallFootball()); renderTelemetry(); @@ -2048,6 +2244,7 @@ async function boot({ scene, camera, renderer, matchConfig: cfg }) { const patch = { matchTime: referee.getMatchTime(), matchState: stateNow, + score: referee.getScore(), lastTouchTeam: lastTouch, ducksState: duckSnap, ballState: { x: fin(q[ballQposAdr]), y: fin(q[ballQposAdr + 1]) }, @@ -2058,6 +2255,7 @@ async function boot({ scene, camera, renderer, matchConfig: cfg }) { stats: matchStats, strategyByTeam, score: referee.getScore(), + loco, }); } setStore(patch); @@ -2262,12 +2460,28 @@ async function boot({ scene, camera, renderer, matchConfig: cfg }) { // Registered for the module-level HMR dispose so this instance's loop is // stopped when the module is thrown away. liveControlLoops.add(() => { running = false; }); + function readSimSpeed() { + const n = store().simSpeed; + return n === 2 || n === 3 ? n : 1; + } + function setSimSpeed(n) { + const s = n === 2 || n === 3 ? n : 1; + setStore({ simSpeed: s }); + try { localStorage.setItem("microduck-sim-speed", String(s)); } catch { /* private */ } + return s; + } + function cycleSimSpeed() { + const cur = readSimSpeed(); + return setSimSpeed(cur >= 3 ? 1 : cur + 1); + } (async function controlLoop() { let next = performance.now(); let count = 0, hzT0 = next; while (running) { - await controlStep(); - count++; + // N physics/AI steps per wall-clock CTRL_DT → Nx match speed. + const steps = readSimSpeed(); + for (let i = 0; i < steps; i++) await controlStep(); + count += steps; const now = performance.now(); if (now - hzT0 > 500) { ctrlHz = (count * 1000) / (now - hzT0); @@ -2356,16 +2570,157 @@ async function boot({ scene, camera, renderer, matchConfig: cfg }) { scene.add(rig.placer); trunkGroup = rig.bodies.get("trunk_base"); } - locos.legs = { - model, data, rig, trunkGroup, - qposAdr, dofAdr, gyroAdr, trunkId, standKeyId, ballQposAdr, ballDofAdr, extraJoints, - }; + locos.legs = isFootball + ? null // football packs live in footballPacks['legs_legs'] below + : { + model, data, rig, trunkGroup, + qposAdr, dofAdr, gyroAdr, trunkId, standKeyId, ballQposAdr, ballDofAdr, extraJoints, + }; // ── Locomotion variant switching (legs <-> rollers) ────────────────── - // The roller stack (XML + 5 extra meshes + kinematics + 2 ONNX policies) - // is lazy-loaded on the first switch, then kept resident. + // Sandbox: single-duck lazy rollers stack (unchanged). + // Football: per-TEAM loco. Packs are keyed "redLoco_blueLoco" and may + // mix walking + roller bodies in one MJCF (3+3). Coach picks each side + // independently on the title card; Kick Off activates the matching pack. + const footballPacks = Object.create(null); // key -> { model, data, duckPacks, duckLocos, ... } + let legsKin = k; + let rollersKin = null; + let footballPackLoading = null; + + function locoPackKey(byTeam) { + const r = byTeam?.red === "rollers" ? "rollers" : "legs"; + const b = byTeam?.blue === "rollers" ? "rollers" : "legs"; + return `${r}_${b}`; + } + + function readLocoByTeam() { + const t = store().locoByTeam; + return { + red: t?.red === "rollers" ? "rollers" : "legs", + blue: t?.blue === "rollers" ? "rollers" : "legs", + }; + } + + async function ensureDrivePolicies() { + if (sessions.drive) return; + const [sDrive, sCrouch] = await Promise.all([ + ort.InferenceSession.create(signed(POLICIES.drive), sessionOpts), + ort.InferenceSession.create(signed(POLICIES.crouch), sessionOpts), + ]); + sessions.drive = sDrive; + sessions.crouch = sCrouch; + } + + async function buildFootballPack(byTeam) { + const key = locoPackKey(byTeam); + if (footballPacks[key]) return footballPacks[key]; + const duckCfgs = matchConfig.ducks.map((dc) => ({ + ...dc, + loco: byTeam[dc.team] === "rollers" ? "rollers" : "legs", + })); + const needsRollers = duckCfgs.some((d) => d.loco === "rollers"); + const xmlTask = buildPhysicsXml("robot_allcollisions.xml", { + ...matchConfig.field, + ducks: duckCfgs, + ballPark: matchConfig.ball.parkPos, + }); + const kinTask = needsRollers && !rollersKin + ? loadKinematics(`${MODEL_DIR}/kinematics_rollers.json`) + : Promise.resolve(rollersKin); + const [{ xml: packXml, meshFiles }, rk] = await Promise.all([xmlTask, kinTask]); + if (needsRollers) { + if (rk) rollersKin = rk; + await ensureDrivePolicies(); + } + await addMeshesToVfs(meshFiles); + + const packModel = mujoco.MjModel.from_xml_string(packXml, vfs); + const packData = new mujoco.MjData(packModel); + const wantNq = duckCfgs.reduce((n, d) => n + (d.loco === "rollers" ? 25 : 21), 7); + console.assert(packModel.nq === wantNq, + `[football] pack ${key} nq ${packModel.nq} (want ${wantNq})`); + console.assert(packModel.nu === ducks.length * NUM_JOINTS, + `[football] pack ${key} nu ${packModel.nu}`); + + const [legsSrc, rollersSrc] = await Promise.all([ + buildRig(legsKin, { materialForMesh: materialHookFor(VARIANTS.classic) }), + needsRollers + ? buildRig(rollersKin, { materialForMesh: materialHookFor(VARIANTS.classic) }) + : Promise.resolve(null), + ]); + // First duck of each loco flavor keeps the prototype; later ones clone. + const taken = { legs: false, rollers: false }; + const duckLocos = duckCfgs.map((d) => d.loco); + const duckPacks = duckCfgs.map((dc, i) => { + const locoName = dc.loco; + const kin = locoName === "rollers" ? rollersKin : legsKin; + const addrs = resolveAddrs(packModel, kin, dc.prefix); + addrs.ctrlOffset = i * NUM_JOINTS; + const src = locoName === "rollers" ? rollersSrc : legsSrc; + let rRig; + if (!taken[locoName]) { + taken[locoName] = true; + rRig = src; + } else { + rRig = cloneRig(src); + } + applyVariant(rRig, dc.team === "red" ? "team_red" : "team_blue"); + if (dc.role === "goalkeeper") addGoalkeeperMark({ team: dc.team, role: "goalkeeper", rig: rRig }); + return { addrs, rig: rRig, loco: locoName }; + }); + + const pack = { + key, + model: packModel, + data: packData, + duckPacks, + duckLocos, + ballQposAdr: duckPacks[0].addrs.ballQposAdr, + ballDofAdr: duckPacks[0].addrs.ballDofAdr, + byTeam: { ...byTeam }, + }; + footballPacks[key] = pack; + return pack; + } + + async function ensureFootballPack(byTeam) { + const key = locoPackKey(byTeam); + if (footballPacks[key]) return footballPacks[key]; + // Serialize builds so overlapping coach clicks don't double-compile. + const run = async () => { + setStore({ rollersLoading: true }); + try { + return await buildFootballPack(byTeam); + } finally { + setStore({ rollersLoading: false }); + } + }; + if (footballPackLoading) { + await footballPackLoading; + if (footballPacks[key]) return footballPacks[key]; + } + footballPackLoading = run().finally(() => { footballPackLoading = null; }); + return footballPackLoading; + } + + // Register the boot legs world as the legs_legs pack (reuses live rigs/addrs). + if (isFootball) { + footballPacks["legs_legs"] = { + key: "legs_legs", + model, + data, + duckPacks: ducks.map((d) => ({ addrs: d.addrs, rig: d.rig, loco: "legs" })), + duckLocos: ducks.map(() => "legs"), + ballQposAdr: ducks[0].addrs.ballQposAdr, + ballDofAdr: ducks[0].addrs.ballDofAdr, + byTeam: { red: "legs", blue: "legs" }, + }; + for (const d of ducks) d.loco = "legs"; + } + let rollersLoading = null; function ensureRollers() { + // Sandbox-only lazy stack. Football uses ensureFootballPack. rollersLoading ??= (async () => { const [{ xml: rXml, meshFiles: rMeshFiles }, rk] = await Promise.all([ buildPhysicsXml("robot_allcollisions_rollers.xml"), @@ -2423,9 +2778,93 @@ async function boot({ scene, camera, renderer, matchConfig: cfg }) { resetSim(); } + let activeFootballPackKey = isFootball ? "legs_legs" : null; + + /** Swap the shared multi-duck model + per-duck rigs for a loco pack. */ + function activateFootballPack(pack) { + if (!pack?.duckPacks || pack.key === activeFootballPackKey) { + // Still refresh duck.loco in case of partial state. + if (pack?.duckLocos) { + for (let i = 0; i < ducks.length; i++) ducks[i].loco = pack.duckLocos[i]; + } + return; + } + celebration?.cancel(); + const poses = ducks.map((d) => ({ + x: Number.isFinite(d.pos[0]) ? d.pos[0] : 0, + y: Number.isFinite(d.pos[1]) ? d.pos[1] : 0, + yaw: Number.isFinite(d.yaw) ? d.yaw : 0, + })); + const q = data.qpos; + const ballPose = { + x: Number.isFinite(q[ballQposAdr]) ? q[ballQposAdr] : 0, + y: Number.isFinite(q[ballQposAdr + 1]) ? q[ballQposAdr + 1] : 0, + z: Number.isFinite(q[ballQposAdr + 2]) ? q[ballQposAdr + 2] : BALL_RADIUS, + }; + for (const d of ducks) { + if (d.rig?.placer?.parent) scene.remove(d.rig.placer); + d.recovery = null; + d.kickRun = null; + d.mode = "walk"; + d.cmd.fill(0); + d.cmdSm.fill(0); + d.lastAction.fill(0); + } + model = pack.model; + data = pack.data; + ballQposAdr = pack.ballQposAdr; + ballDofAdr = pack.ballDofAdr; + for (let i = 0; i < ducks.length; i++) { + const p = pack.duckPacks[i]; + ducks[i].addrs = p.addrs; + ducks[i].rig = p.rig; + ducks[i].loco = pack.duckLocos[i]; + applyVariant(p.rig, ducks[i].team === "red" ? "team_red" : "team_blue"); + scene.add(p.rig.placer); + if (ducks[i].rig) ducks[i].rig.placer.visible = !ducks[i].sentOff; + } + ({ qposAdr, dofAdr, gyroAdr, trunkId, standKeyId, extraJoints, ankleIds } = ducks[0].addrs); + activeFootballPackKey = pack.key; + // Mirror a coarse loco flag for OSD: rollers if either side skates. + loco = (pack.byTeam.red === "rollers" || pack.byTeam.blue === "rollers") + ? "rollers" : "legs"; + setStore({ loco, locoByTeam: { ...pack.byTeam } }); + for (let i = 0; i < ducks.length; i++) { + placeDuck(ducks[i], [poses[i].x, poses[i].y, 0.12], poses[i].yaw); + } + placeBall([ballPose.x, ballPose.y, ballPose.z]); + prevBallX = ballPose.x; + prevBallY = ballPose.y; + cacheDuckPoses(); + } + let locoSwitching = false; + async function applyFootballLocoByTeam(byTeam, { force = false } = {}) { + if (!isFootball) return; + if (!force && !store().menuOpen && formationLocked) return; + const want = { + red: byTeam?.red === "rollers" ? "rollers" : "legs", + blue: byTeam?.blue === "rollers" ? "rollers" : "legs", + }; + const key = locoPackKey(want); + if (key === activeFootballPackKey && !locoSwitching) return; + locoSwitching = true; + setStore({ locoSwitching: true, locoByTeam: want }); + try { + const pack = await ensureFootballPack(want); + activateFootballPack(pack); + } catch (e) { + console.error("[football] team loco apply failed", e); + } finally { + setStore({ locoSwitching: false }); + locoSwitching = false; + } + } + async function setLoco(name, { force = false } = {}) { if (name !== "legs" && name !== "rollers") return; + // Football uses per-team loco — ignore global setLoco except sandbox. + if (isFootball) return; if (loco === name || locoSwitching) return; if (!force && (inputLocked || rollRun || kickRun || crouchRun || pickRun || standTimer || recovery)) return; @@ -2447,16 +2886,16 @@ async function boot({ scene, camera, renderer, matchConfig: cfg }) { } async function toggleLoco() { + if (isFootball) return; // match loco is coach-card only (per team) const next = loco === "legs" ? "rollers" : "legs"; setStore({ locoWant: next }); await setLoco(next); } - // Quickbar loco intent: reconcile locoWant -> actual, retrying until the - // game allows the switch (mid-roll, respawn ceremony, ...). Replaces the - // old index.html reconciler that polled window.rl. + // Quickbar loco intent (sandbox only). let locoReconciler = null; function reconcileLoco() { + if (isFootball) return; const want = store().locoWant; if (want === loco) { if (locoReconciler) { clearInterval(locoReconciler); locoReconciler = null; } @@ -2557,11 +2996,19 @@ async function boot({ scene, camera, renderer, matchConfig: cfg }) { controls.maxPolarAngle = Math.PI / 2 - 0.03; if (isFootball) { // ── Football mode: broadcast framing ── - // Orbit the pitch centre instead of the single spawn cell, and lift the - // distance cap so the whole field stays visible (FootballCanvas parks - // the camera ~6.4 units out; the sandbox cap of 3 would yank it in). + // Keep a high overview of the 6×4 m pitch. Sandbox minDistance (0.25) + // let trackpad zoom bury the camera in the turf so the main view went + // black while FPV PiPs still showed the markings. controls.target.set(0, 0, 0); + controls.minDistance = 3.8; controls.maxDistance = 12; + controls.maxPolarAngle = Math.PI * 0.42; // ~76° from zenith — always look down + controls.minPolarAngle = 0.18; + controls.enableZoom = false; + controls.enablePan = false; + camera.position.set(0, 4.2, 5.2); + camera.lookAt(0, 0, 0); + controls.update(); } // Chase cam (default ON): each frame the camera eases toward a point @@ -3401,6 +3848,19 @@ async function boot({ scene, camera, renderer, matchConfig: cfg }) { ctx.putImageData(new ImageData(fpvFlipBuf, FPV_RT_W, FPV_RT_H), 0, 0); } + // Three.setViewport takes CSS pixels and multiplies by DPR. getViewport + // returns the *drawing-buffer* rect — feeding that back into setViewport + // double-scales and leaves the main canvas painting into a tiny corner + // (rest of the screen stays clear-color black while FPV RTs still work). + const _fpvSize = new THREE.Vector2(); + function restoreMainFramebuffer() { + renderer.setRenderTarget(null); + renderer.getSize(_fpvSize); + renderer.setViewport(0, 0, _fpvSize.x, _fpvSize.y); + renderer.setScissor(0, 0, _fpvSize.x, _fpvSize.y); + renderer.setScissorTest(false); + } + function renderOneFpv(team, cam, canvas) { if (!canvas) return; const duck = pickTeamChaser(team); @@ -3416,17 +3876,17 @@ async function boot({ scene, camera, renderer, matchConfig: cfg }) { 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(); const prevAutoClear = renderer.autoClear; renderer.autoClear = true; renderer.setClearColor(0x08080c, 1); + // setRenderTarget(RT) sets the GL viewport to the RT; do NOT call + // setViewport(320,200) here — that overwrites Three's CSS _viewport. renderer.setRenderTarget(fpvRt); renderer.clear(); renderer.render(scene, cam); - renderer.setRenderTarget(prev); renderer.setClearColor(prevColor, prevAlpha); renderer.autoClear = prevAutoClear; if (hid) hid.visible = wasVis; @@ -3444,13 +3904,19 @@ async function boot({ scene, camera, renderer, matchConfig: cfg }) { const blue = fpvSlots.blue || document.querySelector("canvas[data-fpv='blue']"); if (!red && !blue) return; - // Never blit the same canvas twice if refs somehow alias. - if (red && blue && red === blue) { - renderOneFpv("red", fpvCamRed, red); - return; + try { + // Never blit the same canvas twice if refs somehow alias. + if (red && blue && red === blue) { + renderOneFpv("red", fpvCamRed, red); + } else { + if (red) renderOneFpv("red", fpvCamRed, red); + if (blue) renderOneFpv("blue", fpvCamBlue, blue); + } + } finally { + // Always put the default framebuffer + full CSS viewport back so the + // next R3F main pass fills the window (not a 320×200 corner). + restoreMainFramebuffer(); } - renderOneFpv("red", fpvCamRed, red); - renderOneFpv("blue", fpvCamBlue, blue); } function syncButtons() { @@ -3482,9 +3948,28 @@ async function boot({ scene, camera, renderer, matchConfig: cfg }) { }, requestLoco: (name) => { if (name !== "legs" && name !== "rollers") return; + if (isFootball) { + const userTeam = store().userTeam || "red"; + const next = { ...readLocoByTeam(), [userTeam]: name }; + setStore({ locoByTeam: next }); + applyFootballLocoByTeam(next).catch((e) => { + console.error("[football] requestLoco failed", e); + }); + return; + } setStore({ locoWant: name }); reconcileLoco(); }, + setTeamLoco: (team, name) => { + if (!isFootball) return; + if (team !== "red" && team !== "blue") return; + if (name !== "legs" && name !== "rollers") return; + const next = { ...readLocoByTeam(), [team]: name }; + setStore({ locoByTeam: next }); + applyFootballLocoByTeam(next).catch((e) => { + console.error("[football] setTeamLoco failed", e); + }); + }, resetSim, spawnBall: () => spawnBall(), startEntrance: () => ceremony?.startEntrance(), @@ -3493,6 +3978,9 @@ async function boot({ scene, camera, renderer, matchConfig: cfg }) { setUserTeam: (team) => setUserTeam(team), setFpvSlots, renderTeamFpv, + setSimSpeed, + cycleSimSpeed, + getSimSpeed: readSimSpeed, getStrategy: () => ({ userTeam: store().userTeam, userStrategy: store().userStrategy, @@ -3605,8 +4093,14 @@ async function boot({ scene, camera, renderer, matchConfig: cfg }) { aiDivider: () => aiDivider, config: matchConfig, // Top-level match control (called by FootballTitle "Kick Off" button) - startMatch: () => { + startMatch: async () => { celebration?.cancel(); + // Apply per-team loco packs (walk / rollers / mixed) before the whistle. + try { + await applyFootballLocoByTeam(readLocoByTeam(), { force: true }); + } catch (e) { + console.error("[football] loco apply failed", e); + } // Fresh match: clear discipline carried over from a previous game so // sent-off / sin-binned ducks (and their charcoal skin) don't persist // into the restart. The referee's kickoff event then runs executeKickoff, @@ -3627,6 +4121,16 @@ async function boot({ scene, camera, renderer, matchConfig: cfg }) { formationLocked = true; matchStats?.reset(); setStore({ tacticsCard: null, tacticsBoard: null, matchResult: null }); + const locoSnap = readLocoByTeam(); + matchLog?.startMatch({ + locoByTeam: locoSnap, + userTeam: store().userTeam || "red", + strategyFingerprint: { + red: strategyFingerprint(strategyByTeam.red), + blue: strategyFingerprint(strategyByTeam.blue), + }, + simSpeed: store().simSpeed, + }); if (referee) referee.startMatch(); else { cacheDuckPoses(); spawnBallFootball(); } }, @@ -3639,6 +4143,14 @@ async function boot({ scene, camera, renderer, matchConfig: cfg }) { getCards: () => referee.getCards(), startMatch: () => referee.startMatch(), } : null, + matchLog: matchLog ? { + list: () => matchLog.list(), + get: (id) => matchLog.get(id), + download: (id) => matchLog.download(id), + flush: () => matchLog.flush("manual"), + clear: () => matchLog.clear(), + current: () => matchLog.current(), + } : null, celebration: celebration ? { isActive: () => celebration.isActive(), cancel: () => celebration.cancel(), diff --git a/app/src/store.js b/app/src/store.js index 4e380fa..8f8cfe6 100644 --- a/app/src/store.js +++ b/app/src/store.js @@ -24,8 +24,9 @@ export const useGame = create( // Game state mirrored for the UI modeLabel: "Run", - loco: "legs", // "legs" | "rollers" - what the game is actually running - locoWant: "legs", // what the quickbar asked for (game reconciles) + loco: "legs", // "legs" | "rollers" - sandbox actual; football: coarse flag + locoWant: "legs", // sandbox quickbar intent + locoByTeam: { red: "legs", blue: "legs" }, // football: per-side loco locoSwitching: false, rollersLoading: false, // OSD line while the roller stack streams in variant: "classic", @@ -88,6 +89,14 @@ export const useGame = create( danmakuEnabled: (() => { try { return localStorage.getItem("microduck-danmaku") !== "0"; } catch { return true; } })(), + // Match sim speed multiplier (1 | 2 | 3). Applied as N control steps + // per wall-clock CTRL_DT so MuJoCo/ONNX keep a fixed timestep. + simSpeed: (() => { + try { + const n = Number(localStorage.getItem("microduck-sim-speed")); + return n === 2 || n === 3 ? n : 1; + } catch { return 1; } + })(), // Live dual-team match report (4 Hz) — possession + shots + strategy tags tacticsBoard: null, // Frozen fulltime tactics card (same shape as tacticsBoard) diff --git a/app/test/ai.test.js b/app/test/ai.test.js index 8bccd72..68f86ac 100644 --- a/app/test/ai.test.js +++ b/app/test/ai.test.js @@ -9,7 +9,8 @@ import { BaseAgent, ForwardAgent, DefenderAgent, GoalkeeperAgent, VX_MAX, VX_MIN, WZ_MAX, SHOOT_SPEED, SHOOT_DIST, createAgent, decideAll, assignChaserId, chaseCost, chaseApproachPoint, - isBehindBall, lateralOrbitPoint, repelWalkTarget, BASE_TUNE, + isBehindBall, lateralOrbitPoint, repelWalkTarget, opponentFovFill, isKickSafe, + ballInFov, BASE_TUNE, } from '../src/game/football/ai/index.js'; import { FIELD_HALF_L } from '../src/game/football/constants.js'; @@ -567,7 +568,7 @@ describe('decideAll', () => { it('behind ball and facing shotDir → kick toward opponent goal', () => { const ducks = [ { - id: 0, pos: [-0.35, 0], yaw: 0, role: 'forward', fallen: false, penalized: false, + id: 0, pos: [-0.28, 0], yaw: 0, role: 'forward', fallen: false, penalized: false, team: 'red', spawnX: -0.8, spawnY: 0.5, _ai: { aimTicks: 0, kickCooldown: 0, kickHoldTicks: 0, atFeetTicks: 5 }, }, @@ -579,6 +580,48 @@ describe('decideAll', () => { assert.equal(cmds[0].kick, true); }); + it('does not kick from mid-range (air boot) — must be in KICK_CONTACT', () => { + // Behind + aligned but ~0.5 m away: AIM/chase only, no kick. + const ducks = [ + { + id: 0, pos: [-0.5, 0], yaw: 0, role: 'forward', fallen: false, penalized: false, + team: 'red', spawnX: -0.8, spawnY: 0.5, + _ai: { aimTicks: 10, kickCooldown: 0, kickHoldTicks: 0, atFeetTicks: 5 }, + }, + { id: 1, pos: [-1.5, -0.5], yaw: 0, role: 'forward', fallen: false, penalized: false, team: 'red' }, + { id: 2, pos: [-2.8, 0], yaw: 0, role: 'goalkeeper', fallen: false, penalized: false, team: 'red' }, + ]; + const ball = { x: 0, y: 0, vx: 0, vy: 0 }; + const cmds = decideAll(ducks, { ball, allDucks: ducks, team: 'red' }); + assert.equal(cmds[0].kick, false, 'mid-range must not air-kick'); + assert.ok(cmds[0].vx > 0.05, 'should still walk in toward the ball'); + }); + + it('after a contact kick the chaser keeps claim instead of walking off', () => { + const ducks = [ + { + id: 0, pos: [-0.28, 0], yaw: 0, role: 'forward', fallen: false, penalized: false, + team: 'red', spawnX: -0.8, spawnY: 0.5, + _ai: { aimTicks: 0, kickCooldown: 0, kickHoldTicks: 0, atFeetTicks: 5, holdingChase: true }, + }, + { + id: 1, pos: [-0.4, 0.15], 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' }, + ]; + const ball = { x: 0, y: 0, vx: 0, vy: 0 }; + const cmds = decideAll(ducks, { ball, allDucks: ducks, team: 'red' }); + assert.equal(cmds[0].kick, true); + assert.ok(ducks[0]._ai.postKickClaim > 0, 'post-kick claim should arm'); + // Teammate is closer after the kick tick — sticky claim must still hold. + ducks[0].pos = [-0.55, 0.05]; + ducks[1].pos = [-0.32, 0.02]; + decideAll(ducks, { ball, allDucks: ducks, team: 'red' }); + assert.equal(ducks[0]._ai.holdingChase, true, 'kicker must keep chase claim'); + assert.equal(ducks[1]._ai.holdingChase, false); + }); + it('AIM does not poke while still turning (avoids own-goal boots)', () => { const ducks = [ { @@ -599,6 +642,31 @@ describe('decideAll', () => { assert.equal(kickCount, 0, 'off-angle AIM must not poke'); }); + it('isKickSafe refuses boots aimed at the own goal', () => { + const ball = { x: -1.5, y: 0 }; + assert.equal(isKickSafe(0, 1, ball, -FIELD_HALF_L), true, 'red facing +X is safe'); + assert.equal(isKickSafe(Math.PI, 1, ball, -FIELD_HALF_L), false, 'red facing −X is own-goal'); + assert.equal(isKickSafe(Math.PI, -1, ball, FIELD_HALF_L), true, 'blue facing −X is safe'); + assert.equal(isKickSafe(0, -1, ball, FIELD_HALF_L), false, 'blue facing +X is own-goal'); + }); + + it('own-half chaser facing own goal must not kick', () => { + // Red own half, behind ball, contact range, but yaw toward own net. + const ducks = [ + { + id: 0, pos: [-2.05, 0], yaw: Math.PI, role: 'forward', fallen: false, penalized: false, + team: 'red', spawnX: -0.8, spawnY: 0.5, + _ai: { aimTicks: 20, kickCooldown: 0, kickHoldTicks: 0, atFeetTicks: 15 }, + }, + { id: 1, pos: [-1.0, -0.8], yaw: 0, role: 'forward', fallen: false, penalized: false, team: 'red' }, + { id: 2, pos: [-2.8, 0], yaw: 0, role: 'goalkeeper', fallen: false, penalized: false, team: 'red' }, + ]; + const ball = { x: -1.8, y: 0, vx: 0, vy: 0 }; + const cmds = decideAll(ducks, { ball, allDucks: ducks, team: 'red' }); + assert.equal(cmds[0].kick, false, 'must clear upfield, never boot own goal'); + assert.ok(Math.abs(cmds[0].wz) > 0.2, 'should turn toward upfield clear'); + }); + it('chaseApproachPoint sits behind a still ball on the shot axis (Booster approach_target)', () => { const ball = { x: 0, y: 0, vx: 0, vy: 0 }; const ap = chaseApproachPoint(ball, FIELD_HALF_L, -0.8, 0.5); @@ -736,6 +804,91 @@ describe('decideAll', () => { const dBlock = distanceTo(ducks[1].pos[0], ducks[1].pos[1], slotX, slotY); assert.ok(dBlock > BASE_TUNE.AVOID_RADIUS * 0.45, `walked onto blocker (d=${dBlock})`); }); + + it('opponentFovFill is high when nose-to-nose, low when far', () => { + const close = opponentFovFill(0, 0, 0, { pos: [0.22, 0] }); + const far = opponentFovFill(0, 0, 0, { pos: [1.5, 0] }); + const side = opponentFovFill(0, 0, 0, { pos: [0, 0.22] }); // 90° off axis + assert.ok(close >= BASE_TUNE.BLOCK_FILL, `face-off fill=${close}`); + assert.ok(far < 0.25, `far fill=${far}`); + assert.equal(side, 0, 'side blocker must be outside FOV'); + }); + + it('ballInFov is true ahead and false behind', () => { + const ball = { x: 1, y: 0 }; + assert.equal(ballInFov(0, 0, 0, ball), true); + assert.equal(ballInFov(0, 0, Math.PI, ball), false); + }); + + it('face-off: opponent filling FOV for 3s triggers reverse retreat', () => { + // Red chaser nose-to-nose with a blue duck — after BLOCK_HOLD_TICKS, vx < 0. + const ducks = [ + { + id: 0, pos: [0, 0], yaw: 0, role: 'forward', fallen: false, penalized: false, + team: 'red', spawnX: -0.8, spawnY: 0.5, + _ai: { blockTicks: 0, retreatTicks: 0, holdingChase: true }, + }, + { id: 1, pos: [-1.2, -0.8], yaw: 0, role: 'forward', fallen: false, penalized: false, team: 'red' }, + { id: 2, pos: [-2.8, 0], yaw: 0, role: 'goalkeeper', fallen: false, penalized: false, team: 'red' }, + { id: 3, pos: [0.22, 0], yaw: Math.PI, role: 'forward', fallen: false, penalized: false, team: 'blue' }, + ]; + const ball = { x: 0.8, y: 0, vx: 0, vy: 0 }; + const gs = { ball, allDucks: ducks, team: 'red' }; + let retreated = false; + for (let t = 0; t < BASE_TUNE.BLOCK_HOLD_TICKS + 2; t++) { + const cmds = decideAll(ducks, gs); + if (cmds[0].vx < -0.05) { + retreated = true; + assert.equal(cmds[0].kick, false); + break; + } + } + assert.ok(retreated, 'should reverse after sustained FOV block'); + assert.ok(ducks[0]._ai.retreatTicks > 0, 'retreat fuse should be armed'); + }); + + it('face-off does not retreat when ball is in view and nearer than opponent', () => { + const ducks = [ + { + id: 0, pos: [0, 0], yaw: 0, role: 'forward', fallen: false, penalized: false, + team: 'red', spawnX: -0.8, spawnY: 0.5, + _ai: { blockTicks: BASE_TUNE.BLOCK_HOLD_TICKS - 1, retreatTicks: 0, holdingChase: true }, + }, + { id: 1, pos: [-1.2, -0.8], yaw: 0, role: 'forward', fallen: false, penalized: false, team: 'red' }, + { id: 2, pos: [-2.8, 0], yaw: 0, role: 'goalkeeper', fallen: false, penalized: false, team: 'red' }, + // Opponent close in FOV, but ball is even closer ahead. + { id: 3, pos: [0.35, 0.05], yaw: Math.PI, role: 'forward', fallen: false, penalized: false, team: 'blue' }, + ]; + const ball = { x: 0.2, y: 0, vx: 0, vy: 0 }; + const cmds = decideAll(ducks, { ball, allDucks: ducks, team: 'red' }); + assert.ok(cmds[0].vx >= 0, 'must not reverse when focusing the nearer ball'); + }); + + it('chaser scans after ball leaves FOV for BALL_LOST_TICKS', () => { + // Face away from the ball so it is out of FOV every tick. + const ducks = [ + { + id: 0, pos: [0, 0], yaw: Math.PI, role: 'forward', fallen: false, penalized: false, + team: 'red', spawnX: -0.8, spawnY: 0.5, + _ai: { ballLostTicks: 0, scanTicks: 0, scanDir: 1, holdingChase: true }, + }, + { id: 1, pos: [-1.5, -0.8], yaw: 0, role: 'forward', fallen: false, penalized: false, team: 'red' }, + { id: 2, pos: [-2.8, 0], yaw: 0, role: 'goalkeeper', fallen: false, penalized: false, team: 'red' }, + ]; + const ball = { x: 1.2, y: 0, vx: 0, vy: 0 }; + const gs = { ball, allDucks: ducks, team: 'red' }; + let scanned = false; + for (let t = 0; t < BASE_TUNE.BALL_LOST_TICKS + 5; t++) { + decideAll(ducks, gs); + // Keep back to the ball so lostTicks can accumulate. + ducks[0].yaw = Math.PI; + if (ducks[0]._ai.scanTicks > 0) { + scanned = true; + break; + } + } + assert.ok(scanned, 'chaser should arm scanTicks after losing the ball from FOV'); + }); }); // ═══════════════════════════════════════════════════════════════════════════════ diff --git a/app/test/loco.test.js b/app/test/loco.test.js new file mode 100644 index 0000000..5b9ac71 --- /dev/null +++ b/app/test/loco.test.js @@ -0,0 +1,34 @@ +// Loco limits stay orthogonal to the strategy card. +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; + +import { applyLocoLimits } from '../src/game/football/loco.js'; +import { getTuneOverlay, DEFAULT_STRATEGY, strategyFingerprint } from '../src/game/football/strategy.js'; +import { VEL_FWD, RVEL_FWD, RVEL_ANG } from '../src/game/constants.js'; + +describe('applyLocoLimits', () => { + const stratOverlay = getTuneOverlay(DEFAULT_STRATEGY); + + it('leaves strategy speeds alone on legs', () => { + const out = applyLocoLimits(stratOverlay, 'legs'); + assert.equal(out.CHASE_SPEED, stratOverlay.CHASE_SPEED); + assert.equal(out.VX_MAX, VEL_FWD); + }); + + it('scales chase speed to roller ceiling without inventing new tactics', () => { + const out = applyLocoLimits(stratOverlay, 'rollers'); + const expected = Math.min(RVEL_FWD, stratOverlay.CHASE_SPEED * (RVEL_FWD / VEL_FWD)); + assert.equal(out.CHASE_SPEED, expected); + assert.equal(out.WZ_MAX, RVEL_ANG); + // No strategy-only knobs appear on the loco remap. + assert.equal(out.lineHeight, undefined); + }); + + it('does not change strategy fingerprints when loco changes', () => { + const a = strategyFingerprint(DEFAULT_STRATEGY); + const b = strategyFingerprint(DEFAULT_STRATEGY); + assert.equal(a, b); + // Loco is outside the card — fingerprint ignores it by construction. + assert.match(a, /^[0-9a-f]{8}$/); + }); +}); diff --git a/app/test/match-debug-log.test.js b/app/test/match-debug-log.test.js new file mode 100644 index 0000000..010acb9 --- /dev/null +++ b/app/test/match-debug-log.test.js @@ -0,0 +1,35 @@ +import { describe, it, beforeEach } from 'node:test'; +import assert from 'node:assert/strict'; +import { createMatchDebugLog } from '../src/game/football/match-debug-log.js'; + +describe('createMatchDebugLog', () => { + let store; + beforeEach(() => { + store = {}; + globalThis.localStorage = { + getItem: (k) => (k in store ? store[k] : null), + setItem: (k, v) => { store[k] = String(v); }, + removeItem: (k) => { delete store[k]; }, + }; + }); + + it('starts a match, records loud events, and flushes to storage', () => { + const log = createMatchDebugLog(); + const id = log.startMatch({ simSpeed: 2 }); + log.push('shot', { team: 'blue' }); + log.noteDanger({ + state: 'PLAYING', + score: { red: 0, blue: 0 }, + matchTime: 12, + lastTouch: 'blue', + ball: [-3.01, 0.2, 0.05], + oob: { kind: 'goalline', side: -1 }, + }); + log.push('goal_kick', { team: 'red' }); + const saved = log.flush('fulltime'); + assert.equal(saved.id, id); + assert.ok(saved.summary.nearMissGoalline >= 1); + assert.equal(log.list().length, 1); + assert.equal(log.get(id).events.some((e) => e.type === 'shot'), true); + }); +}); diff --git a/app/test/referee.test.js b/app/test/referee.test.js index 4a47242..9525637 100644 --- a/app/test/referee.test.js +++ b/app/test/referee.test.js @@ -4,7 +4,7 @@ import { describe, it, beforeEach } from 'node:test'; import assert from 'node:assert/strict'; import { - createReferee, decide, checkGoal, checkOutOfBounds, decideSetPiece, detectTouches, REFEREE, + createReferee, decide, checkGoal, checkGoalPath, checkOutOfBounds, decideSetPiece, detectTouches, REFEREE, } from '../src/game/football/referee.js'; import { PENALTY_DURATION_S } from '../src/game/football/constants.js'; @@ -108,6 +108,43 @@ describe('createReferee', () => { assert.equal(last('goal').payload.team, 'red'); }); + it('slow roll into the mouth scores (OOB must not steal the goal)', () => { + // Whole-ball OOB trips at |x|>~2.95; goal plane is at 3.0. A soft finish + // that creeps 2.90 → 2.97 → 3.02 must still count as a goal. + toOpenPlay(ref, 'red'); + ref.step(DT, makeGameState([2.90, 0.05, 0.05], [duck('r0', 'red', [2.7, 0, 0])])); + ref.step(DT, makeGameState([2.97, 0.05, 0.05], [duck('r0', 'red', [2.7, 0, 0])])); + assert.equal(ref.getState(), 'PLAYING', 'mouth corridor is not a set-piece yet'); + ref.step(DT, makeGameState([3.02, 0.05, 0.05], [duck('r0', 'red', [2.7, 0, 0])])); + assert.deepEqual(ref.getScore(), { red: 1, blue: 0 }); + assert.equal(ref.getState(), 'GOAL'); + }); + + it('ball already stranded past the plane inside the mouth still scores', () => { + toOpenPlay(ref, 'red'); + // Both frames past the plane (missed sweep) — recovery path. + ref.step(DT, makeGameState([3.05, 0.1, 0.05], [duck('r0', 'red', [2.8, 0, 0])])); + assert.deepEqual(ref.getScore(), { red: 1, blue: 0 }); + assert.equal(last('goal').payload.team, 'red'); + }); + + it('post-glance path still scores when plane y drifts wide', () => { + // Plane intersection is wide of the mouth, but a deeper sample has + // come back inside the posts — must not become a goal-kick. + toOpenPlay(ref, 'blue'); + ref.step(DT, makeGameState([-2.90, 0.90, 0.05], [duck('b0', 'blue', [-2.7, 0.5, 0])])); + ref.step(DT, makeGameState([-3.10, 0.50, 0.05], [duck('b0', 'blue', [-2.7, 0.5, 0])])); + assert.deepEqual(ref.getScore(), { red: 0, blue: 1 }); + assert.equal(ref.getState(), 'GOAL'); + }); + + it('deep tunnel past the net back panel still scores in the mouth', () => { + toOpenPlay(ref, 'red'); + ref.step(DT, makeGameState([2.9, 0, 0.05], [duck('r0', 'red', [2.7, 0, 0])])); + ref.step(DT, makeGameState([3.45, 0.05, 0.05], [duck('r0', 'red', [2.7, 0, 0])])); + assert.deepEqual(ref.getScore(), { red: 1, blue: 0 }); + }); + it('GOAL → KICKOFF after the reset hold, conceding team restarts', () => { toOpenPlay(ref, 'blue'); ref.step(DT, makeGameState([-2.9, 0, 0.05])); @@ -209,11 +246,12 @@ describe('createReferee', () => { it('ball over the goal line, attacker touched last → goal_kick', () => { toPlaying(ref); - // Red attacks +X; red touches last and the ball exits over the blue line. - const gs = (pos) => makeGameState(pos, [duck('r1', 'red', [pos[0] - 0.2, 0, 0])]); - ref.step(DT, gs([2.9, 0, 0.05])); - ref.step(DT, gs([2.96, 0, 0.05])); - run(ref, Math.ceil(REFEREE.DEAD_BALL_S / DT) + 1, gs([2.95, 0, 0.05])); + // Red attacks +X; red touches last and the ball exits WIDE of the blue + // mouth (inside the mouth would be a goal, not a goal-kick). + const gs = (pos) => makeGameState(pos, [duck('r1', 'red', [pos[0] - 0.2, pos[1], 0])]); + ref.step(DT, gs([2.9, 1.2, 0.05])); + ref.step(DT, gs([2.96, 1.2, 0.05])); + run(ref, Math.ceil(REFEREE.DEAD_BALL_S / DT) + 1, gs([2.95, 1.2, 0.05])); const piece = ref.getSetPiece(); assert.equal(piece.type, 'goal_kick'); assert.equal(piece.team, 'blue'); // defending team restarts @@ -222,11 +260,11 @@ describe('createReferee', () => { it('ball over the goal line, defender touched last → corner', () => { toPlaying(ref); - // Blue defends +X; blue touches last and the ball exits over its own line. + // Blue defends +X; blue touches last and the ball exits wide of its mouth. const gs = (pos) => makeGameState(pos, [duck('b1', 'blue', [pos[0] - 0.2, pos[1] - 0.1, 0])]); - ref.step(DT, gs([2.9, 0.9, 0.05])); - ref.step(DT, gs([2.96, 0.9, 0.05])); - run(ref, Math.ceil(REFEREE.DEAD_BALL_S / DT) + 1, gs([2.95, 0.9, 0.05])); + ref.step(DT, gs([2.9, 1.2, 0.05])); + ref.step(DT, gs([2.96, 1.2, 0.05])); + run(ref, Math.ceil(REFEREE.DEAD_BALL_S / DT) + 1, gs([2.95, 1.2, 0.05])); const piece = ref.getSetPiece(); assert.equal(piece.type, 'corner_blue'); // corner at the blue end, red takes it assert.equal(piece.team, 'red'); @@ -507,7 +545,8 @@ describe('pure decision helpers', () => { assert.deepEqual(gs, frozen); assert.equal(a.goal.team, 'blue'); assert.deepEqual(a.touches, [{ id: 'r1', team: 'red' }]); - assert.equal(a.outOfBounds.kind, 'goalline'); + // Mouth/cage is reserved for the goal path — not a goalline set-piece. + assert.equal(a.outOfBounds, null); }); it('checkGoal: mouth crossing yes, wide/over the bar no', () => { @@ -517,10 +556,21 @@ describe('pure decision helpers', () => { assert.equal(checkGoal(null, [3.2, 0, 0.05]), null); }); + it('checkGoalPath recovers inward drift after a wide plane graze', () => { + assert.equal(checkGoal([2.9, 0.9, 0.05], [3.1, 0.5, 0.05]), null); + assert.deepEqual( + checkGoalPath([2.9, 0.9, 0.05], [3.1, 0.5, 0.05]), + { team: 'red', goal: 'blue' }, + ); + }); + it('checkOutOfBounds: whole-ball rule on both axes', () => { assert.equal(checkOutOfBounds([0, 1.94, 0.05]), null); // 1.99 < 2.0 assert.equal(checkOutOfBounds([0, 1.96, 0.05]).kind, 'sideline'); - assert.equal(checkOutOfBounds([2.96, 0, 0.05]).kind, 'goalline'); + // Wide of the posts past the goal line → goalline OOB. + assert.equal(checkOutOfBounds([2.96, 1.5, 0.05]).kind, 'goalline'); + // Inside the mouth corridor (|y| small) is reserved for goal detection. + assert.equal(checkOutOfBounds([2.96, 0, 0.05]), null); assert.equal(checkOutOfBounds([0, 0, 0.05]), null); }); diff --git a/app/test/strategy.test.js b/app/test/strategy.test.js index ff13849..2c21ac0 100644 --- a/app/test/strategy.test.js +++ b/app/test/strategy.test.js @@ -60,7 +60,7 @@ describe('strategy continuous knobs', () => { assert.equal(atk.LINE_PUSH_MID, 1); assert.ok(atk.SECOND_PRESS_DIST > 1.5); assert.ok(def.SECOND_PRESS_DIST < 0.4); - assert.ok(atk.SHOOT_ANGLE > def.SHOOT_ANGLE + 0.2); + assert.ok(atk.SHOOT_ANGLE > def.SHOOT_ANGLE + 0.1); }); it('knobsToOverlay interpolates continuously', () => {