럿고의 개발 노트

[인프런] 웹 게임을 만들며 배우는 Vue 8. 지뢰찾기 본문

Vue.js Note/[인프런] 웹 게임을 만들며 배우는 Vue(동영상 강의)

[인프런] 웹 게임을 만들며 배우는 Vue 8. 지뢰찾기

KimSeYun 2019. 11. 19. 11:57

[인프런] 웹 게임을 만들며 배우는 Vue

8. 지뢰찾기

8.1. 지뢰찾기 구조 만들기

8.2. 지뢰찾기 코드 부여하기

8.3. 데이터 가공해 화면 그리기

8.4. 타이머 켜고 끄기

8.5. 칸 클릭하기

8.6. 지뢰 밟기와 주변 지뢰 개수 찾기

8.7, 주변 칸 한 번에 열기

8.8. 승리 조건 체크하기와 마무리

- 예제(웹 게임)을 통해 Vue를 만나는 시간으로, 예제를 풀면서 주석으로 배운 내용과 알아야 할 내용을 최대한 적어 놓았습니다. 코드와 주석을 참고해주세요!. 노드와 웹팩을 사용하여 설정파일들이 존재합니다. 아래 깃허브를 참고해주세요.

[main.js]


1
2
3
4
import Vue from 'vue';
import MineSweeper from './MineSweeper';
 
new Vue(MineSweeper).$mount('#root');
cs


[package.json]


1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
{
  "name""mine-sweeper",
  "version""1.0.0",
  "description""",
  "main""index.js",
  "scripts": {
    "bulid""webpack --watch",
    "dev""webpack-dev-server --hot"
  },
  "author""",
  "license""ISC",
  "dependencies": {
    "vue""^2.6.10",
    "vue-loader""^15.7.2",
    "vue-template-compiler""^2.6.10",
    "vuex""^3.1.2",
    "webpack""^4.41.2",
    "webpack-cli""^3.3.10"
  },
  "devDependencies": {
    "css-loader""^3.2.0",
    "vue-style-loader""^4.1.2",
    "webpack-dev-server""^3.9.0"
  }
}
 
cs



[webpack.config.js]


1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
const VueLoaderPlugin = require('vue-loader/lib/plugin');
const path = require('path'); // 경로 불러오는 것
 
module.exports = { // 노드에 모듈을 만듬, 이안에 웹팩 모든 설정을 넣으면 됨.
    mode:'development'// 배포할건지 개발한건지
    devtool: 'eval'// 개발할때는 eval, 배포할때는 hid,den-source-map
    resolve: {
        extensions: ['.js''.vue'], // 이걸 하면 import할때 확장자를 넣어줄 필요가 없다.
    },
    entry: { // 스크립트를 모인것 중에서 대표적인 스크립트
        app : path.join(__dirname, 'main.js'), // app은 하나로 합친 스크립트 이름
    },
    module: { // 웹팩의 핵심 // entry로 처리하다가 이상한거 나오면 loader를 실행
        rules: [{ // 합칠때 어떻게 합칠지를 설정해주는 것
            test: /\.vue$/// .vue로 끝나는 파일은 vue-loader를 사용하겠다, 정규표현식
            loader: 'vue-loader'// npm i vue-loader
        },{
            test: /\.css$/
            use: [ // loader와 user는 똑같은 기능
                'vue-style-loader',
                'css-loader',
            ]
        }],
    },
    plugins: [
        new VueLoaderPlugin(),
    ],
    output: {
        filename: '[name].js'// 출력할 파일 이름(최종결과)
        path: path.join(__dirname, 'dist'), // 폴더 경로
        publicPath: '/dist'// webpack-dev-server 세팅시 필요
    },
};
 
// entry, module, plugins, output이 주 설정 나머지는 부가적인 설정
cs


[store.js]


1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
import Vue from 'vue';
import Vuex from 'vuex';
 
Vue.use(Vuex);
 
