hexlet-codebattle/codebattle

View on GitHub
services/app/apps/codebattle/assets/js/widgets/pages/game/CodebattlePlayer.jsx

Summary

Maintainability
A
1 hr
Test Coverage
import React, { Component } from 'react';

import qs from 'qs';
import { Slider } from 'react-player-controls';
import { Direction } from 'react-player-controls/dist/constants';
import { connect } from 'react-redux';

import RoomContext from '../../components/RoomContext';
import speedModes from '../../config/speedModes';
import { replayerMachineStates } from '../../machines/game';
import * as GameActions from '../../middlewares/Room';
import { playbookRecordsSelector } from '../../selectors';
import { actions } from '../../slices';

import CodebattleSliderBar from './CodebattleSliderBar';
import ControlPanel from './ControlPanel';

const playDelays = {
  [speedModes.normal]: 100,
  [speedModes.fast]: 50,
};

const isEqual = (float1, float2) => {
  const compareEpsilon = Number.EPSILON;
  return Math.abs(float1 - float2) < compareEpsilon;
};

class CodebattlePlayer extends Component {
  constructor(props) {
    super(props);
    const { stepCoefficient } = props;

    const getParams = window.location.href.split('?')[1];
    const nextRecordId = getParams ? Number(qs.parse(getParams).t || 0) : 0;
    this.state = {
      isEnabled: true,
      setGameStateDelay: 10,
      direction: Direction.HORIZONTAL,
      nextRecordId,
      // handlerPosition and intent have range from 0.0 to 1.0
      handlerPosition: stepCoefficient * nextRecordId,
      lastIntent: 0,
    };
  }

  // ControlPanel API

  onPlayClick = () => {
    const { roomMachineState } = this.props;
    const { handlerPosition } = this.state;
    const { mainService } = this.context;

    if (roomMachineState.matches({ replayer: replayerMachineStates.ended })) {
      this.setGameState(0.0);
      mainService.send('PLAY');
      this.play(0.0);
    }

    if (roomMachineState.matches({ replayer: replayerMachineStates.paused })) {
      mainService.send('PLAY');
      this.play(handlerPosition);
    }
  }

  onPauseClick = () => {
    const { mainService } = this.context;
    mainService.send('PAUSE');
  }

  onChangeSpeed = () => {
    const { mainService } = this.context;
    mainService.send('TOGGLE_SPEED_MODE');
  }

  // Slider callbacks

  onSliderHandleChange = value => {
    this.setState({ handlerPosition: value });

    const { roomMachineState } = this.props;
    const { setGameStateDelay } = this.state;

    if (roomMachineState.matches({ replayer: replayerMachineStates.holded })) {
      setTimeout(this.runSetGameState, setGameStateDelay, value);
    }
  }

  onSliderHandleChangeStart = () => {
    const { mainService } = this.context;
    mainService.send('HOLD');
  }

  onSliderHandleChangeEnd = handlerPosition => {
    const { setError, roomMachineState } = this.props;
    const { mainService } = this.context;
    const { holding } = roomMachineState.context;

    switch (holding) {
      case 'play':
        mainService.send('RELEASE_AND_PLAY');
        this.play(handlerPosition);
        break;
      case 'pause':
        mainService.send('RELEASE_AND_PAUSE');
        break;
      default:
        setError(new Error('Unexpected holding state [replayer machine]'));
    }
  }

  onSliderHandleChangeIntent = intent => {
    this.setState(() => ({ lastIntent: intent }));
  }

  onSliderHandleChangeIntentEnd = () => {
    this.setState(() => ({ lastIntent: 0 }));
  }

  // Helpers

  setGameState = handlerPosition => {
    const { setGameStateByRecordId, stepCoefficient, recordsCount } = this.props;
    const { mainService } = this.context;

    // Based on handler position we can calculate next record
    const nextRecordId = Math.floor(handlerPosition / stepCoefficient);

    setGameStateByRecordId(nextRecordId);

    if (nextRecordId + 1 >= recordsCount) {
      mainService.send('END');
    }

    this.setState({ handlerPosition, nextRecordId });
  }

