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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions app/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
},
Expand Down
204 changes: 204 additions & 0 deletions app/src/football/CommentaryDanmaku.jsx
Original file line number Diff line number Diff line change
@@ -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 (
<Box
aria-hidden
sx={{
position: "fixed",
top: { xs: "5.5rem", md: "6.25rem" },
left: 0,
right: 0,
height: { xs: "38%", md: "42%" },
zIndex: 11,
pointerEvents: "none",
overflow: "hidden",
}}
>
{items.map((it) => (
<Box
key={it.id}
sx={{
position: "absolute",
top: `${8 + it.lane * 18}%`,
left: "100%",
whiteSpace: "nowrap",
fontFamily: ANTON,
fontSize: { xs: "0.95rem", md: "1.15rem" },
letterSpacing: "0.04em",
color: it.team === "red" ? RED_ACCENT : it.team === "blue" ? BLUE_ACCENT : CREAM,
textShadow: `0 1px 0 ${COMIC_INK}, 0 0 10px rgba(0,0,0,0.65)`,
animation: `${drift} ${it.duration}ms linear both`,
"@media (prefers-reduced-motion: reduce)": {
animation: "none",
left: "50%",
transform: "translateX(-50%)",
opacity: 0.9,
},
}}
>
{it.text}
</Box>
))}
</Box>
);
}
5 changes: 3 additions & 2 deletions app/src/football/FootballCanvas.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -49,7 +50,7 @@ export default function FootballCanvas() {
<Canvas
style={{ position: "fixed", inset: 0, zIndex: 1 }}
dpr={[1, 1.5]}
gl={{ antialias: true, alpha: true }}
gl={{ antialias: true, alpha: false }}
// Overview framing of the 6 x 4 m pitch from centre; the game core
// takes over the follow-cam once it boots.
camera={{
Expand Down
96 changes: 82 additions & 14 deletions app/src/football/FootballHud.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,14 @@
// palette, zero-radius panel plates with cream keyline frames).
import Box from "@mui/material/Box";
import { keyframes } from "@mui/material/styles";
import { useState } from "react";
import { useGame } from "../store.js";
import { uiClick, isMuted, setMuted } from "../game/audio.js";
import { useGame, gameApi } from "../store.js";
import { uiClick } from "../game/audio.js";
import { ORANGE, MONO } from "../theme.js";
import { ANTON, COMIC_INK, CREAM, COMIC_ORANGE } from "../ui/comic.jsx";
import { GitHubLink } from "../ui/GitHubLink.jsx";
import { formatClock, stateLabel, eventLabel, recentEvents } from "./hud-logic.js";
import { t, teamLabel, strategyTag } from "./i18n.js";
import CommentaryDanmaku from "./CommentaryDanmaku.jsx";
import StrategyBoard from "./StrategyBoard.jsx";
import TeamFpv from "./TeamFpv.jsx";

Expand Down Expand Up @@ -153,13 +153,15 @@ function BackButton() {
<BackArrowIcon /> {t(locale, "back")}
</Box>
</Box>
<SoundMuteButton />
<DanmakuToggleButton />
<SpeedToggleButton />
</Box>
);
}

function SoundMuteButton() {
const [muted, setMutedState] = useState(isMuted);
function DanmakuToggleButton() {
const locale = useGame((s) => s.locale) || "en";
const enabled = useGame((s) => s.danmakuEnabled !== false);
return (
<Box
sx={{
Expand All @@ -172,19 +174,19 @@ function SoundMuteButton() {
<Box
component="button"
type="button"
aria-label={muted ? "Unmute" : "Mute"}
aria-pressed={muted}
aria-label={enabled ? t(locale, "danmakuOff") : t(locale, "danmakuOn")}
aria-pressed={enabled}
onClick={() => {
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",
Expand All @@ -194,7 +196,56 @@ function SoundMuteButton() {
"&:hover": { color: ORANGE },
}}
>
{muted ? "MUTED" : "SFX"}
{enabled ? t(locale, "danmakuOn") : t(locale, "danmakuOff")}
</Box>
</Box>
);
}

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 (
<Box
sx={{
...scorePlateSx,
flexDirection: "row",
minWidth: "unset",
padding: "6px 12px",
}}
>
<Box
component="button"
type="button"
aria-label={t(locale, "speedAria", { n: String(n) })}
onClick={() => {
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) })}
</Box>
</Box>
);
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -447,6 +501,19 @@ export function MatchResultBanner() {
{t(locale, "boardVs")}
</Box>
<Box component="span" sx={{ color: BLUE_ACCENT }}>{blueTag}</Box>
<Box
component="div"
sx={{
mt: "0.35rem",
fontFamily: MONO,
fontSize: "0.52rem",
letterSpacing: "0.12em",
textTransform: "uppercase",
color: "rgba(255,255,255,0.45)",
}}
>
{t(locale, "locoMode")} · {locoTag}
</Box>
</Box>
<Box
sx={{
Expand Down Expand Up @@ -610,6 +677,7 @@ export default function FootballHud() {
}}
/>
<MatchResultBanner />
<CommentaryDanmaku />
<EventTicker />
<PenaltyIndicator />
<StrategyBoard />
Expand Down
Loading
Loading