export const START_GAME = 'START_GAME';
export const OPEN_CELL = 'OPEN_CELL';
export const CLICK_MINE = 'CLICK_MINE';
export const FLAG_CELL = 'FLAG_CELL';
export const QUESTION_CELL = 'QUESTION_CELL';
export const NORMALIZE_CELL = 'NORMALIZE_CELL';
export const INCREMENT_TIMER = 'INCREMENT_TIMER';
 
export const CODE = {
  MINE: -7,
  NORMAL: -1,
  QUESTION: -2,
  FLAG: -3,
  QUESTION_MINE: -4,
  FLAG_MINE: -5,
  CLICKED_MINE: -6,
  OPENED: 0// 0 이상이면 다 opened
};
 
// [ 빈칸은 -1, 오픈은 0이상 값들, 지뢰는 -7, 깃발은 -3 그것이 위의 CODE다
//  ['-1', '-1', '-7],
//  ['-1', '-1', '-1],
//  ['-1', '-1', '-1],
// ]
 
const plantMine = (row, cell, mine) => {
  console.log(row, cell, mine);
  const candidate = Array(row * cell).fill().map((arr, i) => {
    return i;
  });
  const shuffle = [];
  while (candidate.length > row * cell - mine) {
    const chosen = candidate.splice(Math.floor(Math.random() * candidate.length), 1)[0];
    shuffle.push(chosen);
  }
  const data = [];
  for (let i = 0; i < row; i++) {
    const rowData = [];
    data.push(rowData);
    for (let j = 0; j < cell; j++) {
      rowData.push(CODE.NORMAL);
    }
  }
 
  for (let k = 0; k < shuffle.length; k++) {
    const ver = Math.floor(shuffle[k] / cell);
    const hor = shuffle[k] % cell;
    data[ver][hor] = CODE.MINE;
  }
 
  console.log(data);
  return data;
};
 
