LoginSignup
17
12

More than 5 years have passed since last update.

会社の忘年会で使うブラウザビンゴゲームをReactで作ってみた

Last updated at Posted at 2017-12-20

はじめに

掲題の通り。
色々と雑ですが、作ってみて感じた事等、以下にまとめたいと思います。

できたもの

こんなの
konnano.gif

経緯諸々…

  • 会社の忘年会で景品を賭けたゲームをやりたい。
    • オーソドックスにビンゴで行きましょう( ・ㅂ・)و ̑̑
    • 会場にはプロジェクタがあるので、PCで利用出来るビンゴアプリでやりましょう( ・ㅂ・)و ̑̑
    • カスタムしたい機能(後述)もあるので、いっそ作ってしまいましょう( ・ㅂ・)و ̑̑
    • じゃあwebアプリで! ( 筆 者・ㅂ・)و ̑̑  reactで! ( 筆 者・ㅂ・)و ̑̑  僕が作りますので! ( 筆 者・ㅂ・)و ̑̑

要件

  • 普通のビンゴ機能
    • ボタンクリックで1~75の数値をランダムに抽選出来る
    • 当選した数が分かるように常に表示する(過去に当選した数も)
    • ビンゴ結果のクリア(初期化)が出来る
    • もってくPCはchrome入りのwin10だから、とりあえずchromeで動けばよい
  • カスタム要件①
    • 「enterキー押下」でもビンゴの抽選が出来る様にする
      • 参加者一同が注目する中で抽選するため、ボタン押した感を出したかった
  • カスタム要件②
    • 最小値と最大値を自由に変更出来る様にする
      • 目玉景品(Nintendo switch)はビンゴゲームとは別に、1発勝負の抽選で当選者を決めたい

ざっくりシステム構成

ライブラリ : react 16.1.1
UIデザイン : material-ui 0.20.0
バンドラー : webpack 3.6.0
トランスパイラ : babel(-core) 6.26.0
ホスティング : firebase hosting

な ぜ react に し た の か

  • ぶっちゃけると、最近やっとビギナー程度には書ける様になった(と思いたい)reactでのコーディングが楽しくしょうがないからという、極めて個人的な理由エゴでreactを選びました
    • 業務で使った事は一度も無い(使っても好きでいられるだろうか)
    • 今回は業務という訳ではないのでアーキテクチャ諸々は僕の一存!調子に乗りました。すみません。
    • 反省点(後述)でも述べますが、正直これくらいの要件ならreactを使わない方が適切だったのかな・・・とも思い返しました:disappointed_relieved:
    • それでもとにかくreactで書きたかった

アプリ構成

 ├ resources/
 │ ├ bundle.js
 │ └ index.css
 ├ src/
 │ ├ component/
 │ │ ├ BingoNumber.jsx
 │ │ ├ BingoNumbers.jsx
 │ │ ├ ConfigArea.jsx
 │ │ ├ ConfigButton.jsx
 │ │ └ DoButton.jsx
 │ ├ service/
 │ │ └ BingoDrawer.js
 │ ├ utils/
 │ │ └ ArrayExtension.js
 │ └ app.js
 └ index.html # endpoint

見返してみると、ポリシーもへったくれもないものすごく適当な構成になってしまっていて、改めて自分のコード設計力の無さを痛感しました。
(reactに限った話ではありませんが)適切にコンポーネント化やクラス分けが出来る様なスキルを身に着けたいです。。。
 「BingoNumber」と「BingoNumbers」って何…?なんでそれぞれ別れてるの?とか・・・
 単一責任の原則?何それおいしいの?とか・・・

メイン処理(抜粋)

const BINGO_MIN_NUMBER = 1
const BINGO_MAX_NUMBER = 75
const KEY_CODE_ENTER = 13

class BingoGame extends React.Component {
  constructor() {
    super()

    this.state = {
      currentNumber : <BingoNumber size="big" isHit={true} value={0}/>,
      nowDrawing : false,
      doButton : <DoButton label="start" onClick={::this.startDrawing} />,
      bingoDrawer : new BingoDrawer({ min : BINGO_MIN_NUMBER, max : BINGO_MAX_NUMBER }),
      hitNumbers : [],
      isOpenConfig : false,
      max : BINGO_MAX_NUMBER,
      min : BINGO_MIN_NUMBER,
      reSettingMax : BINGO_MAX_NUMBER,
      reSettingMin : BINGO_MIN_NUMBER,
      reSettingMaxError : '',
      reSettingMinError : ''
    }
  }

