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/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() {
{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 +196,56 @@ function SoundMuteButton() {
"&:hover": { color: ORANGE },
}}
>
- {muted ? "MUTED" : "SFX"}
+ {enabled ? t(locale, "danmakuOn") : t(locale, "danmakuOff")}
+
+
+ );
+}
+
+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) })}
);
@@ -305,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);
@@ -447,6 +501,19 @@ export function MatchResultBanner() {
{t(locale, "boardVs")}
{blueTag}
+
+ {t(locale, "locoMode")} · {locoTag}
+
+
diff --git a/app/src/football/FootballTitle.jsx b/app/src/football/FootballTitle.jsx
index 243e827..be84119 100644
--- a/app/src/football/FootballTitle.jsx
+++ b/app/src/football/FootballTitle.jsx
@@ -85,6 +85,8 @@ export default function FootballTitle({ onKickOff }) {
const touchMode = useGame((s) => 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 64042f7..e6f1d7b 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,84 @@ 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 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 (
-
+
{labels[id] || id}
@@ -102,24 +163,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}
/>
+
+
+
+ {t(locale, "tacticsSection")}
+
+
+
+
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..022df81 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 = {
@@ -16,11 +18,31 @@ const STRINGS = {
orbit: "Orbit",
resetCamera: "Reset Camera",
coachFooter: "coach mode — pick tactics, ducks play themselves",
+ locoMode: "Locomotion",
+ locoLegs: "Walk",
+ locoRollers: "Rollers",
+ locoLoading: "Loading…",
+ locoLegsHint: "Walk ONNX · top speed ~0.25 m/s — same tactics, slower feet",
+ locoRollersHint: "Skate ONNX · top speed ~0.6 m/s — same tactics, faster chassis",
+ locoSectionHint: "Peer rating axis to tactics — does not change formation or knobs",
+ locoRival: "Rival loco",
+ locoSplitHint: "Each side picks walk or rollers on its own — mixed matches OK",
+ tacticsSection: "Tactics",
coachDesk: "Coach desk",
yourSide: "Your side",
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 +54,20 @@ 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",
+ speedLabel: "{n}×",
+ speedAria: "Match speed {n}× — click to cycle",
sinBin: "SIN-BIN",
openCoach: "Open coach desk",
langEn: "EN",
@@ -57,6 +92,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 +101,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: "正在加载队伍…",
@@ -78,11 +117,31 @@ const STRINGS = {
orbit: "环绕",
resetCamera: "重置镜头",
coachFooter: "教练模式 — 选战术,鸭子自己踢",
+ locoMode: "移动方式",
+ locoLegs: "走路",
+ locoRollers: "轮滑",
+ locoLoading: "加载中…",
+ locoLegsHint: "步行 ONNX · 最高约 0.25 m/s — 战术相同,只是走得慢",
+ locoRollersHint: "轮滑 ONNX · 最高约 0.6 m/s — 战术相同,底盘更快",
+ locoSectionHint: "与战术同级的评级轴 — 不改阵型与旋钮",
+ locoRival: "对方移动",
+ locoSplitHint: "双方各自选走路或轮滑,可以一边走一边滑",
+ tacticsSection: "战术策略",
coachDesk: "教练席",
yourSide: "己方",
formation: "阵型",
style: "风格",
press: "压迫",
+ fineTune: "细调",
+ fineTuneHide: "收起细调",
+ fineTuneShow: "展开细调",
+ hintPlaceholder: "例如:高位逼抢,别乱射,门将别出门",
+ hintApply: "应用",
+ hintPreview: "将修改",
+ hintEmpty: "没有匹配到战术词 — 换个说法试试",
+ rivalTactics: "对方战术",
+ editingSide: "正在编辑",
+ custom: "自定义",
red: "红队",
blue: "蓝队",
attack: "进攻",
@@ -94,7 +153,20 @@ 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: "弹幕关",
+ speedLabel: "{n}×",
+ speedAria: "比赛倍速 {n}× — 点击切换",
sinBin: "暂罚",
openCoach: "打开教练席",
langEn: "EN",
@@ -116,6 +188,7 @@ const STRINGS = {
state_HALFTIME: "中场",
state_FULLTIME: "全场结束",
ev_goal: "进球!{team}得分",
+ ev_shot: "射门 —{team}",
ev_yellow: "黄牌 —{team}",
ev_red: "红牌 —{team}",
ev_corner: "角球 —{team}",
@@ -124,6 +197,9 @@ const STRINGS = {
ev_kickoff: "开球!",
ev_halftime: "半场",
ev_fulltime: "全场结束",
+ ev_extra: "加时!",
+ ev_throw: "界外球 —{team}",
+ ev_goalkick: "球门球 —{team}",
},
};
@@ -180,10 +256,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..394406d 100644
--- a/app/src/game/football/ai/index.js
+++ b/app/src/game/football/ai/index.js
@@ -18,23 +18,30 @@ export const BASE_TUNE = {
WZ_MAX: 1.0,
TURN_GAIN: 2.2,
- // Shooting
- SHOOT_DIST: 0.35,
- SHOOT_ANGLE: 0.26, // ~15° alignment window
+ // Shooting — engage band vs hard contact (kick only when truly at the ball).
+ SHOOT_DIST: 0.36, // enter AIM / "at feet" band
+ KICK_CONTACT: 0.30, // hard max duck↔ball for kick=true
+ SHOOT_ANGLE: 0.22, // ~12.5° alignment window
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)
+ AIM_SOFT_MULT: 1.35, // soft align widens SHOOT_ANGLE by this (was 1.85)
+ // Field awareness: kick impulse follows body yaw — never boot toward own net.
+ KICK_UPFIELD_COS: 0.30, // min cos(yaw)·attackDir to allow a kick
+ CLEAR_UPFIELD_COS: 0.55, // stricter in own half (clear, don't "shoot" home)
- // 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: 12, // ticks a stuck chaser stops kicking & repositions (~1.2s)
+ KICK_HOLD_BEFORE_COOLDOWN: 3,// consecutive at-feet kick attempts before arming the cooldown
+ POST_KICK_CLAIM_TICKS: 22, // keep chase claim after a kick so we don't walk off
+ 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 +63,43 @@ 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,
+
+ // Face-off retreat: opponent fills most of frontal FOV for ~3s → reverse out.
+ // Geometric proxy for "vision" (no camera pixels): angular size / FOV.
+ BLOCK_FOV: 1.36, // ~78° horizontal (matches FPV)
+ BLOCK_FILL: 0.67, // >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,
@@ -135,6 +176,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 +225,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 +242,72 @@ 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;
- if (duck.id === prevChaserId) cost -= TUNE.CHASE_HYSTERESIS;
+
+ // 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;
+ // Just kicked — stay on the ball instead of yielding and walking to support.
+ if ((duck._ai?.postKickClaim | 0) > 0) cost -= 0.45;
+ }
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;
+ // 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);
+ 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 +343,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(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),
+ };
+}
+
+/**
+ * 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(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 +502,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 +578,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,97 +599,320 @@ 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;
+}
+
+/**
+ * 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 / 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);
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 toGoal = angleTo(sx, sy, 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;
- // 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;
+ if (ai.postKickClaim === undefined) ai.postKickClaim = 0;
- // Tick the kick cooldown down once per decision.
if (ai.kickCooldown > 0) ai.kickCooldown--;
+ if (ai.postKickClaim > 0) ai.postKickClaim--;
- // 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 = !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;
+ 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;
- // 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);
+ // 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,
+ );
}
- // 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);
+ ai.postKickClaim = TUNE.POST_KICK_CLAIM_TICKS;
+ 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.
+ // 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++;
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);
+ // 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(
- moveToward(yaw, toGoal, TUNE.AIM_SPEED),
- turnToward(yaw, toGoal),
- pokeKick,
+ creepToward(yaw, shotDir, creep),
+ 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 +920,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 +992,39 @@ 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;
- // CLEAR: ball at feet in own half → hoof it
- 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);
+ // 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 only if somehow at feet (should be rare for non-chaser) — still require behind.
+ 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, 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)
+ ? 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, clearDir, TUNE.DEF_TURN_GAIN),
+ false,
+ );
}
// SUPPORT: teammate on ball up-field
@@ -510,30 +1036,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),
@@ -541,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;
@@ -552,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
// ═══════════════════════════════════════════════════════════════════════════════
@@ -573,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 e47e5f3..cd1eae9 100644
--- a/app/src/game/football/strategy.js
+++ b/app/src/game/football/strategy.js
@@ -1,27 +1,106 @@
-// 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.
+//
+// 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 } 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 +108,404 @@ 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 — 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),
+ 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..fec2e17 100644
--- a/app/src/game/game.js
+++ b/app/src/game/game.js
@@ -62,15 +62,18 @@ 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,
getTuneOverlay,
normalizeStrategy,
+ mergeStrategy,
strategiesForUser,
+ 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
@@ -78,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";
@@ -207,12 +211,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;
@@ -422,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"];
@@ -441,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);
}
}
@@ -721,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"
@@ -1173,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
@@ -1190,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;
}
@@ -1226,6 +1296,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;
}
@@ -1258,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.
@@ -1295,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;
@@ -1413,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,
@@ -1436,23 +1553,39 @@ 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).
+ 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: shotTime }],
+ });
+ }
}
}
}
@@ -1483,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 };
@@ -1653,23 +1786,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,14 +1828,36 @@ 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;
}
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({
@@ -1788,7 +1960,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": {
@@ -1801,6 +1977,7 @@ async function boot({ scene, camera, renderer, matchConfig: cfg }) {
stats: matchStats,
strategyByTeam,
score: scoreNow,
+ loco,
})
: null;
setStore({
@@ -1809,8 +1986,11 @@ 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();
+ matchLog?.flush("fulltime");
break;
}
}
@@ -1872,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)`,
);
}
}
@@ -1893,6 +2073,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
@@ -1915,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(),
@@ -1926,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());
@@ -1941,80 +2154,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();
}
@@ -2026,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.
@@ -2049,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();
@@ -2075,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]) },
@@ -2085,6 +2255,7 @@ async function boot({ scene, camera, renderer, matchConfig: cfg }) {
stats: matchStats,
strategyByTeam,
score: referee.getScore(),
+ loco,
});
}
setStore(patch);
@@ -2289,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);
@@ -2383,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"),
@@ -2450,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;
@@ -2474,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; }
@@ -2584,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
@@ -3311,8 +3731,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 +3743,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 +3784,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);
@@ -3400,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);
@@ -3411,19 +3872,23 @@ 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;
- 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;
blitRtToCanvas(canvas);
}
@@ -3439,8 +3904,19 @@ async function boot({ scene, camera, renderer, matchConfig: cfg }) {
const blue = fpvSlots.blue
|| document.querySelector("canvas[data-fpv='blue']");
if (!red && !blue) return;
- renderOneFpv("red", fpvCamRed, red);
- renderOneFpv("blue", fpvCamBlue, blue);
+ 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();
+ }
}
function syncButtons() {
@@ -3472,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(),
@@ -3483,10 +3978,18 @@ 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,
+ opponentStrategy: store().opponentStrategy,
strategyByTeam: { ...strategyByTeam },
+ fingerprints: {
+ red: strategyFingerprint(strategyByTeam.red),
+ blue: strategyFingerprint(strategyByTeam.blue),
+ },
formationLocked,
activeSpawns: activeSpawns.map((s) => ({ ...s })),
}),
@@ -3590,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,
@@ -3602,15 +4111,26 @@ 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;
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(); }
},
@@ -3623,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 097d2fd..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",
@@ -47,10 +48,55 @@ 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; }
+ })(),
+ // 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 2e58d0a..68f86ac 100644
--- a/app/test/ai.test.js
+++ b/app/test/ai.test.js
@@ -9,6 +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, opponentFovFill, isKickSafe,
+ ballInFov, BASE_TUNE,
} from '../src/game/football/ai/index.js';
import { FIELD_HALF_L } from '../src/game/football/constants.js';
@@ -121,6 +123,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 +203,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 +415,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 +429,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 +441,278 @@ 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.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 },
+ },
+ { 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('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('chaseApproachPoint sits behind a still ball on the shot axis (MoveToStaticBall)', () => {
+ 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 = [
+ {
+ 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('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);
- // 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 +720,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 +756,139 @@ 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})`);
+ });
+
+ 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/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/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 f70ef48..2c21ac0 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.1);
+ });
+
+ 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)) { i = str.indexOf('?>', 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}${node.tag}>`;
+}
+
+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 };
+