export default new Vuex.Store({ // import store from './store';
  state: {
    tableData: [],
    data: {
      row: 0,
      cell: 0,
      mine: 0,
    },
    timer: 0,
    halted: true// true : 중단, false : 시작
    result: '',
    openedCount: 0,
  }, // vue의 data와 비슷
  getters: {
 
  }, // vue의 computed와 비슷
  mutations: {
    [START_GAME](state, { row, cell, mine }) {
      state.data = { // 데이터를 통으로 변경하는 것이라서 아래와 같은 상황보다는 자유롭다
        row,
        cell,
        mine,
      };
      //state.data[row] = row; -> 객체안에 속성을 이름으로 변경했을때 화면이 안변할수도 있음
      //Vue.set(state.data, 'row', row); -> 위에 명령어를 이렇게 변경해줘야 함
      state.tableData = plantMine(row, cell, mine);
      state.timer = 0;
      state.halted = false;
      state.openedCount = 0;
      state.result = '';
    },
    [OPEN_CELL](state, { row, cell }) {
      let openedCount = 0;
      const checked = [];
      function checkAround(row, cell) { // 주변 8칸 지뢰인지 검색
        const checkRowOrCellIsUndefined = row < 0 || row >= state.tableData.length || cell < 0 || cell >= state.tableData[0].length;
        if (checkRowOrCellIsUndefined) {
          return;
        }
        if ([CODE.OPENED, CODE.FLAG, CODE.FLAG_MINE, CODE.QUESTION_MINE, CODE.QUESTION].includes(state.tableData[row][cell])) {
          return;
        }
        if (checked.includes(row + '/' + cell)) {
          return;
        } else {
          checked.push(row + '/' + cell);
        }
        let around = [];
        if (state.tableData[row - 1]) {
          around = around.concat([
            state.tableData[row - 1][cell - 1], state.tableData[row - 1][cell], state.tableData[row - 1][cell + 1]
          ]);
        }
        around = around.concat([
          state.tableData[row][cell - 1], state.tableData[row][cell + 1]
        ]);
        if (state.tableData[row + 1]) {
          around = around.concat([
            state.tableData[row + 1][cell - 1], state.tableData[row + 1][cell], state.tableData[row + 1][cell + 1]
          ]);
        }
        const counted = around.filter(function(v) {
          return [CODE.MINE, CODE.FLAG_MINE, CODE.QUESTION_MINE].includes(v);
        });
        if (counted.length === 0 && row > -1) { // 주변칸에 지뢰가 하나도 없으면
          const near = [];
          if (row - 1 > -1) {
            near.push([row - 1, cell - 1]);
            near.push([row - 1, cell]);
            near.push([row - 1, cell + 1]);
          }
          near.push([row, cell - 1]);
          near.push([row, cell + 1]);
          if (row + 1 < state.tableData.length) {
            near.push([row + 1, cell - 1]);
            near.push([row + 1, cell]);
            near.push([row + 1, cell + 1]);
          }
          near.forEach((n) => {
            if (state.tableData[n[0]][n[1]] !== CODE.OPENED) {
              checkAround(n[0], n[1]);
            }
          });
        }
        if (state.tableData[row][cell] === CODE.NORMAL) {
          openedCount += 1;
        }
        Vue.set(state.tableData[row], cell, counted.length);
      }
      checkAround(row, cell);
      let halted = false;
      let result = '';
      if (state.data.row * state.data.cell - state.data.mine === state.openedCount + openedCount) {
        halted = true;
        result = `${state.timer}초만에 승리하셨습니다.`;
      }
      state.openedCount += openedCount;
      state.halted = halted;
      state.result = result;
    },
    [CLICK_MINE](state, { row, cell }) {
      state.halted = true;
      Vue.set(state.tableData[row], cell, CODE.CLICKED_MINE);
    },
    [FLAG_CELL](state, { row, cell }) {
      if (state.tableData[row][cell] === CODE.MINE) {
        Vue.set(state.tableData[row], cell, CODE.FLAG_MINE);
      } else {
        Vue.set(state.tableData[row], cell, CODE.FLAG);
      }
    },
    [QUESTION_CELL](state, { row, cell }) {
      if (state.tableData[row][cell] === CODE.FLAG_MINE) {
        Vue.set(state.tableData[row], cell, CODE.QUESTION_MINE);
      } else {
        Vue.set(state.tableData[row], cell, CODE.QUESTION);
      }
    },
    [NORMALIZE_CELL](state, { row, cell }) {
      if (state.tableData[row][cell] === CODE.QUESTION_MINE) {
        Vue.set(state.tableData[row], cell, CODE.MINE);
      } else {
        Vue.set(state.tableData[row], cell, CODE.NORMAL);
      }
    },
    [INCREMENT_TIMER](state) {
      state.timer += 1;
    },
  }, // state를 수정할 때 사용해요. 동기적으로
});
cs




[MineSweeper.html]


1
2
3
4
5
6
7
8
9
10
11
12
13
<!DOCTYPE html>
<html lang="ko">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <meta http-equiv="X-UA-Compatible" content="ie=edge">
    <title>지뢰찾기</title>
</head>
<body>
    <div id="root"></div>
    <script src ="dist/app.js"></script>
</body>
</html>
cs


[MineSweeper.vue]


1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
<template>
  <div>
    <mine-form></mine-form>
    <div>{{timer}}</div>
    <table-component></table-component>
    <div>{{result}}</div>
  </div>
</template>
 
<script>
import { mapState } from 'vuex';
 
import store, { INCREMENT_TIMER } from './store';
import TableComponent from './TableComponent';
import MineForm from './MineForm';
 
let interval; // 메모리 누수 막기 위해 생성
 
  export default {
    store,
    components: {
      TableComponent,
      MineForm,
    },
    computed: {
      ...mapState(['timer''result''halted']),
    },
    methods: {
    },
    watch: {
      halted(value, oldValue){
        if(value === false){
          interval = setInterval(() => {
            this.$store.commit(INCREMENT_TIMER); 
          }, 1000);
        }else// 게임중단
          clearInterval(interval);
        }
      }
    },
};
</script>
 
<style scoped>
    table{
        border-collapse: collapse;
    }
    td{
        border: 1px solid black;
        width: 40px;
        height: 40px;
        text-align: center;
    }
</style>
 
cs




[TableComponent.vue]