  componentWillMount() {
    document.body.addEventListener('keydown', ::this.onEnterKeyDown)
    document.body.addEventListener('keyup', ::this.onEnterKeyUp)
  }

  onEnterKeyDown(e) {
    if(KEY_CODE_ENTER === e.keyCode) {
      document.activeElement.blur()
    }
  }

  onEnterKeyUp(e) {
    if(KEY_CODE_ENTER !== e.keyCode) {
      return
    }

    if(this.state.nowDrawing) {
      this.finishDrawing()
    } else {
      this.startDrawing()
    }
  }

  componentDidUpdate() {
    this.state.doButton = this.state.nowDrawing ?
                            <DoButton label="stop" onClick={::this.finishDrawing} /> :
                            <DoButton label="start" onClick={::this.startDrawing} />
  }

  isFinished() {
    return this.state.hitNumbers.length >= ((this.state.max - this.state.min) + 1)
  }

  startDrawing() {
    if(this.state.nowDrawing) {
      return
    }

    if(this.isFinished()) {
      return
    }

    this.state.nowDrawing = setInterval(() => {
      const randomNumber = Math.floor(Math.random() * (this.state.max - this.state.min + 1) + this.state.min)
      this.setState({
        currentNumber : <BingoNumber size="big" isHit={true} value={randomNumber}/>
      })
    }, 85)
  }

  finishDrawing() {
    if(!this.state.nowDrawing) {
      return
    }

    clearInterval(
      this.state.nowDrawing
    )

    const hitNumber = this.state.bingoDrawer.draw()
    const hitNumbers = this.state.hitNumbers
    hitNumbers.push(hitNumber)

    this.setState({
      nowDrawing : false,
      doButton : <DoButton label="start" onClick={::this.startDrawing} />,
      currentNumber : <BingoNumber size="big" isHit={true} value={hitNumber}/>,
      hitNumbers
    })
  }

  resetAll() {
    const shouldReset = confirm('抽選結果をすべてクリアします。よろしいですか?')
    if(shouldReset) {
      clearInterval(
        this.state.nowDrawing
      )

      this.setState({
        currentNumber : <BingoNumber size="big" isHit={true} value={0}/>,
        nowDrawing : false,
        doButton : <DoButton label="start" onClick={::this.startDrawing} />,
        bingoDrawer : new BingoDrawer({ min : this.state.min, max : this.state.max }),
        hitNumbers : [],
        reSettingMaxError : '',
        reSettingMinError : ''
      })
    }
  }

  toggleConfigArea() {
    this.setState({ isOpenConfig : !this.state.isOpenConfig })
  }

  syncValue(e) {
    const changedState = {}
    changedState[e.target.name] = e.target.value

    this.setState(changedState)
  }

  clearConfigErrorMessage() {
    this.setState({
      reSettingMaxError : '',
      reSettingMinError : ''
    })
  }

  saveConfigAndRestart() {
    this.clearConfigErrorMessage()

    const reSettingMax = +this.state.reSettingMax
    const reSettingMin = +this.state.reSettingMin

    const validateTargets = [
                              {
                                target : 'min',
                                value : reSettingMin
                              },
                              {
                                target : 'max',
                                value : reSettingMax
                              }
                            ]

    const isNanError = ((validateTargets, _this) => {
      let result = false
      validateTargets.forEach(props => {
        if(!props.value || isNaN(props.value) || 0 > props.value) {
          const errorMessage = {}
          errorMessage[`reSetting${props.target.charAt(0).toUpperCase() + props.target.slice(1)}Error`] = `${props.target}には1以上の半角数値を入力してください`
          _this.setState(errorMessage)
          result = true
          return
        }
      })
      return result
    })(validateTargets, this)

    if(isNanError) {
      return
    }

    if(reSettingMin >= reSettingMax) {
      this.setState({ reSettingMaxError : 'minより大きい値を入力して下さい' })
      return
    }

    clearInterval(
      this.state.nowDrawing
    )

    this.setState({
      currentNumber : <BingoNumber size="big" isHit={true} value={0}/>,
      nowDrawing : false,
      doButton : <DoButton label="start" onClick={::this.startDrawing} />,
      bingoDrawer : new BingoDrawer({ min : reSettingMin, max : reSettingMax }),
      hitNumbers : [],
      max : reSettingMax,
      min : reSettingMin,
      isOpenConfig : !this.state.isOpenConfig,
      reSettingMaxError : '',
      reSettingMinError : ''
    })
  }