  updateGameState = () => {
    const { updateGameStateByRecordId, recordsCount } = this.props;
    const { nextRecordId: recordId } = this.state;
    const { mainService } = this.context;
    const nextRecordId = recordId + 1;

    updateGameStateByRecordId(recordId);

    if (nextRecordId >= recordsCount) {
      mainService.send('END');
      this.setState({ handlerPosition: 1.0 });
    }

    this.setState({ nextRecordId });
  }

  play = handlerPosition => {
    const { roomMachineState } = this.props;

    const { speedMode } = roomMachineState.context;
    const playDelay = playDelays[speedMode];

    setTimeout(this.runPlay, playDelay, handlerPosition);
  }

  runPlay = handlerPosition => {
    const { stepCoefficient, roomMachineState } = this.props;
    const { handlerPosition: currentHandlerPosition } = this.state;

    /*
     * User can change handler position and replayer state.
     * We need check them before setting next state.
     */
    const isSync = isEqual(currentHandlerPosition, handlerPosition);

    if (roomMachineState.matches({ replayer: replayerMachineStates.playing }) && isSync) {
      const offset = handlerPosition + stepCoefficient;
      const newPosition = offset > 1 ? 1 : offset;

      this.setState({ handlerPosition: newPosition });

      this.updateGameState();
      this.play(newPosition);
    }
  };

  runSetGameState = handlerPosition => {
    const { handlerPosition: currentHandlerPosition } = this.state;

    /*
     * User can change handler position.
     * We need check this before setting state.
     */
    const isSync = isEqual(currentHandlerPosition, handlerPosition);
    if (isSync) {
      this.setGameState(currentHandlerPosition);
    }
  };

  render() {
    const { recordsCount, mainEvents, roomMachineState } = this.props;

    const {
      isEnabled, direction, handlerPosition, lastIntent, nextRecordId,
    } = this.state;

    if (!roomMachineState.matches({ replayer: replayerMachineStates.on }) || recordsCount === 0) {
      return null;
    }

    return (
      <>
        <div className="py-5" />
        <div className="container-fluid fixed-bottom">
          <div className="px-1">
            <div className="border bg-light">
              <div className="d-flex align-items-center justify-content-center">
                <ControlPanel
                  nextRecordId={nextRecordId}
                  roomMachineState={roomMachineState}
                  onPlayClick={this.onPlayClick}
                  onPauseClick={this.onPauseClick}
                  onChangeSpeed={this.onChangeSpeed}
                >
                  <Slider
                    className="cb-slider col-md-7 ml-1"
                    value={handlerPosition}
                    isEnabled={isEnabled}
                    direction={direction}
                    onChange={this.onSliderHandleChange}
                    onChangeStart={this.onSliderHandleChangeStart}
                    onChangeEnd={this.onSliderHandleChangeEnd}
                    onIntent={this.onSliderHandleChangeIntent}
                    onIntentEnd={this.onSliderHandleChangeIntentEnd}
                  >
                    <CodebattleSliderBar
                      mainEvents={mainEvents}
                      roomMachineState={roomMachineState}
                      handlerPosition={handlerPosition}
                      lastIntent={lastIntent}
                      recordsCount={recordsCount}
                      setGameState={this.setGameState}
                    />
                  </Slider>
                </ControlPanel>
              </div>
            </div>
          </div>
        </div>
      </>
    );
  }
}

CodebattlePlayer.contextType = RoomContext;

const mapStateToProps = state => {
  const recordsCount = playbookRecordsSelector(state).length;
  const { mainEvents } = state.playbook;

  return {
    recordsCount,
    stepCoefficient: 1.0 / recordsCount,
    mainEvents,
  };
};

const mapDispatchToProps = {
  setError: actions.setError,
  setGameStateByRecordId: GameActions.setGameHistoryState,
  updateGameStateByRecordId: GameActions.updateGameHistoryState,
};

export default connect(mapStateToProps, mapDispatchToProps)(CodebattlePlayer);