1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
<template>
<table>
    <tr v-for="(rowData, rowIndex) in tableData" :key="rowIndex">
        <td v-for="(cellData, cellIndex) in rowData" :key="cellIndex" :style="cellDataStyle(rowIndex, cellIndex)" @click="onClickTd(rowIndex, cellIndex)" @contextmenu.prevent="onRightClickTd(rowIndex, cellIndex)">{{cellDataText(rowIndex, cellIndex)}}</td>
        <!-- @contextmenu.prevent : 오른쪽마우스 클릭 막기 및 오른쪽 마우스 클릭시 실행될 메소드 설정 -->
    </tr>
</table>
</template>
 
<script>
import { mapState } from 'vuex';
import { CODE, OPEN_CELL, FLAG_CELL, QUESTION_CELL, NORMALIZE_CELL, CLICK_MINE } from './store';
 
  export default {
    computed: {
      ...mapState(['tableData''halted']),
      cellDataStyle(state) {
        return (row, cell) => {
          switch (this.$store.state.tableData[row][cell]) {
            case CODE.NORMAL:
            case CODE.MINE:
              return {
                background: '#444',
              };
            case CODE.CLICKED_MINE:
            case CODE.OPENED:
              return {
                background: 'white',
              };
            case CODE.FLAG:
            case CODE.FLAG_MINE:
              return {
                background: 'red',
              };
            case CODE.QUESTION:
            case CODE.QUESTION_MINE:
              return {
                background: 'yellow',
              };
            default:
              return {};
          }
        };
      },
      cellDataText() {
        return (row, cell) => {
          switch (this.$store.state.tableData[row][cell]) {
            case CODE.MINE:
              return 'X';
            case CODE.NORMAL:
              return '';
            case CODE.FLAG_MINE:
            case CODE.FLAG:
              return '!';
            case CODE.QUESTION_MINE:
            case CODE.QUESTION:
              return '?';
            case CODE.CLICKED_MINE:
              return '펑';
            default:
              return this.$store.state.tableData[row][cell] || '';
          }
        };
      },
    },
    methods: {
        onClickTd(row, cell){
            if(this.halted){
                return;
            }
        switch (this.tableData[row][cell]) {
          case CODE.NORMAL:
            return this.$store.commit(OPEN_CELL, { row, cell });
          case CODE.MINE:
            return this.$store.commit(CLICK_MINE, { row, cell });
          default:
            return;
        }
        },
        onRightClickTd(row, cell){
            if(this.halted){
                return;
            }
            switch(this.tableData[row][cell]){
                case CODE.NORMAL:
                case CODE.MIME:
                    this.$store.commit(FLAG_CELL, { row, cell });
                    return;
                case CODE.FLAG_MINE:
                case CODE.FLAG:
                    this.$store.commit(QUESTION_CELL, { row, cell });
                    return;
                case CODE.QUESTION_MINE:
                case CODE.QUESTION:
                    this.$store.commit(NORMALIZE_CELL, { row, cell });
                    return;
                default:
                    return;
            }
        }
    },
};
</script>
 
<style scoped>
 
</style>
cs



[MineForm.vue]

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
<template>
  <div>
    <input type="number" placeholder="세로" :value="row" @change="onChangeRow" />
    <input type="number" placeholder="가로" :value="cell" @change="onChangeCell" />
    <input type="number" placeholder="지뢰" :value="mine" @change="onChangeMine" />
    <button @click="onClickBtn">시작</button>
  </div>
</template>
 
<script>
  import { START_GAME } from './store';
 
  //이 지역(컴포넌트)에서만 데이터를 사용하려면 굳이 vuex를 사용할 필요가 없다.(vuex코드만 길어질뿐...)
  export default {
    data() {
      return {
        row: 10,
        cell: 10,
        mine: 20,
      };
    },
    methods: {
      onChangeRow(e) {
        this.row = e.target.value;
      },
      onChangeCell(e) {
        this.cell = e.target.value;
      },
      onChangeMine(e) {
        this.mine = e.target.value;
      },
      onClickBtn() {
        this.$store.commit(START_GAME, { row: this.row, cell: this.cell, mine: this.mine });
      }
    },
  }
</script>
cs





Comments