controller.js

'use strict'

// ******************************** 事件冒泡堆栈管理器 ********************************

const EventBubbleStack = new class {
  // 栈索引
  index = 0

  // 事件冒泡状态栈
  stack = [false]

  /**
   * 获取事件冒泡状态
   * @returns {boolean} false=停止传递事件
   */
  get() {
    return this.stack[this.index]
  }

  /** 停止事件冒泡 */
  stop() {
    this.stack[this.index] = false
  }

  /**
   * 推入事件冒泡状态
   * @param {boolean} bubble 冒泡状态
   */
  push(bubble) {
    this.stack[++this.index] = bubble
  }

  /** 弹出事件冒泡状态 */
  pop() {
    this.index--
  }
}

// ******************************** 输入控制器 ********************************

const Input = new class {
  // 键盘按键状态
  keys = {}

  // 鼠标按键状态
  buttons = new Uint8Array(5)

  // 鼠标管理器
  mouse

  // 控制器管理器
  controller

  // JS原生事件
  event = null

  // 事件冒泡栈
  bubbles = EventBubbleStack

  // 输入事件侦听器
  listeners = {
    keydown: [],
    keyup: [],
    mousedown: [],
    mousedownLB: [],
    mousedownRB: [],
    mouseup: [],
    mouseupLB: [],
    mouseupRB: [],
    mousemove: [],
    mouseleave: [],
    doubleclick: [],
    wheel: [],
    gamepadbuttonpress: [],
    gamepadbuttonrelease: [],
    gamepadleftstickchange: [],
    gamepadrightstickchange: [],
  }

  // 按键黑名单
  keydownBlackList = [
    'F1',     'F2',     'F3',     'F4',
    'F5',     'F6',     'F7',     'F8',
    'F9',     'F10',    'TAB',
  ]

  // 按键白名单(控制键按下时)
  keydownWhiteListOnCtrl = [
    'KeyA',     'KeyC',     'KeyV',     'KeyX',
    'KeyY',     'KeyZ',     'Digit1',   'Digit2',
    'Digit3',   'Digit4',   'Digit5',   'Digit6',
    'Digit7',   'Digit8',   'Digit9',   'Numpad1',
    'Numpad2',  'Numpad3',  'Numpad4',  'Numpad5',
    'Numpad6',  'Numpad7',  'Numpad8',  'Numpad9',
  ]

  /** 初始化输入控制器 */
  initialize() {
    // 引用鼠标管理器
    this.mouse = Mouse

    // 引用游戏手柄管理器
    this.controller = Controller

    // 引用事件冒泡栈
    this.bubbles = EventBubbleStack

    // 侦听事件
    window.on('keydown', this.keydown)
    window.on('keyup', this.keyup)
    window.on('blur', this.blur)
  }

  /** 更新输入状态 */
  update() {
    Input.mouse.update()
    Input.controller.update()
  }

  /**
   * 添加输入事件侦听器
   * @param {string} type 输入事件类型
   * @param {function} listener 回调函数
   * @param {boolean} [priority = false] 是否将该事件设为最高优先级
   */
  on(type, listener, priority = false) {
    const list = this.listeners[type]
    if (!list.includes(listener)) {
      if (priority) {
        list.unshift(listener)
      } else {
        list.push(listener)
      }
    }
  }

  /**
   * 移除输入事件侦听器
   * @param {string} type 输入事件类型
   * @param {function} listener 回调函数
   */
  off(type, listener) {
    const group = this.listeners[type]
    const index = group.indexOf(listener)
    if (index !== -1) {
      const replacer = () => {}
      group[index] = replacer
      Callback.push(() => {
        group.remove(replacer)
      })
    }
  }

  /**
   * 发送输入事件
   * @param {string} type 输入事件类型
   * @param {Array} params 传递参数
   */
  emit(type, ...params) {
    const {bubbles} = this
    bubbles.push(true)
    for (const listener of this.listeners[type]) {
      if (bubbles.get()) {
        listener(...params)
        continue
      }
      break
    }
    bubbles.pop()
  }

  /**
   * 按键过滤器
   * @param {KeyboardEvent} event 键盘事件
   */
  keydownFilter(event) {
    // 如果是本地运行,返回
    if (Stats.isOnClient) {
      return
    }
    // 阻止默认按键行为(Web模式)
    const {code} = event
    if (event.cmdOrCtrlKey) {
      if (!this.keydownWhiteListOnCtrl.includes(code)) {
        event.preventDefault()
      }
    } else if (event.altKey) {
      event.preventDefault()
    } else if (this.keydownBlackList.includes(code)) {
      event.preventDefault()
    }
  }

  /**
   * 键盘按下事件
   * @param {KeyboardEvent} event 键盘事件
   */
  keydown(event) {
    Input.keydownFilter(event)

    // 当文本输入框获得焦点时,返回
    if (document.activeElement instanceof HTMLInputElement) {
      return
    }

    // 触发游戏事件
    const {keys} = Input
    const {code} = event
    if (keys[code] !== 1) {
      keys[code] = 1
      Input.event = event
      Input.emit('keydown')
      Input.event = null
    }

    // 功能快捷键
    switch (event.code) {
      case 'F5':
        if (Stats.debug) {
          location.reload()
        }
        break
      case 'F10':
        Game.switchGameInfoDisplay()
        break
      // case 'Pause':
      //   GL.WEBGL_lose_context.loseContext()
      //   break
    }
  }

  /**
   * 键盘弹起事件
   * @param {KeyboardEvent} event 键盘事件
   */
  keyup(event) {
    // 触发游戏事件
    const {keys} = Input
    const {code} = event
    if (keys[code] === 1) {
      keys[code] = 0
      Input.event = event
      Input.emit('keyup')
      Input.event = null
    }
  }

  /**
   * 失去焦点事件
   * @param {FocusEvent} event 焦点事件
   */
  blur(event) {
    // 弹起所有按下的键盘按键
    for (const [keycode, state] of Object.entries(Input.keys)) {
      if (state === 1) {
        Input.simulateKey('keyup', keycode)
      }
    }
    // 弹起所有按下的鼠标按键
    for (let i = 0; i < Input.buttons.length; i++) {
      if (Input.buttons[i] === 1) {
        Input.simulateButton('pointerup', i)
      }
    }
  }

  /**
   * 模拟键盘按键
   * @param {string} keycode 
   */
  simulateKey(type, keycode) {
    const event = Input.event
    window.dispatchEvent(new KeyboardEvent(type, {code: keycode}))
    Input.event = event
  }

  /**
   * 模拟鼠标按键
   * @param {number} button 
   */
  simulateButton(type, button) {
    const event = Input.event
    window.dispatchEvent(new PointerEvent(type, {button: button}))
    Input.event = event
  }
}

