xboxGamepad.ts 7.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363
  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 onToggleCatalog: (() => void) | null = null
  82. // 每个手柄独立一份按键/摇杆边沿状态,避免多手柄互相串触发
  83. const gamepadStates = new Map<number, GamepadState>()
  84. // =====================================================
  85. // 通用工具函数
  86. // =====================================================
  87. /**
  88. * 平滑翻页
  89. */
  90. function scrollPage(direction: number): void {
  91. const offset = window.innerHeight - 110
  92. const distance = direction === 1 ? offset : -offset
  93. window.scrollBy({
  94. top: distance,
  95. behavior: 'smooth',
  96. })
  97. }
  98. /**
  99. * 顶部
  100. */
  101. function goTop(): void {
  102. log('🎮 前往顶部')
  103. window.scrollTo({
  104. top: 0,
  105. behavior: 'smooth',
  106. })
  107. }
  108. /**
  109. * 底部
  110. */
  111. function goBottom(): void {
  112. log('🎮 前往底部')
  113. window.scrollTo({
  114. top: document.body.scrollHeight,
  115. behavior: 'smooth',
  116. })
  117. }
  118. /**
  119. * 章节切换
  120. */
  121. function switchChapter(direction: number): void {
  122. const buttons = document.querySelectorAll<HTMLElement>('.read-bar .tool-icon')
  123. if (buttons.length < 2) return
  124. if (direction === 1) {
  125. log('🎮 下一章')
  126. buttons[1].click()
  127. } else {
  128. log('🎮 上一章')
  129. buttons[0].click()
  130. }
  131. }
  132. /**
  133. * 全屏切换
  134. */
  135. function toggleFullscreen(): void {
  136. if (!document.fullscreenElement) {
  137. const promise = document.documentElement.requestFullscreen?.()
  138. // 手柄轮询触发通常没有用户激活,可能被浏览器拒绝,需要捕获
  139. promise?.catch(() => log('🎮 进入浏览器全屏被拒绝'))
  140. log('🎮 进入浏览器全屏')
  141. } else {
  142. document.exitFullscreen?.()
  143. log('🎮 退出浏览器全屏')
  144. }
  145. }
  146. /**
  147. * 目录显示隐藏
  148. */
  149. function toggleCatalog(): void {
  150. if (onToggleCatalog) {
  151. onToggleCatalog()
  152. log('🎮 切换目录')
  153. return
  154. }
  155. log('❌ 未注册目录切换回调')
  156. }
  157. /**
  158. * 边沿检测
  159. */
  160. function isPressedOnce(current: boolean, previous: boolean): boolean {
  161. return current && !previous
  162. }
  163. // =====================================================
  164. // 摇杆处理
  165. // =====================================================
  166. function handleAxis(gp: Gamepad, now: number, gs: GamepadState): void {
  167. const axisY = gp.axes[1] || 0
  168. if (Math.abs(axisY) <= CONFIG.AXIS_THRESHOLD) {
  169. gs.lastAxisDirection = 0
  170. return
  171. }
  172. const direction = axisY > 0 ? 1 : -1
  173. const cooldownPassed = now - gs.lastAxisTime > CONFIG.AXIS_COOLDOWN
  174. const directionChanged = direction !== gs.lastAxisDirection
  175. if (cooldownPassed || directionChanged) {
  176. gs.lastAxisTime = now
  177. gs.lastAxisDirection = direction
  178. scrollPage(direction)
  179. }
  180. }
  181. // =====================================================
  182. // DPad + 按键处理
  183. // =====================================================
  184. function handleDPad(gp: Gamepad, gs: GamepadState): void {
  185. const indexes = CONFIG.DPAD_INDEX
  186. const buttons = CONFIG.BUTTON_INDEX
  187. const current: DPadState = {
  188. up: gp.buttons[indexes.UP]?.pressed || false,
  189. down: gp.buttons[indexes.DOWN]?.pressed || false,
  190. left: gp.buttons[indexes.LEFT]?.pressed || false,
  191. right: gp.buttons[indexes.RIGHT]?.pressed || false,
  192. a: gp.buttons[buttons.A]?.pressed || false,
  193. b: gp.buttons[buttons.B]?.pressed || false,
  194. }
  195. // 十字 ↑ 顶部
  196. if (isPressedOnce(current.up, gs.dpadPressed.up)) {
  197. goTop()
  198. }
  199. // 十字 ↓ 底部
  200. if (isPressedOnce(current.down, gs.dpadPressed.down)) {
  201. goBottom()
  202. }
  203. // 左右章节
  204. if (isPressedOnce(current.left, gs.dpadPressed.left)) {
  205. switchChapter(-1)
  206. }
  207. if (isPressedOnce(current.right, gs.dpadPressed.right)) {
  208. switchChapter(1)
  209. }
  210. // A 全屏
  211. if (isPressedOnce(current.a, gs.dpadPressed.a)) {
  212. toggleFullscreen()
  213. }
  214. // B 目录
  215. if (isPressedOnce(current.b, gs.dpadPressed.b)) {
  216. toggleCatalog()
  217. }
  218. gs.dpadPressed = current
  219. }
  220. // =====================================================
  221. // 主手柄处理入口
  222. // =====================================================
  223. function handleGamepad(gp: Gamepad): void {
  224. let gs = gamepadStates.get(gp.index)
  225. if (!gs) {
  226. gs = createGamepadState()
  227. gamepadStates.set(gp.index, gs)
  228. }
  229. const now = performance.now()
  230. handleAxis(gp, now, gs)
  231. handleDPad(gp, gs)
  232. }
  233. // =====================================================
  234. // 主循环
  235. // =====================================================
  236. function gamepadLoop(): void {
  237. const gamepads = navigator.getGamepads?.() || []
  238. for (const gp of gamepads) {
  239. if (gp) handleGamepad(gp)
  240. }
  241. requestAnimationFrame(gamepadLoop)
  242. }
  243. // =====================================================
  244. // 连接 / 断开事件
  245. // =====================================================
  246. window.addEventListener('gamepadconnected', (e: GamepadEvent) => {
  247. log('🎮 手柄已连接:', e.gamepad.id)
  248. if (!running) {
  249. running = true
  250. requestAnimationFrame(gamepadLoop)
  251. }
  252. })
  253. window.addEventListener('gamepaddisconnected', (e: GamepadEvent) => {
  254. log('🎮 手柄断开:', e.gamepad.id)
  255. gamepadStates.delete(e.gamepad.index)
  256. })
  257. // =====================================================
  258. // 外部调用
  259. // =====================================================
  260. export function initXboxGamepad(options?: XboxGamepadOptions): void {
  261. onToggleCatalog = options?.onToggleCatalog ?? null
  262. if (!running) {
  263. running = true
  264. requestAnimationFrame(gamepadLoop)
  265. }
  266. }