[React] 웹 게임-8 지뢰찾기

ji_silver·2020년 9월 3일
1

[React] 웹 게임

목록 보기
8/8
post-thumbnail

1. Context API

전역적으로 데이터가 사용되야 할 때 (TicTacToe-> Table-> Tr-> Td) 사용

2. createContext와 Provider

  • createContext() 함수를 사용하여 Context 만들기
  • Context API의 데이터에 접근해야 하는 컴포넌트를 Provider로 묶어주기
  • 전달할 데이터는 value={} 안에 넣어주기

MineSearch.jsx

import React, { useReducer, createContext, useMemo } from 'react';

export const TableContext = createContext({
  // 초깃값 설정
});

const MineSearch = () => {
    const [state, dispatch] = useReducer(reducer, initialState);
  
    const value = useMemo(() => ({ tableData: state.tableData, dispatch }), [state.tableData]); // tableDate값이 바뀔 때 갱신

    return (
        <TableContext.Provider value={value}>
            <Form />
            <div>{state.timer}</div>
            <Table />
            <div>{state.result}</div>
        </TableContext.Provider>
    );
};

❗ Context API는 성능 최적화하기 힘들기 때문에 useMemo()로 객체값을 기억하게 하여 캐싱 작업하기

3. useContext 사용해 지뢰 칸 렌더링

1) 배열에 지뢰 심기

MineSearch.jsx

export const CODE = {
    MINE: -7, // 지뢰
    NORMAL: -1, // 일반 칸
    QUESTION: -2, // 물음표
    FLAG :-3, // 깃발
    QUESTION_MINE: -4, // 물음표 칸이 지뢰인 경우
    FLAG_MINE: -5, // 깃발 칸이 지뢰인 경우
    CLICKED_MINE: -6, // 지뢰 클릭
    OPENED: 0, // 칸 열었을 때 (0이상이면 다 opened)
};

...

const plantMine = (row, cell, mine) => {
    console.log(row, cell, mine);
    const candidate = Array(row * cell).fill().map((arr, i) => { //0 ~ 99까지
        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); // 랜덤으로 뽑은 20개 숫자들을 shuffle 배열에 넣기
    }
    //2차원 배열 만들기
    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); // 일반칸을 기본으로 설정
        }
    }
    //2차원 배열에 지뢰 심기
    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 const START_GAME = 'START_GAME';

const reducer = (state, action) => {
    switch (action.type) {
        case START_GAME: 
        return {
            ...state,
            tableData: plantMine(action.row, action.cell, action.mine) // tableData에 지뢰가 심어짐
        };
        default:
            return state;
    }
};

2) 화면에 나타내기

Table.jsx

import React, { useContext } from 'react';
import Tr from './Tr';
import { TableContext } from './MineSearch';

const Table = () => {
    const { tableData } = useContext(TableContext);
    return (
        <table>
            {Array(tableData.length).fill().map((tr, i) => <Tr rowIndex={i} />)}
        </table>
    )
};

export default Table;

Tr.jsx

import React, { useContext } from 'react';
import Td from './Td';
import { TableContext } from './MineSearch';

const Tr = ({ rowIndex }) => {
    const { tableData } = useContext(TableContext);
    return (
        <tr>
            {tableData[0] && Array(tableData[0].length).fill().map((td, i) =>
                <Td rowIndex={rowIndex} cellIndex={i} />
            )}
        </tr>
    )
};

export default Tr;

Td.jsx

import React, { useContext } from 'react';
import { CODE, TableContext } from './MineSearch';

const getTdStyle = (code) => {
    switch (code) {
        case CODE.NORMAL:
        case CODE.MINE:
            return {
                background: '#444',
            };
        case CODE.OPENED:
            return {
                background: 'white'
            };
        default:
            return {
                background: 'white'
            };
    }
};

const getTdText = (code) => {
    switch (code) {
        case CODE.NORMAL:
            return '';
        case CODE.MINE:
            return 'X';
        default:
            return code || '';
    }
};

const Td = ({ rowIndex, cellIndex }) => {
    const { tableData } = useContext(TableContext);
    return (
        <td
            style={getTdStyle(tableData[rowIndex][cellIndex])}
        >{getTdText(tableData[rowIndex][cellIndex])}</td>
    )
};

export default Td;

4. 왼쪽 오른쪽 클릭 로직 작성하기

Td.jsx