// ******************************** 鼠标管理器 ********************************

const Mouse = new class {
  rotated = false
  entered = true
  left = 0
  top = 0
  right = 0
  ratioX = 0
  ratioY = 0
  screenX = -1
  screenY = -1
  sceneX = -1
  sceneY = -1
  eventCache = null
  pointerdownEvent = null

  /** 初始化鼠标管理器 */
  initialize() {
    // 调整位置
    this.resize()

    // 侦听事件
    window.on('resize', this.resize)
    window.on('pointerup', this.pointerup)
    window.on('pointermove', this.pointermove)
    window.on('pointerenter', this.pointerenter)
    window.on('pointerleave', this.pointerleave)
    GL.canvas.on('pointerdown', this.pointerdown)
    GL.canvas.on('pointerdown', this.doubleclick)
    GL.canvas.on('wheel', this.wheel)
  }

  /** 更新鼠标的场景坐标 */
  update() {
    Mouse.calculateSceneCoords()
  }

  /**
   * 指针按下事件
   * @param {PointerEvent} event 指针事件
   */
  pointerdown(event) {
    Mouse.calculateCoords(event)
    Input.event = event
    Input.buttons[event.button] = 1
    switch (event.button) {
      case 0: Input.emit('mousedownLB'); break
      case 2: Input.emit('mousedownRB'); break
    }
    Input.emit('mousedown')
    Input.event = null
  }

  /**
   * 指针弹起事件
   * @param {PointerEvent} event 指针事件
   */
  pointerup(event) {
    Input.event = event
    Input.buttons[event.button] = 0
    switch (event.button) {
      case 0: Input.emit('mouseupLB'); break
      case 2: Input.emit('mouseupRB'); break
      // 阻止Chrome浏览器:
      // 前进/后退键弹起页面导航行为
      case 3:
      case 4: event.preventDefault(); break
    }
    Input.emit('mouseup')
    Input.event = null
  }

  /**
   * 指针移动事件
   * @param {PointerEvent} event 指针事件
   */
  pointermove(event) {
    Mouse.calculateCoords(event)
    Input.emit('mousemove')
  }

  /**
   * 指针进入事件
   * @param {PointerEvent} event 指针事件
   */
  pointerenter(event) {
    Mouse.entered = true
  }

  /**
   * 指针离开事件
   * @param {PointerEvent} event 指针事件
   */
  pointerleave(event) {
    Mouse.entered = false
    Input.emit('mouseleave')
  }

  /**
   * 鼠标双击事件
   * @param {PointerEvent} event 指针事件
   */
  doubleclick(event) {
    if (!event.cmdOrCtrlKey &&
      !event.altKey &&
      !event.shiftKey) {
      switch (event.button) {
        case 0: {
          // 用指针按下事件来模拟鼠标双击事件
          // 原生的鼠标双击事件在第二次弹起时触发
          // 而模拟的在第二次按下时触发,手感更好
          // 要求:按键间隔<500ms,抖动偏移<4px
          if (Mouse.pointerdownEvent !== null &&
            event.timeStamp - Mouse.pointerdownEvent.timeStamp < 500 &&
            Math.abs(event.clientX - Mouse.pointerdownEvent.clientX) < 4 &&
            Math.abs(event.clientY - Mouse.pointerdownEvent.clientY) < 4) {
            Input.emit('doubleclick')
            Mouse.pointerdownEvent = null
          } else {
            Mouse.pointerdownEvent = event
          }
          break
        }
        default:
          Mouse.pointerdownEvent = null
          break
      }
    }
  }

  /**
   * 鼠标滚轮事件
   * @param {WheelEvent} event 滚轮事件
   */
  wheel(event) {
    event.preventDefault()
    Input.event = event
    Input.emit('wheel')
    Input.event = null
  }

  /** 重新调整位置 */
  resize() {
    const container = GL.container
    const canvas = GL.canvas
    const rect = container.getBoundingClientRect()
    Mouse.rotated = container.style.transform === 'rotate(90deg)'
    Mouse.left = rect.left
    Mouse.top = rect.top
    Mouse.right = rect.right
    switch (Mouse.rotated) {
      case false:
        // 屏幕未旋转的情况
        Mouse.ratioX = canvas.width / rect.width
        Mouse.ratioY = canvas.height / rect.height
        break
      case true:
        // 屏幕旋转90度的情况
        Mouse.ratioX = canvas.width / rect.height
        Mouse.ratioY = canvas.height / rect.width
        break
    }
    // 重新计算坐标
    if (Mouse.eventCache !== null) {
      Mouse.calculateCoords(Mouse.eventCache)
    }
  }

  /**
   * 计算坐标
   * @param {PointerEvent} event 
   */
  calculateCoords(event) {
    this.eventCache = event
    switch (this.rotated) {
      case false:
        // 屏幕未旋转的情况
        this.screenX = Math.round((event.clientX - this.left) * this.ratioX - 0.0001)
        this.screenY = Math.round((event.clientY - this.top) * this.ratioY - 0.0001)
        break
      case true:
        // 屏幕旋转90度的情况
        this.screenX = Math.round((event.clientY - this.top) * this.ratioX - 0.0001)
        this.screenY = Math.round((this.right - event.clientX) * this.ratioY - 0.0001)
        break
    }
    this.calculateSceneCoords()
  }

  /** 计算场景坐标 */
  calculateSceneCoords() {
    const scene = Scene.binding
    if (scene === null) return
    const x = Math.round(Camera.scrollLeft) + this.screenX / Camera.zoom
    const y = Math.round(Camera.scrollTop) + this.screenY / Camera.zoom
    this.sceneX = x / scene.tileWidth
    this.sceneY = y / scene.tileHeight
  }
}

