12/08/2018, 17:40

Xây dựng sudoku game bằng react

Sudoku game là một trong những game giải đố mà tôi rất thích. Trong bài viết nay, tôi sẽ đề cập đến các bước cơ bản để xây dựng nên sudoku. Luật chơi Đặt số vào tất cả ô 9x9 để mỗi cột, mỗi hàng và mỗi nhóm 9 ô vuông 3x3 tạo thành 1 chuỗi số chứa các số từ 1 đến 9 Ta có khái niệm: peers, là ...

Sudoku game là một trong những game giải đố mà tôi rất thích. Trong bài viết nay, tôi sẽ đề cập đến các bước cơ bản để xây dựng nên sudoku.

Luật chơi

Đặt số vào tất cả ô 9x9 để mỗi cột, mỗi hàng và mỗi nhóm 9 ô vuông 3x3 tạo thành 1 chuỗi số chứa các số từ 1 đến 9

Ta có khái niệm: peers, là các ô cùng thuộc hàng, cột hay ô vuông 3x3

Ta sẽ có thiết kế game dự kiến:

Trong bài viết này, ta sẽ bỏ qua cách khởi tạo game, mà sẽ đi vào tìm hiểu các bước cơ bản để thiết kế và quản lý state trong react. Ta sẽ giả sử game bắt đầu với 1 cách khởi tạo.

Xây dựng mảng dãy số câu đố 2D

Mảng câu đố là một mảng 2 chiều 9x9. Mỗi thành phần của mảng đại diện cho 1 cell

const puzzle = [
  [cell, cell, cell, cell, cell, cell, cell, cell, cell],
  [cell, cell, cell, cell, cell, cell, cell, cell, cell],
  [cell, cell, cell, cell, cell, cell, cell, cell, cell],
  [cell, cell, cell, cell, cell, cell, cell, cell, cell],
  ...
]

const cell = {
  value: 9,
  notes: Set([1,2,3]),
  prefilled: false
}

Để game hoạt động, ta cần phải:

  • Xác định tiến trình của game
  • biết được khi nào game kết thúc
  • còn lại bao nhiêu số cho mỗi peers, các ô trong cùng cột, hàng, ô vuông 3x3
  • theo dõi khả năng mâu thuẫn trong mỗi nhóm peers
  • biết được số lần của một số được sử dụng trong mỗi nhóm peers ( 1 nhóm hoàn thành chỉ khi có chính xác một lần xuất hiện cho mỗi số )

Để xác định mâu thuẫn , ta so sánh giá trị của một cell với giá trị của 24 peers (8 peers mỗi cột, 8 peers mỗi hàng hay 8 peers mỗi ô 3x3) và xác định tiến trình của game bằng cách quét toàn bộ ô 9x9. Tuy nhiên, cách tiếp cận này là không hiệu quả, rất khó về code và cả tốc độ. Để có thể đẩm bảo hiệu năng, code đẹp và dễ dàng thao tác, trạng thái game sẽ theo dõi số lần của mỗi số được sử dụng trong một nhóm peer. Ban đầu, ta sẽ sinh ra câu đố và tạo ra các đối tượng đếm cho tất cả các nhóm peers.

import { List, fromJS } from 'immutable';
/**
 * make size 9 array of 0s
 * @returns {Array}
 */
function makeCountObject() {
  const countObj = [];
  for (let i = 0; i < 10; i += 1) countObj.push(0);
  return countObj;
}

/**
 * given a 2D array of numbers as the initial puzzle, generate the initial game state
 * @param puzzle
 * @returns {any}
 */
function makeBoard({ puzzle }) {
  // create initial count object to keep track of conflicts per number value
  const rows = Array.from(Array(9).keys()).map(() => makeCountObject());
  const columns = Array.from(Array(9).keys()).map(() => makeCountObject());
  const squares = Array.from(Array(9).keys()).map(() => makeCountObject());
  const result = puzzle.map((row, i) => (
    row.map((cell, j) => {
      if (cell) {
        rows[i][cell] += 1;
        columns[j][cell] += 1;
        squares[((Math.floor(i / 3)) * 3) + Math.floor(j / 3)][cell] += 1;
      }
      return {
        value: puzzle[i][j] > 0 ? puzzle[i][j] : null,
        prefilled: !!puzzle[i][j],
      };
    })
  ));
  return fromJS({ puzzle: result, selected: false, choices: { rows, columns, squares } });
}

