はじめに
掲題の通り。
色々と雑ですが、作ってみて感じた事等、以下にまとめたいと思います。
できたもの
経緯諸々…
- 会社の忘年会で景品を賭けたゲームをやりたい。
- オーソドックスにビンゴで行きましょう( ・ㅂ・)و ̑̑
- 会場にはプロジェクタがあるので、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を使わない方が適切だったのかな・・・とも思い返しました
それでもとにかく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>
)
}
}
「読むだけでアプリの大体の動きが想像出来る様なソースコードを!」という理想を胸に抱いて作りましたが、最終的にはエディタの上下移動がキツくて、何をやっているのかも伝わりにくいコードになってしまいました・・・センス×
四苦八苦した所
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