bemusic/bemuse

View on GitHub
bemuse/src/app/ui/OptionsInput.tsx

Summary

Maintainability
B
6 hrs
Test Coverage
import './OptionsInput.scss'

import OmniInput, { getName, key川 } from 'bemuse/omni-input'
import React, { useEffect, useRef, useState } from 'react'
import {
  isContinuousAxisEnabled,
  keyboardMapping,
  nextKeyToEdit,
  optionsSlice,
  playMode,
  scratchPosition,
  sensitivity,
} from '../entities/Options'
import { tap, throttleTime } from 'rxjs'
import { useDispatch, useSelector } from 'react-redux'

import { AppState } from '../redux/ReduxState'
import OptionsButton from './OptionsButton'
import OptionsCheckbox from './OptionsCheckbox'
import OptionsInputField from './OptionsInputField'
import OptionsInputKeys from './OptionsInputKeys'
import OptionsInputScratch from './OptionsInputScratch'
import _ from 'lodash'
import c from 'classnames'
import { createSelector } from 'reselect'

const selectKeyboardMapping = createSelector(
  (state: AppState) => state.options,
  (options) => keyboardMapping(options)
)

const selectKeyboardMappingTexts = createSelector(
  selectKeyboardMapping,
  (mapping) => _.mapValues(mapping, getName)
)

const extractState = (state: AppState) => ({
  scratch: scratchPosition(state.options),
  texts: selectKeyboardMappingTexts(state),
  mode: playMode(state.options),
  isContinuous: isContinuousAxisEnabled(state.options),
  sensitivity: sensitivity(state.options),
})

const OptionsInput = () => {
  const dispatch = useDispatch()
  const { scratch, texts, mode, isContinuous, sensitivity } =
    useSelector(extractState)
  const [editing, setEditing] = useState<string | null>(null)
  const omniInput = useRef<OmniInput | null>(null)

  const handleEdit = (key: string) => {
    setEditing((editing) => (editing === key ? null : key))
  }

  const handleKey = (keyCode: string) => {
    if (editing) {
      dispatch(
        optionsSlice.actions.CHANGE_KEY_MAPPING({
          mode,
          key: editing,
          keyCode,
        })
      )
      setEditing(nextKeyToEdit(editing, scratch))
    }
  }

  const handleSensitivityChange = (sensitivity: number) => {
    if (sensitivity < 0) sensitivity = 0
    else if (sensitivity >= 10) sensitivity = 9
    omniInput.current?.setGamepadSensitivity(sensitivity)
    dispatch(optionsSlice.actions.CHANGE_SENSITIVITY({ sensitivity }))
  }

  const handleMinusButtonClick = () => {
    handleSensitivityChange(sensitivity - 1)
  }

  const handlePlusButtonClick = () => {
    handleSensitivityChange(sensitivity + 1)
  }

  useEffect(() => {
    const handleKeyboardEvent = (e: KeyboardEvent) => {
      if (editing) {
        e.preventDefault()
      }
    }

    // XXX: debounce is needed because some gamepad inputs trigger multiple
    // buttons
    const input = new OmniInput(window, {
      continuous: isContinuous,
    })
    omniInput.current = input
    const subscription = key川(input)
      .pipe(throttleTime(16))
      .pipe(tap((key) => console.log('a', key)))
      .subscribe(handleKey)
    window.addEventListener('keydown', handleKeyboardEvent, true)
    return () => {
      subscription.unsubscribe()
      input.dispose()
      window.removeEventListener('keydown', handleKeyboardEvent, true)
    }
  }, [editing])

  const className = c('OptionsInputのbinding', {
    'is-reverse': scratch === 'right',
  })
  const editIndex = (editing: string | null) => {
    if (editing === 'SC') {
      return 0
    }
    if (editing === 'SC2') {
      return 1
    }
    return -1
  }
  return (
    <div className='OptionsInput'>
      <div className={className}>
        {scratch !== 'off' ? (
          <div className='OptionsInputのzone is-scratch'>
            <div className='OptionsInputのcontrol'>
              <OptionsInputScratch
                text={[texts['SC'], texts['SC2']]}
                isEditing={editing === 'SC' || editing === 'SC2'}
                editIndex={editIndex(editing)}
                onEdit={handleEdit}
              />
            </div>
            <div className='OptionsInputのtitle'>Scratch</div>
          </div>
        ) : null}
        <div className='OptionsInputのzone'>
          <div className='OptionsInputのcontrol'>
            <OptionsInputKeys
              keyboardMode={scratch === 'off'}
              texts={texts}
              editing={editing}
              onEdit={handleEdit}
            />
          </div>
          <div className='OptionsInputのtitle'>Keys</div>
        </div>
      </div>
      <div className='OptionsInputのgamepad'>
        <div className='OptionsInputのgroup'>
          <label>Continuous Axis</label>
          <OptionsCheckbox
            checked={isContinuous}
            onToggle={() => {
              dispatch(optionsSlice.actions.TOGGLE_CONTINUOUS_AXIS())
              omniInput.current?.setGamepadContinuousAxisEnabled(!isContinuous)
            }}
          />
        </div>
        <div className='OptionsInputのgroup'>
          <label>Sensitivity</label>
          <div className='OptionsInputのsensitivity'>
            <span className='OptionsInputのminus'>
              <OptionsButton onClick={handleMinusButtonClick}>-</OptionsButton>
            </span>
            <OptionsInputField
              parse={(str) => parseInt(str, 10) - 1}
              stringify={(value) => String(value + 1)}
              validator={/^\d+$/}
              value={sensitivity}
              onChange={handleSensitivityChange}
            />
            <span className='OptionsInputのplus'>
              <OptionsButton onClick={handlePlusButtonClick}>+</OptionsButton>
            </span>
          </div>
        </div>
      </div>
    </div>
  )
}

export default OptionsInput