class Game extends Compoent {
  constructor(props) {
    super(props);
    this.generateGame();
  }
  
  generateGame = (finalCount = 20) => {
    // get a filled puzzle generated
    const solution = makePuzzle();
    // pluck values from cells to create the game
    const { puzzle } = pluck(solution, finalCount);
    // initialize the board with choice counts
    const board = makeBoard({ puzzle });
    this.setState({ board });
  }
  
  updateBoard = (newBoard) => {
    this.setState({ board: newBoard });
  };
...
}

Tạo component Cell

Cell component hiện màu khác nhau để cung cấp thông tin về giá trị hiện tại, mỗi quan hệ với cell được chọn, các mâu thuẫn và các ghi chú dựa vào trạng thái hiện tại của game

const Cell = (props) => {
  const {
    value, onClick, isPeer, isSelected, sameValue, prefilled, notes, conflict,
  } = props;
  const backgroundColor = getBackGroundColor({
    conflict, isPeer, sameValue, isSelected,
  });
  const fontColor = getFontColor({ conflict, prefilled, value });
  return (
    <div className="cell" onClick={onClick}>
      {
        notes ?
          range(9).map(i =>
            (
              <div key={i} className="note-number">
                {notes.has(i + 1) && (i + 1)}
              </div>
            )) :
          value && value
      }
      {/* language=CSS */}
      <style jsx>{CellStyle}</style>
      <style jsx>{`
                .cell {
                    background-color: ${backgroundColor || 'initial'};
                    color: ${fontColor || 'initial'};
                }
            `}
      </style>
    </div>
  );
};

Cell.propTypes = {
  // current number value
  value: PropTypes.number,
  // cell click handler
  onClick: PropTypes.func.isRequired,
  // if the cell is a peer of the selected cell
  isPeer: PropTypes.bool.isRequired,
  // if the cell is selected by the user
  isSelected: PropTypes.bool.isRequired,
  // current cell has the same value if the user selected cell
  sameValue: PropTypes.bool.isRequired,
  // if this was prefilled as a part of the puzzle
  prefilled: PropTypes.bool.isRequired,
  // current notes taken on the cell
  notes: PropTypes.instanceOf(Set),
  // if the current cell does not satisfy the game constraint
  conflict: PropTypes.bool.isRequired,
};

Cell.defaultProps = {
  notes: null,
  value: null,
};

Tính toán đầu vào của các cell component

Cell component cần { value, isPeer, isSelected, sameValue, prefilled, notes, conflict } để render các thông tin hiển thị cần thiết cho user. { value, notes, prefilled } được truy cập trực tiếp từ cell của mảng đố 2D. Tuy nhiên, với các giá trị còn lại thì cần được suy ra từ trạng thái tổng thể của game dựa vào cell được chọn. Ta lấy cell được chọn trong trạng thái của game bằng cách:

class Game extends Compoent {
...  
  getSelectedCell() {
    const { board } = this.state;
    const selected = board.get('selected');
    return selected && board.get('puzzle').getIn([selected.x, selected.y]);
  }

  selectCell = (x, y) => {
    let { board } = this.state
    board = board.set('selected', { x, y })
    this.setState({ selected: { x, y }, board})
  }
...
}

Với hàm getSelectedCell() thuộc component Game, ta có thể suy ra được giá trị của { isSelected, sameValue, isPeer, conflict} bằng cách so sánh cell hiện tại với cell được chọn:

export function isPeer(a, b) {
  if (!a || !b) return false;
  const squareA = ((Math.floor(a.x / 3)) * 3) + Math.floor(a.y / 3);
  const squareB = ((Math.floor(b.x / 3)) * 3) + Math.floor(b.y / 3);
  return a.x === b.x || a.y === b.y || squareA === squareB;
}