  render() {
    return (
      <div>
        {this.state.doButton}

        <ConfigButton
          onClick={::this.toggleConfigArea}
        />
        <ConfigArea
          onRequestChange={::this.toggleConfigArea}
          open={this.state.isOpenConfig}
        >
          <TextField
            hintText="Min"
            floatingLabelText="Min"
            style={{ marginLeft:'1vw' }}
            defaultValue={this.state.min}
            onChange={::this.syncValue}
            name="reSettingMin"
            errorText={this.state.reSettingMinError}
            underlineShow={false} />
          <TextField
            hintText="Max"
            floatingLabelText="Max"
            style={{ marginLeft:'1vw' }}
            defaultValue={this.state.max}
            onChange={::this.syncValue}
            name="reSettingMax"
            errorText={this.state.reSettingMaxError}
            underlineShow={false} />
          <RaisedButton
            label="save & reStart"
            primary={true}
            icon={<i className="material-icons">refresh</i>}
            fullWidth={true}
            onClick={::this.saveConfigAndRestart}
          />
          <RaisedButton
            label="Secondary"
            secondary={true}
            label="close"
            icon={<i className="material-icons">close</i>}
            style={{ marginTop : '4.0vh' }}
            fullWidth={true}
            onClick={::this.toggleConfigArea}
          />
        </ConfigArea>

        <button
          type="button"
          className="button-clear"
          onClick={::this.resetAll}
          >
          Clear
        </button>

        <div className="main">
          {this.state.currentNumber}
        </div>

        <BingoNumbers
          min={this.state.min}
          max={this.state.max}
          hitNumbers={this.state.hitNumbers}
        />
      </div>
    )
  }
}

「読むだけでアプリの大体の動きが想像出来る様なソースコードを!」という理想を胸に抱いて作りましたが、最終的にはエディタの上下移動がキツくて、何をやっているのかも伝わりにくいコードになってしまいました・・・センス×

四苦八苦した所

  • これどうやるんだろう・・・
    kore.gif
    • setIntervalでBingoNumberコンポーネントをrenderさせまくる
app.js
this.state.nowDrawing = setInterval(() => {
  const randomNumber = Math.floor(Math.random() * (this.state.max - this.state.min + 1) + this.state.min)
  this.setState({
    currentNumber : <BingoNumber size="big" isHit={true} value={randomNumber}/>
  })
}, 85) // マジックナンバー…


// 止める時
clearInterval(
  this.state.nowDrawing
)

処理中はrenderが走り続けるため、パフォーマンス的にどうなんだろう?と疑問に思いましたが、特に影響はありませんでした。react fiberによる恩恵?

反省点諸々

  • reactじゃなくて良かったよね
    • ビンゴゲーム終了後には、githubでソースを見た直属の上司から『なんでreactにしたん?jQueryとes6のクラス構文でも出来たじゃん(これくらいの要件ならその方が速くね?)。お前ただ覚えたてのreact使いたかっただけだろ』と、ぐうの音も出ない大正論を言われ何も言い返せませんでした。そうです。ただreactが書きたかっただけなんです。普段の業務ではこのreact熱を出しすぎない様に、平時はもうちょっと冷静で居る様心がけます
  • cssの管理がすごく適当
    • ロジックを書いて満足し、反面スタイルシートは一元で管理して、変更があれば都度手を加え・・・という、そもそも何がベストかも分かっていない自分からしても『適当だな~』と思う適当さ加減。reactの初歩的なお作法はそこそこ頭に入ってきたのかな、と思うので、次はcssの管理からも目を背けずに開発しようと思います
    • ほぼDrawer用にして、非常に中途半端な使い方をしてしまったmateriul-uiくん・・・
  • ていうか番号可変なら『ビンゴゲーム』っていう名前は不適t(ry

成果物

ホスティング中 : https://bingogame-ee193.firebaseapp.com/
ソースコード : https://github.com/nishiurahiroki/bingo-game-made-with-react

17
12
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
17
12