xboxGamepad.ts 7.5 KB

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