// ******************************** 游戏手柄管理器 ********************************

const Controller = new class {
  buttonCode = -1
  buttonName = ''
  buttons = new Array(16).fill(false)
  buttonNames = {
    0: 'A',
    1: 'B',
    2: 'X',
    3: 'Y',
    4: 'LB',
    5: 'RB',
    6: 'LT',
    7: 'RT',
    8: 'View',
    9: 'Menu',
    10: 'LS',
    11: 'RS',
    12: 'Up',
    13: 'Down',
    14: 'Left',
    15: 'Right',
  }
  states = {
    A: false,
    B: false,
    X: false,
    Y: false,
    LB: false,
    RB: false,
    LT: false,
    RT: false,
    View: false,
    Menu: false,
    LS: false,
    RS: false,
    Up: false,
    Down: false,
    Left: false,
    Right: false,
    LeftStickAngle: -1,
    RightStickAngle: -1,
  }

  // 重置
  reset() {
    this.buttons.fill(false)
    const states = this.states
    for (const key of Object.keys(states)) {
      switch (typeof states[key]) {
        case 'boolean':
          states[key] = false
          continue
        case 'number':
          states[key] = -1
          continue
      }
    }
  }

  // 初始化
  initialize() {
    // 禁用更新函数
    this.update = Function.empty

    // 侦听事件
    window.on('gamepadconnected', this.connect.bind(this))
    window.on('gamepaddisconnected', this.disconnect.bind(this))
  }

  // 更新按键
  update() {
    const pads = navigator.getGamepads()
    const pad = pads[0] ?? pads[1] ?? pads[2] ?? pads[3] ?? null
    if (!pad) return

    const axes = pad.axes
    const pButtons = pad.buttons
    const cButtons = this.buttons
    const buttonNames = this.buttonNames
    const states = this.states

    // 更新按钮
    const length = Math.min(cButtons.length, pButtons.length)
    for (let i = 0; i < length; i++) {
      const button = pButtons[i]
      if (cButtons[i] !== button.pressed) {
        cButtons[i] = button.pressed
        const name = buttonNames[i]
        states[name] = button.pressed
        this.buttonCode = i
        this.buttonName = name
        if (button.pressed) {
          Input.emit('gamepadbuttonpress', pad, name)
        } else {
          Input.emit('gamepadbuttonrelease', pad, name)
        }
      }
    }

    // 重置按钮
    this.buttonCode = -1
    this.buttonName = ''

    // 更新左摇杆
    if (axes[0] ** 2 + axes[1] ** 2 > 0.4) {
      const radians = Math.atan2(axes[1], axes[0])
      const degrees = Math.modDegrees(Math.degrees(radians))
      states.LeftStickAngle = degrees
      Input.emit('gamepadleftstickchange', pad, degrees)
    } else if (states.LeftStickAngle !== -1) {
      states.LeftStickAngle = -1
      Input.emit('gamepadleftstickchange', pad, -1)
    }

    // 更新右摇杆
    if (axes[2] ** 2 + axes[3] ** 2 > 0.4) {
      const radians = Math.atan2(axes[3], axes[2])
      const degrees = Math.modDegrees(Math.degrees(radians))
      states.RightStickAngle = degrees
      Input.emit('gamepadrightstickchange', pad, degrees)
    } else if (states.RightStickAngle !== -1) {
      states.RightStickAngle = -1
      Input.emit('gamepadrightstickchange', pad, -1)
    }
  }

  // 手柄连接事件
  connect(event) {
    const pads = navigator.getGamepads()
    for (const pad of pads) {
      if (pad) {
        delete this.update
        break
      }
    }
  }

  // 手柄失去连接事件
  disconnect(event) {
    const pads = navigator.getGamepads()
    for (const pad of pads) {
      if (pad) {
        return
      }
    }
    this.update = Function.empty
    this.reset()
  }
}