const Td = ({ rowIndex, cellIndex }) => {
    const { tableData, dispatch, halted } = useContext(TableContext);

    const onClickTd = useCallback(() => {
        if (halted) {
            return; // 게임이 멈추면 클릭x
        }
        switch (tableData[rowIndex][cellIndex]) {
            //이미 연 칸, 깃발, ?칸은 그냥 return
            case CODE.OPENED:
            case CODE.FLAG_MINE:
            case CODE.FLAG:
            case CODE.QUESTION_MINE:
            case CODE.QUESTION:
                return;
            case CODE.NORMAL:
                dispatch({ type: OPEN_CELL, row: rowIndex, cell: cellIndex });
                return;
            case CODE.MINE:
                dispatch({ type: CLICK_MINE, row: rowIndex, cell: cellIndex });
                return;
            default:
                return;
        }
    }, [tableData[rowIndex][cellIndex], halted]);

    const onRightClickTd = useCallback((e) => {
        e.preventDefault(); // 우클릭 시 나오는 메뉴 안 뜨게 하기
        if (halted) {
            return;
        }
        switch (tableData[rowIndex][cellIndex]) {
            case CODE.NORMAL:
            case CODE.MINE:
                dispatch({ type: FLAG_CELL, row: rowIndex, cell: cellIndex });
                return;
            case CODE.FLAG_MINE:
            case CODE.FLAG:
                dispatch({ type: QUESTION_CELL, row: rowIndex, cell: cellIndex });
                return;
            case CODE.QUESTION_MINE:
            case CODE.QUESTION:
                dispatch({ type: NORMALIZE_CELL, row: rowIndex, cell: cellIndex });
                return;
            default:
                return;
        }
    }, [tableData[rowIndex][cellIndex], halted]);

    return (
        <td
            style={getTdStyle(tableData[rowIndex][cellIndex])}
            onClick={onClickTd}
            onContextMenu={onRightClickTd} // 우클릭 이벤트
        >{getTdText(tableData[rowIndex][cellIndex])}</td>
    )
};

MineSearch.jsx

export const TableContext = createContext({ // 초깃값 설정
    tableData: [],
    halted: true, // 게임 중단 코드
    dispatch: () => { },
});

const initialState = {
    tableData: [],
    timer: 0,
    result: '',
    halted: false,
};

...

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';

const reducer = (state, action) => {
    switch (action.type) {
        case START_GAME:
            return {
                ...state,
                tableData: plantMine(action.row, action.cell, action.mine),
                halted: false,
            };
        case OPEN_CELL: {
            const tableData = [...state.tableData];
            tableData[action.row] = [...state.tableData[action.row]];
            tableData[action.row][action.cell] = CODE.OPENED; // 클릭한 칸을 OPENED으로 바꾸기
            return {
                ...state,
                tableData,
            };
        }
        case CLICK_MINE: {
            const tableData = [...state.tableData];
            tableData[action.row] = [...state.tableData[action.row]];
            tableData[action.row][action.cell] = CODE.CLICKED_MINE;
            return {
                ...state,
                tableData,
                halted: true,
            };
        }
        case FLAG_CELL:
            const tableData = [...state.tableData];
            tableData[action.row] = [...state.tableData[action.row]];
            if (tableData[action.row][action.cell] === CODE.MINE) {
                tableData[action.row][action.cell] = CODE.FLAG_MINE;
            } else {
                tableData[action.row][action.cell] = CODE.FLAG;
            }
            return {
                ...state,
                tableData,
            };
        case QUESTION_CELL: {
            const tableData = [...state.tableData];
            tableData[action.row] = [...state.tableData[action.row]];
            if (tableData[action.row][action.cell] === CODE.FLAG_MINE) {
                tableData[action.row][action.cell] = CODE.QUESTION_MINE;
            } else {
                tableData[action.row][action.cell] = CODE.QUESTION;
            }
            return {
                ...state,
                tableData,
            };
        }
        case NORMALIZE_CELL: {
            const tableData = [...state.tableData];
            tableData[action.row] = [...state.tableData[action.row]];
            if (tableData[action.row][action.cell] === CODE.FLAG_MINE) {
                tableData[action.row][action.cell] = CODE.MINE;
            } else {
                tableData[action.row][action.cell] = CODE.NORMAL;
            }
            return {
                ...state,
                tableData,
            };
        }
        default:
            return state;
    }
};

5. 지뢰 개수 표시하기

MineSearch.jsx

const reducer = (state, action) => {
    switch (action.type) {
        case OPEN_CELL: {
            const tableData = [...state.tableData];
            tableData[action.row] = [...state.tableData[action.row]];
            let around = [];
            if (tableData[action.row - 1]) { // 클릭한 칸 기준으로 윗줄이 있으면
                around = around.concat(
                    tableData[action.row - 1][action.cell - 1],
                    tableData[action.row - 1][action.cell],
                    tableData[action.row - 1][action.cell + 1]
                );
            }
            around = around.concat(
                tableData[action.row][action.cell - 1],
                tableData[action.row][action.cell + 1]
            );
            if (tableData[action.row + 1]) { // 클릭한 칸 기준으로 밑에줄이 있으면
                around = around.concat(
                    tableData[action.row + 1][action.cell - 1],
                    tableData[action.row + 1][action.cell],
                    tableData[action.row + 1][action.cell + 1]
                );
            }
            // 주변 지뢰 갯수 찾기
            const count = around.filter((v) => [CODE.MINE, CODE.FLAG_MINE, CODE.QUESTION_MINE].includes(v)).length;
            console.log(around, count);
            tableData[action.row][action.cell] = count;
            return {
                ...state,
                tableData,
            };
        }
    }
};

