xboxGamepad.ts 8.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435
  1. // =====================================================
  2. // 类型定义
  3. // =====================================================
  4. interface GamepadConfig {
  5. DEBUG: boolean
  6. AXIS_THRESHOLD: number
  7. AXIS_COOLDOWN: number
  8. DPAD_INDEX: {
  9. UP: number
  10. DOWN: number
  11. LEFT: number
  12. RIGHT: number
  13. }
  14. BUTTON_INDEX: {
  15. A: number
  16. B: number
  17. }
  18. }
  19. interface DPadState {
  20. up: boolean
  21. down: boolean
  22. left: boolean
  23. right: boolean
  24. a: boolean
  25. b: boolean
  26. }
  27. interface GamepadState {
  28. lastAxisTime: number
  29. lastAxisDirection: number
  30. dpadPressed: DPadState
  31. }
  32. export interface XboxGamepadOptions {
  33. /** B 键切换目录:由调用方经 Vue 状态控制,避免直接操作 DOM */
  34. onToggleCatalog?: () => void
  35. }
  36. function createGamepadState(): GamepadState {
  37. return {
  38. lastAxisTime: 0,
  39. lastAxisDirection: 0,
  40. dpadPressed: {
  41. up: false,
  42. down: false,
  43. left: false,
  44. right: false,
  45. a: false,
  46. b: false,
  47. },
  48. }
  49. }
  50. // =====================================================
  51. // 配置区域
  52. // =====================================================
  53. const CONFIG: GamepadConfig = {
  54. DEBUG: false, // 日志总开关
  55. AXIS_THRESHOLD: 0.7, // 摇杆触发阈值
  56. AXIS_COOLDOWN: 300, // 摇杆触发冷却时间 (ms)
  57. DPAD_INDEX: {
  58. // Xbox 标准映射
  59. UP: 12,
  60. DOWN: 13,
  61. LEFT: 14,
  62. RIGHT: 15,
  63. },
  64. // Xbox 按键
  65. BUTTON_INDEX: {
  66. A: 0,
  67. B: 1,
  68. },
  69. }
  70. // =====================================================
  71. // 日志工具函数
  72. // =====================================================
  73. function log(...args: any[]): void {
  74. if (!CONFIG.DEBUG) return
  75. console.log(...args)
  76. }
  77. // =====================================================
  78. // 状态记录
  79. // =====================================================
  80. let running = false
  81. let loopId: number | null = null
  82. let onToggleCatalog: (() => void) | null = null
  83. // 每个手柄独立一份按键/摇杆边沿状态,避免多手柄互相串触发
  84. const gamepadStates = new Map<number, GamepadState>()
  85. // =====================================================
  86. // 通用工具函数
  87. // =====================================================
  88. /**
  89. * 平滑翻页
  90. */
  91. function scrollPage(direction: number): void {
  92. const offset = window.innerHeight - 110
  93. const distance = direction === 1 ? offset : -offset
  94. window.scrollBy({
  95. top: distance,
  96. behavior: 'smooth',
  97. })
  98. }
  99. /**
  100. * 顶部
  101. */
  102. function goTop(): void {
  103. log('🎮 前往顶部')
  104. window.scrollTo({
  105. top: 0,
  106. behavior: 'smooth',
  107. })
  108. }
  109. /**
  110. * 底部
  111. */
  112. function goBottom(): void {
  113. log('🎮 前往底部')
  114. window.scrollTo({
  115. top: document.body.scrollHeight,
  116. behavior: 'smooth',
  117. })
  118. }
  119. /**
  120. * 章节切换
  121. */
  122. function switchChapter(direction: number): void {
  123. const buttons = document.querySelectorAll<HTMLElement>('.read-bar .tool-icon')
  124. if (buttons.length < 2) return
  125. if (direction === 1) {
  126. log('🎮 下一章')
  127. buttons[1].click()
  128. } else {
  129. log('🎮 上一章')
  130. buttons[0].click()
  131. }
  132. }
  133. /**
  134. * 全屏切换
  135. */
  136. function toggleFullscreen(): void {
  137. if (!document.fullscreenElement) {
  138. const promise = document.documentElement.requestFullscreen?.()
  139. // 手柄轮询触发通常没有用户激活,可能被浏览器拒绝,需要捕获
  140. promise?.catch(() => log('🎮 进入浏览器全屏被拒绝'))
  141. log('🎮 进入浏览器全屏')
  142. } else {
  143. document.exitFullscreen?.()
  144. log('🎮 退出浏览器全屏')
  145. }
  146. }
  147. /**
  148. * 目录显示隐藏
  149. */
  150. function toggleCatalog(): void {
  151. if (onToggleCatalog) {
  152. onToggleCatalog()
  153. log('🎮 切换目录')
  154. return
  155. }
  156. log('❌ 未注册目录切换回调')
  157. }
  158. /**
  159. * 边沿检测
  160. */
  161. function isPressedOnce(current: boolean, previous: boolean): boolean {
  162. return current && !previous
  163. }
  164. // =====================================================
  165. // 摇杆处理
  166. // =====================================================
  167. function handleAxis(gp: Gamepad, now: number, gs: GamepadState): void {
  168. const axisY = gp.axes[1] || 0
  169. if (Math.abs(axisY) <= CONFIG.AXIS_THRESHOLD) {
  170. gs.lastAxisDirection = 0
  171. return
  172. }
  173. const direction = axisY > 0 ? 1 : -1
  174. const cooldownPassed = now - gs.lastAxisTime > CONFIG.AXIS_COOLDOWN
  175. const directionChanged = direction !== gs.lastAxisDirection
  176. if (cooldownPassed || directionChanged) {
  177. gs.lastAxisTime = now
  178. gs.lastAxisDirection = direction
  179. scrollPage(direction)
  180. }
  181. }
  182. // =====================================================
  183. // DPad + 按键处理
  184. // =====================================================
  185. function handleDPad(gp: Gamepad, gs: GamepadState): void {
  186. const indexes = CONFIG.DPAD_INDEX
  187. const buttons = CONFIG.BUTTON_INDEX
  188. const current: DPadState = {
  189. up: gp.buttons[indexes.UP]?.pressed || false,
  190. down: gp.buttons[indexes.DOWN]?.pressed || false,
  191. left: gp.buttons[indexes.LEFT]?.pressed || false,
  192. right: gp.buttons[indexes.RIGHT]?.pressed || false,
  193. a: gp.buttons[buttons.A]?.pressed || false,
  194. b: gp.buttons[buttons.B]?.pressed || false,
  195. }
  196. // 十字 ↑ 顶部
  197. if (isPressedOnce(current.up, gs.dpadPressed.up)) {
  198. goTop()
  199. }
  200. // 十字 ↓ 底部
  201. if (isPressedOnce(current.down, gs.dpadPressed.down)) {
  202. goBottom()
  203. }
  204. // 左右章节
  205. if (isPressedOnce(current.left, gs.dpadPressed.left)) {
  206. switchChapter(-1)
  207. }
  208. if (isPressedOnce(current.right, gs.dpadPressed.right)) {
  209. switchChapter(1)
  210. }
  211. // A 全屏
  212. if (isPressedOnce(current.a, gs.dpadPressed.a)) {
  213. toggleFullscreen()
  214. }
  215. // B 目录
  216. if (isPressedOnce(current.b, gs.dpadPressed.b)) {
  217. toggleCatalog()
  218. }
  219. gs.dpadPressed = current
  220. }
  221. // =====================================================
  222. // 主手柄处理入口
  223. // =====================================================
  224. function handleGamepad(gp: Gamepad): void {
  225. let gs = gamepadStates.get(gp.index)
  226. if (!gs) {
  227. gs = createGamepadState()
  228. gamepadStates.set(gp.index, gs)
  229. }
  230. const now = performance.now()
  231. handleAxis(gp, now, gs)
  232. handleDPad(gp, gs)
  233. }
  234. // =====================================================
  235. // 主循环
  236. // =====================================================
  237. function stopLoop(): void {
  238. running = false
  239. if (loopId !== null) {
  240. cancelAnimationFrame(loopId)
  241. loopId = null
  242. }
  243. }
  244. function startLoop(): void {
  245. if (running) return
  246. running = true
  247. loopId = requestAnimationFrame(gamepadLoop)
  248. }
  249. function hasConnectedGamepad(): boolean {
  250. if (typeof navigator.getGamepads !== 'function') return false
  251. const gamepads = navigator.getGamepads()
  252. for (const gp of gamepads) {
  253. if (gp) return true
  254. }
  255. return false
  256. }
  257. function gamepadLoop(): void {
  258. // 不支持 getGamepads 的环境直接停止轮询
  259. if (typeof navigator.getGamepads !== 'function') {
  260. stopLoop()
  261. return
  262. }
  263. const gamepads = navigator.getGamepads()
  264. for (const gp of gamepads) {
  265. if (gp) handleGamepad(gp)
  266. }
  267. // 没有已连接手柄时停止 60fps 轮询,等下次 gamepadconnected 再启动
  268. if (hasConnectedGamepad()) {
  269. loopId = requestAnimationFrame(gamepadLoop)
  270. } else {
  271. stopLoop()
  272. }
  273. }
  274. // =====================================================
  275. // 连接 / 断开事件
  276. // =====================================================
  277. let listenersRegistered = false
  278. function onGamepadConnected(e: GamepadEvent): void {
  279. log('🎮 手柄已连接:', e.gamepad.id)
  280. startLoop()
  281. }
  282. function onGamepadDisconnected(e: GamepadEvent): void {
  283. log('🎮 手柄断开:', e.gamepad.id)
  284. gamepadStates.delete(e.gamepad.index)
  285. if (!hasConnectedGamepad()) stopLoop()
  286. }
  287. function registerListeners(): void {
  288. if (listenersRegistered) return
  289. window.addEventListener('gamepadconnected', onGamepadConnected)
  290. window.addEventListener('gamepaddisconnected', onGamepadDisconnected)
  291. listenersRegistered = true
  292. }
  293. function unregisterListeners(): void {
  294. if (!listenersRegistered) return
  295. window.removeEventListener('gamepadconnected', onGamepadConnected)
  296. window.removeEventListener('gamepaddisconnected', onGamepadDisconnected)
  297. listenersRegistered = false
  298. }
  299. // =====================================================
  300. // 外部调用
  301. // =====================================================
  302. export function initXboxGamepad(options?: XboxGamepadOptions): void {
  303. onToggleCatalog = options?.onToggleCatalog ?? null
  304. registerListeners()
  305. startLoop()
  306. }
  307. export function disposeXboxGamepad(): void {
  308. stopLoop()
  309. unregisterListeners()
  310. gamepadStates.clear()
  311. onToggleCatalog = null
  312. }