diff --git a/src/core/gamepadProfile.js b/src/core/gamepadProfile.js new file mode 100644 index 0000000..839c097 --- /dev/null +++ b/src/core/gamepadProfile.js @@ -0,0 +1,183 @@ +const PROFILE_GLYPH_MAP = { + xbox: { + accept: "A", + back: "B", + menu: "☰", + up: "↑", + down: "↓", + left: "←", + right: "→", + l1: "LB", + r1: "RB", + l2: "LT", + r2: "RT", + clear: "X", + y: "Y", + }, + playstation: { + accept: "✕", + back: "○", + menu: "☰", + up: "↑", + down: "↓", + left: "←", + right: "→", + l1: "L1", + r1: "R1", + l2: "L2", + r2: "R2", + clear: "□", + y: "△", + }, + switch: { + accept: "B", + back: "A", + menu: "+", + up: "↑", + down: "↓", + left: "←", + right: "→", + l1: "L", + r1: "R", + l2: "ZL", + r2: "ZR", + clear: "Y", + y: "X", + }, + generic: { + accept: "A", + back: "B", + menu: "☰", + up: "↑", + down: "↓", + left: "←", + right: "→", + l1: "L1", + r1: "R1", + l2: "L2", + r2: "R2", + clear: "X", + y: "Y", + }, +}; + +const GLYPH_ACTIONS = [ + "accept", + "back", + "menu", + "up", + "down", + "left", + "right", + "l1", + "r1", + "l2", + "r2", + "clear", + "y", +]; + +const CORE_GLYPH_IDS = { + accept: "confirm", + back: "back", + menu: "menu", + up: "dpad-up", + down: "dpad-down", + left: "dpad-left", + right: "dpad-right", + l1: "lb", + r1: "rb", + l2: "lt", + r2: "rt", + clear: "x", + y: "y", +}; + +export const CONTROLLER_PROFILES = ["xbox", "playstation", "switch", "generic"]; + +export const PROFILE_LABELS = { + xbox: "Xbox", + playstation: "PlayStation", + switch: "Nintendo Switch", + generic: "controller", +}; + +export const detectGamepadProfile = (gamepad) => { + const id = `${gamepad?.id ?? ""} ${gamepad?.mapping ?? ""}`.toLowerCase(); + + if (/xbox|x-input|microsoft|045e|8bitdo.*xbox|8bitdo.*x/i.test(id)) { + return "xbox"; + } + + if (/playstation|dualsense|dualshock|ps5|ps4|054c|sony|wireless controller/i.test(id)) { + return "playstation"; + } + + if (/nintendo|switch|pro controller|057e|joy-con|joycon/i.test(id)) { + return "switch"; + } + + return "generic"; +}; + +export const getActiveGamepad = () => { + const pads = navigator.getGamepads?.() ?? []; + return pads.find((pad) => pad?.connected) ?? null; +}; + +export const detectActiveProfile = () => { + const pad = getActiveGamepad(); + return pad ? detectGamepadProfile(pad) : "generic"; +}; + +export const getFallbackGlyphs = (profile = "generic") => ({ + ...(PROFILE_GLYPH_MAP[profile] ?? PROFILE_GLYPH_MAP.generic), +}); + +export const resolveGlyphsForProfile = (profile, glyphsModule) => { + const safeProfile = CONTROLLER_PROFILES.includes(profile) ? profile : "generic"; + const platform = safeProfile === "generic" ? "xbox" : safeProfile; + const fallback = getFallbackGlyphs(safeProfile); + + if (typeof glyphsModule?.getGlyph !== "function") { + return fallback; + } + + const resolved = {}; + GLYPH_ACTIONS.forEach((action) => { + const glyphId = CORE_GLYPH_IDS[action]; + resolved[action] = glyphsModule.getGlyph(platform, glyphId) ?? fallback[action]; + }); + return resolved; +}; + +export const watchGamepadProfile = (onChange) => { + let current = detectActiveProfile(); + + const emitIfChanged = () => { + const next = detectActiveProfile(); + if (next === current) { + return; + } + current = next; + onChange(next); + }; + + window.addEventListener("gamepadconnected", emitIfChanged); + window.addEventListener("gamepaddisconnected", emitIfChanged); + + let rafId = 0; + const poll = () => { + emitIfChanged(); + rafId = requestAnimationFrame(poll); + }; + rafId = requestAnimationFrame(poll); + + return () => { + window.removeEventListener("gamepadconnected", emitIfChanged); + window.removeEventListener("gamepaddisconnected", emitIfChanged); + if (rafId) { + cancelAnimationFrame(rafId); + } + }; +}; diff --git a/src/core/state.js b/src/core/state.js index 51ec883..48b8e53 100644 --- a/src/core/state.js +++ b/src/core/state.js @@ -1,3 +1,9 @@ +import { + detectActiveProfile, + getFallbackGlyphs, + resolveGlyphsForProfile, + watchGamepadProfile, +} from "./gamepadProfile.js"; import { createPasskeyController } from "./passkey.js"; import { loadExistingUser } from "./users.js"; @@ -33,22 +39,6 @@ const FALLBACK_THEME = { }, }; -const FALLBACK_GLYPHS = { - accept: "A", - back: "B", - menu: "≡", - up: "↑", - down: "↓", - left: "←", - right: "→", - l1: "LB", - r1: "RB", - l2: "LT", - r2: "RT", - clear: "X", - y: "Y", -}; - export const createAppState = () => { const passkey = createPasskeyController(); const state = { @@ -68,7 +58,8 @@ export const createAppState = () => { ui: null, }, theme: FALLBACK_THEME, - glyphs: { ...FALLBACK_GLYPHS }, + controllerProfile: "generic", + glyphs: getFallbackGlyphs("generic"), settingsCategory: "system", settingsValues: { network: true, @@ -80,6 +71,25 @@ export const createAppState = () => { passkeySetupRequired: !passkey.hasPasskey(), }; + state.refreshControllerGlyphs = (profile = detectActiveProfile()) => { + state.controllerProfile = profile; + state.glyphs = resolveGlyphsForProfile(profile, state.nebula.glyphs); + window.dispatchEvent( + new CustomEvent("nebula-controller-profile", { + detail: { profile }, + }), + ); + }; + + state.startGamepadProfileWatcher = () => { + if (state.stopGamepadProfileWatcher) { + state.stopGamepadProfileWatcher(); + } + state.stopGamepadProfileWatcher = watchGamepadProfile((profile) => { + state.refreshControllerGlyphs(profile); + }); + }; + const applyThemeToDocument = () => { const root = document.documentElement; const { colors, spacing, radius, typography } = state.theme; @@ -112,23 +122,7 @@ export const createAppState = () => { state.theme = typeof theme.createTheme === "function" ? theme.createTheme({}) : FALLBACK_THEME; - if (typeof glyphs.getGlyph === "function") { - state.glyphs = { - accept: glyphs.getGlyph("xbox", "confirm") ?? FALLBACK_GLYPHS.accept, - back: glyphs.getGlyph("xbox", "back") ?? FALLBACK_GLYPHS.back, - menu: glyphs.getGlyph("xbox", "menu") ?? FALLBACK_GLYPHS.menu, - up: glyphs.getGlyph("xbox", "dpad-up") ?? FALLBACK_GLYPHS.up, - down: glyphs.getGlyph("xbox", "dpad-down") ?? FALLBACK_GLYPHS.down, - left: glyphs.getGlyph("xbox", "dpad-left") ?? FALLBACK_GLYPHS.left, - right: glyphs.getGlyph("xbox", "dpad-right") ?? FALLBACK_GLYPHS.right, - l1: glyphs.getGlyph("xbox", "lb") ?? FALLBACK_GLYPHS.l1, - r1: glyphs.getGlyph("xbox", "rb") ?? FALLBACK_GLYPHS.r1, - l2: glyphs.getGlyph("xbox", "lt") ?? FALLBACK_GLYPHS.l2, - r2: glyphs.getGlyph("xbox", "rt") ?? FALLBACK_GLYPHS.r2, - clear: glyphs.getGlyph("xbox", "x") ?? FALLBACK_GLYPHS.clear, - y: glyphs.getGlyph("xbox", "y") ?? FALLBACK_GLYPHS.y, - }; - } + state.refreshControllerGlyphs(detectActiveProfile()); } catch (_error) { state.nebula = { ...state.nebula, @@ -136,7 +130,7 @@ export const createAppState = () => { source: "local-fallback", }; state.theme = FALLBACK_THEME; - state.glyphs = { ...FALLBACK_GLYPHS }; + state.refreshControllerGlyphs("generic"); } applyThemeToDocument(); diff --git a/src/index.html b/src/index.html index b479205..8c9c358 100644 --- a/src/index.html +++ b/src/index.html @@ -27,12 +27,11 @@
- -
+
@@ -71,10 +70,22 @@
+ +