6. 빈 칸들 한 번에 열기

MineSearch.jsx

case OPEN_CELL: {
    const tableData = [...state.tableData];
    // tableData[action.row] = [...state.tableData[action.row]];
    tableData.forEach((row, i) => { // 클릭한 칸 뿐 아니라 모든 칸을 새로운 객체로 만들기
        tableData[i] = [...state.tableData[i]];
    });
    const checked = [];
    const checkAround = (row, cell) => {
        // 닫힌 칸이 아니면 리턴
        if ([CODE.OPENED, CODE.FLAG_MINE, CODE.FLAG, CODE.QUESTION_MINE, CODE.QUESTION].includes(tableData[row][cell])) {
            return;
        }
        // 상하좌우 없는 칸은 안 열기
        if (row < 0 || row >= tableData.length || cell < 0 || cell >= tableData[0].length) {
            return;
        }
        // 옆에칸 서로 검사하는 거 막아주기
        if (checked.includes(row + ',' + cell)) { // 이미 검사한 칸이면 
            return;
        } else { // 아니면 checked 배열에 넣어주기 
            checked.push(row + ',' + cell);
        }
        let around = [];
        if (tableData[row - 1]) {
            around = around.concat(
                tableData[row - 1][cell - 1],
                tableData[row - 1][cell],
                tableData[row - 1][cell + 1]
            );
        }
        around = around.concat(
            tableData[row][cell - 1],
            tableData[row][cell + 1]
        );
        if (tableData[row + 1]) {
            around = around.concat(
                tableData[row + 1][cell - 1],
                tableData[row + 1][cell],
                tableData[row + 1][cell + 1]
            );
        }
        const count = around.filter((v) => [CODE.MINE, CODE.FLAG_MINE, CODE.QUESTION_MINE].includes(v)).length;
        tableData[row][cell] = count;

        // 지뢰가 없으면 주변 8칸 열기
        if (count === 0) {
            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 < tableData.length) {
                near.push([row + 1, cell - 1]);
                near.push([row + 1, cell]);
                near.push([row + 1, cell + 1]);
            }
            near.forEach((n) => {
                if (tableData[n[0]][n[1]] !== CODE.OPENE) {
                    checkAround(n[0], n[1]);
                }
            })
        }
    };
    checkAround(action.row, action.cell);
    return {
        ...state,
        tableData,
    };
}

7. 승리 조건 체크와 타이머

승리조건: 클릭한 칸이 가로 * 세로 - 지뢰개수 한 값과 같으면 승리

1) 승리조건 체크

const initialState = {
    tableData: [],
    date: {
        row: 0,
        cell: 0,
        mine: 0,
    },
    result: '',
    halted: true,
    openedCount: 0,
};

const reducer = (state, action) => {
    switch (action.type) {
        case START_GAME:
            return {
                ...state,
                data: { // 게임 시작 시 data 기록해두기
                    row: action.row,
                    cell: action.cell,
                    mine: action.mine,
                },
                openedCount: 0,
                tableData: plantMine(action.row, action.cell, action.mine),
                halted: false,
                timer: 0,
            };
        case OPEN_CELL: {
            let openedCount = 0;
            const checkAround = (row, cell) => {
                openedCount += 1; // 칸을 열 때마다 1씩 올려주기
              ...
            };
        ...
        }; 
        checkAround(action.row, action.cell);
            let halted = false;
            let result = '';
            console.log(state.data.row * state.data.cell - state.data.mine, state.openedCount, openedCount);
            if (state.data.row * state.data.cell - state.data.mine === state.openedCount + openedCount) { // 승리
                halted = true; // 승리 시 게임 멈추기
                result = '승리하셨습니다!';
            }
            return {
                ...
                halted,
                result
            };
        }      

2) 타이머


const initialState = {
    ...
    timer: 0,
};

export const INCREMENT_TIMER = 'INCREMENT_TIMER';

const MineSearch = () => {
    useEffect(() => {
        let timer;
        if (halted === false) { // 게임 시작 시 타이머 실행
            timer = setInterval(() => {
                dispatch({ type: INCREMENT_TIMER });
            }, 1000);
        }
        return () => {
            clearInterval(timer);
        }
    }, [halted]);
};

마지막으로 useMemo 사용하여 최적화 하기

profile
🚧개발중🚧

0개의 댓글