class Game extends Compoent {
...  
  isConflict(i, j) {
    const { value } = this.state.board.getIn(['puzzle', i, j]).toJSON();
    if (!value) return false;
    const rowConflict =
      this.state.board.getIn(['choices', 'rows', i, value]) > 1;
    const columnConflict =
      this.state.board.getIn(['choices', 'columns', j, value]) > 1;
    const squareConflict =
      this.state.board.getIn(['choices', 'squares',
        ((Math.floor(i / 3)) * 3) + Math.floor(j / 3), value]) > 1;
    return rowConflict || columnConflict || squareConflict;
  }

  renderCell(cell, x, y) {
    const { board } = this.state;
    const selected = this.getSelectedCell();
    const { value, prefilled, notes } = cell.toJSON();
    const conflict = this.isConflict(x, y);
    const peer = areCoordinatePeers({ x, y }, board.get('selected'));
    const sameValue = !!(selected && selected.get('value')
      && value === selected.get('value'));

    const isSelected = cell === selected;
    return (<Cell
      prefilled={prefilled}
      notes={notes}
      sameValue={sameValue}
      isSelected={isSelected}
      isPeer={peer}
      value={value}
      onClick={() => { this.selectCell(x, y); }}
      key={y}
      x={x}
      y={y}
      conflict={conflict}
    />);
  }
  
  renderPuzzle() {
    const { board } = this.state;
    return (
      <div className="puzzle">
        {board.get('puzzle').map((row, i) => (
          <div key={i} className="row">
            {
              row.map((cell, j) => this.renderCell(cell, i, j)).toArray()
            }
          </div>
        )).toArray()}
        <style jsx>{PuzzleStyle}</style>
      </div>
    );
  }
...
}  

Component Number Control

Mỗi component number control cho phép user thấy các số liên quan với cell được chọn. Và bao gồm cả thanh thông báo tiến trình của một số

const NumberControl = ({ number, onClick, completionPercentage }) => (
  <div
    key={number}
    className="number"
    onClick={onClick}
  >
    <div>{number}</div>
    <CirclularProgress percent={completionPercentage} />
    <style jsx>{NumberControlStyle}</style>
  </div>
);

NumberControl.propTypes = {
  number: PropTypes.number.isRequired,
  onClick: PropTypes.func,
  completionPercentage: PropTypes.number.isRequired,
};

Component NumberControl cần:

  • một callback sẽ cập nhật giá trị của cell được chọn khi user click
  • giá trị đã được tính toán của tiến trình của một số

hàm fillNumber(number) là một hàm của component Game để thực hiện cập nhật giá trị của cell được chọn khi user click

import { Set } from 'immutable';
/**
 * give the coordinate update the current board with a number choice
 * @param x
 * @param y
 * @param number
 * @param fill whether to set or unset
 * @param board the immutable board given to change
 */
function updateBoardWithNumber({
  x, y, number, fill = true, board,
}) {
  let cell = board.get('puzzle').getIn([x, y]);
  // delete its notes
  cell = cell.delete('notes');
  // set or unset its value depending on `fill`
  cell = fill ? cell.set('value', number) : cell.delete('value');
  const increment = fill ? 1 : -1;
  // update the current group choices
  const rowPath = ['choices', 'rows', x, number];
  const columnPath = ['choices', 'columns', y, number];
  const squarePath = ['choices', 'squares',
    ((Math.floor(x / 3)) * 3) + Math.floor(y / 3), number];
  return board.setIn(rowPath, board.getIn(rowPath) + increment)
    .setIn(columnPath, board.getIn(columnPath) + increment)
    .setIn(squarePath, board.getIn(squarePath) + increment)
    .setIn(['puzzle', x, y], cell);
}

class Game extends Compoent {
...  
  // fill currently selected cell with number
  fillNumber = (number) => {
    let { board } = this.state;
    const selectedCell = this.getSelectedCell();
    // no-op if nothing is selected
    if (!selectedCell) return;
    const prefilled = selectedCell.get('prefilled');
    // no-op if it is refilled
    if (prefilled) return;
    const { x, y } = board.get('selected');
    const currentValue = selectedCell.get('value');
    // remove the current value and update the game state
    if (currentValue) {
      board = updateBoardWithNumber({
        x, y, number: currentValue, fill: false, board: this.state.board,
      });
    }
    // update to new number if any
    const setNumber = currentValue !== number && number;
    if (setNumber) {
      board = updateBoardWithNumber({
        x,
            
            
            
         
0