|
@@ -3,28 +3,57 @@
|
|
|
// =====================================================
|
|
// =====================================================
|
|
|
|
|
|
|
|
interface GamepadConfig {
|
|
interface GamepadConfig {
|
|
|
- DEBUG: boolean;
|
|
|
|
|
- AXIS_THRESHOLD: number;
|
|
|
|
|
- AXIS_COOLDOWN: number;
|
|
|
|
|
|
|
+ DEBUG: boolean
|
|
|
|
|
+ AXIS_THRESHOLD: number
|
|
|
|
|
+ AXIS_COOLDOWN: number
|
|
|
|
|
+
|
|
|
DPAD_INDEX: {
|
|
DPAD_INDEX: {
|
|
|
- UP: number;
|
|
|
|
|
- DOWN: number;
|
|
|
|
|
- LEFT: number;
|
|
|
|
|
- RIGHT: number;
|
|
|
|
|
- };
|
|
|
|
|
|
|
+ UP: number
|
|
|
|
|
+ DOWN: number
|
|
|
|
|
+ LEFT: number
|
|
|
|
|
+ RIGHT: number
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ BUTTON_INDEX: {
|
|
|
|
|
+ A: number
|
|
|
|
|
+ B: number
|
|
|
|
|
+ }
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
interface DPadState {
|
|
interface DPadState {
|
|
|
- up: boolean;
|
|
|
|
|
- down: boolean;
|
|
|
|
|
- left: boolean;
|
|
|
|
|
- right: boolean;
|
|
|
|
|
|
|
+ up: boolean
|
|
|
|
|
+ down: boolean
|
|
|
|
|
+ left: boolean
|
|
|
|
|
+ right: boolean
|
|
|
|
|
+
|
|
|
|
|
+ a: boolean
|
|
|
|
|
+ b: boolean
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+interface GamepadState {
|
|
|
|
|
+ lastAxisTime: number
|
|
|
|
|
+ lastAxisDirection: number
|
|
|
|
|
+ dpadPressed: DPadState
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+export interface XboxGamepadOptions {
|
|
|
|
|
+ /** B 键切换目录:由调用方经 Vue 状态控制,避免直接操作 DOM */
|
|
|
|
|
+ onToggleCatalog?: () => void
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
-interface GlobalState {
|
|
|
|
|
- lastAxisTime: number;
|
|
|
|
|
- lastAxisDirection: number;
|
|
|
|
|
- dpadPressed: DPadState;
|
|
|
|
|
|
|
+function createGamepadState(): GamepadState {
|
|
|
|
|
+ return {
|
|
|
|
|
+ lastAxisTime: 0,
|
|
|
|
|
+ lastAxisDirection: 0,
|
|
|
|
|
+ dpadPressed: {
|
|
|
|
|
+ up: false,
|
|
|
|
|
+ down: false,
|
|
|
|
|
+ left: false,
|
|
|
|
|
+ right: false,
|
|
|
|
|
+ a: false,
|
|
|
|
|
+ b: false,
|
|
|
|
|
+ },
|
|
|
|
|
+ }
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
// =====================================================
|
|
// =====================================================
|
|
@@ -32,190 +61,303 @@ interface GlobalState {
|
|
|
// =====================================================
|
|
// =====================================================
|
|
|
|
|
|
|
|
const CONFIG: GamepadConfig = {
|
|
const CONFIG: GamepadConfig = {
|
|
|
- DEBUG: false, // 日志总开关
|
|
|
|
|
- AXIS_THRESHOLD: 0.7, // 摇杆触发阈值
|
|
|
|
|
- AXIS_COOLDOWN: 300, // 摇杆触发冷却时间 (ms)
|
|
|
|
|
- DPAD_INDEX: { // Xbox 标准映射
|
|
|
|
|
|
|
+ DEBUG: false, // 日志总开关
|
|
|
|
|
+ AXIS_THRESHOLD: 0.7, // 摇杆触发阈值
|
|
|
|
|
+ AXIS_COOLDOWN: 300, // 摇杆触发冷却时间 (ms)
|
|
|
|
|
+
|
|
|
|
|
+ DPAD_INDEX: {
|
|
|
|
|
+ // Xbox 标准映射
|
|
|
UP: 12,
|
|
UP: 12,
|
|
|
DOWN: 13,
|
|
DOWN: 13,
|
|
|
LEFT: 14,
|
|
LEFT: 14,
|
|
|
- RIGHT: 15
|
|
|
|
|
- }
|
|
|
|
|
-};
|
|
|
|
|
|
|
+ RIGHT: 15,
|
|
|
|
|
+ },
|
|
|
|
|
+
|
|
|
|
|
+ // Xbox 按键
|
|
|
|
|
+ BUTTON_INDEX: {
|
|
|
|
|
+ A: 0,
|
|
|
|
|
+
|
|
|
|
|
+ B: 1,
|
|
|
|
|
+ },
|
|
|
|
|
+}
|
|
|
|
|
|
|
|
// =====================================================
|
|
// =====================================================
|
|
|
// 日志工具函数
|
|
// 日志工具函数
|
|
|
// =====================================================
|
|
// =====================================================
|
|
|
|
|
|
|
|
function log(...args: any[]): void {
|
|
function log(...args: any[]): void {
|
|
|
- if (!CONFIG.DEBUG) return;
|
|
|
|
|
- console.log(...args);
|
|
|
|
|
|
|
+ if (!CONFIG.DEBUG) return
|
|
|
|
|
+
|
|
|
|
|
+ console.log(...args)
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
// =====================================================
|
|
// =====================================================
|
|
|
// 状态记录
|
|
// 状态记录
|
|
|
// =====================================================
|
|
// =====================================================
|
|
|
|
|
|
|
|
-const state: GlobalState = {
|
|
|
|
|
- lastAxisTime: 0,
|
|
|
|
|
- lastAxisDirection: 0,
|
|
|
|
|
- dpadPressed: {
|
|
|
|
|
- up: false,
|
|
|
|
|
- down: false,
|
|
|
|
|
- left: false,
|
|
|
|
|
- right: false
|
|
|
|
|
- }
|
|
|
|
|
-};
|
|
|
|
|
|
|
+let running = false
|
|
|
|
|
+
|
|
|
|
|
+let onToggleCatalog: (() => void) | null = null
|
|
|
|
|
+
|
|
|
|
|
+// 每个手柄独立一份按键/摇杆边沿状态,避免多手柄互相串触发
|
|
|
|
|
+const gamepadStates = new Map<number, GamepadState>()
|
|
|
|
|
|
|
|
// =====================================================
|
|
// =====================================================
|
|
|
// 通用工具函数
|
|
// 通用工具函数
|
|
|
// =====================================================
|
|
// =====================================================
|
|
|
|
|
|
|
|
/**
|
|
/**
|
|
|
- * 平滑翻页函数
|
|
|
|
|
- * @param direction 1 = 向下, -1 = 向上
|
|
|
|
|
|
|
+ * 平滑翻页
|
|
|
*/
|
|
*/
|
|
|
function scrollPage(direction: number): void {
|
|
function scrollPage(direction: number): void {
|
|
|
- const offset = window.innerHeight - 110;
|
|
|
|
|
- const distance = direction === 1 ? offset : -offset;
|
|
|
|
|
|
|
+ const offset = window.innerHeight - 110
|
|
|
|
|
|
|
|
- log(direction === 1 ? "🎮 翻页:向下" : "🎮 翻页:向上");
|
|
|
|
|
|
|
+ const distance = direction === 1 ? offset : -offset
|
|
|
|
|
|
|
|
window.scrollBy({
|
|
window.scrollBy({
|
|
|
top: distance,
|
|
top: distance,
|
|
|
- behavior: "smooth"
|
|
|
|
|
- });
|
|
|
|
|
|
|
+
|
|
|
|
|
+ behavior: 'smooth',
|
|
|
|
|
+ })
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+/**
|
|
|
|
|
+ * 顶部
|
|
|
|
|
+ */
|
|
|
|
|
+function goTop(): void {
|
|
|
|
|
+ log('🎮 前往顶部')
|
|
|
|
|
+
|
|
|
|
|
+ window.scrollTo({
|
|
|
|
|
+ top: 0,
|
|
|
|
|
+
|
|
|
|
|
+ behavior: 'smooth',
|
|
|
|
|
+ })
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+/**
|
|
|
|
|
+ * 底部
|
|
|
|
|
+ */
|
|
|
|
|
+function goBottom(): void {
|
|
|
|
|
+ log('🎮 前往底部')
|
|
|
|
|
+
|
|
|
|
|
+ window.scrollTo({
|
|
|
|
|
+ top: document.body.scrollHeight,
|
|
|
|
|
+
|
|
|
|
|
+ behavior: 'smooth',
|
|
|
|
|
+ })
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
/**
|
|
|
- * 章节切换函数
|
|
|
|
|
- * @param direction 1 = 下一章, -1 = 上一章
|
|
|
|
|
|
|
+ * 章节切换
|
|
|
*/
|
|
*/
|
|
|
function switchChapter(direction: number): void {
|
|
function switchChapter(direction: number): void {
|
|
|
- const buttons = document.querySelectorAll<HTMLElement>(".read-bar .tool-icon");
|
|
|
|
|
|
|
+ const buttons = document.querySelectorAll<HTMLElement>('.read-bar .tool-icon')
|
|
|
|
|
|
|
|
- if (buttons.length < 2) return;
|
|
|
|
|
|
|
+ if (buttons.length < 2) return
|
|
|
|
|
|
|
|
if (direction === 1) {
|
|
if (direction === 1) {
|
|
|
- log("🎮 切换章节:下一章");
|
|
|
|
|
- buttons[1].click();
|
|
|
|
|
|
|
+ log('🎮 下一章')
|
|
|
|
|
+
|
|
|
|
|
+ buttons[1].click()
|
|
|
} else {
|
|
} else {
|
|
|
- log("🎮 切换章节:上一章");
|
|
|
|
|
- buttons[0].click();
|
|
|
|
|
|
|
+ log('🎮 上一章')
|
|
|
|
|
+
|
|
|
|
|
+ buttons[0].click()
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+/**
|
|
|
|
|
+ * 全屏切换
|
|
|
|
|
+ */
|
|
|
|
|
+function toggleFullscreen(): void {
|
|
|
|
|
+ if (!document.fullscreenElement) {
|
|
|
|
|
+ const promise = document.documentElement.requestFullscreen?.()
|
|
|
|
|
+
|
|
|
|
|
+ // 手柄轮询触发通常没有用户激活,可能被浏览器拒绝,需要捕获
|
|
|
|
|
+ promise?.catch(() => log('🎮 进入浏览器全屏被拒绝'))
|
|
|
|
|
+
|
|
|
|
|
+ log('🎮 进入浏览器全屏')
|
|
|
|
|
+ } else {
|
|
|
|
|
+ document.exitFullscreen?.()
|
|
|
|
|
+
|
|
|
|
|
+ log('🎮 退出浏览器全屏')
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+/**
|
|
|
|
|
+ * 目录显示隐藏
|
|
|
|
|
+ */
|
|
|
|
|
+function toggleCatalog(): void {
|
|
|
|
|
+ if (onToggleCatalog) {
|
|
|
|
|
+ onToggleCatalog()
|
|
|
|
|
+
|
|
|
|
|
+ log('🎮 切换目录')
|
|
|
|
|
+
|
|
|
|
|
+ return
|
|
|
}
|
|
}
|
|
|
|
|
+
|
|
|
|
|
+ log('❌ 未注册目录切换回调')
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
/**
|
|
|
- * 边沿检测函数
|
|
|
|
|
|
|
+ * 边沿检测
|
|
|
*/
|
|
*/
|
|
|
function isPressedOnce(current: boolean, previous: boolean): boolean {
|
|
function isPressedOnce(current: boolean, previous: boolean): boolean {
|
|
|
- return current && !previous;
|
|
|
|
|
|
|
+ return current && !previous
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
// =====================================================
|
|
// =====================================================
|
|
|
// 摇杆处理
|
|
// 摇杆处理
|
|
|
// =====================================================
|
|
// =====================================================
|
|
|
|
|
|
|
|
-function handleAxis(gp: Gamepad, now: number): void {
|
|
|
|
|
- const axisY = gp.axes[1] || 0;
|
|
|
|
|
|
|
+function handleAxis(gp: Gamepad, now: number, gs: GamepadState): void {
|
|
|
|
|
+ const axisY = gp.axes[1] || 0
|
|
|
|
|
|
|
|
if (Math.abs(axisY) <= CONFIG.AXIS_THRESHOLD) {
|
|
if (Math.abs(axisY) <= CONFIG.AXIS_THRESHOLD) {
|
|
|
- state.lastAxisDirection = 0;
|
|
|
|
|
- return;
|
|
|
|
|
|
|
+ gs.lastAxisDirection = 0
|
|
|
|
|
+
|
|
|
|
|
+ return
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- const direction = axisY > 0 ? 1 : -1;
|
|
|
|
|
- const cooldownPassed = now - state.lastAxisTime > CONFIG.AXIS_COOLDOWN;
|
|
|
|
|
- const directionChanged = direction !== state.lastAxisDirection;
|
|
|
|
|
|
|
+ const direction = axisY > 0 ? 1 : -1
|
|
|
|
|
+
|
|
|
|
|
+ const cooldownPassed = now - gs.lastAxisTime > CONFIG.AXIS_COOLDOWN
|
|
|
|
|
+
|
|
|
|
|
+ const directionChanged = direction !== gs.lastAxisDirection
|
|
|
|
|
|
|
|
if (cooldownPassed || directionChanged) {
|
|
if (cooldownPassed || directionChanged) {
|
|
|
- state.lastAxisTime = now;
|
|
|
|
|
- state.lastAxisDirection = direction;
|
|
|
|
|
- log(`🎮 摇杆触发 | axisY=${axisY.toFixed(2)}`);
|
|
|
|
|
- scrollPage(direction);
|
|
|
|
|
|
|
+ gs.lastAxisTime = now
|
|
|
|
|
+
|
|
|
|
|
+ gs.lastAxisDirection = direction
|
|
|
|
|
+
|
|
|
|
|
+ scrollPage(direction)
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
// =====================================================
|
|
// =====================================================
|
|
|
-// DPad 处理
|
|
|
|
|
|
|
+// DPad + 按键处理
|
|
|
// =====================================================
|
|
// =====================================================
|
|
|
|
|
|
|
|
-function handleDPad(gp: Gamepad): void {
|
|
|
|
|
- const indexes = CONFIG.DPAD_INDEX;
|
|
|
|
|
|
|
+function handleDPad(gp: Gamepad, gs: GamepadState): void {
|
|
|
|
|
+ const indexes = CONFIG.DPAD_INDEX
|
|
|
|
|
+
|
|
|
|
|
+ const buttons = CONFIG.BUTTON_INDEX
|
|
|
|
|
|
|
|
const current: DPadState = {
|
|
const current: DPadState = {
|
|
|
up: gp.buttons[indexes.UP]?.pressed || false,
|
|
up: gp.buttons[indexes.UP]?.pressed || false,
|
|
|
|
|
+
|
|
|
down: gp.buttons[indexes.DOWN]?.pressed || false,
|
|
down: gp.buttons[indexes.DOWN]?.pressed || false,
|
|
|
|
|
+
|
|
|
left: gp.buttons[indexes.LEFT]?.pressed || false,
|
|
left: gp.buttons[indexes.LEFT]?.pressed || false,
|
|
|
- right: gp.buttons[indexes.RIGHT]?.pressed || false
|
|
|
|
|
- };
|
|
|
|
|
|
|
|
|
|
- if (isPressedOnce(current.up, state.dpadPressed.up)) {
|
|
|
|
|
- log("🎮 DPad 上");
|
|
|
|
|
- scrollPage(-1);
|
|
|
|
|
|
|
+ right: gp.buttons[indexes.RIGHT]?.pressed || false,
|
|
|
|
|
+
|
|
|
|
|
+ a: gp.buttons[buttons.A]?.pressed || false,
|
|
|
|
|
+
|
|
|
|
|
+ b: gp.buttons[buttons.B]?.pressed || false,
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // 十字 ↑ 顶部
|
|
|
|
|
+
|
|
|
|
|
+ if (isPressedOnce(current.up, gs.dpadPressed.up)) {
|
|
|
|
|
+ goTop()
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // 十字 ↓ 底部
|
|
|
|
|
+
|
|
|
|
|
+ if (isPressedOnce(current.down, gs.dpadPressed.down)) {
|
|
|
|
|
+ goBottom()
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // 左右章节
|
|
|
|
|
+
|
|
|
|
|
+ if (isPressedOnce(current.left, gs.dpadPressed.left)) {
|
|
|
|
|
+ switchChapter(-1)
|
|
|
}
|
|
}
|
|
|
- if (isPressedOnce(current.down, state.dpadPressed.down)) {
|
|
|
|
|
- log("🎮 DPad 下");
|
|
|
|
|
- scrollPage(1);
|
|
|
|
|
|
|
+
|
|
|
|
|
+ if (isPressedOnce(current.right, gs.dpadPressed.right)) {
|
|
|
|
|
+ switchChapter(1)
|
|
|
}
|
|
}
|
|
|
- if (isPressedOnce(current.left, state.dpadPressed.left)) {
|
|
|
|
|
- log("🎮 DPad 左");
|
|
|
|
|
- switchChapter(-1);
|
|
|
|
|
|
|
+
|
|
|
|
|
+ // A 全屏
|
|
|
|
|
+
|
|
|
|
|
+ if (isPressedOnce(current.a, gs.dpadPressed.a)) {
|
|
|
|
|
+ toggleFullscreen()
|
|
|
}
|
|
}
|
|
|
- if (isPressedOnce(current.right, state.dpadPressed.right)) {
|
|
|
|
|
- log("🎮 DPad 右");
|
|
|
|
|
- switchChapter(1);
|
|
|
|
|
|
|
+
|
|
|
|
|
+ // B 目录
|
|
|
|
|
+
|
|
|
|
|
+ if (isPressedOnce(current.b, gs.dpadPressed.b)) {
|
|
|
|
|
+ toggleCatalog()
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- state.dpadPressed = current;
|
|
|
|
|
|
|
+ gs.dpadPressed = current
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
// =====================================================
|
|
// =====================================================
|
|
|
// 主手柄处理入口
|
|
// 主手柄处理入口
|
|
|
// =====================================================
|
|
// =====================================================
|
|
|
|
|
|
|
|
-function handleGamepad(gp: Gamepad | null): void {
|
|
|
|
|
- if (!gp) return;
|
|
|
|
|
- const now = performance.now();
|
|
|
|
|
- handleAxis(gp, now);
|
|
|
|
|
- handleDPad(gp);
|
|
|
|
|
|
|
+function handleGamepad(gp: Gamepad): void {
|
|
|
|
|
+ let gs = gamepadStates.get(gp.index)
|
|
|
|
|
+
|
|
|
|
|
+ if (!gs) {
|
|
|
|
|
+ gs = createGamepadState()
|
|
|
|
|
+
|
|
|
|
|
+ gamepadStates.set(gp.index, gs)
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ const now = performance.now()
|
|
|
|
|
+
|
|
|
|
|
+ handleAxis(gp, now, gs)
|
|
|
|
|
+
|
|
|
|
|
+ handleDPad(gp, gs)
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
// =====================================================
|
|
// =====================================================
|
|
|
// 主循环
|
|
// 主循环
|
|
|
// =====================================================
|
|
// =====================================================
|
|
|
|
|
|
|
|
-let running = false;
|
|
|
|
|
-
|
|
|
|
|
function gamepadLoop(): void {
|
|
function gamepadLoop(): void {
|
|
|
- const gamepads = navigator.getGamepads?.() || [];
|
|
|
|
|
|
|
+ const gamepads = navigator.getGamepads?.() || []
|
|
|
|
|
|
|
|
for (const gp of gamepads) {
|
|
for (const gp of gamepads) {
|
|
|
- if (gp) handleGamepad(gp);
|
|
|
|
|
|
|
+ if (gp) handleGamepad(gp)
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- requestAnimationFrame(gamepadLoop);
|
|
|
|
|
|
|
+ requestAnimationFrame(gamepadLoop)
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
// =====================================================
|
|
// =====================================================
|
|
|
-// 连接 / 断开 事件
|
|
|
|
|
|
|
+// 连接 / 断开事件
|
|
|
// =====================================================
|
|
// =====================================================
|
|
|
|
|
|
|
|
-window.addEventListener("gamepadconnected", (e: GamepadEvent) => {
|
|
|
|
|
- log("🎮 手柄已连接:", e.gamepad.id);
|
|
|
|
|
|
|
+window.addEventListener('gamepadconnected', (e: GamepadEvent) => {
|
|
|
|
|
+ log('🎮 手柄已连接:', e.gamepad.id)
|
|
|
|
|
+
|
|
|
if (!running) {
|
|
if (!running) {
|
|
|
- running = true;
|
|
|
|
|
- requestAnimationFrame(gamepadLoop);
|
|
|
|
|
|
|
+ running = true
|
|
|
|
|
+
|
|
|
|
|
+ requestAnimationFrame(gamepadLoop)
|
|
|
}
|
|
}
|
|
|
-});
|
|
|
|
|
|
|
+})
|
|
|
|
|
+
|
|
|
|
|
+window.addEventListener('gamepaddisconnected', (e: GamepadEvent) => {
|
|
|
|
|
+ log('🎮 手柄断开:', e.gamepad.id)
|
|
|
|
|
|
|
|
-window.addEventListener("gamepaddisconnected", (e: GamepadEvent) => {
|
|
|
|
|
- log("🎮 手柄已断开:", e.gamepad.id);
|
|
|
|
|
-});
|
|
|
|
|
|
|
+ gamepadStates.delete(e.gamepad.index)
|
|
|
|
|
+})
|
|
|
|
|
+
|
|
|
|
|
+// =====================================================
|
|
|
|
|
+// 外部调用
|
|
|
|
|
+// =====================================================
|
|
|
|
|
+
|
|
|
|
|
+export function initXboxGamepad(options?: XboxGamepadOptions): void {
|
|
|
|
|
+ onToggleCatalog = options?.onToggleCatalog ?? null
|
|
|
|
|
|
|
|
-export function initXboxGamepad(): void {
|
|
|
|
|
if (!running) {
|
|
if (!running) {
|
|
|
- running = true;
|
|
|
|
|
- requestAnimationFrame(gamepadLoop);
|
|
|
|
|
|
|
+ running = true
|
|
|
|
|
+
|
|
|
|
|
+ requestAnimationFrame(gamepadLoop)
|
|
|
}
|
|
}
|
|
|
-}
|
|
|
|
|
|
|
+}
|