Skip to content

Commit 98d6cf7

Browse files
feat: Add Joy-Con controller support with dedicated page and hooks
1 parent db6bf17 commit 98d6cf7

File tree

5 files changed

+445
-0
lines changed

5 files changed

+445
-0
lines changed

visual_controller/frontend/src/App.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { Navigate, Route, Routes } from "react-router-dom";
33
import { TopNav } from "./layout/TopNav";
44
import { LegacyControllerPage } from "./pages/LegacyController";
55
import { NtControllerPage } from "./pages/NtController";
6+
import { JoyConControllerPage } from "./pages/JoyConController";
67
import { StatusPage } from "./pages/Status";
78
import { defaultTankId } from "./utils/constants";
89

@@ -35,6 +36,7 @@ export default function App() {
3536
<Route path="/" element={<Navigate to="/legacy" replace />} />
3637
<Route path="/legacy" element={<LegacyControllerPage />} />
3738
<Route path="/nt" element={<NtControllerPage />} />
39+
<Route path="/joycon" element={<JoyConControllerPage />} />
3840
<Route path="/status" element={<StatusPage />} />
3941
</Routes>
4042
</main>
Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
import { useEffect, useMemo, useState } from "react";
2+
3+
const JOYCON_REGEX = /(joy[-\s]?con|nintendo|pro controller)/i;
4+
5+
export interface JoyConSnapshot {
6+
supported: boolean;
7+
connected: boolean;
8+
id?: string;
9+
axes: number[];
10+
buttons: { pressed: boolean; touched: boolean; value: number }[];
11+
timestamp: number;
12+
}
13+
14+
const initialSupported = typeof window !== "undefined" && "getGamepads" in navigator;
15+
16+
const initialState: JoyConSnapshot = {
17+
supported: initialSupported,
18+
connected: false,
19+
id: undefined,
20+
axes: [],
21+
buttons: [],
22+
timestamp: 0
23+
};
24+
25+
/**
26+
* Polls the Gamepad API for any connected Nintendo Joy-Con or Switch Pro controller
27+
* and exposes a normalized snapshot. The hook only re-renders ~12 times per second
28+
* to avoid thrashing the UI.
29+
*/
30+
export function useJoyConGamepad(pollIntervalMs = 80): JoyConSnapshot {
31+
const [state, setState] = useState<JoyConSnapshot>(initialState);
32+
33+
const supported = useMemo(
34+
() => typeof window !== "undefined" && typeof navigator !== "undefined" && "getGamepads" in navigator,
35+
[]
36+
);
37+
38+
useEffect(() => {
39+
if (!supported) {
40+
setState((prev) => ({ ...prev, supported: false, connected: false }));
41+
return;
42+
}
43+
44+
let cancelled = false;
45+
let timer: number | undefined;
46+
47+
const poll = () => {
48+
if (cancelled) {
49+
return;
50+
}
51+
const pads = navigator.getGamepads ? Array.from(navigator.getGamepads()) : [];
52+
const preferred =
53+
pads.find((pad) => pad && /joy[-\s]?con \(l\/r\)/i.test(pad.id)) ??
54+
pads.find((pad) => pad && /pro controller/i.test(pad.id)) ??
55+
pads.find((pad) => pad && JOYCON_REGEX.test(pad.id));
56+
57+
if (preferred) {
58+
setState({
59+
supported: true,
60+
connected: true,
61+
id: preferred.id,
62+
timestamp: preferred.timestamp ?? performance.now(),
63+
axes: preferred.axes ? [...preferred.axes] : [],
64+
buttons: preferred.buttons.map((button) => ({
65+
pressed: button.pressed,
66+
touched: button.touched,
67+
value: button.value
68+
}))
69+
});
70+
} else {
71+
setState((prev) => ({
72+
supported: true,
73+
connected: false,
74+
id: undefined,
75+
axes: [],
76+
buttons: [],
77+
timestamp: performance.now()
78+
}));
79+
}
80+
81+
timer = window.setTimeout(poll, pollIntervalMs);
82+
};
83+
84+
timer = window.setTimeout(poll, pollIntervalMs);
85+
86+
const handleConnected = () => poll();
87+
const handleDisconnected = () => poll();
88+
89+
window.addEventListener("gamepadconnected", handleConnected);
90+
window.addEventListener("gamepaddisconnected", handleDisconnected);
91+
92+
return () => {
93+
cancelled = true;
94+
if (timer) {
95+
window.clearTimeout(timer);
96+
}
97+
window.removeEventListener("gamepadconnected", handleConnected);
98+
window.removeEventListener("gamepaddisconnected", handleDisconnected);
99+
};
100+
}, [supported, pollIntervalMs]);
101+
102+
return state;
103+
}
104+

visual_controller/frontend/src/layout/TopNav.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,9 @@ export function TopNav() {
1818
<NavLink to="/nt" className={({ isActive }) => (isActive ? "active" : "")}>
1919
NT Controller
2020
</NavLink>
21+
<NavLink to="/joycon" className={({ isActive }) => (isActive ? "active" : "")}>
22+
Joy-Con Controller
23+
</NavLink>
2124
<NavLink to="/status" className={({ isActive }) => (isActive ? "active" : "")}>
2225
Tank Status
2326
</NavLink>
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
.joycon-layout {
2+
display: grid;
3+
grid-template-columns: minmax(320px, 1.3fr) minmax(320px, 1fr);
4+
gap: 1.5rem;
5+
min-height: 70vh;
6+
position: relative;
7+
}
8+
9+
.joycon-left,
10+
.joycon-right {
11+
display: flex;
12+
flex-direction: column;
13+
gap: 1rem;
14+
}
15+
16+
.joycon-map-card {
17+
flex: 1;
18+
min-height: 320px;
19+
}
20+
21+
.joycon-telemetry pre {
22+
max-height: 220px;
23+
overflow: auto;
24+
margin: 0.75rem 0 0;
25+
}
26+
27+
.joycon-radar {
28+
flex: 1;
29+
min-height: 320px;
30+
}
31+
32+
.joycon-status {
33+
display: flex;
34+
flex-direction: column;
35+
gap: 0.75rem;
36+
}
37+
38+
.joycon-status-grid {
39+
display: grid;
40+
gap: 0.35rem;
41+
font-size: 0.95rem;
42+
color: rgba(226, 232, 240, 0.85);
43+
}
44+
45+
.joycon-hints ul {
46+
margin: 0.6rem 0 0;
47+
padding-left: 1.2rem;
48+
display: grid;
49+
gap: 0.3rem;
50+
font-size: 0.95rem;
51+
}
52+
53+
.joycon-hints small {
54+
display: block;
55+
margin-top: 0.75rem;
56+
color: rgba(148, 163, 184, 0.85);
57+
}
58+
59+
.joycon-modal-backdrop {
60+
position: fixed;
61+
inset: 0;
62+
background: rgba(2, 6, 23, 0.75);
63+
backdrop-filter: blur(14px);
64+
display: flex;
65+
align-items: center;
66+
justify-content: center;
67+
z-index: 1500;
68+
}
69+
70+
.joycon-modal {
71+
max-width: 580px;
72+
width: min(90vw, 580px);
73+
padding: 2rem;
74+
border: 1px solid rgba(59, 130, 246, 0.4);
75+
}
76+
77+
.joycon-modal ol {
78+
margin: 0 0 1rem;
79+
padding-left: 1.25rem;
80+
display: grid;
81+
gap: 0.4rem;
82+
}
83+
84+
@media (max-width: 1100px) {
85+
.joycon-layout {
86+
grid-template-columns: 1fr;
87+
}
88+
}
89+

0 commit comments

Comments
 (0)